Martin Helmut Fieber

Basic C++ setup with dependency management in CMake

Posted on — Updated

Introduction

When I started seriously learning C++ a couple of years ago, I struggled with a proper setup the most. Beginner tutorials often only use the command line, but lack in larger topics like build and dependency management. Where more advanced guides often assume greater knowledge with at least CMake already.

Coming from web development, dependency management was one of the easier tasks, as simple as npm install. Nevertheless, building a web project got increasingly difficult over the years, assuming the usage of tools like TypeScript, Babel, webpack and others. Of course there is still the option to not use any and go "vanilla".

As I started writing websites with Internet Explorer 5, I kinda grew up with those tools, naturally, as they emerged over the years. In contrast, coming into the large C++ ecosystem with a lot of tools and practices feels like hitting a wall with high speed. Stepping back, starting without tools, and trying to understand every tool I add first, at least on a basic level; that was the plan. And the tool I wanted to understand first was CMake — little I knew what a ride that will be.

Before I start some words about platform support: I regularly work on a M1 Mac, but I also tested this setup with Windows 11 and Linux (Debian).

Learning CMake

I've heard a lot of things about CMake, a lot of those things were bad. Nevertheless, CMake seems to be a very dominant tool in C++ development, and I definitely wanted to understand it, at least good enough to feel comfortable using it.

Searching the internet for resources I saw a lot of CMake code, solving the same problem in many ways. Initially I tried to combine what I found to reach my goals, but even with the little understanding of CMake I had this felt wrong. Not fully understanding the tool, I've got the same frustration probably many felt using CMake and its many versions. It was time to extend my CMake knowledge — I bought Professional CMake: A Practical Guide, that I've heard a lot of good things about. And the book was indeed an amazing source of knowledge. By the way, there is no affiliation, I just genuinely like the book.

From here on I will show how I set up my environment for C++ development with CMake. It may not be the best, but it proved valuable for me multiple times and is, with my limited experience, my favourite choice developing in C++.

Application setup

The minimal project setup looks like this:

build/
  debug/
  release/
cmake/
  CompilerWarnings.cmake
  StandardProjectSettings.cmake
  StaticAnalyzers.cmake
  UniversalAppleBuild.cmake
src/
  app/
    App/
    CMakeLists.txt
  some_library/
    SomeLibrary/
    CMakeLists.txt
  CMakeLists.txt
CMakeLists.txt

Going through it step-by-step; the first folder build is to capture generated project files and the actual application build. The folder is excluded from source control. Inside are the different build artefacts for debug and release builds, but can also contain a different, or more granular, structure when building for multiple targets.

CMakeLists.txt

The root CMakeLists.txt looks like this:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.22)

include(cmake/UniversalAppleBuild.cmake)

project(
  AppName
  DESCRIPTION "A description about the app."
  LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(cmake/StandardProjectSettings.cmake)

# Link project_warnings as "library" to use the warnings
# specified in CompilerWarnings.cmake.
add_library(project_warnings INTERFACE)
include(cmake/CompilerWarnings.cmake)
set_project_warnings(project_warnings)

add_subdirectory(src)

The first line sets the minimum CMake version and can be any version for you, I like to use a rather recent version.

cmake_minimum_required(VERSION 3.22)

The next line includes a file inside the cmake folder.

include(cmake/UniversalAppleBuild.cmake)

The file UniversalAppleBuild.cmake is used to generate universal builds for Apple Intel and Silicon. The contents are as follows:

# cmake/UniversalAppleBuild.cmake

# Generate universal executable for Apple hardware.
# This file needs to be included before calling `project`.
if (APPLE AND RELEASE)
  set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "")
endif ()

Next the project setup.

project(
  AppName
  DESCRIPTION "A description about the app."
  LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

This will define the CMake project, give it a project name, a description and the language used; in this case C++20.

Standard project settings

The next include, StandardProjectSettings.cmake, will define some common settings.

include(cmake/StandardProjectSettings.cmake)

It is located inside the cmake folder that will hold all CMake source files. In its basic form this files looks like this:

# cmake/StandardProjectSettings.cmake

# Set a default build type if none was specified
if (NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  message(
    STATUS
    "Setting build type to 'Debug' as none was specified.")
  set(
    CMAKE_BUILD_TYPE Debug
    CACHE STRING "Choose the type of build." FORCE)

  # Set possible build type values for cmake-gui and ccmake
  set_property(
    CACHE CMAKE_BUILD_TYPE
    PROPERTY STRINGS "Debug" "Release")
endif ()

# Use ccache for faster rebuilds
find_program(CCACHE ccache)
if (CCACHE)
  message(STATUS "Using ccache")
  set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE})
