Levelization
This chapter presents techniques for eliminating cyclic or excessive physical dependencies and for creating levelizable hierarchies of components.
5.1 Some Causes of Cyclic Physical Dependencies
Cyclic physical dependencies often arise even when the original design was levelizable.
Common causes include:
- Enhancements added after the original design.
- Convenience features that make lower-level abstractions depend on higher-level ones.
- Intrinsically interdependent abstractions.
5.1.1 Enhancement
Adding functionality that makes two previously independent components know about each other can introduce a cycle.
Principle:
Allowing two components to know about each other through #include dependencies implies cyclic physical dependency.
A DependsOn B
B DependsOn A
↓
Cycle
Moving the #include directives from header files to implementation files does not necessarily remove the physical cycle.
Levelizable Subsystem:
A subsystem is levelizable if it compiles and the graph implied by the #include directives of its components, including implementation files, is acyclic.
5.1.2 Convenience
A convenient interface can introduce unnecessary physical coupling.
A common example is a base class that directly knows how to construct each of its derived classes.
Base
↑ ↑ ↑
D1 D2 D3
Base also depends on D1, D2, D3
↓
Cycle
If a base-class component depends on its derived-class components, the hierarchy becomes cyclic and the derived classes can no longer be tested or reused independently.
5.1.3 Intrinsic Interdependency
Intrinsic Interdependency:
A situation in which related abstractions naturally refer to one another in their interfaces.
Examples include graph-like structures containing mutually related nodes and edges.
Principle:
Inherent coupling in the interfaces of related abstractions makes them more resistant to hierarchical decomposition.
Logical relationships among object instances may be cyclic even when the physical component structure should remain acyclic.
5.2 Escalation
Escalation:
Moving mutually dependent functionality to a higher level in the physical hierarchy.
Suppose two peer components depend on one another:
A ↔ B
Instead of placing the shared operation in either A or B, move it into a higher-level component:
Utility
/ \
↓ ↓
A B
Now A and B remain independently usable.
Dominance
Dominance:
A component y dominates a component x if y is at a higher level than x and physically depends on x.
y
↓
x
y dominates x
Dominance provides more information than level number alone because it identifies actual dependency paths.
Escalating Peer Functionality
Principle:
If peer components are cyclically dependent, the interdependent functionality may be escalated to static members of a higher-level component that depends on the original components.
Example structure:
Level 2
└── BoxUtil
├── uses Rectangle
└── uses Window
Level 1
├── Rectangle
└── Window
This preserves independent reuse of the lower-level components.
The trade-off is that operations previously available directly or implicitly may need to become explicit higher-level operations.
Escalating Factory Functionality
If an abstract base class contains creation logic for every concrete derived class, move the factory operation into a separate higher-level utility component.
Before
Shape ↔ Circle
Shape ↔ Square
Shape ↔ Triangle
After
ShapeUtil
/ | \
↓ ↓ ↓
Circle Square Triangle
\ | /
Shape
This removes the need for the base-class component to depend on its derived classes.
Benefits include:
- Independent testing of concrete types.
- Independent reuse of concrete types.
- Lower CCD.
- Less maintenance coupling.
- Better scalability as new concrete types are added.
Principle:
Cyclic physical dependencies in large, low-level subsystems have the greatest capacity to increase the overall cost of maintaining a system.
5.3 Demotion
Demotion:
Moving common or interdependent functionality downward into a lower-level component shared by the original components.
Before
A ↔ B
After
A B
\ /
↓ ↓
Common
Principle:
If peer components are cyclically dependent, the interdependent functionality may be demoted to a new lower-level shared component on which both original components depend.
Escalation vs. Demotion
Escalation
└── Move mutual functionality upward
Demotion
└── Move common functionality downward
Both techniques transform cyclic relationships into downward dependencies.
Common Infrastructure
Principle:
Demoting common code enables independent reuse.
If several subsystems depend on only a small common definition, type, enumeration, or infrastructure facility, moving that facility into its own lower-level component prevents clients from depending on an unnecessarily large subsystem.
Policy and Infrastructure
Policy:
Higher-level behavior that determines how lower-level facilities are used.
Infrastructure:
Lower-level functionality or data that supports multiple policies.
Principle:
Escalating policy and demoting infrastructure can combine to enhance independent reuse.
Higher Level
└── Policy
Lower Level
└── Shared Infrastructure
Factoring a Concrete Class
Sometimes one class contains both high-level and low-level functionality.
Principle:
Factoring a concrete class into two classes containing higher and lower levels of functionality can facilitate levelization.
Original Class
├── Low-Level Functionality
└── High-Level Functionality
↓ factor
Low-Level Class
↑
High-Level Class
Factoring an Abstract Base Class
An abstract base class can also mix two different roles:
- Defining a common interface.
- Providing a partial implementation.
Principle:
Factoring an abstract base class into a pure interface class and a partial-implementation class can facilitate levelization.
Interface
↑
Partial Implementation
↑
Concrete Types
Trade-Off
Principle:
Factoring a system into smaller components makes it more flexible but also more physically complex because there are more pieces to manage.
5.4 Opaque Pointers
Physical dependency depends on how strongly a type is used.
Uses in Size
Uses in Size:
A function uses type T in size if compiling the function body requires the complete definition of T.
T object;
object.function();
The compiler must know the definition, size, or layout of T.
This implies compile-time dependency.
Uses in Name Only
Uses in Name Only:
A function uses type T in name only if the function and all components on which it depends can be compiled and linked without the definition of T.
Typical usage:
class T;
void f(T* object);
The type is known only by name.
Component-Level Definitions
Component Uses a Type in Size:
Compiling the component requires the complete definition of the type.
Component Uses a Type in Name Only:
The component and its dependencies can compile and link without the complete definition of the type.
Opaque Pointer
Opaque Pointer:
A pointer whose pointed-to type is known by name but whose definition is intentionally not required by the component using the pointer.
class Object;
class Holder
{
Object* d_object_p;
};
The pointer value can be stored, copied, compared, or passed without knowing the representation of Object.
Principle:
Components that use objects in name only can be thoroughly tested independently of the named object.
Opaque pointers can therefore remove physical dependencies while preserving the conceptual relationship.
Breaking Container/Contained Cycles
A contained object may hold a pointer back to its container.
If the contained object performs substantive operations on that container, the two components become mutually dependent.
The book's technique is to:
- Make the contained object's container pointer opaque.
- Expose access to that pointer without interpreting it locally.
- Escalate operations requiring the container definition to a higher-level component.
Contained Object
└── Holds opaque Container*
Higher-Level Component
└── Knows both types and performs substantive operation
5.5 Dumb Data
Dumb Data:
Information stored by an object that the object itself does not know how to interpret.
The meaning of the data is supplied by another object, usually at a higher level.
Low-Level Object
└── Stores Data
Higher-Level Object
└── Interprets Data
Opaque pointers are a specialized form of this idea.
Examples of dumb data can include:
- Integer identifiers.
- Untyped values.
- Indices.
- Generic handles.
- Other uninterpreted representation values.
Principle:
Dumb data can break in-name-only dependencies, improve testability, and reduce implementation size.
However:
Opaque Pointer
├── Preserves type information
└── Can preserve encapsulation
General Dumb Data
├── May lose type safety
└── May weaken encapsulation
Therefore, opaque pointers are preferable when they can solve the problem cleanly.
5.6 Redundancy
Redundancy:
Deliberately repeating a small amount of code or data to avoid an undesirable physical dependency that reuse would introduce.
Reuse is not free.
Reuse
↓
Dependency
↓
Coupling Cost
Principle:
The additional coupling associated with some forms of reuse may outweigh the advantage gained from that reuse.
A small duplicated value or simple calculation may sometimes be cheaper than depending on a large subsystem solely to obtain that information.
Principle:
Supplying a small amount of redundant data can enable an object to be used in name only, eliminating the cost of linking to the definition of that object's type.
Design Goal
Principle:
Packaging subsystems so as to minimize the cost of linking to other subsystems is a design goal.
Redundancy should be deliberate and limited.
The goal is not to duplicate large algorithms, but to avoid excessive dependency when the reused functionality is trivial compared with the coupling it introduces.
5.7 Callbacks
Callback:
A function supplied by a client to a subsystem so the subsystem can perform a specific operation in the client's context.
Client
│
└── supplies callback
↓
Lower-Level Subsystem
↓
invokes callback
Callbacks invert part of the dependency.
The lower-level subsystem does not need direct knowledge of the higher-level client.
Purpose
Callbacks can be used when:
- A reusable algorithm needs one customizable behavior.
- A lower-level component must request higher-level policy.
- Direct dependency on the higher-level subsystem would introduce unwanted coupling.
Risks
Principle:
Indiscriminate use of callbacks can produce designs that are difficult to understand, debug, and maintain.
Callbacks can make control flow less explicit.
They may also weaken type safety when implemented using overly generic function interfaces.
Principle:
The need for callbacks can be a symptom of a poor overall architecture.
Use callbacks when they express genuine inversion of control, not merely to patch an incorrectly layered design.
5.8 Manager Class
Manager Class:
A class that owns, creates, destroys, and coordinates a collection of lower-level objects.
Without a manager, peer implementation objects may begin managing one another, creating cyclic relationships.
Bad
Object A ↔ Object B
Object B ↔ Object C
Better
Manager
/ | \
↓ ↓ ↓
A B C
Principle:
Establishing hierarchical ownership of lower-level objects makes a system easier to understand and more maintainable.
The manager:
- Owns subordinate objects.
- Controls their lifetime.
- Enforces policy.
- Coordinates relationships.
- Depends on subordinate classes.
The subordinate classes should not need to depend back on the manager unless unavoidable.
Manager
↓
Implementation Objects
This creates a clear ownership hierarchy.
5.9 Factoring
Factoring:
Extracting cohesive, independently testable functionality from a heavily coupled implementation and moving it into lower-level components.
Factoring is related to demotion, but does not necessarily eliminate the original cycle immediately.
Instead, it reduces the amount of functionality trapped inside the cyclic region.
Large Cyclic Component Group
↓ factor
Independent Low-Level Pieces
+
Smaller Cyclic Core
Principle:
Factoring out and demoting independently testable implementation details can reduce the maintenance cost of cyclically dependent classes.
Reduce the Cyclic Core
The goal is to:
- Identify functionality that does not intrinsically participate in the cycle.
- Move that functionality into independent components.
- Leave only the truly interdependent functionality in the cyclic core.
Before
[A B C D E] cyclic group
After
A B C
\ | /
[small cyclic core]
```
### Escalate Unavoidable Cycles
**Principle:**
When cyclic physical dependencies are unavoidable, escalating them to the highest possible level reduces CCD and may allow the cycle to be replaced by a single component of manageable size.
Low-level components should remain as independent as possible because low-level cycles affect many higher-level clients.
### Friendship and Factoring
**Principle:**
Granting friendship does not create dependency by itself, but preserving encapsulation may induce physical coupling.
If several classes need intimate private access, placing them in the same component may avoid long-distance friendship.
However, unnecessarily combining unrelated functionality into that component increases its size and coupling.
Factoring helps keep only the intimate core together.
## 5.10 Escalating Encapsulation
Encapsulation does not always need to occur at the boundary of each individual class or low-level component.
**Principle:**
What is and is not an implementation detail depends on the level of abstraction within the physical hierarchy.
A type can be:
- Public within a subsystem's internal hierarchy.
- Hidden from clients of the overall subsystem.
~~~text
Subsystem Client
↓
Wrapper Interface
↓
Internal Public Types
↓
Lower-Level Implementation
Escalating the Encapsulation Boundary
Escalating Encapsulation:
Moving the point at which implementation details are hidden from clients to a higher level in the physical hierarchy.
Principle:
Escalating the level at which encapsulation occurs can remove the need to grant private access to cooperating components within a subsystem.
Instead of forcing every cooperating low-level class to hide itself from every other low-level class, allow them to communicate through ordinary interfaces and hide their use at the subsystem boundary.
Private Headers
Principle:
Private header files are not a substitute for proper encapsulation because they inhibit side-by-side reuse.
Simply refusing to publish a header does not change the physical structure of the program.
The type still exists and may still be programmatically exposed.
Meaning of Encapsulation in a Hierarchy
Encapsulation in a Hierarchical System:
For a type defined at file scope in a header, encapsulation means hiding its use from higher-level clients, not necessarily hiding the existence of the type itself.
The important question is whether clients can obtain or manipulate the internal instances belonging to the subsystem.
Wrapper Component
Wrapper Component:
A higher-level component that presents the subsystem's public interface while encapsulating the use of lower-level implementation types.
Wrapper
/ | \
↓ ↓ ↓
Internal Components
Principle:
A wrapper component can encapsulate the use of implementation types within a subsystem while allowing appropriate types to pass through its interface.
A wrapper may:
- Hide internal implementation objects.
- Expose safer surrogate or identifier types.
- Coordinate lower-level components.
- Eliminate long-distance friendship.
- Preserve a levelizable hierarchy.
Trade-offs include:
- Less flexible low-level access.
- Additional interface and implementation work.
- Possible communication overhead.
For highly interdependent subsystems, a wrapper may be necessary to achieve both levelization and encapsulation.
5.11 Key Concepts
Levelization
↓
Remove Cyclic / Excessive Dependencies
Causes of Cycles
├── Enhancement
├── Convenience
└── Intrinsic Interdependency
Techniques
├── Escalation
│ └── Move mutual functionality upward
│
├── Demotion
│ └── Move common functionality downward
│
├── Opaque Pointers
│ └── Use another type in name only
│
├── Dumb Data
│ └── Store information without interpreting it
│
├── Redundancy
│ └── Repeat small code/data to avoid coupling
│
├── Callbacks
│ └── Client supplies higher-level behavior
│
├── Manager Class
│ └── Own and coordinate lower-level objects
│
├── Factoring
│ └── Extract independently testable behavior
│
└── Escalating Encapsulation
└── Hide implementation at a higher subsystem boundary
Dependency Goal
↓
Acyclic
↓
Levelizable
↓
Lower CCD
↓
Better Understanding
Better Testing
Better Reuse
Better Maintainability