Basic C++ setup with dependency management in CMake
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.
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
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
$
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
...
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