Going Large-Scale with C++, Part 2: Maximizing Returns on Scale with Hierarchical Reuse
Read Part 1.
Introduction
Widening the scope of a software project brings opportunities for effective reuse. In Part 1 of this series, we considered the development efforts involved in increasing size for a single application. Such applications could be partitioned over time into a sequence of successive projects, shorter development segments (sometimes called sprints), or successive versions of the product.
In this article, we consider that even disparate projects developed within a single enterprise are likely to comprise similar, lower-level components. By adhering to canonical class categories, we can more easily extract commonality at every level of the physical hierarchy, and thereby dramatically improve upon the efficiency gains afforded by conventional reuse.
Multiple Applications and Global Reuse
Suppose your enterprise has multiple related products at varying stages of development. The opportunities for reuse potentially increase by an order of magnitude! A well-factored component arising from one application or product might now be reused in many others. Of course, such a "reusable" component must be segregated from malleable application code, and must reside in a stable library at an appropriate level in the enterprise's physical hierarchy. This kind of refactoring, known as demotion, is one of several levelization techniques commonly used to avoid cyclic or excessive link-time dependencies. [1]
Software Insulation Techniques
As code size continues to increase, we encounter a new problem: excessive compile-time coupling. Ideally, making a local change requires recompiling only a single translation unit. However, even a minor change to the implementation of C++ templates or inline functions can force significant portions of a large code base to be recompiled, with potentially devastating implications for development time.
Fortunately, a variety of specific techniques exist for insulating implementation details (such as abstract base classes and non-inline methods) so that they can be modified without forcing clients to recompile. [2] These insulation techniques are most effective when applied early in the software development lifecycle.
Preventing Client Misuse of Libraries
Misuse of perfectly correct library software by its clients is a common source of errors, and this misuse tends to grow along with a successful code base, leading to increasing support costs. Design by contract, characterized by explicit preconditions and postconditions, facilitates testing—arguably for even small, short-lived programs. As the development effort grows to span several developers or longer time periods, the essential behavior of each function, along with the specific conditions on initial state and input that clients must satisfy prior to invoking it, can no longer reside solely in the minds of developers. Thoughtful, descriptive function and parameter names are always vital, but cannot substitute for concise function-level contracts that are primarily directed at potential human clients, yet sufficient to enable thorough unit testing.
Communicating all of the necessary contract information in source code is not viable. Certain preconditions also require a knowledge of execution history and/or global context that simply cannot be validated mechanically—not even at runtime. The only general, practical way to capture all the information developers need to use a function properly is to spell it out in prose—a dimension of engineering that takes considerable time and effort for most software developers to master.
Defensive Programming
We need to weed out client misuse early, during development and beta testing, without impacting production software. In our methodology, each application must be able to specify coarsely (at compile time) the amount of runtime checking that robust library software, such as Bloomberg's Basic Development Environment (BDE) should spend checking for precondition violations. If a precondition violation is detected, the application should also be able to specify (at runtime) the specific action to be taken, such as aborting the program, throwing an exception, logging an error, saving client data, or whatever else might make sense for that particular application. By using a centralized defensive programming facility such as bsls_assert, [3] each individual application owner retains explicit control over every aspect of defensive checking during each phase of development, up to and including the release of production software.
Moving from Conventional to Hierarchical Reuse
As the sheer magnitude of software grows, we can observe common recurring patterns in even the sub-parts of the components that make up a stable infrastructure. Although we could rewrite these sub-parts each time the need arises, a better option is to factor them out into their own useful, stable components, placing them in appropriate (lower-level) libraries within the enterprise-wide physical hierarchy, and then making those libraries publicly accessible. With this practice, both developers and clients can reuse these well-crafted, thoroughly tested implementation details without continually having to reinvent them.
To this end, we segregate each publicly accessible class into its own distinct physical component, unless there is a compelling reason to do otherwise (such as C++ friendship). [5] This extremely beneficial form of fine-grained reuse constitutes what we refer to as hierarchical reuse.
Vocabulary Types and Interoperability
Not all repeated code is troublesome. Redundant code in the implementations of several modules can cause unnecessary code bloat and lead to increased maintenance costs, but is otherwise relatively innocuous. Compare that with distinctly named types that represent the same concrete value (such as a date) or abstract service (such as a connection) across functional interface boundaries. Duplication of such critical vocabulary types quickly leads to interoperability problems across cooperating subsystems.
A pervasive example of duplicate value types leading to significant performance degradation (due to frequent conversions from one to the other) is that of (const char *) and std::string, as has been observed in the Chromium project. [6] Given an increasing body of software developed by a single entity, a centralized authority will need to ensure a couple of things:
- Duplication of vocabulary types is avoided.
- A single vocabulary type for any required value or service resides at the appropriate level in the enterprise-wide physical hierarchy.
Canonical Rendering
Consistent rendering is another important consideration. A few people can easily agree on a general style. Larger development groups have more difficulty reaching consensus on any sort of consistent, uniform rendering. As projects grow, codifying such standards in writing and reinforcing them with supporting tools becomes increasingly important.
Allowing multiple styles to creep in virtually ensures that no unified style will emerge. Gratuitous variation in rendering adds no value, and it detracts from human understanding by masking important visual cues and obscuring the location of needed information. Unfortunately, the larger the project, the harder it is to avoid such variations. A style-checking tool is an important part of any substantial development effort.
Organizing Code Into Canonical Class Categories
Large bodies of software are notorious for being incomprehensible. Insisting on a regular physical packaging structure and avoiding cyclic, excessive, or otherwise inappropriate physical dependencies goes a long way toward improving human cognition. But there are still many other opportunities for facilitating a better common understanding of how software works in general.
Over time, it has become apparent that we need just a few distinct categories of common types:
- Value-semantic types: Instantiable C++ types that attempt to represent ethereal, Platonic (such as mathematical) values. (Instantiable here means that we intend to construct objects of this type.)
- Mechanisms: Instantiable types that don't try to represent values.
- Protocols: Pure abstract interfaces.
- Utilities: Non-instantiable types comprising collections of non-primitive algorithms that typically operate on value types.
Along with the use of these common types, classes that reside in the same category will share many important, familiar properties. For more information and specific examples, see our taxonomy. [7]
By deliberately designing the overwhelming majority of classes to fit these categories, we achieve a high degree of shared context that greatly facilitates communication among developers.
Ensuring Effective Testing
Given a high degree of both logical and physical regularity, we can tackle one of the most problematic aspects of software development: effective testing. For application programs that fit within a single file, the usual way of testing the program takes one of two forms:
- Running the entire program repeatedly, supplying varying inputs.
- Embedding repeatable test code within the application source itself.
Another common approach to verification is peer review, which brings certain important advantages that testing doesn't address, but alone is generally insufficient to assure correctness on all but the tiniest of programs.
Once the overall program size compels us to segment it into separate translation units, however, we can test the lower-level functionality in these units non-intrusively, independently of the parent application and other peer components. Unit testing involves associating a dedicated test program with each component, allowing the various tests to be rerun independently and automatically.
At its heart, unit testing requires us to render an equivalent (redundant) representation of the functionality of the component under test. We then sample these two "implementations" both appropriately (systematically) and with sufficient frequency (applying enough tests) to be sure that they exhibit the same essential behavior. Knowledge of systematic data-selection methods is essential, as is familiarity with transparent (highly readable and maintainable) test-case implementation techniques. Fine-grained physical modularity makes thorough unit testing possible, and adherence to common class categories provides a blueprint for efficient testing of representatives of each respective category.
Testing Instantiable Types
To test any instantiable type (that is, a value-semantic type or mechanism), we first need to identify two categories of (typically member) functions:
- Primary manipulators: A minimal set of constructor and manipulator methods capable of bringing an object to every state required for thorough testing.
- Basic accessors: A sufficient set of accessor methods having direct access to object state.
Then, in the case of value-semantic types, we must first test the equality-comparison operations before using them to help verify the postconditions of other value-semantic operations, such as copy construction and assignment. Once the kernel of a type is proven, we can then use that functionality to flesh out the remaining tests. By identifying common class categories, we also identify common strategies for organizing our testing, which in turn provides profound benefits in developer productivity.
Dynamic and Static Analysis Tools
The more people who are involved in a project, the more opportunities there are for mistakes. Although thorough testing along with peer review are highly effective, complementary, and synergistic ways to make sure that carefully documented functions behave as described, each demands substantial time and effort.
Successful code bases tend to spawn many applications, not all of which are necessarily tested and reviewed with the same level of care as the underlying library infrastructure. Where applicable, publicly available analysis tools such as Google's (dynamic) ThreadSanitizer and especially Bloomberg's (static) bde_verify are inexpensive to run, effectively offset some of the cost of peer review for library software, and can readily be applied (at very low cost) to improve the quality of application software. At a certain scale, however, it becomes cost-effective (for review tasks that can be automated) to invest in the development of custom tools when existing ones fail to address specific recurring needs.
Scalable Distributed Systems
The nature of large-scale software has changed significantly since my book Large-Scale C++ Software Design was published in 1996. A typical large system these days (such as Solr, Hadoop, or Spark) is not rendered as a monolithic executable running in a single process, but instead as a collection of cooperating programs running in multiple processes. These discrete programs, in addition to having the dimensions of complexity discussed previously, have to deal with complications of inter-process, inter-computer, and even wide-area communication.
Nonetheless, the previously described general approach to developing large-scale C++ software applies to each of these cooperating programs individually. The extent to which hierarchically reusable library software can be leveraged in multiple programs in the same distributed system demonstrates the true value of understanding and addressing the many dimensions of large-scale C++ software design and development from the start.
Conclusion
Hierarchical reuse involves making the sub-parts of each reusable piece available and stable, and therefore themselves reusable. This practice becomes more important with increasing project size, and dramatically so when the development effort encompasses multiple related programs.
To achieve effective reuse, certain key physical design rules, such as avoiding cyclic dependencies and long-distance friendships, must be followed scrupulously. In addition, a canonical rendering strategy and general adherence to a small set of common class categories facilitates human cognition, thereby further promoting profitable reuse. Finally, these common class categories lead to highly effective standard testing strategies, improving reliability and bolstering client confidence, furthering the adoption of the software. If we think ahead, we will gain economies of scale as our software capital asset continues to grow and pay dividends.
References
[1] To learn more about avoiding cycles in software, see Chapter 5, "Levelization," in Large-Scale C++ Software Design.
[2] For more on avoiding compile-time dependencies, see Chapter 6, "Insulation," in Large-Scale C++ Software Design.
[3] See how bsls_assert is used in released software.
[4] The article "Language Support for Contract Assertions (Revision 10)" discusses preconditions as proposed for C++ 17.
[5] I discuss the evils of long-distance friendships in the section "Friendship" in Chapter 3, "Components," in Large-Scale C++ Software Design.
[6] See how duplicate types impacted the Chromium project.
[7] Learn more about our class-category taxonomy.