Philosophy of Software Design - by John K. Ousterhout
ISBN: 173210221XDate read: 2024-06-10
How strongly I recommend it: 8/10
(See my list of 360+ books, for more.)
Go to the Amazon page for details and reviews.
Programming wisdom: modular design, keep APIs simple, how to avoid complexity, etc. Great insights from experience.
my notes
The most fundamental problem is problem decomposition: how to take a complex problem and divide it up into pieces that can be solved independently.
Writing computer software is one of the purest creative activities.
All programming requires is a creative mind and the ability to organize your thoughts.
The greatest limitation is our ability to understand the systems we are creating.
As a program evolves and acquires more features, it becomes complicated, with subtle dependencies between its components.
Over time, complexity accumulates, and it becomes harder and harder for programmers to keep all of the relevant factors in their minds as they modify the system.
Find ways to make software simpler and more obvious.
Eliminate special cases.
Use identifiers in a consistent fashion.
Modular design:
Encapsulate, so that you can work on a system without being exposed to all of its complexity at once.
Incremental development means that software design is never done.
Design happens continuously over the life of a system.
Always be thinking about design issues.
Continuously redesign.
The initial design for a system or component is almost never the best one.
Experience shows better ways to do things.
Always be thinking about complexity.
Recognize red flags: signs that a piece of code is probably more complicated than it needs to be.
Look for an alternate design that eliminates the problem.
The more alternatives you try before fixing the problem, the more you will learn.
Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.
For example, it might be hard to understand how a piece of code works.
It might take a lot of effort to implement a small improvement.
It might not be clear which parts of the system must be modified to make the improvement.
If hard to understand and modify, it is complicated.
If easy to understand and modify, it is simple.
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.
Symptoms of complexity:
A seemingly simple change requires code modifications in many different places.
Cognitive load: how much a developer needs to know.
APIs with many methods, global variables, inconsistencies, and dependencies between modules.
Sometimes an approach that requires more lines of code is actually simpler, because it reduces cognitive load.
It’s obvious which pieces of code must be modified to complete a task, or what information a developer must have.
Of the three manifestations of complexity, unknown unknowns are the worst.
An unknown unknown means that there is something you need to know, but there is no way for you to find out what it is, or even whether there is an issue.
You won’t find out about it until bugs appear after you make a change.
Good design is for a system to be obvious.
You can quickly understand how the existing code works and what is required to make a change.
You can make a quick guess about what to do, without thinking very hard.
Complexity is caused by 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.
Dependencies can’t be completely eliminated.
Every time you write a new class you create dependencies around the API for that class.
Reduce the number of dependencies and to make the dependencies that remain as simple and obvious as possible.
Obscurity occurs when important information is not obvious.
The need for extensive documentation is often a red flag that the design isn’t quite right.
The best way to reduce obscurity is by simplifying the system design.
How systems become complicated:
You don’t spend much time looking for the best design.
You just want to get something working soon.
It’s short-sighted.
You tell yourself that it’s OK to add a bit of complexity or introduce a small kludge or two, if that allows the current task to be completed more quickly.
Take a little extra time to find a simple design for each new class.
Rather than implementing the first idea that comes to mind, try a couple of alternative designs and pick the cleanest one.
Imagine a few ways in which the system might need to be changed in the future and make sure that will be easy with your design.
Design systems so that developers only need to face a small fraction of the overall complexity at any given time.
A collection of modules that are relatively independent.
In an ideal world, each module would be completely independent of the others.
Unfortunately, this ideal is not achievable. Modules must work together by calling each others’s functions.
Modules must know something about each other.
Dependencies can be quite subtle: as one example, a method may not function correctly unless some other method has been invoked first.
Minimize the dependencies between modules.
Think of each module in two parts: an interface and an implementation.
The interface consists of everything that a developer working in a different module must know in order to use the given module.
A developer should not need to understand the implementations of other modules.
A module is any unit of code that has an interface and an implementation.
A simple interface minimizes the complexity.
The formal interface for a method includes
the names and types of its parameters,
the type of its return value,
information about exceptions thrown by the method.
The informal parts of an interface include its high-level behavior.
If a developer needs to know it, then that information is part of the module’s interface.
An abstraction is a simplified view of an entity, which omits unimportant details.
Abstractions are useful because they make it easier for us to think about and manipulate complex things.
The more unimportant details that are omitted from an abstraction, the better.
Understand what is important, and to look for designs that minimize the amount of information that is important.
The best modules are deep: they have a lot of functionality hidden behind a simple interface.
Cost versus benefit:
The benefit is its functionality.
The cost is its interface.
Interface = the complexity that the module imposes on the rest of the system.
Red Flag: Shallow Module
A shallow module is one whose interface is complicated relative to the functionality it provides.
Small classes result in a verbose programming style, due to the boilerplate required for each class.
Interfaces should be designed to make the common case as simple as possible.
The interface reflects a simpler, more abstract view of the module’s functionality.
Hide the details hidden within a module, so that it is irrelevant and invisible to users of the module.
Red Flag: Information Leakage
Information leakage occurs when the same knowledge is used in multiple places.
Information hiding can often be improved by making a class slightly larger.
Bring together all of the code related to a particular capability.
Rather than having separate methods for each of three steps of a computation, have a single method that performs the entire computation.
Avoid exposing internal data structures.
Make the common case as simple as possible.
In the normal case, the caller need not be aware of the existence of the defaulted item.
In the rare cases where a caller needs to override a default, it will have to know about the value, and it can invoke a special method to modify it.
Classes should “do the right thing” without being explicitly asked.
Information hiding can also be applied within a class.
Method encapsulates some information or capability and hides it from the rest of the class.
Specialization leads to complexity.
Simplify the code by eliminating special cases.
General-purpose code is simpler, cleaner, and easier to understand.
Separate special-purpose code from general-purpose code.
Implement a mechanism that can be used to address a broad range of problems, not just the ones that are important today.
Special cases can result in code that is riddled with if statements, which make the code hard to understand and are prone to bugs.
Thus, special cases should be eliminated wherever possible.
Design the normal case in a way that automatically handles the edge conditions without any extra code.
What is the simplest interface that will cover all my current needs?
Each layer provides a different abstraction from the layers above and below it.
Red Flag: Pass-Through Method
A pass-through method is one that does nothing except pass its arguments to another method.
An overlap in responsibility between the classes.
The solution is to refactor the classes so that each class has a distinct and coherent set of responsibilities.
One example where it’s useful for a method to call another method with the same signature is a dispatcher.
A dispatcher is a method that uses its arguments to select one of several other methods to invoke.
Then it passes most or all of its arguments to the chosen method.
A variable that is passed down through a long chain of methods:
Pass-through variables add complexity because they force all of the intermediate methods to be aware of their existence.
Global variables almost always create other problems.
The solution is to introduce a context object representing configuration options.
Pass it to the constructor for the new object.
The context is available everywhere, but it only appears as an explicit argument in constructors.
Most modules have more users than developers, so it is better for the developers to suffer than the users.
As a module developer, you should strive to make life as easy as possible for the users of your module, even if that means extra work for you.
It is more important for a module to have a simple interface than a simple implementation.
Bring pieces of code together if they are closely related.
They share information.
They are used together: anyone using one of the pieces of code is likely to use the other as well.
It is hard to understand one of the pieces of code without looking at the other.
Bring together if it will simplify the interface.
If the same piece of code (or code that is almost the same) appears over and over again, that’s a red flag that you haven’t found the right abstractions.
Developers tend to break up methods too much.
Splitting up a method introduces additional interfaces, which add to complexity.
It also separates the pieces of the original method, which makes the code harder to read if the pieces are actually related.
You shouldn’t break up a method unless it makes the overall system simpler.
If the blocks have complex interactions, it’s even more important to keep them together so readers can see all of the code at once.
Each method should do one thing and do it completely.
Provide clean abstractions.
Splitting up a method only makes sense if it results in cleaner abstractions.
Factoring out a subtask, parent invokes the child, makes sense if
(a) someone reading the child method doesn’t need to know anything about the parent method
(b) someone reading the parent method doesn’t need to understand the implementation of the child method.
Typically this means that the child method is relatively general-purpose: it could conceivably be used by other methods besides the parent.
If you find yourself flipping back and forth between the parent and child to understand how they work together, that is a red flag
If the original method had an overly complex interface because it tried to do multiple things that were not closely related.
This makes sense to break up a method.
The interface for each of the resulting methods should be simpler than the interface of the original method.
Most callers should only need to invoke one of the two new methods.
If callers must invoke both of the new methods, then that adds complexity.
For exceptions that can’t be defined away, mask them at a low level, so their impact is limited, or aggregate several special-case handlers into a single more generic handler.
Design it twice.
Consider multiple options for each major design decision.
It’s unlikely that your first thoughts will produce the best design.
Pick approaches that are radically different from each other; you’ll learn more that way.
Think about the weaknesses and contrast them with the features.
List of the pros and cons of each one.
The most important consideration for an interface is ease of use for higher level software.
Comments are fundamental to abstractions.
The goal is to hide complexity.
An abstraction is a simplified view of an entity, which preserves essential information but omits details that can safely be ignored.
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.
Without comments, the only abstraction of a method is its declaration, which specifies its name and the names and types of its arguments and results.
The declaration is missing too much essential information to provide a useful abstraction by itself.
Comments capture information that was in the mind of the designer but couldn’t be represented in the code.
Without documentation, future developers will have to rederive or guess at the developer’s original knowledge.
Comments should describe things that aren’t obvious from the code.
Comments augment the code by providing information at a different level of detail.
Implementation comments help readers understand what the code is doing.
Good names are a form of documentation:
If a variable or method name is broad enough to refer to many different things, then it is more likely to be misused.
Avoid extra words.
Words that don’t help to clarify the variable’s meaning just add clutter.
The greater the distance between a name’s declaration and its uses, the longer the name should be.
Write the comments first, as you write the code.
Modifying Existing Code:
Resist the temptation to make a quick fix.
Instead, think about whether the current system design is still the best one, in light of the desired change.
If not, refactor the system so that you end up with the best possible design.
If you’re not making the design better, you are probably making it worse.
The problem with test-driven development is that it focuses attention on getting specific features working, rather than finding the best design.
Things that matter should be emphasized and made more obvious.
Things that don’t matter should be hidden as much as possible.
Important things should appear in places where they are more likely to be seen, such as interface documentation, names, or parameters to heavily used methods.
Another way to emphasize is with repetition: key ideas appear over and over again.