Link: https://www.amazon.com.au/Philosophy-Software-Design-John-Ousterhout/dp/1732102201/ref=asc_df_1732102201/

Chapter 2: The Nature of Complexity

Complexity is…

Complexity is determined by the activities that are most common. If a system has a few parts that are very complicated, but those parts almost never need to be touched, then they don’t have much impact on the overall complexity of the system.

Complexity is more apparent to readers than to writers. If you write a piece of code and it seems simple to you, but other people think it is complex, then it is complex.

2.2 - Symptoms of complexity

Complexity manifests itself in three general ways, each of which make it hard to carry out development tasks.

  1. Change amplification: The first symptom of complexity is that a seemingly simple change requires code modifications in many different places.
  2. Cognitive load: Refers to how much a developer needs to know in order to complete a task. Higher cognitive load means developers have to spend much more time learning the required information, and there is a greater risk of bugs because they have missed something important. A common misconception is that lines of code has a direct correlation with cognitive load, e.g. fewer lines means easier to understand, and an easier change to make.
  3. Unknown unknowns: The third symptom of complexity is that it is not obvious which pieces of code must be modified to complete a task, or what information a developer must have to carry out the task successfully. Unknown unknowns can result in wildly incorrect time estimates due to ballooning scope, and bugs that only show up once a piece of software is out in the wild.

2.3 - Causes of complexity

Complexity is caused by two things: dependencies and obscurity. When we talk about dependencies in this context we mean when a piece of code cannot be understood or modified in isolation; the code relates in some way to other code, and the other code must be considered and/or modified if the given code is changed.

Dependencies are a fundamental part of software and can’t be entirely eliminated. We intentionally introduce dependencies as part of the design process. Every new class created adds dependencies around that classes interface. One of the primary goals of software design is to reduce the number of dependencies, and to ensure that the ones that remain are as simple and obvious as possible.

Obscurity occurs when important information is not obvious. A simple example is a variable name that is so generic that it doesn’t convey much useful information about what it represents (e.g. time, or weight). Obscurity can also mean having to dig through implementation code, because method names and associated documentation again do not convey much useful information. Obscurity is often associated with dependencies, where it is not obvious that a dependency exists. Inconsistency is also a major contributor to obscurity: if the same variable name is used for two different purposes, it won’t be obvious to a developer which of these purposes a particular variable serves.

2.4 - Complexity is incremental

In the same way that code usually doesn’t start out as an unclean mess, code doesn’t begin its life burdened with high complexity. The complexity accumulates over time, with each dependency and obscurity added.

Chapter 3: Working Code Isn’t Enough

(Strategic vs. Tactical Programming)

So much of good software design is mindset. Many organisations encourage a tactical mindset, focused on getting features shipped as quickly as possible. In some organisations, this encouragement may not be done with intention, but certain characteristics and certain types of people are praised, leading to the same result.

3.1 - Tactical programming

In the tactical approach, your main focus is to get something working, e.g. a new feature or a bug fix. Tactical programming is short-sighted, the goal is to finish the task as quickly as possible, whether you end up with the best solution, or even just a good solution is bottom of the priority list. You convince yourself that its okay to just add this little bit of hacky code in aid of you moving on more quickly.

Its this mindset that leads to high-complexity systems. It’s these every-day decisions to forego your better judgement mistakenly believing its helping you deliver faster.

Almost every software development shop has at least one developer who takes tactical programming to the extreme: a tactical tornado. The tactical tornado is a prolific programmer who pumps out code far faster than others but works in a totally tactical fashion. When it comes to implementing a quick feature, nobody gets it done faster than the tactical tornado. In some organizations, management treats tactical tornadoes as heroes. However, tactical tornadoes leave behind a wake of destruction. They are rarely considered heroes by the engineers who must work with their code in the future. Typically, other engineers must clean up the messes left behind by the tactical tornado, which makes it appear that those engineers (who are the real heroes) are making slower progress than the tactical tornado.

