Getting Started with CMake - 4 - PUBLIC/PRIVATE/INTERFACE

Jeff posted on  (updated on )

When using CMake to develop large C/C++ projects, it’s common for us to split the code base into separate libraries, and later combine those libs into a final executable, also it’s common to use 3rdparty dependencies. By using CMake, we can config those libraries and add them via commands like target_include_directories, target_link_libraries.

And if we check the document for these commands, we can see the prevalent usage of the keyword INTERFACE/PUBLIC/PRIVATE. For example,

target_include_directories(<target> [SYSTEM] [AFTER|BEFORE]
  <INTERFACE|PUBLIC|PRIVATE> [items1...]
  [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

target_link_libraries(<target>
                      <PRIVATE|PUBLIC|INTERFACE> <item>...
                     [<PRIVATE|PUBLIC|INTERFACE> <item>...]...)

What does the keyword INTERFACE/PUBLIC/PRIVATE do in such commands? In this article, we are going to find out.

Where should I use these?

The INTERFACE/PUBLIC/PRIVATE (keywords for short) is most commonly found in the following commands (not an exhaustive list)

  1. target_include_directories
  2. target_link_libraries
  3. target_link_options
  4. target_compile_definitions
  5. target_compile_options

These commands are used to compile/link a target(be it an executable or library, assume we are building a library targetBase). And the keywords play an important role in how this targetBase can be used by others. In other words, the keywords do 2 jobs

  1. Tell CMake how to build targetBase
  2. Tell other targets that use targetBase (via target_link_libraries), how to handle the include directories/compile options/etc.

Keyword effects

Let’s focus on one particular command target_include_directories, and see how different keywords affect the 2 jobs we mentioned above.

Keywords behave the same in other commands, so understand this one and you will understand the rest.

Here’s what each keyword does, we will examine their effect and talk about how it works under the hood later. In the context of target_include_directories, "Added" and "Not added" refer to whether the given directories are added to the #include search path or not.

KeywordEffect to current targetEffect to other targets that use this target
PUBLICAddedAdded
PRIVATEAddedNot added
INTERFACENot addedAdded

Example project

To better illustrate the effects of keywords, let’s create an example project, consist of 1 executable, built from 1 library.

Library

In this example library, let’s create a simple int add(int, int) method

libA/libA.h

#pragma once
int add(int, int);

libA/libA.c

#include <libA.h> // intentionally use <> here
int add(int a, int b) {
    return a + b;
}

libA/CMakeLists.txt

add_library(libA STATIC libA.c)
target_include_directories(libA PUBLIC "./")

Executable

In the final executable, simply call the libA’s add method.

main.c

#include <stdio.h>
#include <libA.h>
int main() {
    printf("%d\n", add(1, 1));
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(demo)
add_subdirectory(libA)
add_executable(demo main.c)
target_link_libraries(demo PRIVATE libA)

Now, try it yourself and try to build the project with different keywords, there are 3x3=9 combinations of keywords. Most of them would fail to compile.

Why only PUBLIC for libA and PUBLIC/PRIVATE for demo works?

If you play around a bit, you will notice the program will only compile given

  1. target_include_directories(libA PUBLIC …
  2. target_link_libraries(demo PUBLIC/PRIVATE libA)

Now, refer to the table of keywords effects above, and see if you can figure out why.

Why does libA need PUBLIC?

To build libA, we need to add /. (current directory) to #include search path. And if any other target wishes to use libA, they need to know this search path to look for libA.h as well.

Looking at our effects table above, only PUBLIC keyword does both.

We explicitly use #include <libA.h> in libA.c so it will only compile if search path is correctly configured.
If we use #include "libA.h", obviously it will always find the header file regardless of keywords. This is the most common usage in the real world, but for the purpose of learning I intentionally used the first one.

Why can’t demo use INTERFACE?

To build demo executable, we need to know the search path for libA.h as we use #include <libA.h> in main.c.

Looking at our effects table, INTERFACE keyword will not add related info when building the current target, so demo target doesn’t know where to find libA.h.

How does it work under the hood?

From the effect table, you could probably guess that each keyword affects 2 variables: one for building the current target, the other for building other targets that use the current target.

That’s true, let’s see how CMake implements such behavior. Again, let’s focus on target_include_directories.

It turns out there are 2 variables (actually, property of target, to be more precise) of our interest here:

  1. INCLUDE_DIRECTORIES
  2. INTERFACE_INCLUDE_DIRECTORIES

Let’s update the effect table with 2 properties:

KeywordINCLUDE_DIRECTORIESINTERFACE_INCLUDE_DIRECTORIES
PUBLICAddedAdded
PRIVATEAddedNot added
INTERFACENot addedAdded

When building a target, the final include directories (search paths) will be constructed by combining:

  1. The INCLUDE_DIRECTORIES of current target
  2. The combined INTERFACE_INCLUDE_DIRECTORIES of every target this target links with.

For our demo project, that means the final include directories is combined from:

  1. We didn't use target_include_directories on target demo, so empty INCLUDE_DIRECTORIES
  2. We link with libA, it got INTERFACE_INCLUDE_DIRECTORIES="./"

Combining 1 and 2, we got a search path of /xxx/playground/libA/./ when compiling demo.

How to check these 2 properties?

We can print out these properties, just to be sure.

libA/CMakeLists.txt

add_library(libA STATIC libA.c)
target_include_directories(libA PUBLIC "./")
# Get the target libA's property INCLUDE_DIRECTORIES, add assign to variable libA_INCLUDE_DIRS
get_target_property(libA_INCLUDE_DIRS libA INCLUDE_DIRECTORIES) # or INTERFACE_INCLUDE_DIRECTORIES
message("libA_INCLUDE_DIRS: ${libA_INCLUDE_DIRS}")

Output

libA_INCLUDE_DIRS: /xxx/playground/libA/./

Empty INCLUDE_DIRECTORIES for target demo?

If you try to get INCLUDE_DIRECTORIES or INTERFACE_INCLUDE_DIRECTORIES for the executable target demo, you will see these 2 properties not found.

# You would get nothing
get_target_property(demo_INCLUDE_DIRS demo INCLUDE_DIRECTORIES)

You would probably expect them to be /xxx/playground/libs/libA/./.

Remember, INCLUDE_DIRECTORIES and INTERFACE_INCLUDE_DIRECTORIES are properties local to the specific target. They are modified only by the developer's target_include_directories command. Later during config time, CMake will read these properties and generate the final search path, but that value will be stored elsewhere, and we shouldn't worry about it.

Reference

https://kubasejdak.com/modern-cmake-is-like-inheritance
https://leimao.github.io/blog/CMake-Public-Private-Interface/