C++ Project Structure Revisited
As I was setting up my first non-trivial C++ project, my guiding principal became, “do whatever works, address problems as they arise”. I wrote a quick CMake file to compile a short list of source files and dependencies into a binary, which I used to create my first working graphics application. However, when I tried to add some basic unit testing capabilities, I hit a wall. This led to some significant configuration and organization improvements that I think are worth sharing.
Stepping back, I really just needed an easy way to compile multiple binaries and share code between them. That way, I would be able to:
- Organize my code and build configurations into self-contained libraries
- Compile test and application binaries against the same core library code
- Write custom tools to be executed during development or at build time
- Create runnable examples to demonstrate library APIs
As luck would have it, plenty of other people had been thinking about the same problem. I came across The Pitchfork Layout (PFL), a rather opinionated set of guidelines for organizing C++ projects written by Colby Pike. Although PFL didn’t address my scalability issues directly, it contains plenty of useful ideas that I was able to adapt to my project. Most importantly, the PFL project centralized some of the the conversations happening around C++ project structure (I found this GitHub issue to be the most relevant to my problem).
If you prefer exploring real-world examples over reading guided explanations, look here. Otherwise, here’s how I structured my project:
myproject/
├─ apps/
│ ├─ app1.cpp
│ ├─ app2.cpp
│ ├─ CMakeLists.txt
├─ build/
├─ external/
│ ├─ Catch2/
│ ├─ imgui/
│ ├─ stb/
│ ├─ CMakeLists.txt
├─ libs/
│ ├─ mylib/
│ │ ├─ examples/
│ │ │ ├─ example1.cpp
│ │ ├─ src/
│ │ │ ├─ mylib/
│ │ │ │ ├─ srcfile.cpp
│ │ │ │ ├─ srcfile.hpp
│ │ │ │ ├─ srcfile.test.cpp
│ │ ├─ test/
│ │ │ ├─ testsetup.cpp
│ │ ├─ CMakeLists.txt
├─ tools/
│ ├─ tool1.cpp
│ ├─ tool2.cpp
│ ├─ CMakeLists.txt
├─ CMakeLists.txt
Some things to note that may not be obvious at first glance:
- The
build
directory houses CMake and compiler outputs. It is not checked into source control. - The
external
andtools
directories must be added first so that their outputs are available to subsequent build targets. - The
apps
andtools
directories contain thin application entrypoints. Each entrypoint implements a main function and produces an executable, optionally linked to libraries inexternal
andlibs
. Almost all business logic is pushed down intolibs
. - Dependencies are cloned into
external
as git submodules, versioned by SHA. Most C++ libraries contain a top-level CMake file and can be made available via theadd_subdirectory
command. - Example programs within a library directory are written and compiled similarly to
tools
andapps
. - Unit tests sit adjacent to their corresponding source file but are compiled into a separate executable that runs them.
- Each library’s
src
directory contains an extra nested directory that matches the name of the library itself. This allowssrc
to be passed to thetarget_include_directories
CMake command, which also means all headers are public. To support private headers, aninclude
directory can be added at the library root. - The
test
directory within each library includes global test setup and teardown code, e.g. custom Catch2 main functions.
After working under this project structure for several months, I can say that I don’t spend very much time putzing around with build system configuration anymore. As my project grows, I am able to partition logic into libraries and apps as needed, and installing dependencies is straightforward. My development process now centers around building and debugging lightweight unit test binaries rather than full applications. Although there are undoubtedly improvements to be made, in the spirit of, “do whatever works, address problems as they arise”, I feel that this solution has accomplished my goal.