3.2 - Strategic programming

The first step towards becoming a good software designer is to realise that working code isn’t enough. It’s not acceptable to introduce unnecessary complexities in order to finish your current task faster. The most important thing is the long-term structure of the system. Most of the code in any system is written by extending the existing code base, so your most important job as a developer is to facilitate those future extensions. Your primary goal must be to produce a great design, which also happens to work. This is strategic programming.

Strategic programming requires an investment mindset. Rather than taking the fastest path to finish your current project, you must invest time to improve the design of the system. These investments will slow you down a bit in the short term, but they will speed you up in the long term.

Some investments will be proactive, for example, it is worth taking a little extra time to find a simple design for each new class; rather than implementing the first idea that comes to mind, try a couple of alternative designs and pick the cleanest one. It can help to brainstorm ways in which the system may need to change in future, and make sure that change will be easy with your design. Writing good documentation is another example of proactive investment.

Other investments will be reactive. No matter how much time you invest upfront, there will inevitably be mistakes in your design decisions. Over time, these mistakes will become obvious, and left unchecked, can facilitate growth of complexity as developers hack around the code’s shortcomings. When a design problem of this sort arises, don’t just patch around it, and definitely don’t ignore it; take a little extra time to fix it.

3.3 - How much to invest?

A huge upfront investment, such as trying to design the entire system, won’t be effective. This approach is commonly known as waterfall, and it generally doesn’t work very well for building quality software. The ideal design tends to emerge in bits and pieces, as the system comes together and you get more experience with it. Thus, the best approach is to make lots of small investments on a continual basis. A good amount is ~10-20% of total development time on investments. Small enough to not significantly affect schedules, but large enough to yield tangible benefits over time. There isn’t a lot of research on this topic, but anecdotally, a strategic approach will overtake a tactical approach in terms of pace of delivery within weeks, not months (see

Is High Quality Software Worth the Cost?

)

There are occasions where it makes more sense to take a tactical approach, for example, when building an intentionally short-lived piece of software or proof of concept. Just be reeeaally sure that that is what you are building, and that your hacked together proof of concept isn’t the rotten foundation of something long-lived.

Chapter 4: Modules Should Be Deep

One of the most important techniques for managing software complexity is to design systems so that developers only need to face a small fraction of the overall complexity at any given time. This approach is called modular design..

4.1 - Modular design

In modular design, a software system is decomposed into a collection of modules that are relatively independent. Modules can take many forms, such as classes, subsystems, or services. Modules must work together by calling each others’ functions or methods. As a result, modules must know something about each other. This is where dependencies lie: if one module changes, other modules may need to change to match. For example, if the arguments of a method change, all callers of that method must change to adhere to the new method signature.

In order to manage dependencies, we can think of each module in two parts: an interface and an implementation. The interface consists of everything that a developer working in a different module must know in order to use the given module. Typically, the interface describes what the module does but not how it does it. The implementation consists of the code that carries out the promises made by the interface.

In this context, a module is any unit of code that has an interface and an implementation. Each class in an object-oriented language is a module. Methods within a class, or functions in a non-OO language can also be thought of as modules: each have an interface and an implementation, and each can have modular design techniques applied to them. Higher-level subsystems and services are also modules; their interfaces may take different forms, such as kernel calls or HTTP requests.

  • A simple interface minimises the complexity that a module imposes on the rest of the system.
  • If a module is changed in a way that does not change its interface, then it is quite likely that no other module will be directly affected by the change.

4.2 - What’s in an interface?

An interface contains two kinds of information: formal, and informal. Formal information is specified explicitly in the code, for example, the formal interface for a method is its signature, which includes its name and the names and types of its arguments, as well as the methods return type. The formal interface for a class consists of the signatures for all public methods, plus the names and types of any public variables.

