Rating: 8.5/10.
A fairly short book on aspects of software engineering and design, especially on how to manage complexity. At 170 pages, it is very short and more opinionated than some larger tomes like Code Complete, some of the advice is not universally agreed upon by all software engineers, but they are well reasoned. This book is well suited for intermediate to advanced programmers and requires some real-world experience in codebases to make sense of it. Even as a developer who has worked for several years, a lot of the material in this book was not obvious to me.
Complexity arises when there is a high cognitive load required to make a change, either because of the amount of dependencies or modifications needed in different places, or due to obscurity in how things work. When making a new feature, it is good to think strategically instead of “working tactically” to get things working quickly, which may lead to a mess that will slow you down later.
Interfaces should be “deep”, hiding a lot of implementation complexity. A good module should have a small interface hide a lot of complexity, a “shallow” interface is when the interface is complex but implementation is simple, and that is bad. The caller shouldn’t need to think about the details like the order of doing things and internal data structures. Interfaces should provide reasonable defaults for the common case, for example, Java’s BufferedInputStream is a bad design because you almost always want buffering when doing file IO, but the interface is designed in a way that you have to explicitly declare it to use it. A more reasonable interface should just do this by default (maybe with an option to turn it off), but if it’s desirable 99% of the time, it should just be the default without the programmer having to think about it.
A sign that you have a shallow interface is when you have a bunch of pass-through variables where the method doesn’t do anything except pass the variable down to the next level. This means that the interface is not abstract enough and it’s not providing much value. When faced with such a pattern, one way to refactor it is to use context variables that contain multiple pass-through variables at the same time.
It’s better to make an interface general enough that it can support multiple use cases (but not necessarily implementing all functionality in all use cases). The reason is that when your interface is specific, it tends to couple together both modules to be aware of the specific details. For example, a text editor class for text editing should not be aware of any UI elements like the cursor and backspace key, and should only with the data structures related to the text, like position ranges and string insertion.
A common design question is whether you should keep a module together or split it apart into two modules. In general, you should lean towards making larger modules that contain all of the complexity within it, so that the caller doesn’t have to deal with the complexity. It’s often tempting to split larger chunks of code into smaller ones, but this is not useful in reducing complexity. One case to split it apart is when the module is combining several layers of abstraction (ie, general and more specific logic at the same time), then you should split it up into different modules that deal with the different levels of abstraction. Also, if you find yourself repeating code, it’s a sign that you should split it up or otherwise refactor into a subroutine.
Exceptions add a lot of complexity for little benefit because in many cases, the caller can’t do much with this information either: it usually just reraises the exception or logs it. When designing a module, you should avoid the temptation to just throw exceptions and have the caller deal with it later. It’s best to design the contract to not need to throw exceptions (eg: the substring method can return the whole string if the index is out of bounds), or retry automatically by default (eg: network failure), which is what the caller would probably want to do anyways when catching the exception, or crash if the application can’t do anything to recover from it, (eg: out of memory). One case where exceptions are useful is aggregating multiple types of exceptions at the top level to catch them at the same time, do some clean up, and possibly retry.
The next part of the book is about the philosophy of comments. Some people argue that the code should be readable and you shouldn’t write comments, but in practice, reading the code requires cognitive effort. A comment is well worth it if it can reduce the cognitive effort to understand the code, even if just slightly. A comment should be written at a higher level than the code itself and not repeat what is already in the code. For interfaces, comments should only talk about how the interface is used and not about its implementation, which the caller does not need to know about. By working at a higher level, you can describe things in a more intuitive level than the code, which needs to handle all the specific details. Choosing names is important as well: names should be precise enough to avoid confusing it with something else. To keep the comments maintainable, put them near the code where it is likely to be modified so that whenever you modify the code, it is easy to keep the comment updated (in general, keeping comments up to date is a small fraction of the work of actually making a change).
Several patterns are now popular for managing complexity, such as object-oriented programming, Agile, and writing unit tests. The author does not recommend Test Driven Development (TDD) because it focuses too much on behavior instead of clean design. You should not use design patterns too much just for the sake of using them, as they can often be more confusing rather than helpful. When designing for performance: benchmark to find the critical paths and then design around the most common critical paths; any edge cases or less common paths should be handled outside the critical path.