Lately, I’ve noticed a transition in my craft as a software developer. While I once devoted almost all of my mental energy to getting code to run properly, I’m now putting more thought into writing clear and maintainable code. I’ve started to understand that programming languages are not only a way for me to “talk to” computers, but also a way to communicate with others about how a system is meant to work.
Ever since I had this realization, I’ve been searching for specific ways to become a better code communicator. One way I’ve done this is through an influential concept in the field of liguistics: the Cooperative Principle, created by Paul Grice in the 1970s for understanding the field of pragmatics.
It turns out, though, that the Cooperative Principle is also a great guideline for writing comprehensible software.
What is the Cooperative Principle?
This principle describes how people normally behave in conversations in order to be understood. It states:
“Make your contribution such as is required, at the stage at which it occurs, by the accepted purpose or direction of the talk exchange in which you are engaged.”
Linguists are especially interested in the ways this principle is “flouted” or violated, as that usually indicates sarcasm, humor, or other types of non-literal speech. But since we want to be as literal as possible when writing software (please do not try to write a passive-aggressive function), following the Cooperative Principle can help shape a well-communicated codebase for any project.
Writing Code That’s Cooperative
So, how do you follow the Cooperative Principle as a software developer? Linguists decompose the Principle into four “maxims” or rules:
- Quality – Try to make your contribution one that is true.
- Quantity – Make your contribution as informative as is required—but no more.
- Relation – Be relevant.
- Manner – Avoid obscurity, ambiguity, and disorder.
Each of these guidelines conveniently translates into a facet of coding. Whenever you’re reading or writing code, think about whether these rules are being followed or not, and how that affects the code’s readability.
1. The Maxim of Quality
In spoken language, people don’t usually say things which they believe to be false, or for which they don’t have any evidence. To a linguist, violating this maxim might indicate sarcasm or flat-out lying.
For software developers, following this maxim means that the semantic content of your code is aligned with what it actually accomplishes. In other words, other developers should understand what a variable, class, or function is used for based on its name. Their understanding might not necessarily be complete, but it should at least not be wrong.
Here’s a snippet of code that violates the Maxim of Quality:
public double areaOfCircle(int radius){
return radius * radius;
}
It’s pretty obvious why this is bad code. It’s bad because it’s wrong, and it’s wrong because the function’s name does not describe its output.
This is a contrived example, but I have seen similar examples in production codebases. One common scenario is a test with a name that doesn’t describe what it’s actually testing. Another example is a void function whose body is fully commented-out.
Violating the Maxim of Quality can be dangerous for a few reasons. The first is that it can lead directly to logic errors. If someone discovers and uses the areaOfCircle
function without reviewing its implementation, they could end up with a logic error that goes unnoticed for months.
Another danger is leading developers to second-guess themselves. If areaOfCircle
were something complicated like sendEmailToEveryImportantUser
, but the implementation didn’t seem to actually send any emails, I would probably feel less confident about making changes to its implementation or usages. While I trust my reading of the code, I also have some implicit trust that the function really does what it says, so I might just be misunderstanding something. This type of mistake can result in a lot of wasted time.
How do you avoid accidentally violating the Maxim of Quality? I don’t think there’s a surefire way other than being cautious when changing code that’s already been written. For example, if you have a function which calls verifyForm
and then submitForm
, but then moves the call to submitForm
inside of verifyForm
, you should consider renaming verifyForm
to verifyAndSubmitForm
.
If you ever fix a bug caused by a misnamed token, or you spend more than a few minutes parsing code due to poor naming, consider renaming things for the benefit of the next developer.
2. The Maxim of Quantity
In conversation, people usually say just enough to get their point across. This is important to the back-and-forth flow of information so that no one involved is starved or overloaded with information.
For the same reason, it’s important to say just the right amount in a given block of code. This helps others understand the intention without being bogged down with implementation details. Here’s a function which violates the Maxim of Quantity:
public bool validateForm() {
if (!self.username) return false;
if (!self.password) return false;
if (!self.passwordConfirmation) return false;
if (!self.username.isEmpty()) return false;
if (!self.password.isEmpty()) return false;
if (!self.passwordConfirmation.isEmpty()) return false;
if (!self.username.isValid()) return false;
if (!self.password.isValid()) return false;
if (!self.passwordConfirmation.isValid()) return false;
...
return true;
}
The problem with this function is that much of the implementation detail should be abstracted away. Something like this would be more appropriate:
public boolean validateForm(){
return formIsComplete() && fieldsAreValid();
}
Of course, it can be just as bad to go in the opposite direction:
public boolean validateForm(){
return delegate.validateForm(self);
}
I’ve encountered this pattern many times, i.e. a function that has exactly one caller, which has one caller, which itself has one caller. Usually they each embody a layer of abstraction (such as adding an action to a worker queue), yet each function has a very simple implementation. The result is that when I try to trace through a code path, I have to pass through many unnecessary layers to get to the “meat” of a function. It’s like playing a game of 20 questions to find out what your friend ate for dinner last night.
To follow the Maxim of Quantity, consider another developer’s first impression of your code. Without looking at the actual implementation details, are functions more than a dozen lines long? Does one class take up several hundred lines? Or does every function do the exact same one-line transformation on a different piece of data? In general, if the information seems to be too dense or too sparse, consider ways to adjust your verbosity. That could mean introducing some abstraction, removing it, or maybe currying some functions together.
3. The Maxim of Relation
Conversation would be nearly impossible if it were normal to change the subject randomly. The same is true for code. Just as conversational context is crucial for dialog, functional context is crucial for writing understandable code. Once again, this is a sort of Goldilocks problem: You want the context to be broad enough that you can communicate important details, but narrow enough that readers aren’t confused by the code.
Here’s a violation of the Maxim of Relation:
class Animal
def eat(food)
...
end
def sleep(duration)
...
end
def factorial(n)
if (n > 0)
n * factorial(n - 1)
else
1
end
end
end
The factorial
function’s implementation is perfectly fine, but it does not belong in that class. Someone browsing through the class would have to decide whether it’s relevant or not, which requires cognitive overhead. Even if the answer is obviously “no,” it will still take more time to navigate through the parts of the class that are relevant. In this example, factorial
is essentially a mental speed bump.
This usually happens because there isn’t an immediately-obvious alternative place for the code. Keeping unrelated code out of the way while still making it accessible to callers and consumers is definitely a balancing act. Experience makes these decisions easier, but here are two rules of thumb to help:
- If a function seems out of place within a module but is used by multiple other modules, it should be moved out.
- Organize class variables and methods by type. For example, keep all of the member variables at the top, sorted by visibility, and then do the same for functions. For modules in functional languages, keep public functions above private functions.
IDEs can make it easy to forget about this rule. When you can simply control-click into a function’s definition, putting much thought into its location may not seem worthwhile. But keep in mind that having the option to use less intelligent editors, from TextEdit to Vim, can be beneficial. Additionally, you can’t always predict which editing tools will be available to others who need to read your code. And lastly, practicing the Maxim of Relation will help you keep a clear mental model of how your system is laid out.
4. The Maxim of Manner
In natural language, following the Maxim of Manner essentially means avoiding ambiguity and obscurity. People don’t normally beat around the bush when they’re trying to communicate something directly. I think the equivalent form for programming is to follow conventions. These include naming conventions, formatting styles, and architectural patterns.
Conventions are a powerful tool for adding implicit information to a bit of code. For example, I can infer some things about a token named THEME_PRIMARY_COLOR
no matter what language it’s used in. I can also infer things about the name UserController
without knowing a single thing about its implementation.
But I can only rely on these inferences if conventions are being used. In other words, I only know that THEME_PRIMARY_COLOR
is a constant because all-caps tokens are usually constant. Similarly, I can infer that UserController
is an MVC-like controller if I’m inside an MVC codebase, but not if I’m inside a codebase using MVVM.
The consequence of violating this maxim is once agin cognitive overhead. If a bit of code is not capitalized the way I expect, or the spacing is not easily parsable, it will take time to sort things out mentally. Even if that timespan is extremely small, the wasted time adds up quickly.
Like the previous maxim, following the Maxim of Manner is a matter of discipline. But unlike the previous maxim, tools and procedures exist to make sure conventions are followed. Linters and code formatters are one very effective way to accomplish this. Teams should also agree upon conventions internally, and then create accountability for following them.
Practice Makes Perfect
Communication is an essential skill, even if you spend 95% of your working day writing code. We’ve all suffered the consequences of poorly-communicated code, but becoming a better communicator will help keep you from becoming the villain.
Remember to think about the four maxims whether you’re writing code or reading it. Could you easily put into words what the code is doing, and what it’s for? If not, it could probably benefit from some refactoring. While the problem domain might just be hard to understand, there’s a good chance that it’s simply being communicated poorly. But if you can tell someone how a system works in plain, natural language, you should be able to do it in code as well.