Decoupling
It's ultimately about decoupling to me at the end of the day at the most fundamental design level devoid of the nuance of the characteristics of our compilers and linkers. I mean you can do things like make each header define just one class, use pimpls, forward declarations to types that only need be declared, not defined, maybe even use headers that just contain forward declarations (ex: <iosfwd>
), one header per source file, organize the system consistently based on type of thing being declared/defined, etc.
Techniques to Reduce "Compile-Time Dependencies"
And some of the techniques can help quite a bit but you can find yourself exhausting these practices and still find your average source file in your system needs a two-page preamble of #include
directives to do anything slightly meaningful with skyrocketed build times if you place too much focus on reducing compile-time dependencies at the header level without reducing logical dependencies in your interface designs, and while that might not be considered "spaghetti headers" strictly speaking, I'd still say it translates to similar detrimental issues to productivity in practice. At the end of the day if your compilation units still require a boatload of information to be visible to do anything, then it's going to translate to increasing build times and multiply the reasons you have to potentially go back and have to change things while making developers feel like they're headbutting the system just trying to get their daily coding finished. It's like those types of techniques absent proper decoupling might hand you one spaghetti noodle at a time but you're still going to end up making spaghetti in your compilation units anyway.
You can, for example, make each subsystem provide one very abstract header file and interface. But if the subsystems aren't decoupled from one another, then you get something resembling spaghetti again with subsystem interfaces depending on other subsystem interfaces with a dependency graph that looks like a mess in order to work.
Forward Declarations to External Types
Of all the techniques I exhausted to try to get a former codebase that took two hours to build while the developers sometimes waited 2 days for their turn at CI on our build servers (you can almost imagine those build machines as exhausted beasts of burden frantically trying to keep up and failing while developers push their changes), the most questionable to me was forward declaring types defined in other headers. And I did manage to get that codebase down to 40 mins or so after ages of doing this in little incremental steps while trying to reduce "header spaghetti", the most questionable practice in hindsight (as in making me lose sight of the fundamental nature of design while tunnel visioned on header interdependencies) was forward declaring types defined in other headers.
If you imagine a Foo.hpp
header that has something like:
#include "Bar.hpp"
And it only uses Bar
in the header a way that requires it declaration, not definition. then it might seem like a no-brainer to declare class Bar;
to avoid making the definition of Bar
visible in the header. Except in practice often you'll either find most of the compilation units that use Foo.hpp
still end up needing Bar
to be defined anyway with the additional burden of having to include Bar.hpp
themselves on top of Foo.hpp
, or you run into another scenario where that does genuinely help and 99% of your compilation units can work without including Bar.hpp
, except then it raises the more fundamental design question (or at least I think it should nowadays) of why they need to even see the declaration of Bar
and why Foo
even needs to be bothered to know about it if it's irrelevant to most use cases (why burden a design with dependencies to another one barely ever used?).
Because conceptually we haven't really decoupled Foo
from Bar
. We've just made it so the header of Foo
doesn't need as much info about the header of Bar
, and that's not nearly as substantial as a design which genuinely makes these two completely independent of each other.
Embedded Scripting
This is really for larger scale codebases but another technique I find immensely useful is to use an embedded scripting language for at least the most high-level parts of your system. I found I was able to embed Lua in a day and have it uniformly able to call all commands in our system (the commands were abstract, thankfully). Unfortunately I ran into a roadblock where the devs distrusted the introduction of another language and, perhaps most bizarrely, with performance as their greatest suspicion. Yet while I could understand other concerns, performance should be a non-issue if we're only utilizing the script to invoke commands when users click buttons, for example, that perform no hefty loops of their own (what are we trying to do, worry about nanosecond differences in response times for a button click?). So that might not be so applicable to you but it's an option I'd consider for larger codebases focusing more on the issues of build times than the other issues associated with "header spaghetti", since it can eliminate a lot of what might otherwise need to be pre-compiled and linked (nowadays I write even all my integration tests in high-level script which is a load off on the build system and CI).
Example
Meanwhile the most effective way I've ever witnessed after exhausting techniques to reduce compile times in large codebases are architectures which genuinely reduce the amount of information required for any one thing in the system to work, not just decoupling one header from another from a compiler perspective but requiring the users of these interfaces to do what they need to do while knowing (both from a human and compiler standpoint, true decoupling that goes beyond compiler dependencies) the bare minimum.
The ECS is just one example (and I'm not suggesting you use one), but encountering it showed me that you can have some really epic codebases that still build surprisingly quickly while happily utilizing templates and lots of other goodies because the ECS, by nature, creates a very decoupled architecture where systems only need to know about the ECS database and typically only a handful of component types (sometimes just one) to do their thing:
Design, Design, Design
And these sorts of decoupled architectural designs at a human, conceptual level is more effective in terms of minimizing compile-times than any of the techniques I explored above as your codebase grows and grows and grows, because that growth doesn't translate to your average compilation unit multiplying the amount information required at compilation and link times to work (any system which requires your average developer to include a boatload of stuff to do anything also requires them and not just the compiler to know about a great deal of information to do anything). It also has more benefits than reduced build times and untangling headers, since it also means your developers don't need to know much about the system beyond what is immediately required in order to do something with it.
If, for example, you can hire an expert physics developer to develop a physics engine for your AAA game which spans millions of LOC, and he can get started very quickly while knowing the absolute bare minimum information as far as things like types and interfaces available as well as your system concepts, then that's naturally going to translate to reduced amount of information for both him and the compiler to require to build his physics engine, and likewise translate to a great reduction in build times while generally implying that there's nothing resembling spaghetti anywhere in the system. And that's what I'm suggesting to prioritize above all these other techniques: how you design your systems. Exhausting other techniques will be icing on the top if you do it while, otherwise, it can be like icing without the cake in the worst-case scenario.