Starting a new C++ project with CMake the modern way
"Modern CMake" means one thing above all: describe targets and their requirements; never set global variables. A target says what it needs (include directories, compile features, other targets) and CMake propagates those to whatever links against it. Get that right on day one and the project grows without pain. This is the skeleton I start every project from. It needs CMake 3.25 or newer.
Layout
myproj/
├── CMakeLists.txt
├── CMakePresets.json
├── include/myproj/greet.hpp
├── src/
│ ├── CMakeLists.txt
│ └── greet.cpp
├── app/
│ ├── CMakeLists.txt
│ └── main.cpp
└── tests/
├── CMakeLists.txt
└── greet_test.cpp
Top-level CMakeLists.txt
cmake_minimum_required(VERSION 3.25)
project(myproj VERSION 0.1.0 LANGUAGES CXX)
# Only when we are the top-level project: tests, warnings, tooling.
if(PROJECT_IS_TOP_LEVEL)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # for clangd / clang-tidy
include(CTest) # defines BUILD_TESTING
endif()
add_subdirectory(src)
add_subdirectory(app)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
include(GNUInstallDirs)
install(TARGETS greet myproj_app EXPORT myprojTargets)
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT myprojTargets NAMESPACE myproj:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/myproj)
The library: src/CMakeLists.txt
add_library(greet greet.cpp)
add_library(myproj::greet ALIAS greet) # consumers use the namespaced name
target_include_directories(greet
PUBLIC $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)
target_compile_features(greet PUBLIC cxx_std_20) # propagates: anyone linking greet gets C++20
target_compile_options(greet PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wall -Wextra -Wpedantic -Wshadow -Wconversion>
$<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive->)
PUBLIC means "I need this and so does anyone who uses me"; PRIVATE means "only I need this". Warnings are PRIVATE because you don't want to impose your flags on downstream users.
// include/myproj/greet.hpp
#pragma once
#include <string>
#include <string_view>
namespace myproj { std::string greet(std::string_view name); }
// src/greet.cpp
#include "myproj/greet.hpp"
#include <format>
namespace myproj { std::string greet(std::string_view name) { return std::format("Hello, {}!", name); } }
The executable: app/CMakeLists.txt
add_executable(myproj_app main.cpp)
target_link_libraries(myproj_app PRIVATE myproj::greet)
set_target_properties(myproj_app PROPERTIES OUTPUT_NAME myproj)
// app/main.cpp
#include "myproj/greet.hpp"
#include <print>
int main(int argc, char** argv) { std::println("{}", myproj::greet(argc > 1 ? argv[1] : "world")); }
Tests with CTest and Catch2
FetchContent pulls a pinned version of the test framework at configure time; no submodules, no system packages.
# tests/CMakeLists.txt
include(FetchContent)
FetchContent_Declare(Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.7.1
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(Catch2)
add_executable(greet_test greet_test.cpp)
target_link_libraries(greet_test PRIVATE myproj::greet Catch2::Catch2WithMain)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(Catch)
catch_discover_tests(greet_test) # each TEST_CASE becomes a CTest test
// tests/greet_test.cpp
#include "myproj/greet.hpp"
#include <catch2/catch_test_macros.hpp>
TEST_CASE("greet formats the name") { REQUIRE(myproj::greet("phookit") == "Hello, phookit!"); }
Presets: one command for everyone
CMakePresets.json replaces the README paragraph that explains which flags to pass. It is understood by the command line, VS Code, CLion and Visual Studio.
{
"version": 6,
"configurePresets": [
{ "name": "base", "hidden": true, "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}" },
{ "name": "debug", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug",
"CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer" } },
{ "name": "release", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } }
],
"buildPresets": [ { "name": "debug", "configurePreset": "debug" }, { "name": "release", "configurePreset": "release" } ],
"testPresets": [ { "name": "debug", "configurePreset": "debug", "output": { "outputOnFailure": true } } ],
"workflowPresets": [ { "name": "ci", "steps": [ { "type": "configure", "name": "debug" }, { "type": "build", "name": "debug" }, { "type": "test", "name": "debug" } ] } ]
}
cmake --workflow --preset ci # configure + build + test, sanitisers on
cmake --preset release && cmake --build --preset release
./build/release/app/myproj Paul # Hello, Paul!
Install and package
cmake --install build/release --prefix /opt/myproj
cpack --config build/release/CPackConfig.cmake -G TGZ # after include(CPack) in the top-level file
Because the library exported its targets with a namespace, another project can now write find_package(myproj CONFIG REQUIRED) and target_link_libraries(theirs PRIVATE myproj::greet) and inherit the include directory and C++ standard automatically.
Rules of thumb
- Never
include_directories(),link_libraries()or setCMAKE_CXX_FLAGSglobally; use thetarget_*commands. - Never
file(GLOB ...)for sources: listing files explicitly is what makes adding a file trigger a re-configure. - Pin dependency versions (
GIT_TAGto a tag or commit, not a branch). - Guard tests and tooling with
PROJECT_IS_TOP_LEVELso your project is pleasant to consume as a dependency. - Prefer
cmake --presetover shell scripts; presets are data, scripts rot.
Version 3 of this post switched the test framework from GoogleTest to Catch2 and added the workflow preset; version 2 added the install/export section.
Further reading
- The official CMake tutorial (BSD licensed documentation)
- An Introduction to Modern CMake
Comments 0
Log in or register to join the conversation.
No comments yet.