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 larger topics like build and dependency management, where more advanced guides often assume greater knowledge of 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 use 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 kind of grew up with those tools 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 at high speed. Stepping back, starting without tools, and trying to understand every tool I add first, at least on a basic level, was the plan. And the tool I wanted to understand first was CMake — little did I know what a ride that would be.
Learning CMake
I've heard a lot of things about CMake, and 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 well 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 that probably many felt using CMake and its many versions. It was time to extend my CMake knowledge, so I bought Professional CMake: A Practical Guide, which 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 favorite choice for developing in C++.
Application setup
The minimal project setup will look 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 the generated project files and the actual
application build. The folder is
excluded from source control. Inside are the different build
artifacts for debug and release builds, but they 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 file 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 of 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 maybe it is, but it 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 can then use this
project_warnings
interface and link it as a library to my app and the libraries I want to
build.
Here is 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 number of applications and libraries in your project.
# src/CMakeLists.txt
add_subdirectory(some_library)
add_subdirectory(app)
Defining a library
Now let's take 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 with 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 the 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 the fact that 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 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 the 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 for defining 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, then 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 a 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, the 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 but also SVN or any URL really containing an archive (e.g., a tar.gz file) can be used.
The benefit of FetchContent is that dependencies are fetched only once at 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 with 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"
Run, build, and start can now be done via litr build,start
.
Epilogue
That's about it when it comes to the base setup. I do actually have a little more, namely tests via doctest, but I 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