Setting up C++: compilers, CMake and the build workflow

Install a modern compiler, pick a language standard, wire warnings and sanitizers into the build, and structure a CMake project.

Compiler and standard

The three mainstream compilers are GCC, Clang/LLVM and MSVC. They agree on the language but differ in diagnostic quality and in how quickly new standard features land. Pick one, pin a version in CI, and keep the standard flag explicit rather than relying on whatever the compiler defaults to.

g++   --version
clang++ --version
cmake --version

# compile one translation unit with the warnings you actually want
g++ -std=c++20 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -g -O2 main.cpp -o app

# feature-test a specific library facility instead of guessing
g++ -std=c++23 -dM -E -x c++ /dev/null | grep -i __cpp_lib
StandardFlagHeadline features
C++17-std=c++17std::optional, string_view, structured bindings, filesystem
C++20-std=c++20Concepts, ranges, std::format, coroutines, modules, std::span
C++23-std=c++23std::expected, std::print, mdspan, ranges improvements
GNU extensions-std=gnu++20Adds GNU extensions; fine for Linux tools, less portable

Guard new features with the feature-test macros rather than the compiler version: __cpp_lib_expected, __cpp_concepts, __cpp_lib_format. These are defined in the standard headers and reflect what your standard library actually implements.

A minimal but correct CMake project

Modern CMake is target-based: you describe a target and attach properties to it. Global variables such as include_directories are the legacy style and leak settings between targets.

cmake_minimum_required(VERSION 3.20)

project(app LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)          # -std=c++20, not gnu++20

# a library plus a thin executable: always testable, always reusable
add_library(core STATIC src/parse.cpp src/model.cpp)
target_include_directories(core PUBLIC include)
target_compile_features(core PUBLIC cxx_std_20)

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE core)

# warnings belong to a target, via an interface library
add_library(warnings INTERFACE)
target_compile_options(warnings INTERFACE
  $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic -Wshadow>
  $<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive->)
target_link_libraries(core PRIVATE warnings)
target_link_libraries(app  PRIVATE warnings)

include(CTest)
if (BUILD_TESTING)
  add_executable(core_tests tests/core_tests.cpp)
  target_link_libraries(core_tests PRIVATE core)
  add_test(NAME core COMMAND core_tests)
endif()
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug          # configure
cmake --build build -j                                # build
ctest --test-dir build --output-on-failure            # test
cmake --build build --target install --config Release

# an out-of-tree build directory keeps the source tree clean;
# never configure in the source directory itself
  • add_library over file(GLOB ...): listing sources explicitly makes CMake re-run when files are added.
  • Generator expressions such as $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall> apply a flag only for the right compiler.
  • target_link_libraries(app PRIVATE core) propagates include directories automatically; PRIVATE keeps them out of the public interface.
  • Use target_precompile_headers only after profiling a slow build — it hides include churn but can produce stale-object surprises.

Debug, sanitize and release configurations

# debug build with the sanitisers, driven from CMake options
cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g"
cmake --build build-asan -j && ctest --test-dir build-asan

# thread sanitizer needs its own tree: it cannot be combined with ASan
cmake -S . -B build-tsan -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_CXX_FLAGS="-fsanitize=thread -g"
💡
Treat -Wall -Wextra plus a sanitiser job in CI as the baseline, not an extra. Almost every hour spent debugging a mysterious crash is saved by having built with the sanitisers from the first commit.

FAQ

What is the difference between -O2 and -DNDEBUG?
-DNDEBUG disables assert; -O2 optimises. They are independent. CMake's Release build type sets both, which is why asserts vanish in a release build and you should use a logging or expect-style check for conditions that must hold in production.
Why is my build rebuilding everything after a tiny change?
Usually a header included by many translation units, or precompiled headers being invalidated. Check with ninja -d explain or cmake --build build --verbose to see which dependency triggered it.

Modern C++ basics CMake, dependencies and project structure

Last refreshed 2026-09-18.