As Ousterhout (2018) reminds us in his book ‘A Philosophy of Software Design” when addressing the problem of how to design good software we should bear in mind some principles in order to dig deeper into the skills that make the so-called “10X programmers” an order of magnitude more effective than average programmers.
A modular design decomposes systems into components/modules by replacing global complexity with local complexity. Modularity also provides a malleable structure to change the design over time. In a modular design with optimized complexity, the work is proportional to the functionality being developed, and developers do not need a lot of prior knowledge to design their portion of the system.
On the other hand, as modules must work together to build an overall system, the interfaces between modules create dependencies. In other words, every new interface adds complexity. As an interface creates an opportunity for additional bugs (information leakage, side effects) and misunderstanding (users can extrapolate about assumptions) a new module should only be introduced only when the benefits outweigh the complexity.
Within the light of this information, here are some tips to design classes and interfaces:
Classes should be deep: Conventional wisdom is that classes should be small (readable). Yet, too many classes can be non-ideal. Instead, classes should be “deep” in the sense that the interface is simple in proportion to the functionality it implements (visualized as a rectangle, the top interface edge is small, and the implementation depth of the box is much larger, akin to a “deep” stack of paper). A great example files IO in Linux where the interface is simple (open, close, read, write, lseek) but the implementation abstracts out a lot of complexity.
Classes should be “somewhat” generic: A key challenge with defining a new class is on how general-purpose it needs to be made. A good rule of thumb is to implement it as “somewhat generic”, yet future-proof the design to define a set of capabilities that can potentially be used for other purposes in the future. Even when implementing special-purpose code, it is a good idea to separate special purpose code from general-purpose code.
Keep interfaces simple: The class interface represents the information that developers working on other classes need to know about this class. Thus, the interface represents the complexity that the class imposes on the rest of the system; the simpler and smaller the interface, the less complex it creates. While the formal parts of the interface are those explicitly specified in the code, informal elements capture high-level behavior of the method such as side effects or constraints on how the class can be invoked. If a developer needs to know a piece of information to use a class, that is part of the interface.
Hide local information and avoid information leakage: Each class should encapsulate a few pieces of knowledge (“hide information”) which typically represent design decisions such as how to implement a protocol, or how to schedule threads. Information hiding reduces complexity in two ways: (1) it simplifies the interface to a class reducing the cognitive load on developers, and (2) it makes it easier to evolve the system; design changes can be localized.
Yet, hiding information also creates a hard issue as in practice, information can leak across a class boundary, for example when a design decision creates a dependency between multiple classes or when a class has informal interfaces that touch multiple places (e.g., a common file format or protocol). Information leakage can be addressed in multiple ways:
- Larger classes and higher-level interfaces: The design can be re-organized in such a way that a particular piece of knowledge affects only a single class. Often, information hiding can be improved by making a class slightly larger when the structure follows a temporal decomposition based on the time order of the operations- e.g., classes for file read, modify, write; the information leakage about the file format can be avoided by rewriting to have a larger class. Information hiding can also be improved by raising the level of the interface.
- Partial information hiding and making the common case simple: API (Application programming interface)s for commonly used features should be separated from those that are rarely used so that users don’t have to learn about features they may not use. Whenever possible, classes should just do the right thing without being explicitly asked.
Exceptions and subtle implicit interfaces: define them out of existence: An exception is an uncommon condition that alters the normal flow of control in a program. Every class has conditions for exceptions ranging from bad arguments or configurations, or when an invoked operation fails (e.g., lost packets). Exceptions can lead to the inconsistent state requiring operations to unwind changes; an exception code is often not well tested and might not work or cause additional exceptions.
The best way to reduce such complexity is to reduce the number of places where exceptions have to be handled. For example, an unset function, rather than defined to delete a variable, can be defined to ensure that a variable does not exist; this subtle change to the interface eliminates the need to throw an exception when the class is invoked with an undefined variable. Such approaches can significantly reduce complexity without appreciably changing the end outcome.
Problem decomposition: determining when to create a new class: Given two pieces of functionality, a key challenge is to determine whether they should be together or separate. Two pieces of the code shall be brought together if they share information or overlap conceptually or combine into a more useful higher-level category or when combining code permits interfaces that are easier to use. Also, code combination should occur if they are most likely to be always used together and if it will avoid duplication while not complicating the interface significantly.
In general, introduce a new abstraction for a new layer (network stack, file system stack, compilers, etc). But watch out for some “classitis” red flags: adjacent layers with similar abstractions or an implementation that is very similar to the interface. Some exceptions to these red flags, however, are exceptional methods that act as dispatchers (they hide information that is essentially a table of rules) or peer methods with the same interface used to address heterogeneity.
Implementation by pushing complexity down: Even if the implementation is complex, the effects are localized to that class. The implementation of the class does not affect the rest of the system. However, once the best interface is designed for a class, the same design principles can be applied recursively to the class internals and subclasses, to find the simplest implementation.
Separating interfaces and implementations creates abstractions that can fight complexity by providing simple ways to think about complex pieces of code.