Must classes be small? Wrong question.
"A Philosophy of Software Design" pt.2 - Deep vs Shallow modules
Let’s start from real code. Take a look at these two snippets.
This represents the signatures of the five basic I/O UNIX system calls:
And this was the old/classic way in Java to open a file and read it into serialized objects:
You don't have to be a C expert or a Java expert. Obviously, we are not even comparing the same thing, but are you able to notice something in terms of abstraction?
On one hand, we have the simplicity (some would say the beauty) of the UNIX interface, concealing incredibly complicated abstractions and underlying behaviors. Just think about the many different scenarios this interface allows to be used in. On the other hand, we can see a somewhat cumbersome surfacing of lower-level details. In Java, a FileInputStream lacks buffered I/O and serialization support, requiring explicit use of BufferedInputStream and ObjectInputStream to address these limitations. This explicit need is dictated by the unfortunately chosen default behavior, not well-balanced in this interface design.
Software design is a complex process, and managing this complexity is crucial for creating maintainable and scalable systems. In his book "A Philosophy of Software Design," John Ousterhout explores the concept of deep vs shallow modules, shedding light on a fundamental principle in software design.
Modular Design
The cornerstone of Ousterhout's philosophy is modular design, where a software system is decomposed into modules, each serving a specific function. These modules, whether classes, subsystems, or services, aim to be relatively independent. While the ideal of complete independence is unattainable due to necessary interactions between modules, the goal is to minimize dependencies, allowing for easier maintenance and modification.
To manage dependencies effectively, we have to understand the role of interfaces, including formal and informal elements. Formal elements, explicitly specified in the code, include method signatures and class attributes, as obvious. Informal elements, usually described through comments or documentation in general, encompass high-level behaviors and usage constraints. A well-defined interface helps developers understand what is essential for utilizing a module, mitigating the "unknown unknowns" problem.
Abstractions play a key role in modular programming, providing simplified views of entities by omitting unimportant details. In the context of modules, the interface serves as an abstraction, presenting a streamlined perspective of the module's functionality while hiding implementation intricacies. Ousterhout emphasizes the need to discern what details are truly important to avoid both unnecessary complexity and obscurity.
Deep vs Shallow Modules
Deep Modules: “Less is better and hide complexity”
The concept of deep modules encapsulates the idea that the best modules offer powerful functionality with simple interfaces. Visualizing modules as rectangles, the area represents functionality, and the top edge signifies interface complexity. Deep modules provide substantial benefits with minimal costs, as their interfaces hide internal complexities. Examples such as the Unix I/O interface or garbage collectors (where the interface is mostly zero) showcase the effectiveness of deep modules in providing powerful abstractions.
Shallow Modules: “More is better and expose complexity”
Conversely, shallow modules have relatively complex interfaces compared to the functionality they provide.
Sometimes, we end up with shallow modules due to laziness in the design process, simply accepting requests for new features and adding them on top of what we have, instead of thinking ahead (see Tactical vs Strategic Developer) about how to prepare our software to accept changes. When the time arrives, we should pause and consider the best way to incorporate these changes. You may even discover that the best course of action is to not add them at all, or that the initial thought you had was merely a shortcut rather than a wise design decision.
Sometimes, the system is already designed in such a way that keeping the abstraction simple becomes too complicated or expensive. Once again, we may have overlooked something in the design process so far, and in the worst-case scenario, we should at least try to mitigate the problem instead of relying on the fact that anyway we are already working on something broken.
In general, while occasionally unavoidable, shallow modules contribute less to managing complexity and lead us into a vicious cycle that incrementally worsens the quality of our system and interfaces.
Classitis
Many of us have heard something like, “A class should be at most X lines of code” or “A method/function longer than Y lines of code must be split into multiple methods/functions”. This is sometimes blindly taken to the extreme without considering the specific case or, even worse, without considering the damage brought at the interface level. Think about jumping around in a codebase, navigating from a small function to a small function, only called from one or two points in the code and exposing an obscure subset of parameters, clearly part of a higher business logic. You clearly feel lost. Does this sound familiar?
Ousterhout refers to this phenomenon as “classitis”, the tendency to promote numerous small classes, overlooking the risks of verbose code, escalating complexity, and shallow interfaces.
Conclusion
In conclusion, when designing a module, a system, an API, a service, a library, or a function, don’t just rely on principles that sound cool on paper. Ask yourself what the real outcome of your decision and design is. It could be something small, like adding a parameter to a function or exposing a seemingly insignificant implementation detail to the user of your interface. Designing deep classes and dividing your module interface from its implementation hides complexity, allowing users to interact with a simplified abstraction that prioritizes common use cases.