I really liked A Philosophy of Software Design by John Ousterhout. It is compact and short, only 170 pages, so it is a quick read, but it contains many good ideas. The focus is on how to structure systems to make them easy to understand and work with. The author is a professor of Computer Science at Stanford, but he has also spent 14 years developing commercial software.
The book starts with a good chapter on complexity. The author defines complexity as anything related to the structure of a software system that makes it hard to understand and modify. There are three symptoms of complexity: change amplification (a change requires code modifications in many different parts), cognitive load (how much you, as a developer, need to know in order to complete a task), and unknown unknowns (it is not obvious which parts of the program that need to be modified to do the task).
The causes of these symptoms are dependencies and obscurity. Method calls create explicit dependencies. But there are also implicit dependencies. An example is when you have implemented a message protocol with a sender and a receiver. Any change to the sender usually also requires a change to the receiver. Obscurity can often be related to unclear dependencies. An example is if you add a new exception, and you then also need to add a new entry in an error message table, but the connection between them is not obvious.
The goal of software design is to reduce the complexity of the system. This is a continuous activity, both because software systems are usually continuously modified, and because every little change can contribute to the complexity (“complexity is incremental” is repeated in several places in the book).
A central theme in the book is that modules should be deep. This means that the interface of some functionality should be a lot simpler than its implementation. That way, the cost of understanding and using the interface is lower than the benefit of the implemented functionality, thus helping to lower the overall complexity of the system. A module in this sense can be anything from a method or function, to a class or a subsystem.
The opposite of deep modules are shallow modules. The implementation is not much bigger than the interface. The benefit of using the interface rather than the implementation directly is not very big, so it doesn’t help in reducing the complexity of the system. Small modules are thus often shallow. It is interesting that this is the complete opposite of the advice in for example Clean Code. There the mantra is to use many small classes and methods, rather than a few bigger ones.
An example of a deep module is the Unix/Linux system calls for I/O operations. There are five of them (open, read, write, lseek, close), and the signatures are simple, but they hide an enormous amount of implementation details on how files are stored and accessed on disk. In contrast, an example of a shallow module is the Java I/O classes. In order to open and read from a file, you need to use a FileInputStream, a BufferedInputStream and a ObjectInputStream. The functionality could have been provided by just one such class, reducing the boilerplate code needed. Furthermore, commonly needed features, such as buffered I/O, should be the default behavior, with extra parameters or setup only needed when it isn’t the common case. That would also contribute to lowering the system complexity.
Related to the concept of deep modules is the advice to make them “somewhat general purpose”. By this the author means that the interface should be general enough to support multiple implementations, even though the implementation only covers what is needed today. Reducing the API to a few general purpose methods instead of many special purpose will do this and make the module deep. An example for an interactive text editor is given, where several functions, like delete, backspace, deleteSelection etc, were replaced by the more general purpose functions insert and delete. This resulted in clearer division of functionality, as well as a simplified interface.
Finally, also somewhat related to deep modules, is the idea that different layers should use different abstractions. For example in TCP, the top level abstraction is that of a stream of bytes. The lower level uses an abstraction of packets that can be lost or reordered. If adjacent layers contain the same or very similar abstractions, perhaps they should be combined to create a deeper module. A sign that this is the case is that there are pass-through methods – methods that does little more than calling other methods with the same or very similar signatures.
If your code throws an exception, you are forcing all callers of that code to be prepared to handle it if it happens. You often throw an exception because you don’t know what to do in that case. But if you have trouble knowing what to do, chances are the caller also has trouble knowing what to do. If you can define your functionality such that it never needs to throw an exception, then you have reduced the complexity of the system.
The author gives several good examples. In the scripting language Tcl that the author created, the unset instruction removes a variable. If the variable does not exist, an error is thrown. However, it turns out that the most common use of unset was to clean up temporary state created by a previous operation. But it was hard to know how far the previous operation had progressed, so it was hard to know if a variable had been created or not. Thus you had to be prepared to handle exceptions from unset in a lot of places. It would have been much more useful, and simpler, if unset had been defined to mean that it ensures a variable with that name does not exist after it has been run, regardless of if it existed before or not.
Another example is the substring method in Java. If the lower index is below zero, or the higher index is beyond the string length, an IndexOutOfBoundsException is thrown. This forces the caller to handle these cases before calling substring. Instead you can define it to return the characters of the string (if any) with index greater than or equal to beginIndex and less than endIndex. That way no exception needs to be thrown, which greatly simplifies the usage. Python does something similar when returning an empty result for out-of-range list slices.
Other strategies include masking, where the exception is caught and handled at a lower level, so the caller doesn’t have to, and exception aggregation, reducing the number of exceptions that must be handled. The tactic of defining cases out of existence can also be used on special cases. An example is from the text editor, where sometimes text is selected, sometimes no text is selected. Using a boolean to explicitly keep track of if there is a selection or not leads to lots of special handling. Instead, if you allow an empty selection of zero characters, there is no need for special handling.
The chapter on optimization starts by listing typical times taken by various operations such as network communication, I/O to storage (disk, flash), memory allocation and cache misses. It continues with the standard advice of always measuring instead of assuming. When optimizing, the biggest gains can usually be had by fundamental changes. For example by introducing a cache, or changing the algorithm or data structure.
After that, you have to concentrate on speeding up the most commonly executed path. Imagine what the ideal code for this case would look like. Currently maybe several method calls are used, but perhaps all the work could be done in a single method call. If all special cases can be excluded at the beginning, no checks are needed after that. Maybe all the data needed could be combined in a single data structure. This fast ideal critical path is the goal you are working towards. The next step is to rearrange the code to be optimized to get as close as possible to this ideal case. A connection with previous chapters is that deep modules are more efficient than shallow, since more work is done for each method call. The chapter ends with a good example from the RAMCloud system, where this thinking is applied to buffer allocation code, for a speed-up of a factor of two for the most common operation.
The author is a big proponent of writing a lot of comments. On page 103 he writes: “Every class should have an interface comment, every class variable should have a comment, and every method should have an interface comment”. This is excessive.
The problem with comments is that they can get out of sync with the code, and that they clog up the code. Therefore, they should only be used when there is no other way. In most cases, well-chosen names for methods and variables makes comments unnecessary. If the code is so complicated that it needs to be explained with comments, perhaps it can be rewritten to make it clearer.
Furthermore, when you work in a codebase, you can easily navigate in and out of methods. You often need to do this when modifying the code. In these cases, a comment describing what a method does doesn’t help much. You still often need to check the details of the code to know that your change will be correct.
In some examples, the author recommends that some domain concepts are explained in the comments, in case the reader isn’t familiar with them. However, for well-known domain concepts, the reader will almost certainly already be familiar with those concepts. Even if they are not, the comments aren’t the right place to explain them.
In a lot of places, the author uses comments and documentation interchangeably. However, in my opinion, the documentation of a system is not the sum of the comments. It is a separate document that describes how the system fits together. Extracting the JavaDoc comments from all the classes does not become the documentation. Nothing highlights which classes are most important, and what the overall structure is. It is just a big collection of class comments.
There are some good parts in the comments chapters though. I agree with the notion that if there are tricky aspects of the code that are not obvious from reading it, then you should write a comment. This is the main thesis in my blog post On Comments in Code. There are also two good examples on pages 118 and 119 on how to write comments for cross-module design decisions.
There are also a few weaker chapters towards the end of the book, for example Code Should Be Obvious and Software Trends. Mostly they don’t say enough on the subject. The chapter Choosing Names isn’t bad, but it too is too short. Code Complete has a lot more to say about names, and does it better. However, I really liked the story of how a too generic variable name (block) caused a bug that took six months to find.
It would also have been interesting if the author had included something about testing and how to write code that is easy to test.
A Philosophy of Software Design is a well-written book with many good and practical ideas on how to reduce complexity to make systems easier to understand and work with. There are good examples illustrating the various techniques, and the writing is clear and concise. Even if you don’t agree with everything it is still a good addition to your library of programming books.