Informal information, by contrast, is any information relating to a module that is not explicitly captured code. This can include things like high-level behavior or side-effects. If there are constraints on the usage of a class (perhaps one method must be called before another), these are also part of the classes interface. In general, if a developer needs to know a particular piece of information in order to use a module, then that information is part of the module’s interface. The informal aspects of an interface can only be described using comments, and the programming language cannot ensure that the description is complete or accurate.

One of the benefits of a clearly specified interface is that it indicates exactly what a developer needs to know in order to use the associated module. This helps eliminate the “unknown unknowns” problem from earlier.

4.3 - Abstractions

The term abstraction is closely related to the idea of modular design.

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 the form of its interface. The interface presents a simplified view of the module’s functionality.

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 if it is unimportant. An abstraction can go wrong in two ways:

  1. It can include details that are not really important; when this happens, i makes the abstraction more complicated than necessary, increasing the cognitive load on developers using the abstraction.
  2. The 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.

4.4 - Deep modules

Untitled

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 module imposes on the rest of the system: the smaller and simpler the interface, the less complexity that it introduces. Interfaces are good, but more, or larger, interfaces are not necessarily better.

4.5 - Shallow modules

A shallow module is one whose interface is relatively complex in comparison to the functionality it provides.

Here is an extreme example of a shallow method, taken from a project in a software design class:

private void addNullValueForAttribute(String attribute) {
    data.put(attribute, null);
}

From the standpoint of managing complexity, this method makes things worse, not better. This method offers no abstraction, since all of the functionality is visible through the interface. It is no simpler to think about the abstraction, than it is to think about the full implementation. If it were to be documented properly, the documentation would be longer than the code it is documenting. It even takes more keystrokes to invoke the method than it would take for the caller to just manipulate the data object directly. The method adds complexity (in the form of a new interface for developers to know about, and a new layer of indirection between them and the behavior they want to invoke) but provides no compensating benefit.

4.6 - Classitis

Conventional wisdom in OO programming is that classes should be small, not deep. That the most important thing in class design is to break up larger classes into smaller ones. The same is often said about methods: “Any method larger 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 complexity.

Classitis takes this conventional wisdom to the extreme: “classes are good, so more classes is better”. In systems suffering from classitis, developers are encouraged to minimise the amount of functionality in each new class: if you want more functionality, introduce more classes. Each class in isolation may be fairly simple, but they don’t contribute much functionality, so there has to be a lot of them. Each new class adds its own interface, and these interfaces accumulate to create incredible complexity at the system level.

4.7 - Example: Java and Unix I/O

One particularly egregious example of classitis today is the Java system library. There is nothing in the Java language that necessitates lots of small classes, but a culture of classitis seems to have taken root in the Java programming community. For example, to open a file in order to read serialized objects from it, you must create three different objects:

FileInputStream fileStream = new FileInputStream(fileName);
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);

The first object, FileInputStream, only provides the most basic of I/O: it cannot perform buffered I/O or read or write serialized objects. The BufferedInputStream object adds buffering to a FileInputStream, and the ObjectInputStream adds the ability to read and write serialized objects. The first two objects in the code above, fileStream and bufferedStream, are never used once the file has been opened; all future operations use objectStream.

It is particularly annoying (and error-prone) that buffering must be requested explicitly by creating a separate BufferedInputStream object; if a developer forgets to create this object, there will be no buffering and I/O will be slow.

Perhaps the Java developers would argue that not everyone wants to use buffering for file I/O, so it shouldn’t be built into the base mechanism. They might argue that it’s better to keep buffering separate, so people can choose whether or not to use it. Providing choice is good, but

Almost every user of file I/O will want buffering, so it should be provided by default. For those few situations where buffering is not desirable, the library can provide a mechanism to disable it. Any mechanism for disabling buffering should be cleanly separated in the interface (for example, by providing a different constructor for FileInputStream, or through a method that disables or replaces the buffering mechanism), so that most developers do not even need to be aware of its existence.

