2018-John Ousterhout-A Philosophy of Software Design
2018-John Ousterhout-A Philosophy of Software Design
John Ousterhout
Stanford University
A Philosophy of Software Design
by John Ousterhout
Printing History:
April 2018: First Edition (v1.0)
November 2018: First Edition (v1.01)
ISBN 978-1-7321022-0-0
Preface
1 Introduction
1.1 How to use this book
11 Design it Twice
13 Comments Should Describe Things that Aren’t Obvious from the Code
13.1 Pick conventions
13.2 Don’t repeat the code
13.3 Lower-level comments add precision
13.4 Higher-level comments enhance intuition
13.5 Interface documentation
13.6 Implementation comments: what and why, not how
13.7 Cross-module design decisions
13.8 Conclusion
13.9 Answers to questions from Section 13.5
14 Choosing Names
14.1 Example: bad names cause bugs
14.2 Create an image
14.3 Names should be precise
14.4 Use names consistently
14.5 A different opinion: Go style guide
14.6 Conclusion
17 Consistency
17.1 Examples of consistency
17.2 Ensuring consistency
17.3 Taking it too far
17.4 Conclusion
19 Software Trends
19.1 Object-oriented programming and inheritance
19.2 Agile development
19.3 Unit tests
19.4 Test-driven development
19.5 Design patterns
19.6 Getters and setters
19.7 Conclusion
21 Conclusion
Index
People have been writing programs for electronic computers for more than 80
years, but there has been surprisingly little conversation about how to design
those programs or what good programs should look like. There has been
considerable discussion about software development processes such as agile
development and about development tools such as debuggers, version control
systems, and test coverage tools. There has also been extensive analysis of
programming techniques such as object-oriented programming and functional
programming, and of design patterns and algorithms. All of these discussions
have been valuable, but the core problem of software design is still largely
untouched. David Parnas’ classic paper “On the Criteria to be used in
Decomposing Systems into Modules” appeared in 1971, but the state of the art in
software design has not progressed much beyond that paper in the ensuing 45
years.
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. Problem decomposition is the central design task
that programmers face every day, and yet, other than the work described here, I
have not been able to identify a single class in any university where problem
decomposition is a central topic. We teach for loops and object-oriented
programming, but not software design.
In addition, there is a huge variation in quality and productivity among
programmers, but we have made little attempt to understand what makes the best
programmers so much better or to teach those skills in our classes. I have talked
with several people I consider to be great programmers, but most of them had
difficulty articulating specific techniques that give them their advantage. Many
people assume that software design skill is an innate talent that cannot be taught.
However, there is quite a bit of scientific evidence that outstanding performance
in many fields is related more to high-quality practice than innate ability (see, for
example, Talent is Overrated by Geoff Colvin).
For many years these issues have perplexed and frustrated me. I have
wondered whether software design can be taught, and I have hypothesized that
design skill is what separates great programmers from average ones. I finally
decided that the only way to answer these questions was to attempt to teach a
course on software design. The result is CS 190 at Stanford University. In this
class I put forth a set of principles of software design. Students then work
through a series of projects to assimilate and practice the principles. The class is
taught in a fashion similar to a traditional English writing class. In an English
class, students use an iterative process where they write a draft, get feedback, and
then rewrite to make improvements. In CS 190, students develop a substantial
piece of software from scratch. We then go through extensive code reviews to
identify design problems, and students revise their projects to fix the problems.
This allows students to see how their code can be improved by applying design
principles.
I have now taught the software design class three times, and this book is
based on the design principles that emerged from the class. The principles are
fairly high level and border on the philosophical (“Define errors out of
existence”), so it is hard for students to understand the ideas in the abstract.
Students learn best by writing code, making mistakes, and then seeing how their
mistakes and the subsequent fixes relate to the principles.
At this point you may well be wondering: what makes me think I know all
the answers about software design? To be honest, I don’t. There were no classes
on software design when I learned to program, and I never had a mentor to teach
me design principles. At the time I learned to program, code reviews were
virtually nonexistent. My ideas about software design come from personal
experience writing and reading code. Over my career I have written about
250,000 lines of code in a variety of languages. I’ve worked on teams that
created three operating systems from scratch, multiple file and storage systems,
infrastructure tools such as debuggers, build systems, and GUI toolkits, a
scripting language, and interactive editors for text, drawings, presentations, and
integrated circuits. Along the way I’ve experienced firsthand the problems of
large systems and experimented with various design techniques. In addition, I’ve
read a considerable amount of code written by other people, which has exposed
me to a variety of approaches, both good and bad.
Out of all of this experience, I’ve tried to extract common threads, both about
mistakes to avoid and techniques to use. This book is a reflection of my
experiences: every problem described here is one that I have experienced
personally, and every suggested technique is one that I have used successfully in
my own coding.
I don’t expect this book to be the final word on software design; I’m sure
there are valuable techniques that I’ve missed, and some of my suggestions may
turn out to be bad ideas in the long run. However, I hope that the book will start a
conversation about software design. Compare the ideas in this book with your
own experiences and decide for yourself whether the approaches described here
really do reduce software complexity. This book is an opinion piece, so some
readers will disagree with some of my suggestions. If you do disagree, try to
understand why. I’m interested in hearing about things that work for you, things
that don’t work, and any other ideas you may have about software design. I hope
that the ensuing conversations will improve our collective understanding of
software design. I will incorporate what I learn in future editions of this book.
The best way to communicate with me about the book is to send email to the
following address:
I’m interested in hearing specific feedback about the book, such as bugs or
suggestions for improvement, as well as general thoughts and experiences related
to software design. I’m particularly interested in compelling examples that I can
use in future editions of the book. The best examples illustrate an important
design principle and are simple enough to explain in a paragraph or two. If you
would like to see what other people are saying on the email address and
participate in discussions, you can join the Google Group software-design-book.
If for some reason the software-design-book Google Group should disappear
in the future, search on the Web for my home page; it will contain updated
instructions for how to communicate about the book. Please don’t send book-
related email to my personal email address.
I recommend that you take the suggestions in this book with a grain of salt.
The overall goal is to reduce complexity; this is more important than any
particular principle or idea you read here. If you try an idea from this book and
find that it doesn’t actually reduce complexity, then don’t feel obligated to keep
using it (but, do let me know about your experience; I’d like to get feedback on
what works and what doesn’t).
Many people have offered criticisms or made suggestions that improved the
Many people have offered criticisms or made suggestions that improved the
quality of the book. The following people offered helpful comments on various
drafts of the book: Jeff Dean, Sanjay Ghemawat, John Hartman, Brian
Kernighan, James Koppel, Amy Ousterhout, Kay Ousterhout, Rob Pike, Partha
Ranganathan, Keith Schwartz, and Alex Snaps. Christos Kozyrakis suggested the
terms “deep” and “shallow” for classes and interfaces, replacing previous terms
“thick” and “thin”, which were somewhat ambiguous. I am indebted to the
students in CS 190; the process of reading their code and discussing it with them
has helped to crystallize my thoughts about design.
Chapter 1
Introduction
(It’s All About Complexity)
Many of the design principles described here are somewhat abstract, so they may
Many of the design principles described here are somewhat abstract, so they may
be hard to appreciate without looking at actual code. It has been a challenge to
find examples that are small enough to include in the book, yet large enough to
illustrate problems with real systems (if you encounter good examples, please
send them to me). Thus, this book may not be sufficient by itself for you to learn
how to apply the principles.
The best way to use this book is in conjunction with code reviews. When you
read other people’s code, think about whether it conforms to the concepts
discussed here and how that relates to the complexity of the code. It’s easier to
see design problems in someone else’s code than your own. You can use the red
flags described here to identify problems and suggest improvements. Reviewing
code will also expose you to new design approaches and programming
techniques.
One of the best ways to improve your design skills is to learn to recognize red
flags: signs that a piece of code is probably more complicated than it needs to be.
Over the course of this book I will point out red flags that suggest problems
related to each major design issue; the most important ones are summarized at
the back of the book. You can then use these when you are coding: when you see
a red flag, stop and look for an alternate design that eliminates the problem.
When you first try this approach, you may have to try several design alternatives
before you find one that eliminates the red flag. Don’t give up easily: the more
alternatives you try before fixing the problem, the more you will learn. Over
time, you will find that your code has fewer and fewer red flags, and your designs
are cleaner and cleaner. Your experience will also show you other red flags that
you can use to identify design problems (I’d be happy to hear about these).
When applying the ideas from this book, it’s important to use moderation and
discretion. Every rule has its exceptions, and every principle has its limits. If you
take any design idea to its extreme, you will probably end up in a bad place.
Beautiful designs reflect a balance between competing ideas and approaches.
Several chapters have sections titled “Taking it too far,” which describe how to
recognize when you are overdoing a good thing.
Almost all of the examples in this book are in Java or C++, and much of the
discussion is in terms of designing classes in an object-oriented language.
However, the ideas apply in other domains as well. Almost all of the ideas related
to methods can also be applied to functions in a language without object-oriented
features, such as C. The design ideas also apply to modules other than classes,
such as subsystems or network services.
With this background, let’s discuss in more detail what causes complexity,
and how to make software systems simpler.
Chapter 2
This book is about how to design software systems to minimize their complexity.
The first step is to understand the enemy. Exactly what is “complexity”? How
can you tell if a system is unnecessarily complex? What causes systems to
become complex? This chapter will address those questions at a high level;
subsequent chapters will show you how to recognize complexity at a lower level,
in terms of specific structural features.
The ability to recognize complexity is a crucial design skill. It allows you to
identify problems before you invest a lot of effort in them, and it allows you to
make good choices among alternatives. It is easier to tell whether a design is
simple than it is to create a simple design, but once you can recognize that a
system is too complicated, you can use that ability to guide your design
philosophy towards simplicity. If a design appears complicated, try a different
approach and see if that is simpler. Over time, you will notice that certain
techniques tend to result in simpler designs, while others correlate with
complexity. This will allow you to produce simpler designs more quickly.
This chapter also lays out some basic assumptions that provide a foundation
for the rest of the book. Later chapters take the material of this chapter as given
and use it to justify a variety of refinements and conclusions.
2.5 Conclusion
Complexity comes from an accumulation of dependencies and obscurities. As
complexity increases, it leads to change amplification, a high cognitive load, and
unknown unknowns. As a result, it takes more code modifications to implement
each new feature. In addition, developers spend more time acquiring enough
information to make the change safely and, in the worst case, they can’t even find
all the information they need. The bottom line is that complexity makes it
difficult and risky to modify an existing code base.
Chapter 3
One of the most important elements of good software design is the mindset
you adopt when you approach a programming task. Many organizations
encourage a tactical mindset, focused on getting features working as quickly as
possible. However, if you want a good design, you must take a more strategic
approach where you invest time to produce clean designs and fix problems. This
chapter discusses why the strategic approach produces better designs and is
actually cheaper than the tactical approach over the long run.
Figure 3.1: At the beginning, a tactical approach to programming will make progress more quickly than a
strategic approach. However, complexity accumulates more rapidly under the tactical approach, which
reduces productivity. Over time, the strategic approach results in greater progress. Note: this figure is
intended only as a qualitative illustration; I am not aware of any empirical measurements of the precise
shapes of the curves.
Conversely, if you program tactically, you will finish your first projects 10–
20% faster, but over time your development speed will slow as complexity
accumulates. It won’t be long before you’re programming at least 10–20%
slower. You will quickly give back all of the time you saved at the beginning, and
for the rest of system’s lifetime you will be developing more slowly than if you
had taken the strategic approach. If you haven’t ever worked in a badly degraded
code base, talk to someone who has; they will tell you that poor code quality
slows development by at least 20%.
3.5 Conclusion
Good design doesn’t come for free. It has to be something you invest in
continually, so that small problems don’t accumulate into big ones. Fortunately,
good design eventually pays for itself, and sooner than you might think.
It’s crucial to be consistent in applying the strategic approach and to think of
investment as something to do today, not tomorrow. When you get in a crunch it
will be tempting to put off cleanups until after the crunch is over. However, this
is a slippery slope; after the current crunch there will almost certainly be another
one, and another after that. Once you start delaying design improvements, it’s
easy for the delays to become permanent and for your culture to slip into the
tactical approach. The longer you wait to address design problems, the bigger
they become; the solutions become more intimidating, which makes it easy to
put them off even more. The most effective approach is one where every
engineer makes continuous small investments in good design.
Chapter 4
4.3 Abstractions
The term abstraction is closely related to the idea of modular design. 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.
In modular programming, each module provides an abstraction in form of its
interface. The interface presents a simplified view of the module’s functionality;
the details of the implementation are unimportant from the standpoint of the
module’s abstraction, so they are omitted from the interface.
In the definition of abstraction, the word “unimportant” is crucial. The more
unimportant details that are omitted from an abstraction, the better. However, a
detail can only be omitted from an abstraction if it is unimportant. An abstraction
can go wrong in two ways. First, it can include details that are not really
important; when this happens, it makes the abstraction more complicated than
necessary, which increases the cognitive load on developers using the
abstraction. The second error is when an abstraction omits details that really are
important. This results in obscurity: developers looking only at the abstraction
will not have all the information they need to use the abstraction correctly. An
abstraction that omits important details is a false abstraction: it might appear
simple, but in reality it isn’t. The key to designing abstractions is to understand
what is important, and to look for designs that minimize the amount of
information that is important.
As an example, consider a file system. The abstraction provided by a file
system omits many details, such as the mechanism for choosing which blocks on
a storage device to use for the data in a given file. These details are unimportant
to users of the file system (as long as the system provides adequate performance).
However, some of the details of a file system’s implementation are important to
users. Most file systems cache data in main memory, and they may delay writing
new data to the storage device in order to improve performance. Some
applications, such as databases, need to know exactly when data is written
through to storage, so they can ensure that data will be preserved after system
crashes. Thus, the rules for flushing data to secondary storage must be visible in
the file system’s interface.
We depend on abstractions to manage complexity not just in programming,
but pervasively in our everyday lives. A microwave oven contains complex
electronics to convert alternating current into microwave radiation and distribute
that radiation throughout the cooking cavity. Fortunately, users see a much
simpler abstraction, consisting of a few buttons to control the timing and
intensity of the microwaves. Cars provide a simple abstraction that allows us to
drive them without understanding the mechanisms for electrical motors, battery
power management, anti-lock brakes, cruise control, and so on.
Module depth is a way of thinking about cost versus benefit. The benefit
provided by a module is its functionality. The cost of a module (in terms of
system complexity) is its interface. A module’s interface represents the
complexity that the module imposes on the rest of the system: the smaller and
simpler the interface, the less complexity that it introduces. The best modules are
those with the greatest benefit and the least cost. Interfaces are good, but more,
or larger, interfaces are not necessarily better!
The mechanism for file I/O provided by the Unix operating system and its
descendants, such as Linux, is a beautiful example of a deep interface. There are
only five basic system calls for I/O, with simple signatures:
int open(const char* path, int flags, mode_t permissions);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t lseek(int fd, off_t offset, int referencePosition);
int close(int fd);
The open system call takes a hierarchical file name such as /a/b/c and returns an
integer file descriptor, which is used to reference the open file. The other
arguments for open provide optional information such as whether the file is being
opened for reading or writing, whether a new file should be created if there is no
existing file, and access permissions for the file, if a new file is created. The read
and write system calls transfer information between buffer areas in the
application’s memory and the file; close ends the access to the file. Most files
are accessed sequentially, so that is the default; however, random access can be
achieved by invoking the lseek system call to change the current access position.
4.6 Classitis
Unfortunately, the value of deep classes is not widely appreciated today. The
conventional wisdom in programming is that classes should be small, not deep.
Students are often taught that the most important thing in class design is to break
up larger classes into smaller ones. The same advice is often given about
methods: “Any method longer than N lines should be divided into multiple
methods” (N can be as low as 10). This approach results in large numbers of
shallow classes and methods, which add to overall system complexity.
The extreme of the “classes should be small” approach is a syndrome I call
classitis, which stems from the mistaken view that “classes are good, so more
classes are better.” In systems suffering from classitis, developers are encouraged
to minimize the amount of functionality in each new class: if you want more
functionality, introduce more classes. Classitis may result in classes that are
individually simple, but it increases the complexity of the overall system. Small
classes don’t contribute much functionality, so there have to be a lot of them,
each with its own interface. These interfaces accumulate to create tremendous
complexity at the system level. Small classes also result in a verbose
programming style, due to the boilerplate required for each class.
4.8 Conclusion
By separating the interface of a module from its implementation, we can hide the
complexity of the implementation from the rest of the system. Users of a module
need only understand the abstraction provided by its interface. 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. This maximizes the amount of complexity that is
concealed.
1There exist languages, mostly in the research community, where the overall behavior of a method or
function can be described formally using a specification language. The specification can be checked
automatically to ensure that it matches the implementation. An interesting question is whether such a formal
specification could replace the informal parts of an interface. My current opinion is that an interface
described in English is likely to be more intuitive and understandable for developers than one written in a
formal specification language.
Chapter 5
Chapter 4 argued that modules should be deep. This chapter, and the next few
that follow, discuss techniques for creating deep modules.
Rather than returning a single parameter, the method returns a reference to the
Map used internally to store all of the parameters. This method is shallow, and it
exposes the internal representation used by the HTTPRequest class to store
parameters. Any change to that representation will result in a change to the
interface, which will require modifications to all callers. When implementations
are modified, the changes often involve changes in the representation of key data
structures (to improve performance, for example). Thus, it’s important to avoid
exposing internal data structures as much as possible. This approach also makes
more work for callers: a caller must first invoke getParams, then it must call
another method to retrieve a specific parameter from the Map. Finally, callers
must realize that they should not modify the Map returned by getParams, since
that will affect the internal state of the HTTPRequest.
Here is a better interface for retrieving parameter values:
public String getParameter(String name) { ... }
public int getIntParameter(String name) { ... }
getParameter returns a parameter value as a string. It provides a slightly deeper
interface than getParams above; more importantly, it hides the internal
representation of parameters. getIntParameter converts the value of a parameter
from its string form in the HTTP request to an integer (e.g., the photo_id
parameter in Figure 5.1). This saves the caller from having to request string-to-
integer conversion separately, and hides that mechanism from the caller.
Additional methods for other data types, such as getDoubleParameter, could be
defined if needed. (All of these methods will throw exceptions if the desired
parameter doesn’t exist, or if it can’t be converted to the requested type; the
exception declarations have been omitted in the code above).
5.10 Conclusion
Information hiding and deep modules are closely related. If a module hides a lot
of information, that tends to increase the amount of functionality provided by the
module while also reducing its interface. This makes the module deeper.
Conversely, if a module doesn’t hide much information, then either it doesn’t
have much functionality, or it has a complex interface; either way, the module is
shallow.
When decomposing a system into modules, try not to be influenced by the
order in which operations will occur at runtime; that will lead you down the path
of temporal decomposition, which will result in information leakage and shallow
modules. Instead, think about the different pieces of knowledge that are needed
to carry out the tasks of your application, and design each module to encapsulate
one or a few of those pieces of knowledge. This will produce a clean and simple
design with deep modules.
1David Parnas, “On the Criteria to be Used in Decomposing Systems into Modules,” Communications
of the ACM, December 1972.
Chapter 6
One of the most common decisions that you will face when designing a new
module is whether to implement it in a general-purpose or special-purpose
fashion. Some might argue that you should take a general-purpose approach, in
which you implement a mechanism that can be used to address a broad range of
problems, not just the ones that are important today. In this case, the new
mechanism may find unanticipated uses in the future, thereby saving time. The
general-purpose approach seems consistent with the investment mindset
discussed in Chapter 3, where you spend a bit more time up front to save time
later on.
On the other hand, we know that it’s hard to predict the future needs of a
software system, so a general-purpose solution might include facilities that are
never actually needed. Furthermore, if you implement something that is too
general-purpose, it might not do a good job of solving the particular problem you
have today. As a result, some might argue that it’s better to focus on today’s
needs, building just what you know you need, and specializing it for the way you
plan to use it today. If you take the special-purpose approach and discover
additional uses later, you can always refactor it to make it general-purpose. The
special-purpose approach seems consistent with an incremental approach to
software development.
Each of these methods takes the cursor position as its argument; a special type
Cursor represents this position. The editor also had to support a selection that
could be copied or deleted. The students handled this by defining a Selection
class and passing an object of this class to the text class during deletions:
void deleteSelection(Selection selection);
The students probably thought that it would be easier to implement the user
interface if the methods of the text class corresponded to the features visible to
users. In reality, however, this specialization provided little benefit for the user
interface code, and it created a high cognitive load for developers working on
either the user interface or the text class. The text class ended up with a large
number of shallow methods, each of which was only suitable for one user
interface operation. Many of the methods, such as delete, were only invoked in a
single place. As a result, a developer working on the user interface had to learn
about a large number of methods for the text class.
This approach created information leakage between the user interface and the
text class. Abstractions related to the user interface, such as the selection or the
backspace key, were reflected in the text class; this increased the cognitive load
for developers working on the text class. Each new user interface operation
required a new method to be defined in the text class, so a developer working on
the user interface was likely to end up working on the text class as well. One of
the goals in class design is to allow each class to be developed independently, but
the specialized approach tied the user interface and text classes together.
This method returns a new position that is a given number of characters away
from a given position. If the numChars argument is positive, the new position is
later in the file than position; if numChars is negative, the new position is before
position. The method automatically skips to the next or previous line when
necessary. With these methods, the delete key can be implemented with the
following code (assuming the cursor variable holds the current cursor position):
text.delete(cursor, text.changePosition(cursor, 1));
With the general-purpose text API, the code to implement user interface
functions such as delete and backspace is a bit longer than with the original
approach using a specialized text API. However, the new code is more obvious
than the old code. A developer working in the user interface module probably
cares about which characters are deleted by the backspace key. With the new
code, this is obvious. With the old code, the developer had to go to the text class
and read the documentation and/or code of the backspace method to verify the
behavior. Furthermore, the general-purpose approach has less code overall than
the specialized approach, since it replaces a large number of special-purpose
methods in the text class with a smaller number of general-purpose ones.
A text class implemented with the general-purpose interface could potentially
be used for other purposes besides an interactive editor. As one example, suppose
you were building an application that modified a specified file by replacing all
occurrences of a particular string with another string. Methods from the
specialized text class, such as backspace and delete, would have little value for
this application. However, the general-purpose text class would already have
most of the functionality needed for the new application. All that is missing is a
method to search for the next occurrence of a given string, such as this:
Position findNext(Position start, String string);
Of course, an interactive text editor is likely to have a mechanism for searching
and replacing, in which case the text class would already include this method.
6.6 Conclusion
General-purpose interfaces have many advantages over special-purpose ones.
They tend to be simpler, with fewer methods that are deeper. They also provide a
cleaner separation between classes, whereas special-purpose interfaces tend to
leak information between classes. Making your modules somewhat general-
purpose is one of the best ways to reduce overall system complexity.
Chapter 7
Software systems are composed in layers, where higher layers use the facilities
provided by lower layers. In a well-designed system, each layer provides a
different abstraction from the layers above and below it; if you follow a single
operation as it moves up and down through layers by invoking methods, the
abstractions change with each method call. For example:
In a file system, the uppermost layer implements a file abstraction. A file
consists of a variable-length array of bytes, which can be updated by
reading and writing variable-length byte ranges. The next lower layer in the
file system implements a cache in memory of fixed-size disk blocks; callers
can assume that frequently used blocks will stay in memory where they can
be accessed quickly. The lowest layer consists of device drivers, which move
blocks between secondary storage devices and memory.
In a network transport protocol such as TCP, the abstraction provided by the
topmost layer is a stream of bytes delivered reliably from one machine to
another. This level is built on a lower level that transmits packets of bounded
size between machines on a best-effort basis: most packets will be delivered
successfully, but some packets may be lost or delivered out of order.
If a system contains adjacent layers with similar abstractions, this is a red
flag that suggests a problem with the class decomposition. This chapter discusses
situations where this happens, the problems that result, and how to refactor to
eliminate the problems.
Figure 7.1: Pass-through methods. In (a), class C1 contains three pass-through methods, which do nothing
but invoke methods with the same signature in C2 (each symbol represents a particular method signature).
The pass-through methods can be eliminated by having C1’s callers invoke C2 directly as in (b), by
redistributing functionality between C1 and C2 to avoid calls between the classes as in (c), or by combining
the classes as in (d).
For example, when a Web server receives an incoming HTTP request from a
Web browser, it invokes a dispatcher that examines the URL in the incoming
request and selects a specific method to handle the request. Some URLs might be
handled by returning the contents of a file on disk; others might be handled by
invoking a procedure in a language such as PHP or JavaScript. The dispatch
process can be quite intricate, and is usually driven by a set of rules that are
matched against the incoming URL.
It is fine for several methods to have the same signature as long as each of
them provides useful and distinct functionality. The methods invoked by a
dispatcher have this property. Another example is interfaces with multiple
implementations, such as disk drivers in an operating system. Each driver
provides support for a different kind of disk, but they all have the same interface.
When several methods provide different implementations of the same interface,
it reduces cognitive load. Once you have worked with one of these methods, it’s
easier to work with the others, since you don’t need to learn a new interface.
Methods like this are usually in the same layer and they don’t invoke each other.
7.3 Decorators
The decorator design pattern (also known as a “wrapper”) is one that encourages
API duplication across layers. A decorator object takes an existing object and
extends its functionality; it provides an API similar or identical to the underlying
object, and its methods invoke the methods of the underlying object. In the Java
I/O example from Chapter 4, the BufferedInputStream class is a decorator: given
an InputStream object, it provides the same API but introduces buffering. For
example, when its read method is invoked to read a single character, it invokes
read on the underlying InputStream to read a much larger block, and saves the
extra characters to satisfy future read calls. Another example occurs in
windowing systems: a Window class implements a simple form of window that is
not scrollable, and a ScrollableWindow class decorates the Window class by
adding horizontal and vertical scrollbars.
The motivation for decorators is to separate special-purpose extensions of a
class from a more generic core. However, decorator classes tend to be shallow:
they introduce a large amount of boilerplate for a small amount of new
functionality. Decorator classes often contain many pass-through methods. It’s
easy to overuse the decorator pattern, creating a new class for every small new
feature. This results in an explosion of shallow classes, such as the Java I/O
example.
Before creating a decorator class, consider alternatives such as the following:
Could you add the new functionality directly to the underlying class, rather
than creating a decorator class? This makes sense if the new functionality is
relatively general-purpose, or if it is logically related to the underlying class,
or if most uses of the underlying class will also use the new functionality.
For example, virtually everyone who creates a Java InputStream will also
create a BufferedInputStream, and buffering is a natural part of I/O, so
these classes should have been combined.
If the new functionality is specialized for a particular use case, would it
make sense to merge it with the use case, rather than creating a separate
class?
Could you merge the new functionality with an existing decorator, rather
than creating a new decorator? This would result in a single deeper
decorator class rather than multiple shallow ones.
Finally, ask yourself whether the new functionality really needs to wrap the
existing functionality: could you implement it as a stand-alone class that is
independent of the base class? In the windowing example, the scrollbars
could probably be implemented separately from the main window, without
wrapping all of its existing functionality.
Sometimes decorators make sense, but there is usually a better alternative.
Figure 7.2: Possible techniques for dealing with a pass-through variable. In (a), cert is passed through
methods m1 and m2 even though they don’t use it. In (b), main and m3 have shared access to an object, so
the variable can be stored there instead of passing it through m1 and m2. In (c), cert is stored as a global
variable. In (d), cert is stored in a context object along with other system-wide information, such as a
timeout value and performance counters; a reference to the context is stored in all objects whose methods
need access to it.
The context object unifies the handling of all system-global information and
eliminates the need for pass-through variables. If a new variable needs to be
added, it can be added to the context object; no existing code is affected except
for the constructor and destructor for the context. The context makes it easy to
identify and manage the global state of the system, since it is all stored in one
place. The context is also convenient for testing: test code can change the global
configuration of the application by modifying fields in the context. It would be
much more difficult to implement such changes if the system used pass-through
variables.
Contexts are far from an ideal solution. The variables stored in a context have
most of the disadvantages of global variables; for example, it may not be obvious
why a particular variable is present, or where it is used. Without discipline, a
context can turn into a huge grab-bag of data that creates nonobvious
dependencies throughout the system. Contexts may also create thread-safety
issues; the best way to avoid problems is for variables in a context to be
immutable. Unfortunately, I haven’t found a better solution than contexts.
7.6 Conclusion
Each piece of design infrastructure added to a system, such as an interface,
argument, function, class, or definition, adds complexity, since developers must
learn about this element. In order for an element to provide a net gain against
complexity, it must eliminate some complexity that would be present in the
absence of the design element. Otherwise, you are better off implementing the
system without that particular element. For example, a class can reduce
complexity by encapsulating functionality so that users of the class needn’t be
aware of it.
The “different layer, different abstraction” rule is just an application of this
idea: if different layers have the same abstraction, such as pass-through methods
or decorators, then there’s a good chance that they haven’t provided enough
benefit to compensate for the additional infrastructure they represent. Similarly,
pass-through arguments require each of several methods to be aware of their
existence (which adds to complexity) without contributing additional
functionality.
Chapter 8
This chapter introduces another way of thinking about how to create deeper
classes. Suppose that you are developing a new module, and you discover a piece
of unavoidable complexity. Which is better: should you let users of the module
deal with the complexity, or should you handle the complexity internally within
the module? If the complexity is related to the functionality provided by the
module, then the second answer is usually the right one. 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. Another way
of expressing this idea is that it is more important for a module to have a
simple interface than a simple implementation.
As a developer, it’s tempting to behave in the opposite fashion: solve the easy
problems and punt the hard ones to someone else. If a condition arises that
you’re not certain how to deal with, the easiest thing is to throw an exception and
let the caller handle it. If you are not certain what policy to implement, you can
define a few configuration parameters to control the policy and leave it up to the
system administrator to figure out the best values for them.
Approaches like these will make your life easier in the short term, but they
amplify complexity, so that many people must deal with a problem, rather than
just one person. For example, if a class throws an exception, every caller of the
class will have to deal with it. If a class exports configuration parameters, every
system administrator in every installation will have to learn how to set them.
8.4 Conclusion
When developing a module, look for opportunities to take a little bit of extra
suffering upon yourself in order to reduce the suffering of your users.
Chapter 9
One of the most fundamental questions in software design is this: given two
pieces of functionality, should they be implemented together in the same place,
or should their implementations be separated? This question applies at all levels
in a system, such as functions, methods, classes, and services. For example,
should buffering be included in the class that provides stream-oriented file I/O,
or should it be in a separate class? Should the parsing of an HTTP request be
implemented entirely in one method, or should it be divided among multiple
methods (or even multiple classes)? This chapter discusses the factors to consider
when making these decisions. Some of these factors have already been discussed
in previous chapters, but they will be revisited here for completeness.
When deciding whether to combine or separate, the goal is to reduce the
complexity of the system as a whole and improve its modularity. It might appear
that the best way to achieve this goal is to divide the system into a large number
of small components: the smaller the components, the simpler each individual
component is likely to be. However, the act of subdividing creates additional
complexity that was not present before subdivision:
Some complexity comes just from the number of components: the more
components, the harder to keep track of them all and the harder to find a
desired component within the large collection. Subdivision usually results
in more interfaces, and every new interface adds complexity.
Subdivision can result in additional code to manage the components. For
example, a piece of code that used a single object before subdivision might
now have to manage multiple objects.
Subdivision creates separation: the subdivided components will be farther
apart than they were before subdivision. For example, methods that were
together in a single class before subdivision may be in different classes after
subdivision, and possibly in different files. Separation makes it harder for
developers to see the components at the same time, or even to be aware of
their existence. If the components are truly independent, then separation is
good: it allows the developer to focus on a single component at a time,
without being distracted by the other components. On the other hand, if
there are dependencies between the components, then separation is bad:
developers will end up flipping back and forth between the components.
Even worse, they may not be aware of the dependencies, which can lead to
bugs.
Subdivision can result in duplication: code that was present in a single
instance before subdivision may need to be present in each of the
subdivided components.
Bringing pieces of code together is most beneficial if they are closely related.
If the pieces are unrelated, they are probably better off apart. Here are a few
indications that two pieces of code are related:
They share information; for example, both pieces of code might depend on
the syntax of a particular type of document.
They are used together: anyone using one of the pieces of code is likely to
use the other as well. This form of relationship is only compelling if it is
bidirectional. As a counter-example, a disk block cache will almost always
involve a hash table, but hash tables can be used in many situations that
don’t involve block caches; thus, these modules should be separate.
They overlap conceptually, in that there is a simple higher-level category
that includes both of the pieces of code. For example, searching for a
substring and case conversion both fall under the category of string
manipulation; flow control and reliable delivery both fall under the category
of network communication.
It is hard to understand one of the pieces of code without looking at the
other.
The rest of this chapter uses more specific rules as well as examples to show
when it makes sense to bring pieces of code together and when it makes sense to
separate them.
In this case, the selection and cursor were not closely enough related to
combine them. When the code was revised to separate the selection and the
cursor, both the usage and the implementation became simpler. Separate objects
provided a simpler interface than a combined object from which selection and
cursor information had to be extracted. The cursor implementation also got
simpler because the cursor position was represented directly, rather than
indirectly through a selection and a boolean. In fact, in the revised version no
special classes were used for either the selection or the cursor. Instead, a new
Position class was introduced to represent a location in the file (a line number
and character within line). The selection was represented with two Positions and
the cursor with one. Positions also found other uses in the project. This example
also demonstrates the benefits of a lower-level but more general-purpose
interface, which were discussed in Chapter 6.
Rather than logging the error at the point where it was detected, a separate
method in a special error logging class was invoked. The error logging class was
defined at the end of the same source file:
private static class NetworkErrorLogger {
/**
* Output information relevant to an error that occurs when trying
* to open a connection to send an RPC.
*
* @param req
* The RPC request that would have been sent through the
connection
* @param dest
* The destination of the RPC
* @param e
* The caught error
*/
public static void logRpcOpenError(RpcRequest req, AddrPortTuple
dest, Exception e) {
logger.log(Level.WARNING, "Cannot send message: " + req + ". \n" +
"Unable to find or open connection to " + dest + " :" +
e);
}
...
}
History() {...}
In this design, the History class manages a collection of objects that implement
the interface History.Action. Each History.Action describes a single operation,
such as a text insertion or a change in the cursor location, and it provides
methods that can undo or redo the operation. The History class knows nothing
about the information stored in the actions or how they implement their undo and
redo methods. History maintains a history list describing all of the actions
executed over the lifetime of an application, and it provides undo and redo
methods that walk backwards and forwards through the list in response to user-
requested undos and redos, calling undo and redo methods in the
History.Actions.
This approach divides the functionality of undo into three categories, each of
which is implemented in a different place:
A general-purpose mechanism for managing and grouping actions and
invoking undo/redo operations (implemented by the History class).
The specifics of particular actions (implemented by a variety of classes,
each of which understands a small number of action types).
The policy for grouping actions (implemented by high-level user interface
code to provide the right overall application behavior).
Each of these categories can be implemented without any understanding of the
other categories. The History class does not know what kind of actions are being
undone; it could be used in a variety of applications. Each action class
understands only a single kind of action, and neither the History class nor the
action classes needs to be aware of the policy for grouping actions.
The key design decision was the one that separated the general-purpose part
of the undo mechanism from the special-purpose parts and put the general-
purpose part in a class by itself. Once that was done, the rest of the design fell
out naturally.
Note: the suggestion to separate general-purpose code from special-purpose
code refers to code related to a particular mechanism. For example, special-
purpose undo code (such as code to undo a text insertion) should be separated
from general-purpose undo code (such as code to manage the history list).
However, it often makes sense to combine special-purpose code for one
mechanism with general-purpose code for another. The text class is an example
of this: it implements a general-purpose mechanism for managing text, but it
includes special-purpose code related to undoing. The undo code is special-
purpose because it only handles undo operations for text modifications. It doesn’t
make sense to combine this code with the general-purpose undo infrastructure in
the History class, but it does make sense to put it in the text class, since it is
closely related to other text functions.
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 method should have a clean and simple interface, so that users don’t need to
have much information in their heads in order to use it correctly. The method
should be deep: its interface should be much simpler than its implementation. If
a method has all of these properties, then it probably doesn’t matter whether it is
long or not.
Splitting up a method only makes sense if it results in cleaner abstractions,
overall. There are two ways to do this, which are diagrammed in Figure 9.3. The
best way is by factoring out a subtask into a separate method, as shown in Figure
9.3(b). The subdivision results in a child method containing the subtask and a
parent method containing the remainder of the original method; the parent
invokes the child. The interface of the new parent method is the same as the
original method. This form of subdivision makes sense if there is a subtask that
is cleanly separable from the rest of the original method, which means (a)
someone reading the child method doesn’t need to know anything about the
parent method and (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 make a split of this form and then find
yourself flipping back and forth between the parent and child to understand how
they work together, that is a red flag (“Conjoined Methods”) indicating that the
split was probably a bad idea.
The second way to break up a method is to split it into two separate methods,
each visible to callers of the original method, as in Figure 9.3(c). This makes
sense if the original method had an overly complex interface because it tried to
do multiple things that were not closely related. If this is the case, it may be
possible to divide the method’s functionality into two or more smaller methods,
each of which has only a part of the original method’s functionality. If you make
a split like this, the interface for each of the resulting methods should be simpler
than the interface of the original method. Ideally, 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, which makes it less likely that the split is a
good idea. The new methods will be more focused in what they do. It is a good
sign if the new methods are more general-purpose than the original method (i.e.,
you can imagine using them separately in other situations).
Splits of the form shown in Figure 9.3(c) don’t make sense very often,
because they result in callers having to deal with multiple methods instead of
one. When you split this way, you run the risk of ending up with several shallow
methods, as in Figure 9.3(d). If the caller has to invoke each of the separate
methods, passing state back and forth between them, then splitting is not a good
idea. If you’re considering a split like the one in Figure 9.3(c), you should judge
it based on whether it simplifies things for callers.
There are also situations where a system can be made simpler by joining
methods together. For example, joining methods might replace two shallow
methods with one deeper method; it might eliminate duplication of code; it
might eliminate dependencies between the original methods, or intermediate data
structures; it might result in better encapsulation, so that knowledge that was
previously present in multiple places is now isolated in a single place; or it might
result in a simpler interface, as discussed in Section 9.2.
9.9 Conclusion
The decision to split or join modules should be based on complexity. Pick the
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.
Chapter 10
Just the basic try-catch boilerplate accounts for more lines of code than the
code for normal-case operation, without even considering the code that actually
handles the exceptions. It is hard to relate the exception handling code to the
normal-case code: for example, it’s not obvious where each exception is
generated. An alternative approach is to break up the code into many distinct try
blocks; in the extreme case there could be a try for each line of code that can
generate an exception. This would make it clear where exceptions occur, but the
try blocks themselves break up the flow of the code and make it harder to read;
in addition, some exception handling code might end up duplicated in multiple
try blocks.
It’s difficult to ensure that exception handling code really works. Some
It’s difficult to ensure that exception handling code really works. Some
exceptions, such as I/O errors, can’t easily be generated in a test environment, so
it’s hard to test the code that handles them. Exceptions don’t occur very often in
running systems, so exception handling code rarely executes. Bugs can go
undetected for a long time, and when the exception handling code is finally
needed, there’s a good chance that it won’t work (one of my favorite sayings:
“code that hasn’t been executed doesn’t work”). A recent study found that more
than 90% of catastrophic failures in distributed data-intensive systems were
caused by incorrect error handling1. When exception handling code fails, it’s
difficult to debug the problem, since it occurs so infrequently.
Figure 10.1: The code at the top dispatches to one of several methods in a Web server, each of which
handles a particular URL. Each of those methods (bottom) uses parameters from the incoming HTTP
request. In this figure, there is a separate exception handler for each call to getParameter; this results in
duplicated code.
Figure 10.2: This code is functionally equivalent to Figure 10.1, but exception handling has been
aggregated: a single exception handler in the dispatcher catches all of the NoSuchParameter exceptions from
all of the URL-specific methods.
For the same reason that it makes sense to define errors out of existence, it also
For the same reason that it makes sense to define errors out of existence, it also
makes sense to define other special cases out of existence. Special cases can
result in code that is riddled with if statements, which make the code hard to
understand and lead to bugs. Thus, special cases should be eliminated wherever
possible. The best way to do this is by designing the normal case in a way that
automatically handles the special cases without any extra code.
In the text editor project described in Chapter 6, students had to implement a
mechanism for selecting text and copying or deleting the selection. Most students
introduced a state variable in their selection implementation to indicate whether
or not the selection exists. They probably chose this approach because there are
times when no selection is visible on the screen, so it seemed natural to represent
this notion in the implementation. However, this approach resulted in numerous
checks to detect the “no selection” condition and handle it specially.
The selection handling code can be simplified by eliminating the “no
selection” special case, so that the selection always exists. When there is no
selection visible on the screen, it can be represented internally with an empty
selection, whose starting and ending positions are the same. With this approach,
the selection management code can be written without any checks for “no
selection”. When copying the selection, if the selection is empty then 0 bytes will
be inserted at the new location (if implemented correctly, there will be no need to
check for 0 bytes as a special case). Similarly, it should be possible to design the
code for deleting the selection so that the empty case is handled without any
special-case checks. Consider a selection all on a single line. To delete the
selection, extract the portion of the line preceding the selection and concatenate
it with the portion of the line following the selection to form the new line. If the
selection is empty, this approach will regenerate the original line.
This example also illustrates the “different layer, different abstraction” idea
from Chapter 7. The notion of “no selection” makes sense in terms of how the
user thinks about the application’s interface, but that doesn’t mean it has to be
represented explicitly inside the application. Having a selection that always
exists, but is sometimes empty and thus invisible, results in a simpler
implementation.
10.11 Conclusion
Special cases of any form make code harder to understand and increase the
likelihood of bugs. This chapter focused on exceptions, which are one of the
most significant sources of special-case code, and discussed how to reduce the
number of places where exceptions must be handled. The best way to do this is
by redefining semantics to eliminate error conditions. For exceptions that can’t
be defined away, you should look for opportunities to mask them at a low level,
so their impact is limited, or aggregate several special-case handlers into a single
more generic handler. Together, these techniques can have a significant impact
on overall system complexity.
1Ding Yuan et. al., “Simple Testing Can Prevent Most Critical Failures: An Analysis of Production
Failures in Distributed Data-Intensive Systems,” 2014 USENIX Conference on Operating System Design
and Implementation.
Chapter 11
Design it Twice
Designing software is hard, so it’s unlikely that your first thoughts about how to
structure a module or system will produce the best design. You’ll end up with a
much better result if you consider multiple options for each major design
decision: design it twice.
Suppose you are designing the class that will manage the text of a file for a
GUI text editor. The first step is to define the interface that the class will present
to the rest of the editor; rather than picking the first idea that comes to mind,
consider several possibilities. One choice is a line-oriented interface, with
operations to insert, modify, and delete whole lines of text. Another option is an
interface based on individual character insertions and deletions. A third choice is
a string-oriented interface, which operates on arbitrary ranges of characters that
may cross line boundaries. You don’t need to pin down every feature of each
alternative; it’s sufficient at this point to sketch out a few of the most important
methods.
Try to pick approaches that are radically different from each other; you’ll
learn more that way. Even if you are certain that there is only one reasonable
approach, consider a second design anyway, no matter how bad you think it will
be. It will be instructive to think about the weaknesses of that design and contrast
them with the features of other designs.
After you have roughed out the designs for the alternatives, make a list of the
pros and cons of each one. The most important consideration for an interface is
ease of use for higher level software. In the example above, both the line-oriented
interface and the character-oriented interface will require extra work in software
that uses the text class. The line-oriented interface will require higher level
software to split and join lines during partial-line and multi-line operations such
as cutting and pasting the selection. The character-oriented interface will require
loops to implement operations that modify more than a single character. It is also
worth considering other factors:
Does one alternative have a simpler interface than another? In the text
example, all of the text interfaces are relatively simple.
Is one interface more general-purpose than another?
Does one interface enable a more efficient implementation than another? In
the text example, the character-oriented approach is likely to be significantly
slower than the others, because it requires a separate call into the text
module for each character.
Once you have compared alternative designs, you will be in a better position
to identify the best design. The best choice may be one of the alternatives, or you
may discover that you can combine features of multiple alternatives into a new
design that is better than any of the original choices.
Sometimes none of the alternatives is particularly attractive; when this
happens, see if you can come up with additional schemes. Use the problems you
identified with the original alternatives to drive the new design(s). If you were
designing the text class and considered only the line-oriented and character-
oriented approaches, you might notice that each of the alternatives is awkward
because it requires higher level software to perform additional text
manipulations. That’s a red flag: if there’s going to be a text class, it should
handle all of the text manipulation. In order to eliminate the additional text
manipulations, the text interface needs to match more closely the operations
happening in higher level software. These operations don’t always correspond to
single characters or single lines. This line of reasoning should lead you to a
range-oriented API for text, which eliminates the problem with the earlier
designs.
The design-it-twice principle can be applied at many levels in a system. For a
module, you can use this approach first to pick the interface, as described above.
Then you can apply it again when you are designing the implementation: for the
text class, you might consider implementations such as a linked list of lines,
fixed-size blocks of characters, or a “gap buffer.” The goals will be different for
the implementation than for the interface: for the implementation, the most
important things are simplicity and performance. It’s also useful to explore
multiple designs at higher levels in the system, such as when choosing features
for a user interface, or when decomposing a system into major modules. In each
case, it’s easier to identify the best approach if you can compare a few
alternatives.
Designing it twice does not need to take a lot of extra time. For a smaller
Designing it twice does not need to take a lot of extra time. For a smaller
module such as a class, you may not need more than an hour or two to consider
alternatives. This is a small amount of time compared to the days or weeks you
will spend implementing the class. The initial design experiments will probably
result in a significantly better design, which will more than pay for the time spent
designing it twice. For larger modules you’ll spend more time in the initial
design explorations, but the implementation will also take longer, and the
benefits of a better design will also be higher.
I have noticed that the design-it-twice principle is sometimes hard for really
smart people to embrace. When they are growing up, smart people discover that
their first quick idea about any problem is sufficient for a good grade; there is no
need to consider a second or third possibility. This makes it easy to develop bad
work habits. However, as these people get older, they get promoted into
environments with harder and harder problems. Eventually, everyone reaches a
point where your first ideas are no longer good enough; if you want to get really
great results, you have to consider a second possibility, or perhaps a third, no
matter how smart you are. The design of large software systems falls in this
category: no-one is good enough to get it right with their first try.
Unfortunately, I often see smart people who insist on implementing the first
idea that comes to mind, and this causes them to underperform their true
potential (it also makes them frustrating to work with). Perhaps they
subconsciously believe that “smart people get it right the first time,” so if they try
multiple designs it would mean they are not smart after all. This is not the case.
It isn’t that you aren’t smart; it’s that the problems are really hard! Furthermore,
that’s a good thing: it’s much more fun to work on a difficult problem where you
have to think carefully, rather than an easy problem where you don’t have to
think at all.
The design-it-twice approach not only improves your designs, but it also
improves your design skills. The process of devising and comparing multiple
approaches will teach you about the factors that make designs better or worse.
Over time, this will make it easier for you to rule out bad designs and hone in on
really great ones.
Chapter 12
None of these comments provide any value. For the first two comments, the code
is already clear enough that it doesn’t really need comments; in the third case, a
comment might be useful, but the current comment doesn’t provide enough
detail to be helpful.
After you have written a comment, ask yourself the following question: could
someone who has never seen the code write the comment just by looking at the
code next to the comment? If the answer is yes, as in the examples above, then
the comment doesn’t make the code any easier to understand. Comments like
these are why some people think that comments are worthless.
Another common mistake is to use the same words in the comment that
appear in the name of the entity being documented:
/*
* Obtain a normalized resource name from REQ.
*/
private static String[] getNormalizedResourceNames(
HTTPRequest req) ...
/*
* Downcast PARAMETER to TYPE.
*/
private static Object downCastParameter(String parameter, String type)
...
/*
* The horizontal padding of each line in the text.
*/
private static final int textHorizontalPadding = 4;
These comments just take the words from the method or variable name, perhaps
add a few words from argument names and types, and form them into a sentence.
For example, the only thing in the second comment that isn’t in the code is the
word “to”! Once again, these comments could be written just by looking at the
declarations, without any understanding the methods of variables; as a result,
they have no value.
Red Flag: Comment Repeats Code
If the information in a comment is already obvious from the code next to the
comment, then the comment isn’t helpful. One example of this is when the
comment uses the same words that make up the name of the thing it is
describing.
At the same time, there is important information that is missing from the
comments: for example, what is a “normalized resource name”, and what are the
elements of the array returned by getNormalizedResourceNames? What does
“downcast” mean? What are the units of padding, and is the padding on one side
of each line or both? Describing these things in comments would be helpful.
A first step towards writing good comments is to use different words in the
comment from those in the name of the entity being described. Pick words
for the comment that provide additional information about the meaning of the
entity, rather than just repeating its name. For example, here is a better comment
for textHorizontalPadding:
/*
* The amount of blank space to leave on the left and
* right sides of each line of text, in pixels.
*/
private static final int textHorizontalPadding = 4;
This comment provides additional information that is not obvious from the
declaration itself, such as the units (pixels) and the fact that padding applies to
both sides of each line. Instead of using the term “padding”, the comment
explains what padding is, in case the reader isn’t already familiar with the term.
In the first example, it’s not clear what “current” means. In the second example,
it’s not clear that the keys in the TreeMap are line widths and values are
occurrence counts. Also, are widths measured in pixels or characters? The
revised comments below provide additional details:
// Position in this buffer of the first object that hasn't
// been returned to the client.
uint32_t offset;
Given this documentation, it’s easy to infer that the variable must be set to true
when a heartbeat is received and false when the election timer is reset.
The second way in which comments can augment code is by providing intuition.
The second way in which comments can augment code is by providing intuition.
These comments are written at a higher level than the code. They omit details
and help the reader to understand the overall intent and structure of the code.
This approach is commonly used for comments inside methods, and for interface
comments. For example, consider the following code:
// If there is a LOADING readRpc using the same session
// as PKHash pointed to by assignPos, and the last PKHash
// in that readRPC is smaller than current assigning
// PKHash, then we put assigning PKHash into that readRPC.
int readActiveRpcId = RPC_ID_NOT_ASSIGNED;
for (int i = 0; i < NUM_READ_RPC; i++) {
if (session == readRpc[i].session
&& readRpc[i].status == LOADING
&& readRpc[i].maxPos < assignPos
&& readRpc[i].numHashes < MAX_PKHASHES_PERRPC) {
readActiveRpcId = i;
break;
}
}
The comment is too low-level and detailed. On the one hand, it partially repeats
the code: “if there is a LOADING readRPC” just duplicates the test
readRpc[i].status == LOADING. On the other hand, the comment doesn’t explain
the overall purpose of this code, or how it fits into the method that contains it. As
a result, the comment doesn’t help the reader to understand the code.
Here is a better comment:
// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.
This comment doesn’t contain any details; instead, it describes the code’s overall
function at a higher level. With this high-level information, a reader can explain
almost everything that happens in the code: the loop must be iterating over all the
existing remote procedure calls (RPCs); the session test is probably used to see
if a particular RPC is destined for the right server; the LOADING test suggests that
RPCs can have multiple states, and in some states it isn’t safe to add more
hashes; the MAX - PKHASHES_PERRPC test suggests that there is a limit to how
many hashes can be sent in a single RPC. The only thing not explained by the
comment is the maxPos test. Furthermore, the new comment provides a basis for
readers to judge the code: does it do everything that is needed to add the key
hash to an existing RPC? The original comment didn’t describe the overall intent
of the code, so it’s hard for a reader to decide whether the code is behaving
correctly.
Higher-level comments are more difficult to write than lower-level comments
because you must think about the code in a different way. Ask yourself: What is
this code trying to do? What is the simplest thing you can say that explains
everything in the code? What is the most important thing about this code?
Engineers tend to be very detail-oriented. We love details and are good at
managing lots of them; this is essential for being a good engineer. But, great
software designers can also step back from the details and think about a system
at a higher level. This means deciding which aspects of the system are most
important, and being able to ignore the low-level details and think about the
system only in terms of its most fundamental characteristics. This is the essence
of abstraction (finding a simple way to think about a complex entity), and it’s
also what you must do when writing higher-level comments. A good higher-level
comment expresses one or a few simple ideas that provide a conceptual
framework, such as “append to an existing RPC.” Given the framework, it
becomes easy to see how specific code statements relate to the overall goal.
Here is another code sample, which has a good higher-level comment:
if (numProcessedPKHashes < readRpc[i].numHashes) {
// Some of the key hashes couldn't be looked up in
// this request (either because they aren't stored
// on the server, the server crashed, or there
// wasn't enough space in the response message).
// Mark the unprocessed hashes so they will get
// reassigned to new RPCs.
for (size_t p = removePos; p < insertPos; p++) {
if (activeRpcId[p] == i) {
if (numProcessedPKHashes > 0) {
numProcessedPKHashes--;
} else {
if (p < assignPos)
assignPos = p;
activeRpcId[p] = RPC_ID_NOT_ASSIGNED;
}
}
}
}
This comment does two things. The second sentence provides an abstract
description of what the code does. The first sentence is different: it explains (in
high level terms) why the code is executed. Comments of the form “how we get
here” are very useful for helping people to understand code. For example, when
documenting a method, it can be very helpful to describe the conditions under
which the method is most likely to be invoked (especially if the method is only
invoked in unusual situations).
Now consider the following code, which shows the first version of the
documentation for the isReady method in IndexLookup:
/**
* Check if the next object is RESULT_READY. This function is
* implemented in a DCFT module, each execution of isReady() tries
* to make small progress, and getNext() invokes isReady() in a
* while loop, until isReady() returns true.
*
* isReady() is implemented in a rule-based approach. We check
* different rules by following a particular order, and perform
* certain actions if some rule is satisfied.
*
* \return
* True means the next Object is available. Otherwise, return
* false.
*/
bool IndexLookup::isReady() { ... }
Once again, most of this documentation, such as the reference to DCFT and the
entire second paragraph, concerns the implementation, so it doesn’t belong here;
this is one of the most common errors in interface comments. Some of the
implementation documentation is useful, but it should go inside the method,
where it will be clearly separated from interface documentation. In addition, the
first sentence of the documentation is cryptic (what does RESULT_READY mean?)
and some important information is missing. Finally, it isn’t necessary to describe
the implementation of getNext here. Here is a better version of the comment:
/*
* Indicates whether an indexed read has made enough progress for
* getNext to return immediately without blocking. In addition, this
* method does most of the real work for indexed reads, so it must
* be invoked (either directly, or indirectly by calling getNext) in
* order for the indexed read to make progress.
*
* \return
* True means that the next invocation of getNext will not block
* (at least one object is available to return, or the end of
the
* lookup has been reached); false means getNext may block.
*/
This version of the comment provides more precise information about what
“ready” means, and it provides the important information that this method must
eventually be invoked if the indexed retrieval is to move forward.
For loops, it’s helpful to have a comment before the loop that describes what
For loops, it’s helpful to have a comment before the loop that describes what
happens in each iteration:
// Each iteration of the following loop extracts one request from
// the request message, increments the corresponding object, and
// appends a response to the response message.
Notice how this comment describes the loop at a more abstract and intuitive
level; it doesn’t go into any details about how a request is extracted from the
request message or how the object is incremented. Loop comments are only
needed for longer or more complex loops, where it may not be obvious what the
loop is doing; many loops are short and simple enough that their behavior is
already obvious.
In addition to describing what the code is doing, implementation comments
are also useful to explain why. If there are tricky aspects to the code that won’t be
obvious from reading it, you should document them. For example, if a bug fix
requires the addition of code whose purpose isn’t totally obvious, add a comment
describing why the code is needed. For bug fixes where there is a well-written
bug report describing the problem, the comment can refer to the issue in the bug
tracking database rather than repeating all its details (“Fixes RAM-436, related
to device driver crashes in Linux 2.4.x”). Developers can look in the bug
database for more details (this is an example of avoiding duplication in
comments, which will be discussed in Chapter 16).
For longer methods, it can be helpful to write comments for a few of the most
important local variables. However, most local variables don’t need
documentation if they have good names. If all of the uses of a variable are visible
within a few lines of each other, it’s usually easy to understand the variable’s
purpose without a comment. In this case it’s OK to let readers read the code to
figure out the meaning of the variable. However, if the variable is used over a
large span of code, then you should consider adding a comment to describe the
variable. When documenting variables, focus on what the variable represents, not
how it is manipulated in the code.
// Note: if you add a new status value you must make the following
// additional updates:
// (1) Modify STATUS_MAX_VALUE to have a value equal to the
// largest defined status value, and make sure its definition
// is the last one in the list. STATUS_MAX_VALUE is used
// primarily for testing.
// (2) Add new entries in the tables "messages" and "symbols" in
// Status.cc.
// (3) Add a new exception class to ClientException.h
// (4) Add a new "case" to ClientException::throwException to map
// from the status value to a status-specific ClientException
// subclass.
// (5) In the Java bindings, add a static class for the exception
// to ClientException.java
// (6) Add a case for the status of the exception to throw the
// exception in ClientException.java
// (7) Add the exception to the Status enum in Status.java, making
// sure the status is in the correct position corresponding to
// its status code.
}
New status values will be added at the end of the existing list, so the comments
are also placed at the end, where they are most likely to be seen.
Unfortunately, in many cases there is not an obvious central place to put
cross-module documentation. One example from the RAMCloud storage system
was the code for dealing with zombie servers, which are servers that the system
believes have crashed, but in fact are still running. Neutralizing zombie servers
required code in several different modules, and these pieces of code all depend
on each other. None of the pieces of code is an obvious central place to put
documentation. One possibility is to duplicate parts of the documentation in each
location that depends on it. However, this is awkward, and it is difficult to keep
such documentation up to date as the system evolves. Alternatively, the
documentation can be located in one of the places where it is needed, but in this
case it’s unlikely that developers will see the documentation or know where to
look for it.
I have recently been experimenting with an approach where cross-module
issues are documented in a central file called designNotes. The file is divided up
into clearly labeled sections, one for each major topic. For example, here is an
excerpt from the file:
...
Zombies
-------
A zombie is a server that is considered dead by the rest of the
cluster; any data stored on the server has been recovered and will
be managed by other servers. However, if a zombie is not actually
dead (e.g., it was just disconnected from the other servers for a
while) two forms of inconsistency can arise:
* A zombie server must not serve read requests once replacement servers
have taken over; otherwise it may return stale data that does not
reflect writes accepted by the replacement servers.
* The zombie server must not accept write requests once replacement
servers have begun replaying its log during recovery; if it does,
these writes may be lost (the new values may not be stored on the
replacement servers and thus will not be returned by reads).
Then, in any piece of code that relates to one of these issues there is a short
comment referring to the designNotes file:
// See "Zombies" in designNotes.
With this approach, there is only a single copy of the documentation and it is
relatively easy for developers to find it when they need it. However, this has the
disadvantage that the documentation is not near any of the pieces of code that
depend on it, so it may be difficult to keep up-to-date as the system evolves.
13.8 Conclusion
The goal of comments is to ensure that the structure and behavior of the system
is obvious to readers, so they can quickly find the information they need and
make modifications to the system with confidence that they will work. Some of
this information can be represented in the code in a way that will already be
obvious to readers, but there is a significant amount of information that can’t
easily be deduced from the code. Comments fill in this information.
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). When writing comments, try to put
yourself in the mindset of the reader and ask yourself what are the key things he
or she will need to know. If your code is undergoing review and a reviewer tells
you that something is not obvious, don’t argue with them; if a reader thinks it’s
not obvious, then it’s not obvious. Instead of arguing, try to understand what they
found confusing and see if you can clarify that, either with better comments or
better code.
Choosing Names
Selecting names for variables, methods, and other entities is one of the most
underrated aspects of software design. Good names are a form of documentation:
they make code easier to understand. They reduce the need for other
documentation and make it easier to detect errors. Conversely, poor name
choices increase the complexity of code and create ambiguities and
misunderstandings that can result in bugs. Name choice is an example of the
principle that complexity is incremental. Choosing a mediocre name for a
particular variable, as opposed to the best possible name, probably won’t have
much impact on the overall complexity of a system. However, software systems
have thousands of variables; choosing good names for all of these will have a
significant impact on complexity and manageability.
It’s clear from this code that i is being used to iterate over each of the lines in
some entity. If the loop gets so long that you can’t see it all at once, or if the
meaning of the iteration variable is harder to figure out from the code, then a
more descriptive name is in order.
It’s also possible for a name to be too specific, such as in this declaration for
a method that deletes a range of text:
void delete(Range selection) {...}
The argument name selection is too specific, since it suggests that the text being
deleted is always selected in the user interface. However, this method can be
invoked on any range of text, selected or not. Thus, the argument name should be
more generic, such as range.
If you find it difficult to come up with a name for a particular variable that is
precise, intuitive, and not too long, this is a red flag. It suggests that the variable
may not have a clear definition or purpose. When this happens, consider
alternative factorings. For example, perhaps you are trying to use a single
variable to represent several things; if so, separating the representation into
multiple variables may result in a simpler definition for each variable. The
process of choosing good names can improve your design by identifying
weaknesses.
Loops are another area where consistent naming can help. If you use names
such as i and j for loop variables, always use i in outermost loops and j for
nested loops. This allows readers to make instant (safe) assumptions about what’s
happening in the code when they see a given name.
Personally, I don’t find the second version any more difficult to read than the
first. If anything, the name count gives a slightly better clue to the behavior of
the variable than n. With the first version I ended up reading through the code
trying to figure out what n means, whereas I didn’t feel that need with the second
version. But, if n is used consistently throughout the system to refer to counts
(and nothing else), then the short name will probably be clear to other
developers.
The Go culture encourages the use of the same short name for multiple
different things: ch for character or channel, d for data, difference, or distance,
and so on. To me, ambiguous names like these are likely to result in confusion
and error, just as in the block example.
Overall, I would argue that readability must be determined by readers, not
writers. If you write code with short variable names and the people who read it
find it easy to understand, then that’s fine. If you start getting complaints that
your code is cryptic, then you should consider using longer names (a Web search
for “go language short names” will identify several such complaints). Similarly,
if I start getting complaints that long variable names make my code harder to
read, then I’ll consider using shorter ones.
Gerrand makes one comment that I agree with: “The greater the distance
between a name’s declaration and its uses, the longer the name should be.” The
earlier discussion about using loop variables named i and j is an example of this
rule.
14.6 Conclusion
Well chosen names help to make code more obvious; when someone encounters
the variable for the first time, their first guess about its behavior, made without
much thought, will be correct. Choosing good names is an example of the
investment mindset discussed in Chapter 3: if you take a little extra time up front
to select good names, it will be easier for you to work on the code in the future.
In addition, you will be less likely to introduce bugs. Developing a skill for
naming is also an investment. When you first decide to stop settling for mediocre
names, you may find it frustrating and time-consuming to come up with good
names. However, as you get more experience you’ll find that it becomes easier;
eventually, you’ll get to the point where it takes almost no extra time to choose
good names, so you will get the benefits almost for free.
1https://talks.golang.org/2014/names.slide#1
Chapter 15
Many developers put off writing documentation until the end of the
development process, after coding and unit testing are complete. This is one of
the surest ways to produce poor quality documentation. The best time to write
comments is at the beginning of the process, as you write the code. Writing the
comments first makes documentation part of the design process. Not only does
this produce better documentation, but it also produces better designs and it
makes the process of writing documentation more enjoyable.
Even if you do have the self-discipline to go back and write the comments
Even if you do have the self-discipline to go back and write the comments
(and don’t fool yourself: you probably don’t), the comments won’t be very good.
By this time in the process, you have checked out mentally. In your mind, this
piece of code is done; you are eager to move on to your next project. You know
that writing comments is the right thing to do, but it’s no fun. You just want to
get through it as quickly as possible. Thus, you make a quick pass over the code,
adding just enough comments to look respectable. By now, it’s been a while
since you designed the code, so your memories of the design process are
becoming fuzzy. You look at the code as you are writing the comments, so the
comments repeat the code. Even if you try to reconstruct the design ideas that
aren’t obvious from the code, there will be things you don’t remember. Thus, the
comments are missing some of the most important things they should describe.
When you change existing code, there’s a good chance that the changes will
When you change existing code, there’s a good chance that the changes will
invalidate some of the existing comments. It’s easy to forget to update comments
when you modify code, which results in comments that are no longer accurate.
Inaccurate comments are frustrating to readers, and if there are very many of
them, readers begin to distrust all of the comments. Fortunately, with a little
discipline and a couple of guiding rules, it’s possible to keep comments up-to-
date without a huge effort. This section and the following ones put forth some
specific techniques.
The best way to ensure that comments get updated is to position them
close to the code they describe, so developers will see them when they change
the code. The farther a comment is from its associated code, the less likely it is
that it will be updated properly. For example, the best place for a method’s
interface comment is in the code file, right next to the body of the method. Any
changes to the method will involve this code, so the developer is likely to see the
interface comments and update them if needed.
An alternative for languages like C and C++ that have separate code and
header files, is to place the interface comments next to the method’s declaration
in the .h file. However, this is a long way from the code; developers won’t see
those comments when modifying the method’s body, and it takes additional work
to open a different file and find the interface comments to update them. Some
might argue that interface comments should go in header files so that users can
learn how to use an abstraction without having to look at the code file. However,
users should not need to read either code or header files; they should get their
information from documentation compiled by tools such as Doxygen or Javadoc.
In addition, many IDEs will extract and present documentation to users, such as
by displaying a method’s documentation when the method’s name is typed.
Given tools such as these, the documentation should be located in the place that
is most convenient for developers working on the code.
When writing implementation comments, don’t put all the comments for an
entire method at the top of the method. Spread them out, pushing each comment
down to the narrowest scope that includes all of the code referred to by the
comment. For example, if a method has three major phases, don’t write one
comment at the top of the method that describes all of the phases in detail.
Instead, write a separate comment for each phase and position that comment just
above the first line of code in that phase. On the other hand, it can also be helpful
to have a comment at the top of a method’s implementation that describes the
overall strategy, like this:
// We proceed in three phases:
// Phase 1: Find feasible candidates
// Phase 2: Assign each candidate a score
// Phase 3: Choose the best, and remove it
Additional details can be documented just above the code for each phase.
In general, the farther a comment is from the code it describes, the more
abstract it should be (this reduces the likelihood that the comment will be
invalidated by code changes).
It’s important that readers can easily find all the documentation needed to
understand your code, but that doesn’t mean you have to write all of that
documentation.
Consistency
17.4 Conclusion
Consistency is another example of the investment mindset. It will take a bit of
extra work to ensure consistency: work to decide on conventions, work to create
automated checkers, work to look for similar situations to mimic in new code,
and work in code reviews to educate the team. The return on this investment is
that your code will be more obvious. Developers will be able to understand the
code’s behavior more quickly and accurately, and this will allow them to work
faster, with fewer bugs.
Chapter 18
Obscurity is one of the two main causes of complexity described in Section 2.3.
Obscurity occurs when important information about a system is not obvious to
new developers. The solution to the obscurity problem is to write code in a way
that makes it obvious; this chapter discusses some of the factors that make code
more or less obvious.
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. If code is obvious, a reader doesn’t need to spend much time or
effort to gather all the information they need to work with the code. If code is not
obvious, then a reader must expend a lot of time and energy to understand it. Not
only does this reduce their efficiency, but it also increases the likelihood of
misunderstanding and bugs. Obvious code needs fewer comments than
nonobvious code.
“Obvious” is in the mind of the reader: it’s easier to notice that someone
else’s code is nonobvious than to see problems with your own code. Thus, 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. By trying to understand what made the code
nonobvious, you will learn how to write better code in the future.
// Next, see if there is extra space at the end of the last chunk.
if (extraAppendBytes >= numBytes32) {
extraAppendBytes -= numBytes32;
return lastChunk->data + lastChunk->length + extraAppendBytes;
}
This approach works particularly well if the first line after each blank line is a
comment describing the next block of code: the blank lines make the comments
more visible.
White space within a statement helps to clarify the structure of the statement.
Compare the following two statements, one of which has whitespace and one of
which doesn’t:
for(int pass=1;pass>=0&&!empty;pass--) {
To compensate for this obscurity, use the interface comment for each handler
To compensate for this obscurity, use the interface comment for each handler
function to indicate when it is invoked, as in this example:
/**
* This method is invoked in the dispatch thread by a transport if a
* transport-level error prevents an RPC from completing.
*/
void
Transport::RpcNotifier::failed() {
...
}
Generic containers. Many languages provide generic classes for grouping two
or more items into a single object, such as Pair in Java or std::pair in C++.
These classes are tempting because they make it easy to pass around several
objects with a single variable. One of the most common uses is to return multiple
values from a method, as in this Java example:
return new Pair<Integer, Boolean>(currentTerm, false);
Unfortunately, generic containers result in nonobvious code because the grouped
elements have generic names that obscure their meaning. In the example above,
the caller must reference the two returned values with result.getKey() and
result.getValue(), which give no clue about the actual meaning of the values.
Thus, it’s better not to use generic containers. If you need a container, define
a new class or structure that is specialized for the particular use. You can then
use meaningful names for the elements, and you can provide additional
documentation in the declaration, which is not possible with the generic
container.
This example illustrates a general rule: software should be designed for
ease of reading, not ease of writing. Generic containers are expedient for the
person writing the code, but they create confusion for all the readers that follow.
It’s better for the person writing the code to spend a few extra minutes to define a
specific container structure, so that the resulting code is more obvious.
Different types for declaration and allocation. Consider the following Java
example:
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
The variable is declared as a List, but the actual value is an ArrayList. This code
is legal, since List is a superclass of ArrayList, but it can mislead a reader who
sees the declaration but not the actual allocation. The actual type may impact
how the variable is used (ArrayLists have different performance and thread-
safety properties than other subclasses of List), so it is better to match the
declaration with the allocation.
Code that violates reader expectations. Consider the following code, which is
the main program for a Java application
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
Most applications exit when their main programs return, so readers are likely to
assume that will happen here. However, that is not the case. The constructor for
RaftClient creates additional threads, which continue to operate even though the
application’s main thread finishes. This behavior should be documented in the
interface comment for the RaftClient constructor, but the behavior is
nonobvious enough that it’s worth putting a short comment at the end of main as
well. The comment should indicate that the application will continue executing
in other threads. Code is most obvious if it conforms to the conventions that
readers will be expecting; if it doesn’t, then it’s important to document the
behavior so readers aren’t confused.
18.3 Conclusion
Another way of thinking about obviousness is in terms of information. If code is
nonobvious, that usually means there is important information about the code
that the reader does not have: in the RaftClient example, the reader might not
know that the RaftClient constructor created new threads; in the Pair example,
the reader might not know that result.getKey() returns the number of the
current term.
To make code obvious, you must ensure that readers always have the
information they need to understand it. You can do this in three ways. The best
way is to reduce the amount of information that is needed, using design
techniques such as abstraction and eliminating special cases. Second, you can
take advantage of information that readers have already acquired in other
contexts (for example, by following conventions and conforming to expectations)
so readers don’t have to learn new information for your code. Third, you can
present the important information to them in the code, using techniques such as
good names and strategic comments.
Chapter 19
Software Trends
19.7 Conclusion
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? Many proposals sound good on
the surface, but if you look more deeply you will see that some of them make
complexity worse, not better.
Chapter 20
Up until this point, the discussion of software design has focused on complexity;
the goal has been to make software as simple and understandable as possible.
But what if you are working on a system that needs to be fast? How should
performance considerations affect the design process? This chapter discusses
how to achieve high performance without sacrificing clean design. The most
important idea is still simplicity: not only does simplicity improve a system’s
design, but it usually makes systems faster.
Figure 20.1: A Buffer object uses a collection of memory chunks to store what appears to be a linear array
of bytes. Internal chunks are owned by the Buffer and freed when the Buffer is destroyed; external chunks
are not owned by the Buffer.
Figure 20.3: The new code for allocating new space in an internal chunk of a Buffer.
Figure 20.3 shows the new critical path for allocating internal space in a
Buffer. The new code is not only faster, but it is also easier to read, since it avoids
shallow abstractions. The entire path is handled in a single method, and it uses a
single test to rule out all of the special cases. The new code introduces a new
instance variable, extraAppendBytes, in order to simplify the critical path. This
variable keeps track of how much unused space is available immediately after the
last chunk in the Buffer. If there is no space available, or if the last chunk in the
Buffer isn’t an internal chunk, or if the Buffer contains no chunks at all, then
extraAppendBytes is zero. The code in Figure 20.3 represents the least possible
amount of code to handle this common case.
Note: the update to totalLength could have been eliminated by recomputing
the total Buffer length from the individual chunks whenever it is needed.
However, this approach would be expensive for a large Buffer with many chunks,
and fetching the total Buffer length is another common operation. Thus, we
chose to add a small amount of extra overhead to alloc in order to ensure that the
Buffer length is always immediately available.
The new code is about twice as fast as the old code: the total time to append a
1-byte string to a Buffer using internal storage dropped from 8.8 ns to 4.75 ns.
Many other Buffer operations also speeded up because of the revisions. For
example, the time to construct a new Buffer, append a small chunk in internal
storage, and destroy the Buffer dropped from 24 ns to 12 ns.
20.5 Conclusion
The most important overall lesson from this chapter is that clean design and high
performance are compatible. The Buffer class rewrite improved its performance
by a factor of 2 while simplifying its design and reducing code size by 20%.
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.
Chapter 21
Conclusion
This book is about one thing: complexity. Dealing with complexity is the most
important challenge in software design. It is what makes systems hard to build
and maintain, and it often makes them slow as well. Over the course of the book
I have tried to describe the root causes that lead to complexity, such as
dependencies and obscurity. I have discussed red flags that can help you identify
unnecessary complexity, such as information leakage, unneeded error conditions,
or names that are too generic. I have presented some general ideas you can use to
create simpler software systems, such as striving for classes that are deep and
generic, defining errors out of existence, and separating interface documentation
from implementation documentation. And, finally, I have discussed the
investment mindset needed to produce simple designs.
The downside of all these suggestions is that they create extra work in the
early stages of a project. Furthermore, if you aren’t used to thinking about design
issues, then you will slow down even more while you learn good design
techniques. If the only thing that matters to you is making your current code
work as soon as possible, then thinking about design will seem like drudge work
that is getting in the way of your real goal.
On the other hand, if good design is an important goal for you, then the ideas
in this book should make programming more fun. Design is a fascinating puzzle:
how can a particular problem be solved with the simplest possible structure? It’s
fun to explore different approaches, and it’s a great feeling to discover a solution
that is both simple and powerful. A clean, simple, and obvious design is a
beautiful thing.
Furthermore, the investments you make in good design will pay off quickly.
The modules you defined carefully at the beginning of a project will save you
time later as you reuse them over and over. The clear documentation that you
wrote six months ago will save you time when you return to the code to add a
new feature. The time you spent honing your design skills will also pay for itself:
as your skills and experience grow, you will find that you can produce good
designs more and more quickly. Good design doesn’t really take much longer
than quick-and-dirty design, once you know how.
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.
Index
abstraction, 21
aggregating exceptions, 82
agile development, 2, 153
change amplification, 7, 99
class interface comment, 110
classitis, 26
coding style, 141
cognitive load, 7, 43, 99
comments
as design tool, 131
benefits, 98
canary in the coal mine, 131
conventions for, 102
duplication, 138
for intuition, 107
for precision, 105
implementation, 116
interface, 110
near code, 137
obsolete, 98
procrastination, 129
repeating code, 103
role in abstraction, 101
worthless, 98
writing before code, 129
complexity
causes of, 9
definition, 5
incremental nature of, 11, 161
pulling downwards, 55, 82
symptoms, 7
composition, 152
configuration parameters, 56
conjoined methods, 71
consistency, 141, 146
context object, 51
cross-module design decisions, 117
decorator, 49
deep module, 22
defaults, 36
dependency, 9
design it twice, 91
design patterns, 142, 156
designNotes file, 118, 139
Facebook, 17
false abstraction, 22, 43
fence, for undo, 69
file data loss example, 121
file deletion example, 79
file descriptor, 23
flash storage, 160
implementation, 19, 50
implementation documentation, 116
implementation inheritance, 152
incremental development, 2, 39
IndexLookup example, 112
information hiding, 29
information leakage, 30
inheritance, 151
integration tests, 154
interface, 19, 50
formal parts, 20
informal parts, 21
interface comment
class, 110
method, 110
interface documentation, 110
interface inheritance, 151
invariants, 142
investment mindset, 15, 128, 136, 144
Java I/O example, 26, 49, 61
Java substring example, 80
masking exceptions, 81
memory allocation, dynamic, 160
method interface comment, 110
micro-benchmark, 160
missing parameter example, 82
modular design, 2, 19
module, 20
names
consistency, 126, 141
generic, 123
how to choose, 121
making code more obvious, 146
precise, 123
short names in Go, 126
network communication, 160
NFS server crash example, 81
non-existent selection example, 87
nonvolatile memory, 160
Parnas, David, 29
pass-through method, 46
pass-through variable, 50
performance
micro-benchmark, 160
performance, designing for, 159
private variables, 30
selection/cursor example, 65
self-documenting code, 96
setter, 156
shallow module, 25
small classes, 26
special-purpose code, 62, 67
specification, formal, 21
strategic programming, 14, 135
style, coding, 141
substring example (Java), 80
system tests, 154
undo example, 67
unit tests, 154
Unix I/O example, 23
unknown unknowns, 8, 99
URL encoding, 34
VMware, 17
waterfall model, 2
Web site colors example, 7
white space, 146
Summary of Design Principles
Here are the most important software design principles discussed in this book:
1. Complexity is incremental: you have to sweat the small stuff (see p. 11).
2. Working code isn’t enough (see p. 14).
3. Make continual small investments to improve system design (see p. 15).
4. Modules should be deep (see p. 22)
5. Interfaces should be designed to make the most common usage as simple as
possible (see p. 27).
6. It’s more important for a module to have a simple interface than a simple
implementation (see pp. 55, 71).
7. General-purpose modules are deeper (see p. 39).
8. Separate general-purpose and special-purpose code (see p. 62).
9. Different layers should have different abstractions (see p. 45).
10. Pull complexity downward (see p. 55).
11. Define errors (and special cases) out of existence (see p. 79).
12. Design it twice (see p. 91).
13. Comments should describe things that are not obvious from the code (see p.
101).
14. Software should be designed for ease of reading, not ease of writing (see p.
149).
15. The increments of software development should be abstractions, not
features (see p. 154).
Summary of Red Flags
Here are a few of of the most important red flags discussed in this book. The
presence of any of these symptoms in a system suggests that there is a problem
with the system’s design:
Shallow Module: the interface for a class or method isn’t much simpler than its
implementation (see pp. 25, 110).
Information Leakage: a design decision is reflected in multiple modules (see p.
31).
Temporal Decomposition: the code structure is based on the order in which
operations are executed, not on information hiding (see p. 32).
Overexposure: An API forces callers to be aware of rarely used features in order
to use commonly used features (see p. 36).
Pass-Through Method: a method does almost nothing except pass its arguments
to another method with a similar signature (see p. 46).
Repetition: a nontrivial piece of code is repeated over and over (see p. 62).
Special-General Mixture: special-purpose code is not cleanly separated from
general purpose code (see p. 65).
Conjoined Methods: two methods have so many dependencies that its hard to
understand the implementation of one without understanding the implementation
of the other (see p. 72).
Comment Repeats Code: all of the information in a comment is immediately
obvious from the code next to the comment (see p. 104).
Implementation Documentation Contaminates Interface: an interface
comment describes implementation details not needed by users of the thing
being documented (see p. 114).
Vague Name: the name of a variable or method is so imprecise that it doesn’t
convey much useful information (see p. 123).
Hard to Pick Name: it is difficult to come up with a precise and intuitive name
for an entity (see p. 125).
Hard to Describe: in order to be complete, the documentation for a variable or
method must be long. (see p. 131).
Nonobvious Code: the behavior or meaning of a piece of code cannot be
understood easily. (see p. 148).
About the Author