else ()
  message(STATUS "Ccache not found")
endif ()

# Generate compile_commands.json to make it easier to work
# with clang based tools. Used in combination with Ninja.
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# Debug option that also enables asserts and profiling
option(DEBUG "Enable debug statements" OFF)
if (DEBUG OR CMAKE_BUILD_TYPE STREQUAL "Debug")
  add_compile_definitions(
    APP_DEBUG APP_ENABLE_ASSERTS APP_PROFILE)
endif ()

This will, if a Debug build is enabled, also define three compiler directives; APP_DEBUG to be used to enable debug build only code paths, APP_ENABLE_ASSERTS for assertions and APP_PROFILE for performance profiling. All are prefixed with APP_, representative for a more unique prefix, like your app name or a short form of it.

Project warnings

Now comes the code quality part, or at least some of it.

# Link project_warnings as "library" to use the warnings
# specified in CompilerWarnings.cmake.
add_library(project_warnings INTERFACE)
include(cmake/CompilerWarnings.cmake)
set_project_warnings(project_warnings)

This looks like a hack, and it maybe is, but works like a charm. I create an empty interface called project_warnings and include another file, CompilerWarnings.cmake, that will create a function set_project_warnings that takes a "library" name and attaches a set of compiler options to it via CMake's target_compile_options. I then can use this project_warnings interface and link it as a library to my app and libraries I want to build.

Here a shortened look inside the CompilerWarnings.cmake file with some added comments for clarification (the long version can be found here).

# cmake/CompilerWarnings.cmake

# This will define the `set_project_warnings` function
# that takes the interface name.
function(set_project_warnings project_name)
  # I always treat warnings as errors, making my life easier
  # in the long run.
  option(
    WARNINGS_AS_ERRORS
    "Treat compiler warnings as errors" TRUE)
  message(STATUS "Treat compiler warnings as errors")

  set(MSVC_WARNINGS
    # Baseline reasonable warnings
    /W4
    # Whatever other MSVC compiler warnings you want.
    # ...
    )

  set(CLANG_WARNINGS
    # Reasonable and standard
    -Wall
    -Wextra
    # Whatever other CLang compiler warnings you want.
    # ...
    )

  # Stop the build if there are any warnings,
  if (WARNINGS_AS_ERRORS)
    set(CLANG_WARNINGS ${CLANG_WARNINGS} -Werror)
    set(MSVC_WARNINGS ${MSVC_WARNINGS} /WX)
  endif ()

  set(GCC_WARNINGS
    ${CLANG_WARNINGS}
    # Whatever other GCC compiler warnings you want.
    # ...
    )

  # Enable the right set of warnings depending on
  # the used compiler.
  if (MSVC)
    set(PROJECT_WARNINGS ${MSVC_WARNINGS})
  elseif (CMAKE_CXX_COMPILER_ID MATCHES ".*Clang")
    set(PROJECT_WARNINGS ${CLANG_WARNINGS})
  elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
    set(PROJECT_WARNINGS ${GCC_WARNINGS})
  else ()
    message(
      AUTHOR_WARNING
      "No compiler warnings set for '${CMAKE_CXX_COMPILER_ID}' compiler.")
  endif ()

  # This will "link" the warnings to the defined
  # project name, in my case "project_warnings".
  target_compile_options(
    ${project_name} INTERFACE ${PROJECT_WARNINGS})
endfunction()

The last line inside the root CMakeLists.txt file is to include the source directory.

add_subdirectory(src)

Source structure

The CMakeLists.txt inside the src folder contains two lines, one to include the library folder and one for the app. This is dependent on the amount of applications and libraries in your project.

# src/CMakeLists.txt
add_subdirectory(some_library)
add_subdirectory(app)

Defining a library

Now let's have a look at the src/some_library/CMakeLists.txt file.

# src/some_library/CMakeLists.txt
set(NAME "SomeLibrary")

include(${PROJECT_SOURCE_DIR}/cmake/StaticAnalyzers.cmake)

add_library(${NAME} STATIC
  SomeLibrary/SomeFile.cpp SomeLibrary/SomeFile.hpp)