Contrast this with I/O system calls in Unix. For example, they recognised that sequential I/O is the most commonly use, so they made that the default behaviour. Random access is still possible, using the lseek system call, but developers doing only sequential access do not need to be aware of that mechanism at all.

Chapter 10: Define errors out of existence

The key lesson from this chapter is to reduce the number of places where exceptions must be handled; in many cases the semantics of operations can be modified so that the normal behavior handles all situations and there is no exceptional condition to report.

The exceptions thrown by a class are part of its interface; classes with lots of exceptions have complex interfaces, and they are shallower than classes with fewer exceptions.

Four techniques for reducing exception handling

  1. Define errors out of existence

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

    File deletion in Windows

    The Windows operating system does not permit a file to be deleted while it is open in a process. Users are forced to hunt down the process in which the file is open, as often there is no indication which process it might be. Users may just give up and reboot their system, just so they can delete a file.

    The Unix operating system handles file deletion much more elegantly. In Unix, if a user deletes a file, it is not actually deleted immediately. Instead the file is marked for deletion, and then the delete operation returns successfully. The file name has been removed from its directory, so no other processes can open the old file and a new file with the same name can be created, but the existing file data persists. Processes that already have the file open can continue to read it and write it normally. Once the file has been closed by all of the accessing processes, its data is freed.

    Java substring

    Given two indexes into a string, substring returns the substring starting at the character given by the first index and ending with the character just before the second index. However, if either index is outside the range of the string, then substring throws IndexOutOfBoundsException.

    The Java substring method would be easier to use if it performed this adjustment automatically, so that it implemented the following API: “returns the characters of the string (if any) with index greater than or equal to beginIndex and less than endIndex.” This is a simple and natural API, and it defines the IndexOutOfBoundsException exception out of existence. The method’s behavior is now well-defined even if one or both of the indexes are negative, or if beginIndex is greater than endIndex.

  2. Mask exceptions

    With this approach, an exceptional condition is detected and handled at a low level in the system, so that higher levels of software need not be aware of the condition. Exception masking is particularly common in distributed systems. For instance, in a network transport protocol such as TCP, packets can be dropped for various reasons such as corruption and congestion. TCP masks packet loss by resending lost packets within its implementation, so all data eventually gets through and clients are unaware of the dropped packets.

    Exception masking is an example of pulling complexity downward.

  3. Exception aggregation

    The idea behind exception aggregation is to handle many exceptions with a single piece of code; rather than writing distinct handlers for many individual exceptions, handle them all in one place with a single handler.

    Exception aggregation works best if an exception propagates several levels up the stack before it is handled; this allows more exceptions from more methods to be handled in the same place. This is the opposite of exception masking: masking usually works best if an exception is handled in a low-level method. For masking, the low-level method is typically a library method used by many other methods, so allowing the exception to propagate would increase the number of places where it is handled. Masking and aggregation are similar in that both approaches position an exception handler where it can catch the most exceptions, eliminating many handlers that would otherwise need to be created.

  4. Just crash?

    In most applications there will be certain errors that it’s not worth trying to handle. Typically, these errors are difficult or impossible to handle and don’t occur very often. The simplest thing to do in response to these errors is to print diagnostic information and then abort the application.

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.

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.

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.

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.

Chapter 12: Why write Comments?

The Four Excuses

12.1: Good code is self-documenting

The idea here is that your variable names and method names make it obvious what the code is doing. The are however, things that can’t easily be captured in this way, like design decisions, high-level description of what a method does, or the meaning of its result.

Some people may then argue that if others want to know what a method does, they should just read the code. This is time-consuming, and there may still be information missing from their understanding, like the why of a particularly interesting design decision.

Moreover, comments are fundamental to abstractions. Recall from Chapter 4 that the goal of abstractions is to hide complexity: an abstraction is a simplified view of an entity, which preserves essential information but omits details that can safely be ignored.

