Using CMake
Although I have quite a long experience in C++ – I started using it in 1998
– most of my experience have been in Windows, and I have hardly worked with
makefiles.
Visual Studio is not only an IDE: it’s also a complete frontend for the make
system. For instance, it’s enough to add a source file to automatically have
it added to the build.
Of course, there are a lot of issues with this approach; and using CI
systems required a bit of juggling, but for all projects I have been working
with, this was already a solved problem.
This time, I wanted to tackle the problem and finally use a build system.
Theoretically, I need to just create Makefiles. However, it seems that
nowadays no one uses them, because unsuitable for large projects. Not being
an expert here, I will just take this as “fact”, and move on.
There are several tools that will handle the build – very often, just by
creating the makefiles based on a DSL.
The most common is certainly CMake, which is the de-facto standard when
talking about build systems. However, there are also alternatives; the most
commonly mentioned is Bezel, from Google, which seems gaining more and
more consensus as the better alternative.
Unfortunately it seems to have also a higher initial setup cost (also in
terms of learning), so this time I decided to stay on the “lesser alternative”,
that is CMake.
Basic usage
I will start with an example:
add_executable(tests "")
target_sources(tests PRIVATE tests/test-catch.cpp)
target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)
This will create a target test
, with test-catch.cpp
and linked against
the Catch2 test library. Note that using Catch2 was not that straightforward,
but that’s the topic of another blog post.
Now, for a bit more detail.
The default name for the configuration file is CMakeLists.txt
.
The base file was created by the CMake Tools extension in VS Code. the first
execution on the project is a bit annoying, but it will create the basic
file and then allows building with one click. Most probably it has more uses,
but so far, its utility appeared a bit limited.
You add a target using add_executable
(there is also the equivalent to add a
library), and then add sources using include
or target_sources
. Likewise,
add libraries using target_link_libraries
.
Adding sources
Adding all sources in that way is not really maintainable, especially in very large projects, with thousands of files. There must be a better way!
In particular, I was looking for a way to automatically add all sources. Again,
this might be because of my legacy with IDEs, but I did not expect having to add
a file twice: once on the filesystem, and once on the make system.
Unfortunately, I was not able to find something like this.
Truth to be told, there is a way, using file(GLOB)
and file (GLOB_RECURSE)
, but it has issues and the documentation does not recommend
it:
- by default, it might not detect new/deleted files in a project
- using the
CONFIGURE_DEPENDS
additional option will force CMake to re-check the directory every time, on every build, and it might not work reliably at all.
The recommended way is to split the file into smaller units, one for each directory, and then include them. This helps in containing the complexity of large builds, because there is no single file with maybe thousands of sources, but still requires the double booking I was talking before.
Maybe the solution is just in some better tooling: in the end, maintenance of that list is a work of the IDE. So far, I could not find a way to have that working in VSCode: that might be just my lack of google-fu, otoh the documentation does not seem to mention that kind of usage.
Adding libraries
In order to install a library, that must be available on the local system.
It might be because it was part of the operating system, some external package,
or just built on the fly.
Libraries already available are not very interesting, because they usually
just work. But what if the library is not available? CMake has the
FetchContent
module, which ensures all dependencies are actually
populated
and made available to the project. Note: it’s also possible to use
ExternalProject
with similar results, but I did not try it.
For example:
Include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.5.1 # or a later release
)
FetchContent_MakeAvailable(Catch2)
First of all, we need to import the module.
Then we declare the additional content, together with a way to fetch it; in
this case, it’s git.
Finally, we tell to make the content available; this ensures all the
dependencies are actually populated.
Unfortunately, I could not get that working: the library was being built in
some “private” directory (in the project dir, but managed by CMake), and it
was not available.
Eventually, I gave up and went for option #2: install them with a package
manager and make them available in that way.
On my Mac, it meant using brew:
brew install catch2
This was still not working, as we need to tell CMake where it should fetch the libraries. This means first of all knowing [where homebrew installs libs] 9, and then telling that location to CMake (warning: link to Medium).
# add extra include directories
include_directories(/opt/homebrew/include)
# add extra lib directories from the library folder
link_directories(/opt/homebrew/lib)
# ...
target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)
Finally, now I have a project in VSCode, which not only builds, but has
multiple targets, and tests.
It took me literally few hours of experimentation, and through all that time
I could not stop thinking why things could not be a bit simpler.
On the other hand, that’s a one time cost. I am pretty sure the next time I will
set up a C++ project I will just be using this one as a blueprint.