target_include_directories(
  ${NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(${NAME} PRIVATE cxx_std_20)
target_link_libraries(${NAME} PRIVATE project_warnings)

The first line is, for convenience, the definition of the library name.

set(NAME "SomeLibrary")

Then I include a file I add to every library as well as the app, StaticAnalyzers.cmake.

Static analysis

The StaticAnalyzers.cmake file inside the cmake folder enables clang-tidy (if installed) and the address sanitizer for debug builds.

# cmake/StaticAnalyzers.cmake
if (NOT RELEASE)
  find_program(CLANGTIDY clang-tidy)
  if (CLANGTIDY)
    message(STATUS "Using clang-tidy")
    set(CMAKE_CXX_CLANG_TIDY ${CLANGTIDY})
  else ()
    message(SEND_ERROR "clang-tidy requested but executable not found")
  endif ()

  message(STATUS "Using address sanitizer")
  set(CMAKE_CXX_FLAGS
    "${CMAKE_CXX_FLAGS} -O0 -fsanitize=address -g")
endif ()

The clang-tidy options are defined in a .clang-tidy file at the project root.

# .clang-tidy
---
Checks: >
  *,
  -android-*,
  -abseil-*,
  -altera-*,
  -darwin-*,
  -fuchsia-*,
  -google-*,
  -objc-*,
  -zircon-*,
  -llvm*,
  -cppcoreguidelines-non-private-member-variables-in-classes,
  -cppcoreguidelines-pro-bounds-pointer-arithmetic,
  -cppcoreguidelines-macro-usage,
  -readability-function-cognitive-complexity,
  -misc-non-private-member-variables-in-classes,
  -clang-analyzer-optin.cplusplus.UninitializedObject,
  -misc-static-assert,
  -modernize-use-trailing-return-type,
  -bugprone-easily-swappable-parameters,
  -cert-env33-c,
  -cert-err58-cpp

WarningsAsErrors: '*'
HeaderFilterRegex: ''
FormatStyle: none

CheckOptions:
  - key: readability-identifier-naming.NamespaceCase
    value: CamelCase
  - key: readability-identifier-naming.ClassCase
    value: CamelCase
  - key: readability-identifier-naming.PrivateMemberPrefix
    value: m_
  - key: readability-identifier-naming.StructCase
    value: CamelCase
  - key: readability-identifier-naming.ClassMethodCase
    value: lower_case
  - key: readability-identifier-naming.ClassMemberCase
    value: lower_case
  - key: readability-identifier-naming.FunctionCase
    value: lower_case
  - key: readability-identifier-naming.VariableCase
    value: lower_case
  - key: readability-identifier-naming.GlobalConstantCase
    value: UPPER_CASE
  - key: readability-identifier-length.MinimumVariableNameLength
    value: 2
  - key: readability-identifier-length.MinimumParameterNameLength
    value: 2
  - key: cppcoreguidelines-explicit-virtual-functions.IgnoreDestructors
    value: '1'

As many projects there are options, and this set of options is only mine. You can find a list of all checks for clang-tidy here.

Library files and build settings

Next some source files:

add_library(${NAME} STATIC
  SomeLibrary/SomeFile.cpp SomeLibrary/SomeFile.hpp)

This will add needed source files to create a static library. I like splitting my code into smaller libraries, like "core", "ui", "rendering", etc. to create logical chunks, but also keep build times lower. This is due to only libraries with changed files need to be rebuilt, otherwise they will only be linked.

At last, setting the "include directory", building with C++20, and linking the project_warnings as mentioned earlier.

target_include_directories(${NAME}
  PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(${NAME} PRIVATE cxx_std_20)
target_link_libraries(${NAME} PRIVATE project_warnings)

Defining an application

Alas, a look at the src/app/CMakeLists.txt file and how to define an executable.

# src/app/CMakeLists.txt
set(NAME "App")

include(${PROJECT_SOURCE_DIR}/cmake/StaticAnalyzers.cmake)

add_executable(${NAME} App/Main.cpp)

target_include_directories(${NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(${NAME} PRIVATE cxx_std_20)
target_link_libraries(${NAME} PRIVATE project_warnings SomeLibrary)

Same as for the library, it defines a name for convenience.

set(NAME "App")

Again, adding static analysers to the app.

include(${PROJECT_SOURCE_DIR}/cmake/StaticAnalyzers.cmake)

Throw in all app source files.

add_executable(${NAME} App/Main.cpp)

And define the "include directory" and the C++ standard to build with.

target_include_directories(${NAME}
  PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(${NAME} PRIVATE cxx_std_20)

Besides linking the project_warnings as mentioned earlier, I also link the libraries I want to use with the application by name; in that case my example SomeLibrary.

target_link_libraries(${NAME}
  PRIVATE project_warnings SomeLibrary)

And that's about it to define an application. This setup supports multiple applications sharing the same (or different) libraries. Making this a kind of mono repository.

Adding dependencies

How about the big topic dependencies? This was my greatest issue when starting C++, first using Git submodules, directly throwing source files into the project, failing at using Conan, and many other attempts. Though, finally I settled on using CMake's FetchContent.

Adding fmtlib

The way I structure dependencies is by having a vendor folder in the root of the project containing a CMakeLists.txt and one folder per dependency with another CMakeLists.txt. Adding {fmt} to the project structure looks like this:

build/
cmake/
src/
vendor/
  fmt/
    CMakeLists.txt
  CMakeLists.txt
CMakeLists.txt
Minified view of the folder structure.

First, I include the vendor folder inside the root CMakeLists.txt.

# CMakeLists.txt
cmake_minimum_required(VERSION 3.22)

# Other CMake code

add_subdirectory(src)
add_subdirectory(vendor)

The dependency vendor/fmt/CMakeLists.txt file contains a message for fetching the dependency, potential dependency settings and the statement to make the dependency available to the project.

# vendor/fmt/CMakeLists.txt
message(STATUS "Fetching fmt ...")

# Here you can define build settings for fmt.

FetchContent_MakeAvailable(fmt)

The vendor/CMakeLists.txt file declares the actual dependency and the version to fetch (and from where).

# vendor/CMakeLists.txt
include(FetchContent)

FetchContent_Declare(
  fmt
  GIT_REPOSITORY "https://github.com/fmtlib/fmt.git"
  GIT_TAG 9.0.0
)
add_subdirectory(fmt)

It includes the FetchContent module from CMake, then declares a dependency by giving it a name, what Git repository to use and the Git tag (or alternatively a commit hash). At last, it adds the dependency subdirectory.

With FetchContent_Declare not only Git repositories can be used, also SVN or any URL really containing an archive (e.g. a tar.gz file).

The benefit with FetchContent is that dependencies are fetched only once on configure time, not on every build as with other solutions.

Build and run

Before building the project, the CMake configuration step needs to be executed, looking like this for the debug setup (using Ninja to build the project):

$ cmake -GNinja -DCMAKE_BUILD_TYPE=Debug -B build/debug
The $ is used to show a command will be entered.

Building the project:

$ cmake --build build/debug

Alas, running the generated executable (under Mac in that case):

$ ./build/debug/src/app/App

Project formatting

Being used to Prettier I wanted something similar for C++, seeing clang-format as a worthy replacement. Adding a .clang-format file to the project root containing some basic formatting options.

# .clang-format
---
Language: Cpp
BasedOnStyle: Google
AlignAfterOpenBracket: DontAlign
AllowShortBlocksOnASingleLine: Empty
AllowShortFunctionsOnASingleLine: Empty
AllowShortCaseLabelsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false
AllowShortLambdasOnASingleLine: Empty
AllowShortLoopsOnASingleLine: false
AllowAllConstructorInitializersOnNextLine: false
BinPackArguments: false
BinPackParameters: false
ColumnLimit: 100

...

A list of all options can be found here.

To format all the project source files, having clang-format installed, run:

$ find src -iname *.hpp -o -iname *.cpp \
  | xargs clang-format -i

Bonus: Litr

As biased as I am, of course I use my own project Litr to define some common commands for the project. Adding a litr.toml file in the project root to have shortcut commands for building, running and formatting the project.

# litr.toml
[commands.build]
script = [
  "cmake -GNinja -DCMAKE_BUILD_TYPE=%{target} -B build/%{target}",
  "cmake --build build/%{target}"
]
description = "Build the application for a given target."

[commands.start]
script = "./build/%{target}/src/app/App"
description = "Start the application."

[commands.format]
script = "find src -iname *.hpp -o -iname *.cpp | xargs clang-format -i"
description = "Format project sources via clang-format."

[params.target]
shortcut = "t"
description = "Define the application build target."
type = ["debug", "release"]
default = "debug"

Running build and start can now be done via litr build,start.

Epilogue

That's about it when it comes the base setup. I do actually have a little more, namely tests via doctest, but wanted to spare the setup here to keep it short. Nevertheless, this and a full example of the setup can be found on GitHub.

Template repository: https://github.com/MartinHelmut/cpp-base-template

← Back to all posts