📅 February 4, 2022

Book Review | A Philosophy of Software Design

A Philosophy of Software Design had many bits of information that were worth highlighting. There wasn't a lot of fluff which made for an easy read. You could pick this book up and start from any chapter. The overarching theme was around complexity, how it emerges, how to prevent it, and how to eliminate it.

My favorite highlights from the book are below.

The most fundamental problem in computer science is problem decomposition: how to take a complex problem and divide it up into pieces that can be solved independently.

The greatest limitation in writing software is our ability to understand the systems we are creating.

Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.

Complexity is more apparent to readers than writers. If you write a piece of code and it seems simple to you, but other people think it is complex, then it is complex. When you find yourself in situations like this, it’s worth probing the other developers to find out why the code seems complex to them; there are probably some interesting lessons to learn from the disconnect between your opinion and theirs. Your job as a developer is not just to create code that you can work with easily, but to create code that others can also work with easily.

One of the most important goals of good design is for a system to be obvious.

Complexity is caused by two things: dependencies and obscurity.

A dependency exists when a given piece of code cannot be understood and modified in isolation; the code relates in some way to other code, and the other code must be considered and/or modified if the given code is changed.

The signature of a method creates a dependency between the implementation of that method and the code that invokes it.

If we can find design techniques that minimize dependencies and obscurity, then we can reduce the complexity of software.

An abstraction is a simplified view of an entity, which omits unimportant details.

The best modules are deep: they have a lot of functionality hidden behind a simple interface. A deep module is a good abstraction because only a small fraction of its internal complexity is visible to its users.

A shallow module is one whose interface is complicated relative to the functionality it provides. Shallow modules don’t help much in the battle against complexity, because the benefit they provide (not having to learn about how they work internally) is negated by the cost of learning and using their interfaces. Small modules tend to be shallow.

The most important issue in designing classes and other modules is to make them deep, so that they have simple interfaces for the common use cases, yet still provide significant functionality.

Whenever possible, classes should “do the right thing” without being explicitly asked. Defaults are an example of this.

Try to design the private methods within a class so that each method encapsulates some information or capability and hides it from the rest of the class.

It is more important for a module to have a simple interface than a simple implementation.

Avoid configuration parameters as much as possible. Before exporting a configuration parameter, ask yourself: “will users (or higher-level modules) be able to determine a better value than we can determine here?” When you do create configuration parameters, see if you can compute reasonable defaults automatically.

You shouldn’t break up a method unless it makes the overall system simpler.

When designing methods, the most important goal is to provide clean and simple abstractions. Each method should do one thing and do it completely.

The decision to split or join modules should be based on complexity. Pick the structure that results in the best information hiding, the fewest dependencies, and the deepest interfaces.

The best way to eliminate exception handling complexity is to define your APIs so that there are no exceptions to handle: define errors out of existence.

If users must read the code of a method in order to use it, then there is no abstraction: all of the complexity of the method is exposed.

The overall idea behind comments is to capture information that was in the mind of the designer but couldn’t be represented in the code.

Developers should be able to understand the abstraction provided by a module without reading any code other than its externally visible declarations.

Comments can fill in missing details such as: If a null value is permitted, what does it imply?

When following the rule that comments should describe things that aren’t obvious from the code, “obvious” is from the perspective of someone reading your code for the first time (not you).

You shouldn’t settle for names that are just “reasonably close”. Take a bit of extra time to choose great names, which are precise, unambiguous, and intuitive.

If it’s hard to find a simple name for a variable or method that creates a clear image of the underlying object, that’s a hint that the underlying object may not have a clean design.

When writing a commit message, ask yourself whether developers will need to use that information in the future. If so, then document this information in the code. This illustrates the principle of placing documentation in the place where developers are most likely to see it; the commit log is rarely that place.

If code is obvious, it means that someone can read the code quickly, without much thought, and their first guesses about the behavior or meaning of the code will be correct.

The best way to determine the obviousness of code is through code reviews. If someone reading your code says it’s not obvious, then it’s not obvious, no matter how clear it may seem to you.

It isn’t possible to visualize a complex system well enough at the outset of a project to determine the best design. The best way to end up with a good design is to develop a system in increments, where each increment adds a few new abstractions and refactors existing abstractions based on experience. This is similar to the agile development approach.

One place where it makes sense to write the tests first is when fixing bugs. Before fixing a bug, write a unit test that fails because of the bug. Then fix the bug and make sure that the unit test now passes.

Whenever you encounter a proposal for a new software development paradigm, challenge it from the standpoint of complexity: does the proposal really help to minimize complexity in large software systems?

Complicated code tends to be slow because it does extraneous or redundant work. On the other hand, if you write clean, simple code, your system will probably be fast enough that you don’t have to worry much about performance in the first place. In the few cases where you do need to optimize performance, the key is simplicity again: find the critical paths that are most important for performance and make them as simple as possible.

The reward for being a good designer is that you get to spend a larger fraction of your time in the design phase, which is fun. Poor designers spend most of their time chasing bugs in complicated and brittle code. If you improve your design skills, not only will you produce higher quality software more quickly, but the software development process will be more enjoyable.

# development | book-review