… all of the complexity of the method is exposed.

12.2: I don’t have time to write comments

Software projects are almost always under time pressure, and there will always be things that seem higher priority than writing comments. Thus, if you allow documentation to be de-prioritized, you’ll end up with no documentation.

Ask yourself how much time you spend writing code, as compared to the time spent reading; designing; compiling; and testing code. It is unlikely that the answer is more than 10% of the total time spent being writing code.

Chapter 14: Choosing Names

Chapter 15: Write The Comments First

(Use Comments As Part Of The Design Process)

An approach:

  • For a new class, I start by writing the class interface comment.
  • Next, I write interface comments and signatures for the most important public methods, but I leave the method bodies empty.
  • I iterate a bit over these comments until the basic structure feels about right.
  • At this point I write declarations and comments for the most important class instance variables in the class.
  • Finally, I fill in the bodies of the methods, adding implementation comments as needed.
  • While writing method bodies, I usually discover the need for additional methods and instance variables. For each new method I write the interface comment before the body of the method; for instance variables I fill in the comment at the same time that I write the variable declaration.

Comments serve as a canary in the coal mine of complexity. If a method or variable requires a long comment, it is a red flag that you don’t have a good abstraction. Remember from Chapter 4 that classes should be deep: the best classes have very simple interfaces yet implement powerful functions.

Chapter 16: Modifying Existing Code

If you want to maintain a clean design for a system, you must take a strategic approach when modifying existing code. Ideally, when you have finished with each change, the system will have the structure it would have had if you had designed it from the start with that change in mind.

16.2 - Maintaining comments: keep the comments near the code

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.

16.3 - Comments belong in the code, not the commit log

For example, a commit message describing a subtle problem that motivated a code change. If this isn’t documented in the code, then a developer might come along later and undo the change without realizing that they have re-created a bug.

16.4 - Maintaining comments: avoid duplication

If documentation is duplicated, it is more difficult for developers to find and update all of the relevant copies. Instead, try to document each design decision exactly once. If there are multiple places in the code that are affected by a particular decision, try to find the most obvious single place to put the documentation.

If there is no “obvious” single place to put a particular piece of documentation where developers will find it, create a designNotes file. Or, pick the best of the available places and put the documentation there.

Don’t redocument one module’s design decisions in another module. For example, don’t put comments before a method call that explain what happens in the called method. If readers want to know, they should look at the interface comments for the method.

Try to make it easy for developers to find appropriate documentation, but don’t do it by repeating the documentation.

If information is already documented someplace outside your program, don’t repeat the documentation inside the program; just reference the external documentation.

Chapter 17: Consistency

Consistency creates cognitive leverage: once you have learned how something is done in one place, you can use that knowledge to immediately understand other places that use the same approach.

Consistency reduces mistakes. If a system is not consistent, two situations may appear the same when in fact they are different.

17.1 - Examples of consistency

  • Names. Chapter 14 has already discussed the benefits of using names in a consistent way.
  • Coding style. It is common nowadays for development organizations to have style guides that restrict program structure beyond the rules enforced by compilers.
  • Interfaces. An interface with multiple implementations is another example of consistency. Once you understand one implementation of the interface, any other implementation becomes easier to understand because you already know the features it will have to provide.
    • Conversely, an interface with only one implementation may be a smell for a poor abstraction.
  • Design patterns. Design patterns are generally-accepted solutions to certain common problems, such as the model-view-controller approach to user interface design. If you can use an existing design pattern to solve the problem, the implementation will proceed more quickly, it is more likely to work, and your code will be more obvious to readers.
  • Invariants. An invariant is a property of a variable or structure that is always true. For example, a data structure storing lines of text might enforce an invariant that each line is terminated by a newline character. Invariants reduce the number of special cases that must be considered in code and make it easier to reason about the code’s behavior.

