As an engineer, it's extremely important to have a good aesthetic sense of code. At the end of the day, how can a developer write high quality code if they don’t know what that looks like? For many years, I believed that writing high quality code meant making a multivariate optimization toward qualities like efficiency, testability, reliability, readability, modularity, and reusability. While managing trade-offs among those different objectives isn't always easy, this viewpoint had been acceptable. Recently, I found a much simpler approach.
Instead of a multivariate optimization, writing high quality code can be treated as a bivariate optimization between two simple objectives: speed and comprehensibility. Furthermore, because premature optimization is evil and modern hardware is extremely powerful, speed is typically not a concern unless developers are working on core infrastructure or highly responsive user-facing elements. Consequently, writing high quality code essentially becomes writing comprehensible code. In this post, as speed it typically not a concern, I will focus on showing what I mean by comprehensibility, why it trumps other objectives like testability or modularity, and how developers can take advantage of this simple principle to write better code.
What is comprehensibility
While this is subjective, comprehensible code to me means that a completely new developer can understand and modify the codebase quickly and reliably.
Imagine a hypothetical situation in which a developer wants to launch a TCP server. One way to implement it is as follows:
int main() { int listenfd; struct sockaddr_in servaddr; listenfd = socket(AF_INET, SOCK_STREAM,0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(32000); bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); listen(listenfd,1024); .... }
The coding style above is common and quickly leads to a messy and unreadable codebase. Alternatively, consider the following code, in which logic is extracted into separate functions.
int listenfd; struct sockaddr_in servaddr; void setup() { listenfd = socket(AF_INET, SOCK_STREAM,0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(32000); } void run() { bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); listen(listenfd,1024); ... } int main() { setup(); run(); }
While the code above is much more readable, it is still not very comprehensible. Imagine a new developer wants to add new functionality to the TCP server. He/she likely has to trace the source code from the main() function to the setup() and run() functions, and perhaps deeper and deeper. If the codebase is large and complex, the life of the new developer quickly becomes miserable.
Now, let's consider the following code.
int main() { const int PORT = 8080; TCPServer *tcpServer = TCPServer::create(PORT); tcpServer->start(); }
With better semantical naming, proper encapsulation, and explicit dependencies, the code above becomes much more comprehensible. Readers can simply read the code line by line and understand what each line is exactly doing. In the above example, if the new developer needs to add more functionality to the TCP server, he/she likely doesn't have to trace the source code of TCPServer::create and only has to check out the source code of tcpServer->start.
Generally speaking, when developers need to trace deeper and deeper into the code to understand its functionality, the code is incomprehensible.
Why comprehensibility trumps objectives like modularity, testability, etc.?
First of all, spaghetti code simply can't be comprehensible, and only when the code is modular (not spaghetti), can it be testable, reusable, and reliable. Consequently, code needs to be comprehensible before it can be modular, testable, reusable or reliable. Not to mention that comprehensibility is the foundation of efficient team collaboration. Without it, the entire engineering team will not scale effectively.
For readers who are familiar with the DRY (“don’t repeat yourself”) principle, similar connections can also be drawn. A great definition of this principle can be found on wikipedia
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system
Per the definition, un-DRY code is where a single functionality or knowledge has multiple representations. Such code is not comprehensible. Imagine a new developer trying to change user permissions in a system which has 3 different authorization mechanisms. Before the developer can reliably make the change, he/she has to: a) understand all 3 mechanisms, b) understand how they affect each other and c) invest time into making sure there is no 4th mechanism in the system.
How to improve code comprehensibility
Since this is by itself a huge topic, I will only cover some high level ideas here.
1. write from readers' perspective
To make sure the code is comprehensible, developers really need to wear two hats. In one hat, developers write code that is correct and performs well. In the other hat, developers review the newly written code as if they were completely new to the codebase and see if they can quickly understand it. When reviewing the code, stay honest and keep revising the code until it is comprehensible.
2. understand code smell
"Code smell" are patterns like duplicate code, long methods, etc., which indicate that a program is susceptible to bugs. Code smells typically imply the existence of deeper code structure problems. For instance,
In the code above, the constructor is smelly as the implementation is not only long but also contains many branching logic. By introducing a separate builder class and move the logic of handling the optional parameters into builder’s instance methods, the code becomes much more comprehensiblepublic class Cache { public Cache(int maximumSize, int expireAfterWrite, TimeUnit timeUnit, RemovalListenser removalListener) { if (maximumSize > 0) { this.maximumSize = maximumSize; } else { this.maximumSize = DEFAULT_MAXIMUM_SIZE; } if (expireAfterWrite > 0) { this.expireAfterWrite = expireAfterWrite; this.timeUnit = timeUnit; } else { this.expireAfterWrite = 10; this.timeUnit = TimeUnit.MINUTES; } if (removalListener != null) { this.removalListener = removalListener; } else { this.removalListener = DUMMY_REMOVAL_LISTENER; } } ... }
public class Cache { ... } public class CacheBuilder { public CacheBuilder maximumSize(int maximumSize) {...} public CacheBuilder expireAfterWrite(int expireAfterWrite, TimeUnit timeUnit) {...} public CacheBuilder removalListener(RemovalListener removalListener) {...} public Cache build() { ... } }
While identifying a code smell’s underlying cause can be challenging, understanding code smells helps developers sharpen their radar for detecting incomprehensible code.
3. keep your code DRY
For effective collaboration in the physical world, you want clear task ownership and accountability. In code, each component or module should also have clearly defined ownership and responsibilities. Ambiguities will quickly compound and result in an incomprehensible codebase.
4. have consistent convention
From my personal experience, many developers keep consistent conventions for the sake of complying with company policy. However, proper conventions also helps comprehensibility tremendously. At the framework level, this is how Rails, Maven, etc. achieve simplicity. At the business logic level, consider the following snippet.
StatusCode authorize(const Policy &policy, const User &user, Response *response) { .... }
Having common C++ conventions like “input parameters must be value or const references and output parameters must be non-const pointers” helps readers better understand method signatures and other developers’ intentions.
5. understand design patterns
While most developers think of design patterns as best practices to dis-entangle spaghetti code into modular components, another advantage of applying design patterns is to give developers a common vocabulary to communicate with. For instance, when a developer sees a class called UserPresenter, he/she can directly infer that the class is adopting the Presenter pattern for the User model, i.e. Decorator of the User model for view-specific logic.
6. adopt dependency injection
This tip is more controversial and heavily depends on the language and the framework. Nevertheless, I find the fundamental idea still worth sharing.
Many years ago, developers began adopting dependency injection (DI) in Java for better testability. For more static language like Java, DI is particularly important as it allows developers to inject mock objects during tests while injecting real objects in production. Consequently, DI greatly enhanced testability. However, this success has led people to believe that dependency injection is only for improving testability in static languages. I want to demonstrate that dependency injection is much more than testability, and that there are at least three ways it improves the code comprehensibility.
First, it makes the dependency explicit so that readers don't have to trace the implementation to uncover it. For instance, in the following code, the reader can infer that for a Computer to exist, it needs a valid RAM and a valid Disk for its entire lifetime.
In the following code, however, the reader needs to trace the implementation of the constructor for the same information.public class Computer { public Computer(RAM ram, Disk disk) { ... } }
public class Computer { public Computer() { ... Disk disk = new Disk(); ... RAM ram = new RAM(); ... } }
Second, DI prompts the developer to think about an object’s life cycle. For instance, whether A depends on B during the entire lifetime or just during the execution of certain tasks. For instance,
vspublic class Computer { public Computer(RAM ram, Disk disk) { ... } public print(Printer printer, File file) { ... } }
public class Computer { public Computer(RAM ram, Disk disk, Printer printer) { ... } public void print(File file) { ... } }
Finally, DI separates object creation logic from business logic. This is particularly important as it not only simplifies the implementation, but also simplifies the transitive dependency needed for creating the dependency. Moreover, from the reader's perspective, code will be much more comprehensible if readers can focus on business logic rather than creation logic. For instance,
vspublic class Computer { public Computer(RAM ram, Disk disk) { ... } public print(Printer printer, File file) { printer.print(disk.loadContent(file)); } }
public class Computer { public Computer(RAM ram, Disk disk) { ... } public void print(Ink ink, Toner toner, Document document) { Printer printer = null; if (config.useLaserPrinter()) { printer = new LaserPrinter(toner); } else if (config.userInkPrinter()) { printer = new InkPrinter(ink); } printer.print(disk.loadContent(file)); } }
Conclusion
To sum up, developers should strive to make their code fast and comprehensible. While premature optimization is evil and modern hardware is extremely powerful, oftentimes, developers only need to worry about code comprehensibility. In this post, we talked about how comprehensible code looks, why it's a top priority, and some high level ideas for writing more comprehensible code. While this post is not exhaustive, I want to illustrate the importance of comprehensibility, and I will cover more details in separate posts. Lastly, for readers who only want to walk out with one idea, just remember the following.
Your code should read like a story in which each line should clearly tell the reader what they do. If you do it right, you don't even need comments.
If you like the post, you can follow me (@chengtao_chu) on Twitter or subscribe to "ML in the Valley". Also, special thanks Leo Polovets for reading a draft of this.