"A Philosophy Of Software Design" Summary
This is a summary of the book "A Philosophy of Software Design" by John Ousterhout.
Key Takeaways
Always when writing code, try to reduce complexity
Working code isn't enough: Primary goal is to create a great design solving the problem -> This might need some upfront investment
Try to write deep modules (functionality of a module is much greater than its interface) instead of shallow modules (module with a complex interface compared to its functionality)
Abstraction and information hiding is your friend. If some complex functionality can be hidden within a module, try to do it
Try to create somewhat general-purpose classes and methods (interface is general-purpose, implementation special purpose for what your current usecase needs)
Each layer of a system should have another abstraction -> If you have a lot of pass-through methods, maybe your layering could be improved
It is more important for a module to have a simple interface than a simple implementation
Try to avoid exceptions and errors if possible, since exception handling is generally complex code
Write good comments, adding precision about implementation or describing intent to the reader
Choose good names for variables, classes and methods
Be consistent with implementations, code style, names, by using interfaces and enforce consistency whenever possible
1 Introduction
The greatest limitation in writing software is our ability to understand the systems we are creating
It is inevitable that complexity increases over time, but simple designs keep larger and more powerful systems manageable
Two approaches to reduce complexity:
- Making code simpler and more obvious
- Encapsulate complexity -> modular designs
Software design is a continuous process spanning the entire lifecycle
The initial design for a system or component is almost never the best one
1.1 How to Use This Book
Try to identify red flags and then work to fix them
2 The Nature of Complexity
It is simpler to tell whether a design is simple than it is to create a simple design
But once one can recognize that a design is too complicated, a different approach can be chosen to see if it leads to a simpler design
Certain techniques tend to result in simpler designs, others correlate with complexity
2.1 Complexity Defined
"Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system"
Complexity can take different forms:
- System is hard to understand
- It takes a lot of effort to implement an improvement
- Fixing a bug might be difficult without introducing another
A large system with sophisticated features is not complex if it is easy to work on it (for the purposes of this book)
Overall complexity is determined by the complexity of each part (c_p) weighted by the fraction of time developers work on that part (t_p):
C = sum(c_p * t_p)
-> if only a small part of the system is complex but never touched, it might not be that bad
Complexity is more apparent to readers than writers -> if somebody finds your easy code complex, it's complex
2.2 Symptoms of Complexity
Change Amplification
A small change requires code modifications in many different places
Goal is to reduce the amount of code affected by each design decision
Cognitive Load
Refers to how much a developer needs to know in order to complete a task
Higher cognitive load means more time required to learn required information and more risk for bugs
Complexity can not be measured by lines of code -> Approach that needs more lines of code could be simpler by reducing cognitive load
Unknown Unknowns
It is not clear what pieces must be modified to complete a task
Unknown unknowns are the worst manifestation of complexity: There is something you need to know but no way to find out what it is or if it is an issue
Problems often only arise after the change
2.3 Causes of Complexity
Complexity is caused by two things: Dependencies and obscurity
A dependency exists when a piece of code can not be understood or modified in isolation
Dependencies can't be eliminated but should remain as simple and obvious as posible
Obscurity occurs when important information is not obvious (e.g. unclear variable names)
Obscurity is often a problem of insufficient documentation, but the best way to reduce obscurity is by simplifying the system design
2.4 Complexity is Incremental
Complexity is not caused by a single catastrophic error, but is an accumulation of dependencies and obscurities over time
It's easy to convince yourself that a little bit of complexity introduced by your small change isn't a big deal
Incremental nature of complexity makes it hard to control -> once accumulated it's hard to eliminate
3 Working Code Isn't Enough
3.1 Tactical Programming
Main focus is to get something working
Tactical programming is short-sighted -> Finish before a deadline and don't spend time looking for the best design
Complexity accumulates -> Refactoring helps but takes time -> Quick patches make it worse
"Tactical tornado": Developer who pumps out code far faster than others but works in a totally tactical fashion -> Mess often needs to be cleaned up by someone else
3.2 Strategic Programming
Working code isn't enough
The most important thing is the long-term structure of the system
Primary goal is to produce a great design which also happens to work
Strategic programming requires time investment:
- Proactive investment: Try a couple of approaches and use the simplest one, take extra time to find a simple design for each class
- Reactive investment: Fix discovered design problems when they are discovered
3.3 How Much to Invest?
Spend about 10-20% of your total development time on investments
The initial time investment will be recovered easily by timesavings later due to better design
3.4 Startups and Investment
Mindset in startups is often tactical -> Big pressure to get releases out quickly
However the payoff for bad design comes quicly and one might pay high dev costs for the whole life of the product
4 Modules Should Be Deep
4.1 Modular Design
In an ideal world: Every Module is completely independent of others -> But modules need to call each others functions -> dependencies
Think of each module in two parts:
- Interface: Describes what the module does (but not how it does it)
- Implementation: Code that carries out the promises made by the interface
A developer should not need to understand the implementations of other modules
The best modules have an interface much simpler than their implementation
4.2 What's In An Interface
Interface contains formal and informal information:
- Formal: For a method: number of arguments, type of arguments. Can often by checked for correctness by the programming language
- Informal: High-level behavior (a function deletes a file defined by one of the arguments), other constraints for usage of a module (other method has to be called before calling this method)
Informal information can not be checked by the compiler and relies on comments
For most interfaces, the informal aspects are larger and more complex
4.3 Abstractions
An abstraction is a simplified view of an entity, which omits unimportant details
Interface is an abstraction for a module -> It hides the implementation for a user of the module
Abstraction should not abstract too much or too little -> An abstraction that omits important details is a false abstraction
4.4 Deep Modules
Deep Module: Module with powerful functionality and a simple interface
Example for a deep module: Garbage collector -> Huge complexity with now interface at all
4.5 Shallow Modules
Shallow Module: Module whose interface is complex in comparison to its functionality
🚩 Red Flag: Shallow Module: Doesn't help much in the battle against complexity -> Their benefit is negated by the cost of learning and using their interfaces
4.6 Classitis
Value of deep classes is not widely appreciated today -> Conventional wisdom is that classes should be small, not deep
Classitis: Developers encouraged to to minimize the amount of functionality of each class
Small classes don't contribute much functionality -> There have to be a lot of them -> All these interfaces accumulate to a big complexity
4.7 Examples
Java Example:
To open a file in order to read serialized objects from it -> Three objects must be created
FileInputStream fileStream = new FileInputStream(fileName);
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);
- fileStream and bufferedStream are never used, all operations use objectStream
- Buffering must be requested explicitly (by using BufferedInputStream) -> slow if forgotten
- Better solution: buffer by default, provide different FileInputStream constructor which does not use buffering
4.8 Conclusion
By separating interface of a module from the implementation one can hide complexity
Deep modules maximize the amount of complexity that is concealed
5 Information Hiding (and Leakage)
5.1 Information Hiding
Each module should encapsulate a few pieces of knowledge
Information hiding reduces complexity in two ways:
- Simplifies the interface
- Information hiding makes evolution simpler -> hidden information is not accessed from outside of module
5.2 Information Leakage
Opposite of information hiding -> information is exposed ("leaked") via the interface
Leakage can happen outside of an interface: when two classes depend on a certain file format, both will have to be modified if the format changes, even when the format is not reflected in the interface
When you see information leakage, ask yourself: "How can I reorganize this so that this particular piece of knowledge only affects one class?"
- For small classes -> maybe merge them
- Pull the leaked information out into a separate class that encapsulates only this info
🚩 Information Leakage: Same knowledge is used in multiple places
5.3 Temporal Decomposition
In temporal decomposition, the structure of a system corresponds to the time order in which operations occur.
Example:
- Application that reads file, modifies file and writes new file version
- Temporal decomposition: three classes: ReadFile, ModifyFile, WriteFile
- File reading and file writing classes have knowledge about the file format -> leakage
- Solution: Core mechanisms of reading and writing go into one class
When designing modules, focus on knowledge required for each task, not on execution order
🚩 Temporal Decomposition: When classes are structured based on order, the same information might be used in multiple places (leakage)
Example: HTTPRequest object
- To get a parameter of the parsed HTTP request: public Map<> getParams()
- -> This exposes the complete map -> might even be modified by external code
- better: public String getParameter(string name)
- -> Deeper interface but better information hiding
🚩 Overexposure: Commonly used feature of API forces user to learn about other (rarely used) features -> Increases cognitive load on users
5.9 Taking It Too Far
If information is needed outside -> don't hide it!
Example: Performance of a module in different use cases is affected by configuration parameters -> expose them in the interface
5.10 Conclusion
Information hiding and deep modules are closely related
When decomposing a system into modules, try not to be influced by operation order but think about pieces of knowledge required to complete the different operations
6 General-Purpose Modules Are Deeper
General purpose: Solution that addresses a broad range of problems
- May find unanticipated uses in the future -> saves time
- Spend a bit more time up front to save time later on
- Maybe this additional functionality is never needed though
- Being to general purpose might result in the solution being bad for the actual usecase
Special purpose: Focus on the current use case and add functionality later
6.1 Make Classes Somewhat General-Purpose
Modules functionality reflets current needs, but the interface is more general-purpose
Example: Instead of deleteBackspace(Cursor), deleteDeletekey(Cursor), deleteSelection(Selection) use delete(PositionStart, PositionEnd)
6.5 Questions to Ask Yourself
What is the simplest interface that will cover all my current needs?
-> If you reduce the number of methods of your API without reducing its capabilities, you are probably creating more general-purpose methods (e.g. backspace, delete, deleteSelection -> delete)
In how many situations will this method be used?
-> One method for one particular use -> too special purpose!
Is this API easy to use for my current needs?
If the current API needs a lot of additional code to be used -> maybe to general-purpose
6.6 Conclusion
General-purpose interfaces have advantages over special-purpose ones
- Tend to be simpler
- Provide a clener separation between classes
7 Different Layer, Different Abstraction
In a well-designed system, each layer provides a different abstraction
7.1 Pass-Through Methods
Pass-through method: Method that does little except invoke another method
-> This is an indication that adjacent layers have similar abstractions
🚩 Pass-Through Method: Indicates that there is not a clean division of responsibility between the classes
When seeing a class with pass-through methods ask yourself: "Which features and abstractions is each of these classes responsible for?" -> overlap likely
Possible solutions:
- Expose lower level class directly to the caller of the higher level class
- Redistribute functionality between the classes
- Merge classes
7.2 When Is Interface Duplication OK?
Methods with the same signature are not always bad -> e.g. dispatcher
7.3 Decorators
Decorators also lead to API duplication across layers -> decorator takes existing object and extends its functionality while providing an similar or identical API
Decorators are easy to overuse, resulting in a lot of shallow classes
Alternatives:
- Add functionality directly to the underlying class (for general purpose or closely related functionality)
- Merge new functionality with existing decorator
- Implement as standalone class
7.5 Pass-Through Variables
Pass-through variable: Variable which is passed through a lot of methods (e.g. commandline argument describing a certificate required by low level method to open socket)
Changing or adding these variables requires a lot of work, try to avoid
To eliminate:
- Use a shared object between high and low level class / method
- Store information in global variable (might create other problems)
- Prefered solution: Introduce context object which stores application's global state (to prevent passing through context through methods -> inject reference to context via constructor)
-> context have however similar problems to global variables ("Where is this used?")
7.6 Conclusion
Every piece of design infrastructure (interface, argument, function, class) adds complexity
Therefore, every element must eliminate some more complexity than it creates
For different layers with the same abstraction there's a good chance that they don't add enough benefit to compensate for the additional infrastructure they represent
8 Pull Complexity Downwards
It is more important for a module to have a simple interface than a simple implementation
If possible, try to handle complexity related to the functionality of a module within the module
8.2 Example: Configuration Parameters
Rather than determining a behavior internally → export a few parameters which control a classes behavior and let the user specify them
-
- → Users are more familiar with their domains
-
- → Excuse to avoid dealing with important issues
Example: Network protocol that must deal with lost packets
- Retry interval could be a configuration parameter
- Or could be calculated based on a multiple of the response time for successful transmissions → this pulls complexity down
Avoid configuration parameters as much as possible (”will users be able to determine a better value than we can determine here?”)
If not avoidable, try to provide a good default value so that it only has to be set in exceptional conditions
8.3 Taking It Too Far
Only pull complexity down if:
- Complexity being pulled down is closely related
- Results in many simplifications elsewhere in the application
- Pulling down complexity simplifies the classes interface
8.4 Conclusion
Look for opportunities to take a little bit of extra suffering upon yourself in order to reduce suffering of your users
9 Better Together Or Better Apart
When deciding to combine or separate implementations, the goal is to reduce the complexity of the system as a whole
Dividing the system into a large number of small components might not be the best solution:
- Some complexity comes from the number of components itself
- Subdivision can result in additional code to manage components
- Subdivision creates separation (methods and up in different classes or even files)
- Subdivision can result in duplication
Related code is generally better together. Indicators that code is related:
- Share information
- Used together (when using one, the other must be used too)
- Hard to understand one piece of the code without the other
9.1 Bring Together if Information is Shared
If some specific information is needed in two methods / classes -> bring them together
🚩 Repetition: Repeted code indicates that you haven't found the right abstraction
9.9 Conclusion
Pick structure resulting in the best information hiding, fewest dependencies and deepest interfaces
10 Define Errors Out Of Existence
10.1 Why Exceptions Add Complexity
Exception handling code is inherently more difficult to write
Exception handling code may be larger than actual logic code
Exception handling code rarely gets executed and is harder to test -> bug prone
10.2 Too Many Exceptions
Over-defensive coding style can lead to unnecessary exceptions
Exceptions are inherently part of a classes interface -> they influence calling classes and methods
Throwing exceptions is easy -> handling them is hard
10.3 Define Errors Out Of Existence
Try to change method definition so that no exceptions are required.
Example: unset method which deletes a set variable
- Definition 1: unset deletes a variable, if it does not exist, exception is thrown
- Definition 2: unset ensures that a variable no longer exists
- 2 does not require an exception, 1 does
10.8 Just Crash?
For unrecoverable errors, it makes sense to crash the application
10.11 Conclusion
Special cases make code harder to understand (e.g. exceptions)
If possible, redefine semantics that eliminate error conditions
If elimination is not possible, try to mask exceptions at a low level to limit their impact
11 Design It Twice
Always consider multiple design solutions for a problem. If you are not happy with any of them, try to combine or come up with additional designs
12 Why Write Comments? The Four Excuses
12.1 "Good Code Is Self-Documenting"
Things as good variable names can reduce the need for comments -> but it doesn't replace it
Some information can not be represented in the code, only in a comment:
- Conditions required to call a certain method
- Design decisions
- Informal aspects of an interface such as a high-level description of the methods
Abstraction using methods only makes sense when the user does not have to read and understand the whole method -> comment can provide required information
12.2 "I Don't Have Time To Write Comments"
Benefits of having good documentation will quickly offset time invested
12.3 "Comments Get Out Of Date"
Changes to documentation are only required with changes of the code
Code reviews should help detecting stale comments
12.5 Benefits Of Well-Written Comments
Good comments capture information that was in the mind of the designer but couldn't be represented in the code -> having this information simplifies future changes
13 Comments Should 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
Comment Categories:
- Interface comment: Comment preceding a class or method describing its abstraction and behaviour, as well as side effects or exceptions
- Data structure member comment: Comment next to the declaration of a field in a data structure
- Implementation comment: Comment within the code describing how it works internally
- Cross-module comment: Comment describing dependencies that cross module boundaries
🚩 Comment that repeats code
13.3 Lower-Level Comments Add Precision
Used e.g. for commenting variable declarations:
- Units
- Inclusive or exclusive boundaries
- Invariants
13.4 Higher-Level Comments Enhance Intuition
Help the reader understand intent and structure of the code
13.5 Interface Documentation
Interface comments provide information that someone needs in order to use a class or method
14 Choosing Names
🚩 Vague Name: Variables with vague names are more likely to be misused
🚩 Hard to pick name: If it is hard to find a simple name for a variable or method, the design might be unclean
Be consistent with naming over the whole application
15 Write The Comments First
Writing comments can be part of the design process
Benefits:
- Produces better comments. Lets you focus on the abstractions of methods. During the coding and testingn you will notice and fix problems with the comments
- Produces better designs because you prevalidate your abstractions
- Makes comment writing more fun
🚩 Hard to describe: If it is difficult to write a simple and complete comment, there may be a problem with the design
16 Modifying Existing Code
Always think if the current system design is still the best one after that change
17 Consistency
Consistency creates cognitive leverage and reduces mistakes
17.1 Examples of Consistency
- Names
- Coding style -> Coding style guide within an organization
- Interfaces -> An interface enforces consistency over multiple implementations. When you understand one you can easily understand others
- Design patterns
17.2 Ensuring Consistency
Document rules and coding style guides and review regularly
Enfore rules (if possible automatically, otherwise in code reviews)
Don't change existing conventions. If conventions are updated, all old conventions in the code should be replaced -> make sure it's worth it!
18 Code Should Be Obvious
Code should always be easy to understand. It should be obvious what it does.
Your code is only obvious if it is obvious for someone else
18.1 Things That Make Code More Obvious
-> Good names (chapter 14)
-> Consistency (chapter 17)
🚩 Nonobvious code: If the meaning and behavior of code cannot be understood quickly -> change it
18.2 Things That Make Code Less Obvious
Event driven programming: Makes it hard to follow the flow of control. Event handlers are invoked indirectly by the event module
Generic containers: Tuples (without names) can be hard to immediately understand.
-> Software should be designed for ease of reading, not ease of writing!
Code that violates reader expectations: Unexpected behavior makes code hard to understand
18.3 Conclusion
To make code obvious:
- Reduce the amount of information that is needed
- Take advantage of information that readers already acquired in other contexts (by using conventions and adhering to expectations)
- Present important information to the reader via good names or comments
19 Software Trends
19.1 Object-Oriented Programming and Inheritance
Dependency Injection over Inheritance -> Interfaces are your friend, inheritance is not
19.2 Agile Development
Increments of development should be abstractions, not features
Take your time for clean design and make it somewhat general purpose
19.3 Unit Tests
Makes developers more confident to refactor and make structural improvements which improves system design
19.4 Test-Driven Development
Problem with TDD is that it focuses attention on getting features done instead of getting the best design
19.5 Design Patterns
Don't try to force a problem into a design pattern when a custom approach is cleaner
19.7 Conclusion
Always challenge new software development pardigms from the standpoint of complexity
20 Designing For Performance
Simplicity does not only improve a system's design, but it usually also makes systems faster
Complicated code tends to be slow because it does extraneous or redundant work
21 Conclusion
Avoid complexity