17.2 - Ensuring consistency

  • Document. Create a document that lists the most important overall conventions, such as coding style guidelines. Place the document in a spot where developers are likely to see it, such as a conspicuous place on the project Wiki, or in a CONTRIBUTING.md.

    • Encourage new people joining the group to read the document, and encourage existing people to review it every once in a while. Several style guides from various organizations have been published on the Web; consider starting with one of these.
    • For conventions that are more localized, such as invariants, find an appropriate spot in the code to document them. If you don’t write the conventions down, it’s unlikely that other people will follow them.
  • Enforce. Even with good documentation, it’s hard for developers to remember all of the conventions. The best way to enforce conventions is to write a tool that checks for violations, and make sure that code cannot be committed to the repository unless it passes the checker. Automated checkers work particularly well for low-level syntactic conventions.

    • He mentions code review as a good opportunity to enforce code style conventions, going as far as saying

    The more nit-picky that code reviewers are, the more quickly everyone on the team will learn the conventions, and the cleaner the code will be.

    My experience has been that code review is not a good time to enforce nit-picky code style conventions. Enforced via something like prettier or style cop, or nothing at all. Code review should primarily be for code design or bug catching.

  • When in Rome … The most important convention of all is that every developer should follow the old adage “When in Rome, do as the Romans do.” When working in a new file, look around to see how the existing code is structured.

    • Are all public variables and methods declared before private ones? Are the methods in alphabetical order? Do variables use “camel case,” as in firstServerName, or “snake case,” as in first_server_name? When you see anything that looks like it might possibly be a convention, follow it. When making a design decision, ask yourself if it’s likely that a similar decision was made elsewhere in the project; if so, find an existing example and use the same approach in your new code.
  • Don’t change existing conventions. Resist the urge to “improve” on existing conventions. Having a “better idea” is not a sufficient excuse to introduce inconsistencies.

    • Your new idea may indeed be better, but the value of consistency over inconsistency is almost always greater than the value of one approach over another. Before introducing inconsistent behavior, ask yourself two questions. First, do you have significant new information justifying your approach that wasn’t available when the old convention was established? Second, is the new approach so much better that it is worth taking the time to update all of the old uses?

17.3 - Taking it too far

Consistency means not only that similar things should be done in similar ways, but that dissimilar things should be done in different ways.

If you become overzealous about consistency and try to force dissimilar things into the same approach, such as by using the same variable name for things that are really different or using an existing design pattern for a task that doesn’t fit the pattern, you’ll create complexity and confusion. Consistency only provides benefits when developers have confidence that “if it looks like an x, it really is an x.”

Chapter 18: Code Should Be 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.

18.1 Things that make code more obvious

  • Precise and meaningful names clarify the behavior of the code and reduce the need for documentation. If a name is vague or ambiguous, then readers will have read through the code in order to deduce the meaning of the named entity; this is time-consuming and error-prone.
  • The second technique is consistency. If similar things are always done in similar ways, then readers can recognize patterns they have seen before and immediately draw (safe) conclusions without analyzing the code in detail.
  • Judicious use of white space. The way code is formatted can impact how easy it is to understand.
  • Comments. Sometimes it isn’t possible to avoid code that is nonobvious. When this happens, it’s important to use comments to compensate by providing the missing information. To do this well, you must put yourself in the position of the reader and figure out what is likely to confuse them, and what information will clear up that confusion.

