A philosophy of software design - John Ousterhout

I read A Philosophy of Software Design about 18 months back. It's a well-structured, concise read about managing complexity in software design. I don't think the suggested approaches are applicable in all situations (and John Ousterhout says this himself IIRC), but I recognised a lot of the problems described in the book and found it provided some useful ways to articulate concepts during code reviews (eg. whether adding a shallow function is increasing complexity in a codebase, if complexity can be pulled down into an implementation, or where it's useful to have consistency in the code).

Below are the notes I made on takeaways from the book and my thoughts on a couple of the ideas (minus some fun references to real code that I've worked on). I'm publishing the notes as it's a nice way for me to re-read them and retain the information. This doesn't cover all the content in the book, and it's possible that I misrepresent the author in some of my paraphrasing. If you're interested in the content I definitely recommend buying a copy - it's not expensive and it's an easy read.

If anyone connected to the author thinks this is sharing too much detail then I'm happy to take it down.

1. Summary

When building software systems, the core challenge is managing complexity. Complexity makes it more difficult for a programmer to understand and change software, it increases the rate of errors, it slows development velocity, and has other negative affects.

Software design is one of the key tools for managing complexity. Ousterhout discusses the different types and causes of complexity, and then various software design considerations and their relationship to complexity - patterns, antipatterns, questions to ask, etc.

2. The nature of complexity

Ultimately, complexity makes it more expensive to modify a program: changes are more difficult, take longer, and are more likely to introduce errors to a program.

Ousterhout identifies three general ways that complexity manifests itself:

  1. Change amplification: where a seemingly simple change requires code modifications in many different places.
  2. Cognitive load: where a developer needs to know a large number of things in order to complete a task.
  3. Unknown unknowns: where it's unclear what to do, or whether a proposed solution will even work.

The overall complexity of a system can be determined by the complexity of each part, weighted by the fraction of time developers spend working on that part. If you isolate complexity in a place where it will never be seen, then that's almost as good as eliminating it entirely.

3. Causes of complexity

Ousterhout recognises two main causes of complexity.

  1. Dependencies between software components, which can lead to change amplification and a high cognitive load. Dependencies are a fundamental part of software and can't be eliminated, but one of the goals of software design is to eliminate dependencies where possible, and to make the dependencies that remain as simple and obvious as possible.
  2. Obscurity - when important information is not obvious. This creates unknown unknowns, and also contributes to cognitive load.

4. Tactical vs strategic programming

Ousterhout advocates for a strategic approach to software development, rather than a wholly tactical approach. This essentially just means ongoing, regular investment of some of your development time towards system design, rather than just working code.

One pitfall is that complexity in software development is incremental. A single shortcut or tactical decision that adds complexity won't have much impact, but small decisions can accumulate to dozens or hundreds of things that do have an impact. Then refactoring becomes a big task that you can't easily schedule with the business, so you look for quick patches, and this creates yet more complexity, which requires more patches, and so forth.

Once a codebase gets complex enough, it is nearly impossible to fix, and you will probably pay high development costs for the rest of its life.

Agile does not encourage strategic programming

"Agile" and similar approaches to software development tend to be focused on small, tactical changes. It's easy in this environment to forget about investing in the codebase, especially in startup companies that have a lot of pressure to deliver features.

5. Write code for the reader, not the writer

If someone says your code is not obvious, then it isn't. This is stated a few times through the book.

6. Deep modules are less complex than shallow modules

This is one of the core themes in the book. A software system is usually decomposed into a collection of modules that are relatively independent. Modules inevitably have dependencies between them - they work together by calling each other, and therefore must know about each other. In order to manage these dependencies, we think about a module in two parts: an interface (what the module does), and an implementation (how it does it).

The idea of abstraction is closely related to modules. An abstraction is a simplified view of an entity which omits unimportant details, making it easier for us to think about and manipulate complex things.

Ousterhout argues that the best modules are those that provide powerful functionality, but have a simple interface. He describes these as deep modules, in contrast to shallow modules, which have a complex interface but not much functionality, thereby not hiding significant complexity.

The file I/O interface provided by Unix is a good example of a deep interface - the API only has a few system calls (open, read, write, seek, close), but hides a huge amount of complexity around implementation of files, directories, permissions, concurrent access, etc.

Programmers are often encouraged to write small modules

Ousterhout says that the conventional wisdom is to write small components (keeping the LoC low in each method) rather than deep components, but this results in large numbers of shallow classes and methods, which add to overall system complexity.

I have seen this myself, and also contributed to this problem. People often break a routine into multiple functions for the purpose of making it "easier" to read, or to avoid some code duplication, or to lower a cyclomatic complexity score. This kind of decomposition has a different purpose to designing public interfaces, but sometimes get added to the public API of a class or module.

7. General-purpose modules are deeper

This doesn't mean "generalised" implementations that support extra features that you don't need - it means writing methods that are not overly specialised. The sweet spot is a somewhat general-purpose approach, which hopefully provides a simpler and deeper interface.

"If you reduce the number of methods in an API without reducing its overall capabilities, then you are probably creating more general-purpose methods."

8. Pass-through variables add complexity

Pass-through variables add complexity because they force intermediate methods to be aware of their existence, even though the methods have no use for the variables.

Eliminating this anti-pattern can be very difficult. The two main approaches are to see if you can add the pass-through state onto an object that is already shared between the top and bottom methods, or to make it a global variable. These can have their own problems.

Ousterhout's most often-used approach to this problem is to introduce a context object, which stores the application's global state - anything that would otherwise be a pass-through or global variable. They are not an ideal solution because they have a lot of the disadvantages of global variables, but they can reduce the complexity of a method's signature.

9. Pass-through methods are shallow and add complexity

A pass-through method is one that does little except invoke another method with a similar signature. This increases the interface complexity of a module, without increasing the total functionality of the system. It can indicate that there is confusion over the division of responsibility between modules or classes.

This is not always bad - the important thing is that each new method should contribute significant functionality. For example, a dispatcher method is a pass-through method that can be very useful.

Decorators are often shallow pass-through methods that add a lot of boilerplate without adding much new functionality.

10. Pull complexity downwards

It's more important for a module to have a simple interface than a simple implementation. If you have complexity that is closely related to your module's functionality, you should consider pulling that complexity into the module's implementation.

One example Ousterhout provides is configuration parameters, which are an example of moving complexity upwards rather than down. Consider a network protocol that has to deal with lost packets: one way to determine an appropriate retry interval is to introduce a configuration parameter to provide control to the user. However, it may be preferable to compute a reasonable retry value automatically during runtime (by eg. measuring the response time). This approach pulls system complexity downwards.

11. Define errors out of existence

"Exception handling is one of the worst sources of complexity in software systems". They can leak abstraction details upwards, making for a more shallow abstraction. Programmers are often taught that they need to handle exceptional cases, leading for an over-defensive programming style.

There are two main ways to handle exceptions, each with their own complexity. The first is to complete the work in spite of the exception, and the second is to report the exception upwards (perhaps also running some unwinding / handling logic).

Ousterhout argues in favour of defining errors out of existence, by automatically handling certain cases. He contrasts the behaviour of deleting a file on Windows vs Unix. On Windows, if a process is using the file to be deleted, an error is raised. In the Unix implementation, if another process is using the file, the file is internally marked for deletion and the operation returns successfully. Unix actually deletes the file when the other process has finished using it. Errors are avoided in both the process that initiated the deletion, and the other process that was using the file. Another common example is when accessing a substring by index: you could raise an out of bounds error, or you could just return the whole string.

Where you must handle exceptions, you should prefer to handle many exceptions with a single piece of code.

12. A thoughtful approach to code comments

Ousterhout shares a few opinions on comments:

  • A programming language can't capture all of the important information that was in the mind of the developer when the code was written. Comments should be used to describe things that aren't obvious from the code.
  • "Developers should be able to understand the abstraction provided by a module without reading any code other than its externally visible declarations." If you want code that presents good abstractions, you must document those abstractions with comments.
  • Write comments early, because they can help to improve the system design. Comments are likely to be low-quality if you write them as a token gesture at the end of a piece of work.
  • Comments can indicate complexity: if a method or variable describes a long comment, it is a red flag that you don't have a good abstraction.
  • Comments should not repeat the code. Could somebody who has never seen the code write your comment just by looking at the code? If so, the comment is worthless.
  • Comments should be written at a different level of detail to the code - either higher (eg. concerned with top-level behaviour), or lower (concerned with specific details that are not obvious by reading the code). Comments are easier to maintain if they are higher-level and more abstract than the code. This is because they're less likely to be affected by minor changes in the code.
  • Comments should occur close to the code that they describe. This increases the ease of reading and updating them. It's more important that comments are in the code, than in the commit log. This is because it's significantly easier to find the comments if they're close to the code - finding the right log message is difficult.
  • Try to document each design decision exactly once. If information is already documented outside your program, don't repeat the documentation inside the program, just reference the external documentation.

Refuting arguments against comments

  • "Good code is self documenting": to this, Ousterhout argues that there are many things that can't be described in the code, such as the rationale for a particular design decision, or the conditions under which it makes sense to call a particular method. More importantly, if there are no comments accompanying the interface of a method, then there is no abstraction: you must read the method's code, and all of its complexity is exposed.
  • "I don't have time": good comments shouldn't add more than 10% to your development time. The benefits of having good documentation should quickly offset this cost.
  • "Comments become out of date and misleading": keeping comments up to date is not an enormous effort - it should be flagged in code reviews. Large changes to documentation should only be required if there are large changes to the codebase. You do have to take some care to structure comments to be useful though.
  • "All the comments I have seen are worthless": Ousterhout agrees that most documentation is "so-so at best".

Different types of comments

Ousterhout identifies some different types of comments:

  1. Interface: a comment block that goes with the declaration of a class, data structure, function, or method. They describe the thing's interface - the overall behaviour, arguments and return values, any side effects or exceptions, and other requirements that the caller must satisfy. They can also fill in missing details: the units for a variable, whether null values are permitted, whether boundary conditions are inclusive or exclusive, etc.
  2. Data structure member: a comment next to the declaration of a field, describing what it represents.
  3. Implementation: a comment describe how a piece of code works.
  4. Cross-module: a comment describing dependencies that cross module boundaries.

It's important not to mix up these purposes. Comments describing the interface of a component should not have to share any implementation details: this is information leakage, and it adds complexity for the reader. If interface comments must also describe the implementation, then the class or method is shallow.

Comments and programming fluency

My own thought on this is that in order to write and work with high-quality comments, you need to be at a certain level of fluency. When I started programming, I had to use comments liberally to describe to myself what certain lines of code meant, because it was significantly faster for me to understand an English description than to read a block of 4-5 lines of code.

I suspect that some of the "bad" comments where the comment just duplicates the information in the code fall under this category: for some people, those comments are helpful, but for others who are more fluent in the programming language, they're less useful. In a professional environment there should ideally be a base level of understanding and verbose comments that duplicate what the code is doing should not be required often.

13. Naming is important

Good names are a form of documentation, and another place where all programmers have seen incremental complexity: a single bad name does not hurt a program, but a program full of poorly-named components can be very difficult to use.

Names should be precise (you usually want to avoid overly-general names like data), and consistent - if a name is used in multiple places it should refer to the same thing. If the same name is used to refer to different concepts, then at some point a reader will be confused, which is likely to introduce errors to the program.

14. Whitespace can help break up code into logical blocks

One way to make code more obvious to the reader is to have blank lines between parts that are logically separate, and maybe to preface the code block with an implementation comment.

15. TDD focuses on features rather than abstractions and design

"The problem with test-driven development is that it focuses on getting specific features working, rather than finding the best design." Ousterhout argues that the unit of development should be abstractions rather than features, and that once you discover the need for an abstraction, you should design it all at once.

He says that TDD does make sense when fixing bugs (which aligns with my experience).

16. Consistency is important

Consistency minimises complexity because it provides cognitive leverage: you learn how something is done in one place, and you can immediately understand other places that use the same approach. If a system is not implemented consistently, then developers must learn about each situation separately, which takes more time.

Consistency also reduces mistake: if a system is not consistent, two situations that appear the same may actually be different.

Consistency can be considered in things like:

  • Naming.
  • Coding style.
  • Interfaces.
  • Design patterns.
  • Invariants - cases that are always true. For example, a data structure storing lines of text might enforce an invariant that each line is terminated by a newline character. The programmer can then always assume this to be true.

There are things you can do to ensure consistency:

  • Write conventions down.
  • Enforce conventions, ideally automatically through tools.
  • "When in Rome…" follow existing conventions. Having a better idea is not a sufficient excuse to introduce inconsistencies. Is your new approach so much better that it's worth taking the time to update all of the old uses?

17. Implementation inheritance increases complexity

Implementation inheritance creates dependencies between the parent class and each of its subclasses, results in information leakage between the parent and child classes, and makes it hard to modify one class in the hierarchy without looking at the other.

Ousterhout suggests composition can be a less-complex alternative to inheritance.

The first example that jumps to mind personally is Django - the class hierarchy always seemed to add more complexity than it solved in a lot of cases. In general I think it's very easy for the complexity of inheritance to outweigh the utility.

18. Event-driven programming makes code less obvious

Event-driven programming makes it hard to follow the flow of control. This is because handler functions aren't invoked directly - it depends on which handlers were registered at runtime.

This doesn't mean that there is no use-case for event-driven programming, but care should be taken to mitigate this complexity.

19. Design it twice

You'll end up with a much better result if you consider multiple options for each major design decision. There isn't much to say about this, other than that I intuitively agree.

2021-Apr-07