Ousterhout (2018) mentions in his book ‘A Philosophy of Software Design” 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. Some of the concepts he often mentions are the difference between tactical programming and strategic programming.
- Tactical programming: The goal is to get something working, often before a deadline. This approach plays into incremental complexity that compounds and soon the code is a mess. This culture also encourages “tactical tornado” programmers who produce prolific code but leave behind a wake of “spaghetti code” and high complexity. Tactical programming could work in case of the code that implements features. The typical code in a software system can be broken into features and infrastructure. Features include the top-level methods or code for user-visible features — e.g., GUI, top-level interfaces, dispatchers, etc. These are often not general-purpose or reusable, but are relatively too simple to write and usually invoked from only one place. These can be implemented as a single method with fewer comments; in fact, these are great places to put “tactical tornadoes” to work.
- Strategic programming: According to this approach, working code is not good enough on its own; in other words, it is not acceptable to introduce complexity in order to finish the current task as there should be a concurrent focus on the long-term structure of the system. The motto can be summarized as producing a great design that also works. Strategic programming also emphasizes continual investment in the design of the system where developers take some extra time to find the simplest possible design, write good documentation, refactor, etc. These small but constant improvements might take a bit more up-front costs but have a virtuous compounding effect increasing overall productivity in the long-term.
Given the iterative nature of system design is iterative, a lot of software is usually about changing existing code. Therefore, there is a need to prioritize design and think strategically. Thinking tactically about the smallest possible change to get to the outcome can introduce special cases, dependencies, and incremental complexity. Rather, the modifications should yield a system structure close to the case if the system is designed from the start with that change in mind. Refactor can be used to keep reducing complexity. An important aspect of effective refactoring is to have testing tightly integrated with development. Having good tests (unit and system) facilitates strategic refactoring, reducing the friction to making significant changes to the code to improve the design.
A good software designer of a module would prefer to suffer once rather than have all the module’s users suffer many times. On the other hand, adopting a hedonist approach would solve easy problems and leave hard problems for others to solve downstream which would amplify complexity since many other people must now deal with a problem that could have been effectively addressed by the developer. A better option would be to push complexity down. To give a specific example, configuration parameters or variable values could be set by default or provide a straightforward way to set the right value.
Furthermore, simplifying systems would not only improve the design but also make them faster. As optimizing for performance indiscriminately will increase complexity, it should only be done in case of a problem. Some strategies for optimizing include:
- Measuring performance both to identify the places to focus on for the biggest impact as well as to establish a quantitative baseline that can help reason about performance-complexity tradeoffs.
- Identifying fundamental design changes (such as adding a cache or using a different algorithmic approach).
- Identifying the smallest amount of code needed to carry out the desired task in the common case and cleaning up the implementation to confirm the closest to this structure.
Strategic programming can also work in the context of existing software development processes. Specifically, the support for classes, inheritance, private methods, instance variable, etc in object-oriented programming, if used carefully, can provide better software designs. However, they should not be used excessively. Interface inheritance (same interface for multiple purposes) allows for deeper interfaces and the notion of capturing essential features of all underlying implementations while steering clear of differences between implementation is at the heart of abstraction. Similarly, with implementation inheritance, having default implementations, and subclasses to reduce change amplification might create further dependencies where subclasses must know how parent classes work.
Overall, mechanisms for object-oriented programming can be conducive for clean designs, yet don’t ensure them. Similarly, agile development encourages an incremental approach to avoiding complexity by iterating over design, test, and feedback, yet it can sometimes encourage tactical programming with a focus on features versus abstractions. Other software development approaches such as test-driven development (writing tests before writing code and stopping coding when tests pass) and the use of getter/setter functions are more antithetical to the strategic programming approach.
Given the advantages and disadvantages of each approach, a balanced view should be adopted depending on the final aim of the software engineer.