18.2 - Things that make code less obvious

  • Event-driven programming. In event-driven programming, an application responds to external occurrences, such as the arrival of a network packet or the press of a mouse button. One module is responsible for reporting incoming events. Other parts of the application register interest in certain events by asking the event module to invoke a given function or method when those events occur.

    • Event-driven programming makes it hard to follow the flow of control. The event handler functions are never invoked directly; they are invoked indirectly by the event module, typically using a function pointer or interface. Even if you find the point of invocation in the event module, it still isn’t possible to tell which specific function will be invoked: this will depend on which handlers were registered at runtime. Because of this, it’s hard to reason about event-driven code or convince yourself that it works.

    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 may be 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:

    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.

    19.1 - Object-oriented programming and inheritance

    Interface inheritance (interface ⇒ class) provides leverage against complexity by reusing the same interface for multiple purposes.

    Another way of thinking about this is in terms of depth:

    In order for an interface to have many implementations, it must capture the essential features of all the underlying implementations while steering clear of the details that differ between the implementations; this notion is at the heart of abstraction.

    Implementation inheritance (parent class ⇒ child class) can remove some need for duplication, if related types would implement the same functionality.

    However, implementation inheritance creates dependencies between the parent class and each of its subclasses. Class instance variables in the parent class are often accessed by both the parent and child classes; this results in information leakage between the classes in the inheritance hierarchy and makes it hard to modify one class in the hierarchy without looking at the others.

    Implementation inheritance should be used with caution. Before using implementation inheritance, consider whether an approach based on composition can provide the same benefits. For instance, it may be possible to use small helper classes to implement the shared functionality. Rather than inheriting functions from a parent, the original classes can each build upon the features of the helper classes.

    If there is no viable alternative to implementation inheritance, try to separate the state managed by the parent class from that managed by subclasses. One way to do this is for certain instance variables to be managed entirely by methods in the parent class, with subclasses using them only in a read-only fashion or through other methods in the parent class. This applies the notion of information hiding within the class hierarchy to reduce dependencies.

    19.4 - Test-driven development

    The problem with test-driven development is that it focuses attention on getting specific features working, rather than finding the best design.

    This is tactical programming pure and simple, with all of its disadvantages. Test-driven development is too incremental: at any point in time, it’s tempting to just hack in the next feature to make the next test pass. There’s no obvious time to do design, so it’s easy to end up with a mess.

    The units of development should be abstractions, not features. Once you discover the need for an abstraction, don’t create the abstraction in pieces over time; design it all at once (or at least enough to provide a reasonably comprehensive set of core functions). This is more likely to produce a clean design whose pieces fit together well.

    One place where it makes sense to write the tests first is when fixing bugs. Before fixing a bug, write a unit test that fails because of the bug. Then fix the bug and make sure that the unit test now passes. This is the best way to make sure you really have fixed the bug. If you fix the bug before writing the test, it’s possible that the new unit test doesn’t actually trigger the bug, in which case it won’t tell you whether you really fixed the problem.

    19.5 - Design patterns

    A design pattern is a commonly used approach for solving a particular kind of problem, such as an iterator or an observer.

    If a design pattern works well in a particular situation, it will probably be hard for you to come up with a different approach that is better.

    The greatest risk with design patterns is over-application. Not every problem can be solved cleanly with an existing design pattern; don’t try to force a problem into a design pattern when a custom approach will be cleaner.

    19.6 - Getters and setters

    Getters and setters aren’t strictly necessary, since instance variables can be made public. The argument for getters and setters is that they allow additional functions to be performed while getting and setting, such as updating related values when a variable changes, notifying listeners of changes, or enforcing constraints on values. Even if these features aren’t needed initially, they can be added later without changing the interface.

    Although it may make sense to use getters and setters if you must expose instance variables, it’s better not to expose instance variables in the first place. Exposed instance variables mean that part of the class’s implementation is visible externally, which violates the idea of information hiding and increases the complexity of the class’s interface. Getters and setters are shallow methods (typically only a single line), so they add clutter to the class’s interface without providing much functionality. It’s better to avoid getters and setters (or any exposure of implementation data) as much as possible.

    Chapter 20: Designing for Performance

    20.1 - How to think about performance

    The first question to address is “how much should you worry about performance during the normal development process?”

    The best approach is something between the extremes of “optimise all the things” and completely ignoring performance. We can use basic knowledge of performance to choose design alternatives that are “naturally efficient” yet also clean and simple. The key is to develop an awareness of which operations are fundamentally expensive. Here are a few examples of operations that are relatively expensive today:

    • Network communication

    • I/O to secondary storage

    • Dynamic memory allocation (malloc in C, new in C++ or Java)

    • Cache misses

      look into this ^

    Once you have a general sense for what is expensive and what is cheap, you can use that information to choose cheap operations whenever possible. In many cases, a more efficient approach will be just as simple as a slower approach.

    For example, when storing a large collection of objects that will be looked up using a key value, you could use either a hash table or an ordered map. Both are commonly available in library packages, and both are simple and clean to use. However, hash tables can easily be 5–10x faster. Thus, you should always use a hash table unless you need the ordering properties provided by the map.

    If the more efficient design adds only a small amount of complexity, and if the complexity is hidden, so it doesn’t affect any interfaces, then it may be worthwhile (but beware: complexity is incremental). If the faster design adds a lot of implementation complexity, or if it results in more complicated interfaces, then it may be better to start off with the simpler approach and optimize later if performance turns out to be a problem. However, if you have clear evidence that performance will be important in a particular situation, then you might as well implement the faster approach immediately.

    20.2 - Measure before modifying

    This serves two purposes. First, the measurements will identify the places where performance tuning will have the biggest impact.

    The second purpose of the measurements is to provide a baseline, so that you can re-measure performance after making your changes to ensure that performance actually improved. If the changes didn’t make a measurable difference in performance, then back them out (unless they made the system simpler). There’s no point in retaining complexity unless it provides a significant speedup.

    20.3 - Design around the critical path

    At this point, let’s assume that you have carefully analyzed performance and have identified a piece of code that is slow enough to affect the overall system performance. The best way to improve its performance is with a “fundamental” change, such as introducing a cache, or using a different algorithmic approach (balanced tree vs. list, for instance).

    Unfortunately, situations will sometimes arise where there isn’t a fundamental fix. Redesigning an existing piece of code so that it runs faster should be your last resort, and it shouldn’t happen often, but there are cases where it can make a big difference. The key idea is to design the code around the critical path.

    The method

    Start off by asking yourself what is the smallest amount of code that must be executed to carry out the desired task in the common case. Disregard any existing code structure. Imagine instead that you are writing a new method that implements just the critical path, which is the minimum amount of code that must be executed in the the most common case. The current code is probably cluttered with special cases; ignore them in this exercise. The current code might pass through several method calls on the critical path; imagine instead that you could put all the relevant code in a single method. The current code may also use a variety of variables and data structures; consider only the data needed for the critical path, and assume whatever data structure is most convenient for the critical path. For example, it may make sense to combine multiple variables into a single value. Assume that you could completely redesign the system in order to minimize the code that must be executed for the critical path. Let’s call this code “the ideal.”

    The ideal code probably clashes with your existing class structure, and it may not be practical, but it provides a good target: this represents the simplest and fastest that the code can ever be. The next step is to look for a new design that comes as close as possible to the ideal while still having a clean structure. You can apply all of the design ideas from previous chapters of this book, but with the additional constraint of keeping the ideal code (mostly) intact. You may have to add a bit of extra code to the ideal in order to allow clean abstractions; for example, if the code involves a hash table lookup, it’s OK to introduce an extra method call to a general-purpose hash table class. In my experience it’s almost always possible to find a design that is clean and simple, yet comes very close to the ideal.

    One of the most important things that happens in this process is to remove special cases from the critical path. When code is slow, it’s often because it must handle a variety of situations, and the code gets structured to simplify the handling of all the different cases. Each special case adds a little bit of code to the critical path, in the form of extra conditional statements and/or method calls. Each of these additions makes the code a bit slower. When redesigning for performance, try to minimize the number of special cases you must check.

0 items under this folder.