This page is still under construction
No matter how many principles and guidelines there are, writing well-designed code is not easy and requires thinking.
First, we need to define a metric. For SCETlib the design goals are to make the code
- long-term maintenable
- extendable/adaptable to new/changing user requirements
- easy/intuitive to use and hard to accidentally misuse for clients
A reasonably good design achieves these design goals reasonably well, a better design achieves them better.
Note that "make the code run fast" is not a design goal, but one among many possible user requirements. (So it enters in the 2nd point above but has to compete against other user requirements.)
Second, code design is important at all levels from the highest/outer to the lowest/inner levels. For example, the outermost level corresponds to SCETlib's design in terms of modules and the dependencies between them. The next level is the design of modules in terms of various classes, then the design of individual classes, and finally the design of individual functions. The design goals apply to all levels.
The design goals set the target but they don't say how to achieve them. This is where the design principles come in. They are meant as guiding principles to follow in the design and implementation process in order to achieve the design goals. A few important design principles are
- Divide and Conquer
- Minimize dependencies
- Separate interfaces from implementation
- Premature optimization is the root of all evil
They are also still rather abstract. All the specific design guidelines and recommendations discussed below are in one way or another a concrete realization. Sometimes specific guidelines and recommendations will be in conflict. In this case, the overall design principles and eventually design goals should decide which direction to take.
Naively, one first designs, then implements, then tests, and then the code is done. Unfortunately this never works. Either, by the time one gets to the testing stage, the user requirements have changed so much that one starts from scratch. Or, one tries to anticipate all possible extensions and use cases in a grand design up front, in which case one never leaves the design stage or if one does eventually start implementing one realizes that the design is broken.
In modern days, code design is understood as a continuous process that goes hand in hand with the implementation. This may seem counter-intuitive but by anticipating redesign as natural part of an iterative design-implementation-redesign process, an incremental design process becomes possible, which makes reaching the design goals much easier and avoids falling into the above overdesigning trap. The point is that the feedback one gets from implementation, testing, and users is an integral part of the design process, which is impossible to anticipate up front. So, rather than aiming for a fixed design that already accomodates all possible future requirements, the code should be designed for the current use cases with the additional requirement that it can be easily refactorized and redesigned later on if needed. The design principles are meant to make this possible. For example, one example of "Divide and Conquer" is to organize the code into well-separated layers such that it is possible to redesign a higher layer without having to touch the lower layers and vice versa.
Good code design is essentially equivalent to good dependency management. Good dependency management means to have no hidden dependencies and to overall minimize the coupling between different pieces. With minimal coupling, the code is easy to extend/adapt to changing user requirements. This is the basic principle of
Minimize Dependencies: Minimizing dependencies reduces coupling
- Avoid long dependency chains
A -> B -> C -> Dor worse dependency cycles
A -> B -> C -> A
- To break dependency chains use the Dependency Inversion Principle (DIP):
A -> Interface <- B
- To break dependency chains use the Dependency Inversion Principle (DIP):
- Avoid having many dependencies and many dependees at the same time
- If A has lots of dependencies it should have as little as possible dependees. These are typically high-level users/clients.
- If A has lots of dependees it should have as little as possible dependencies. These are typically low-level providers/servers.
- Something having both tends to be an over-achiever (it is using and providing)
Some strategies that reduce dependencies in the code
- Always try to use the weakest form of dependency available.
- Prefer member function arguments to data members and data members (composition) to inheritance
- Abstract interfaces should be as minimal as possible and correspond to the smallest common denominator
- Minimize amount and detail of information passed
- Functions should receive as arguments directly the objects they operate on and nothing more
- Functions/classes should ask for what they need, not where they might find what they need
When using inheritance
- Avoid a deep hierarchy tree (it's a long dependency chain)
- Only derive from classes that are meant to be derived from, i.e., that are designed to be base classes
- When using public inheritance, it must be possible to substitute the derived class for its base class
Each entity (module, class, function) should have a specific purpose or task. It should do that task well and nothing else. This focus on single purpose/task allows to break the code into small and well-defined pieces. This is the basic principle of
Divide and Conquer: If something is too complex to tackle at once, break it into smaller pieces.
Some typical high-level examples
- Break a complex calculation or algorithm into smaller pieces
- Management of resources and use of resources are different tasks
Some low-level examples
- Functions should not have side-effects
- A member function to compute the result should not also set options affecting the next computation
- An output function to stream something
- its purpose is to put something into a specific format
- it should not compute the result to be output
- it should not manage the output stream (e.g. open a file), but instead ask for an
Separate interfaces from implementation: Distinguish between the logical interface of a class and its concrete implementation
- The class interface defines the class behaviour and is the contract with the clients
- The interface should be designed according to how clients can or should use the class
- The interface design should depend as little as possible on the specific implementation
- Ideally, it should be possible to change the implementation without clients having to know about
- This is key for being able to refactorize
Low-Level Code Guidelines
- Do not use hard-coded magic numbers, use enums to define flags and constants
- Use const-ness
- Member functions should be const by default and only non-const if they are explicitly meant to change the state of the object
Use private data members primarily to represent the state of an object
Prefer passing needed resources as arguments to member functions rather than storing pointers/references to them
Do not use them to pass intermediate results between member functions
- Pass function argument that are nontrivial class types by const reference rather than by value
- A class must guarantee that its objects are always in a well-defined state
- All initialization should happen in the constructor, if this is not possible, the default initialization should not lead to undefined behavior.
- Be clear about boundary conditions for valid input
- What is the client's responsibility
- No silent errors
- Do not return 0, but instead throw an exception
... to be continued
- No labels