CMake and a folder structure scale a C++ project by turning a pile of source files into named targets with declared dependencies. A target knows what it is built from, what it needs to include, what it links against and where it goes. Once the build is described in a [[CMakeLists.txt]], adding a file is a one-line change and every developer and every continuous integration server builds the project the same way.
From a command line to a build file
A small project can be built with one command.
g++ -std=c++17 -Wall -Wextra main.cpp inventory.cpp -o inventoryThat works until the project grows. Once there are ten source files, four libraries and two build configurations, the command line becomes unmanageable and every developer invents their own version of it.
CMake replaces that command with a description of the project that generates the right commands for your platform.
cmake_minimum_required(VERSION 3.16)
project(inventory LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(inventory
src/main.cpp
src/inventory.cpp
)
target_include_directories(inventory PRIVATE include)
if(MSVC)
target_compile_options(inventory PRIVATE /W4)
else()
target_compile_options(inventory PRIVATE -Wall -Wextra -Wpedantic)
endif()The important parts are the first three lines and the last block. The project declares its language and its standard, and the warning flags are chosen per compiler so that everybody sees the same warnings.
A folder structure that scales
The conventional layout separates the public interface from the implementation, the tests, and the build output.
inventory/
├── CMakeLists.txt
├── include/
│ └── inventory.hpp the public interface
├── src/
│ ├── main.cpp the entry point
│ └── inventory.cpp the implementation
├── tests/
│ ├── CMakeLists.txt
│ └── test_inventory.cpp
├── data/ sample data files
└── build/ generated, never committedThe rule behind the layout is that include/ holds what other code may use and src/ holds what nobody outside may. A reviewer can check the headers to see the whole public surface of the project in one place, and the implementation is free to change without touching anybody's build.
The build/ directory holds everything the build generates, and it is the one directory that should never be in version control.
Targets and libraries
A target is anything the build produces: an executable, a static library, a shared library. Naming them clearly means the dependencies between them are explicit.
# A library of reusable code.
add_library(inventory_core STATIC
src/inventory.cpp
)
target_include_directories(inventory_core PUBLIC include)
# The command line program that uses it.
add_executable(inventory_cli
src/main.cpp
)
target_link_libraries(inventory_cli PRIVATE inventory_core)The two keywords in target_include_directories and target_link_libraries control how the setting propagates. PUBLIC means the setting applies to this target and to anything that links to it. PRIVATE means it applies only to this target. That distinction is what makes the include directory visible to the tests without repeating the path.
Out-of-source builds
The build should happen in its own directory, never in the source tree. It keeps generated files out of the way, lets you have several build configurations at once, and makes "delete the build directory" a complete reset.
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failureThe three commands are the whole workflow: configure, build, test. The -S and -B flags are the source and the build directory. The second command compiles, and the third runs the tests.
Adding tests
CMake has a built-in test runner, and registering a test is two lines.
# tests/CMakeLists.txt
add_executable(inventory_tests
test_inventory.cpp
)
target_link_libraries(inventory_tests PRIVATE inventory_core)
add_test(NAME inventory_tests COMMAND inventory_tests)And in the top-level file:
enable_testing()
add_subdirectory(tests)Now ctest finds the test, runs it, and reports the result. The exit status of the test program is what decides pass or fail, which is why a test runner returns non-zero when anything fails.
Build configurations
CMake handles the difference between a debug build and a release build with a single flag.
cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=ReleaseThe two common types are:
- Debug — no optimisation, debug symbols included, assertions enabled.
- Release — optimisation on, debug symbols usually off,
NDEBUGdefined so thatassertdisappears.
The NDEBUG definition is the part that surprises people, because a release build silently removes every assertion.
The pieces before the build file exists
Before there is a CMakeLists.txt, a project is just source files. The library file has no main; the executable file has exactly one. The separation is what lets the tests link against the library instead of duplicating its code.
#include
#include
#include
// inventory.hpp declares:
struct Item {
std::string sku;
int quantity;
};
int total_quantity(const std::vector& items);
// inventory.cpp defines:
int total_quantity(const std::vector& items) {
int total = 0;
for (const Item& item : items) {
total += item.quantity;
}
return total;
}
// main.cpp uses:
int main() {
std::vector items = {{"A-100", 5}, {"B-200", 3}};
std::cout << "total: " << total_quantity(items) << '\n';
return 0;
}The output shows the library function being called from the executable:
total: 8Splitting the same code across three files changes nothing about how it behaves. What it changes is the build: the library is compiled once, the executable and the tests link against it, and a change to the implementation rebuilds only the library and whatever depends on it.
A library and an executable side by side
In a real project the library and the executable live in different directories and are compiled separately. The library exposes a header; the executable includes it and links against the compiled library.
#include
#include
// inventory.hpp — the public interface
struct Item {
std::string sku;
int quantity;
};
int total_quantity(const std::vector& items);
// inventory.cpp — the implementation
int total_quantity(const std::vector& items) {
int total = 0;
for (const Item& item : items) {
total += item.quantity;
}
return total;
}The executable is a separate file that includes the header.
#include
#include
#include
// main.cpp — the program that uses the library
int main() {
std::vector items = {{"A-100", 5}, {"B-200", 3}};
std::cout << "total: " << total_quantity(items) << "\n";
return 0;
}The output shows the library function being called from the executable:
total: 8A complete top-level file
Putting it together, a realistic top-level file looks like this.
cmake_minimum_required(VERSION 3.16)
project(inventory VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(INVENTORY_BUILD_TESTS "Build the unit tests" ON)
add_library(inventory_core STATIC
src/inventory.cpp
src/storage.cpp
)
target_include_directories(inventory_core PUBLIC include)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(inventory_core PRIVATE -Wall -Wextra -Wpedantic)
endif()
add_executable(inventory_cli
src/main.cpp
)
target_link_libraries(inventory_cli PRIVATE inventory_core)
if(INVENTORY_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()That is the whole build description: one library, one executable, one test suite, and the dependencies between them stated in one place each.
Quick reference
- CMake describes *targets* and their dependencies; the generator produces the platform commands.
- Keep public headers in
include/and implementations insrc/. - Use PUBLIC for settings that consumers need and PRIVATE for internal ones.
- Always build out of source:
cmake -S . -B build. - Register tests with
add_testand run them withctest. CMAKE_BUILD_TYPE=Debugkeeps symbols and assertions; Release definesNDEBUG.
Try it yourself: take your inventory program and split it into a header, an implementation and a main file. Write a CMakeLists.txt for it with a library target and an executable target, configure and build it out of source, and run it. Then add a second executable that links the same library and confirm that changing the library requires only a rebuild of the executable.