The CMake’s target_link_libraries() function has different scopes (PRIVATE/INTERFACE/PUBLIC), and I never understood what exactly each one of them means and how do they actually affect the final result.

CMake, linking 3rd-party dependency, your SDK and application

As there is a limit for how long one can postpone one’s ignorance, it finally came a time for me to investigate the matter, which I did by (reading the documentation and) conducting a small experiment of my own.

Theory

If anything, I am now on CMake v3.26.4, which might be important to state, as (much) older versions had different policies for things like include directories propagation, and newer versions might have something different too.

What are these scopes

Before reading my interpretation, do read the documentation and this post from Craig Scott (author of Professional CMake).

As I understood it, scopes define how dependencies of a library will affect consumers of that library. So if you only deal with direct dependencies and don’t have consumers upper in the dependency chain, then you probably don’t need to care about all that, as this is really about transitive dependencies (dependencies of dependencies).

For example, let’s say we have a Project, which is a collection of libraries. This Project is used by some Tool (so Tool depends on Project) and also it has a dependency of its own (so Project depends on Dependency). In this case that Dependency library will be a direct dependency for our Project and a transitive dependency for the Tool:

Relations between projects

Applying to this example, my understanding of the scopes would be the following:

  • PRIVATE - the Tool gets doThingy() functionality of Dependency through doTheThing() function of AnotherLibrary and cannot get doThingy() function directly from Dependency: CMake, PRIVATE linking

  • INTERFACE - the Tool gets doThingy() function directly from Dependency, while AnotherLibrary does not (and so it no longer has doTheThing() function available): CMake, INTERFACE linking

  • PUBLIC - the Tool can do both: get doThingy() functionality of Dependency through doTheThing() function of AnotherLibrary and get doThingy() function directly from Dependency: CMake, PUBLIC linking

While PRIVATE and PUBLIC scopes make sense to me, I cannot think of a real-world example of INTERFACE linkage. Why would a library A link to library B only to make B available higher in the dependency chain, so without using B’s functionality itself?

The INTERFACE scope would probably make sense for a so-called “header-only” library (which has no sources of its own, only headers), when it provides linking to other libraries without using them itself. But I myself never needed to create such a thing, so I’m still puzzled about the actual use of the INTERFACE scope.

Which scope is the best

I saw this question being asked in a couple of places: either “which one is the best” or “which one should I use”. I wouldn’t say that any of the scopes is better or worse than another, it’s just different scopes for different purposes.

If we take one of the most common dependencies, such as zlib, then applying to the same example from above, if the Tool has no intention of using any of zlib’s functionality on its own, as it only needs what AnotherLibrary provides in its doTheThing() function, then one should choose PRIVATE scope when linking to zlib from AnotherLibrary.

However, if, in addition to whatever doTheThing() does, Tool also needs to perform some compression/decompression operations of its own, then it indeed would require access to zlib’s API too. So now it’s both AnotherLibrary and Tool who should have access to zlib functions, and in that case one should choose PUBLIC scope when linking to zlib from AnotherLibrary. Although I don’t quite understand why wouldn’t Tool just find_package() zlib and link to it itself then, so AnotherLibrary could keep its linking to zlib as PRIVATE. This would also make the Tool’s dependency on zlib more clear/visible in its CMakeLists.txt.

If after reading the last sentence you were going to say “but in that case Tool will need to have zlib binary for linking, while when it gets zlib through AnotherLibrary’s private linking it won’t”, then I have some news for you.

A silly analogy

If previous sections didn’t add much clarity, let’s try the following silly analogy.

Say, you’d like to steal a game. Such a process usually involves getting the base game and finding a crack for it (modified executable or/and accompanying files). In general, the latter is provided by crackers, and the former is provided by repackers, who incorporate the crack into the base game and distribute the result.

To make the analogy fit a bit better, let’s assume that you don’t know the original cracker website (it frequently gets shut down and thus moves around a lot), so you cannot download the crack yourself, only repacker always knows how to get it.

The scopes in this case will be the following:

  • PRIVATE - repacker takes the original crack, applies it to the base game, compresses everything one hundred times into an archive, compresses that archive one thousand times more and makes an installer out of it (which of course will require Administrator elevation on your machine) with distorted pictures, shitty music and some advertisement banners. Of course, he doesn’t provide any links/credits to the original cracker;
    • this option might seem to be the worst, but most of the time you don’t have any other way to get the base game in order to apply the crack yourself (which you also don’t have);
  • INTERFACE - repacker doesn’t do anything, he doesn’t even re-upload the original crack, he just posts a link to the original cracker release page, where you can download the crack from;
    • assuming that you somehow already have the base game, here the INTERFACE scope actually makes sense;
  • PUBLIC - repacker still does all that horrible shit as in PRIVATE variant, but this time he also provides a link to the original cracker release page, so if you already have the base game, you might prefer to download just the crack from its origin and apply it to the base game yourself, instead of using repacker’s garbage installer.

As you can see, the analogy is very silly indeed and not entirely correct too, so don’t hang to it too much. I almost regret adding it to the article.

Practical example

That trio of projects from above (Dependency, Project and Tool) is what I used in this repository for experimenting with target_link_libraries() scopes to see what exactly do they do in practice.

Once again, it is 3 different entities:

  1. Dependency - a library, that is a direct dependency for Project (and a transitive dependency for Tool);
  2. Project - a set of libraries, one of which depends on Dependency;
  3. Tool - a CLI application that depends on libraries from Project.

This is a simplified variation of what we actually have in our project at work (as shown on the main picture): we develop an SDK or rather a framework (Project), which has several 3rd-party dependencies (Dependency), and we deliver that SDK to our customers, who in turn use it to build their own applications (Tool). So our direct dependencies are transitive dependencies for our customers.

The Dependency is a library called Thingy, and it is built the same way for all the cases, as nothing changes for it:

$ cd /path/to/cmake-target-link-libraries-example/dpndnc
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" ..
$ cmake --build . --target install

$ tree ../install/
├── include
   └── Thingy
       └── thingy.h
├── lib
   └── libThingy.a
└── share
    └── Thingy
        ├── ThingyConfig.cmake
        ├── ThingyConfigVersion.cmake
        ├── ThingyTargets-release.cmake
        └── ThingyTargets.cmake

Now let’s see how different scopes of linking to it from Project will affect Tool.

STATIC libraries

Here the Project libraries are built as STATIC libraries.

PRIVATE

Linking to Thingy library with PRIVATE scope:

find_package(Thingy CONFIG REQUIRED)

target_link_libraries(${PROJECT_NAME}
    PRIVATE
        dpndnc::Thingy
)

Configure and build the project:

$ cd /path/to/cmake-target-link-libraries-example/prjct
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..
$ cmake --build . --target install

Let’s see what we have inside install/share/AnotherLibrary/AnotherLibraryTargets.cmake:

# Create imported target prjct::AnotherLibrary
add_library(prjct::AnotherLibrary STATIC IMPORTED)

set_target_properties(prjct::AnotherLibrary PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include;${_IMPORT_PREFIX}/include"
  INTERFACE_LINK_LIBRARIES "\$<LINK_ONLY:dpndnc::Thingy>"
)

Okay, so INTERFACE_LINK_LIBRARIES property got dpndnc::Thingy linking target. That looks concerning already, because we chose PRIVATE scope, so linking to Thingy should have ended with AnotherLibrary and should have not propagated upper in the dependency chain, right? That we will find out soon enough.

But first let’s check what symbols are available in the resulting binary (here I’m using nm instrument on Mac OS, on other platforms it might be something else):

$ nm --demangle ../install/lib/libAnotherLibrary.a

another.cpp.o:
000000000000038c s GCC_except_table0
00000000000003a0 s GCC_except_table3
00000000000003e4 s GCC_except_table4
0000000000000400 s __GLOBAL__sub_I_another.cpp
                 U __Unwind_Resume
0000000000000638 b anotherString
00000000000000d0 T prjct::lbrAnother::doTheThing()
0000000000000000 T prjct::lbrAnother::doAnother()
                 U dpndnc::doThingy()
                 ...

As you can see, it contains prjct::lbrAnother::doAnother() and prjct::lbrAnother::doTheThing() own functions, but also there is dpndnc::doThingy() function from Thingy.

Now let’s try to configure the Tool. It has the following in its CMakeLists.txt:

find_package(SomeLibrary CONFIG REQUIRED)
find_package(AnotherLibrary CONFIG REQUIRED)

target_link_libraries(${CMAKE_PROJECT_NAME}
    PRIVATE
        prjct::SomeLibrary
        prjct::AnotherLibrary
)

so everything should be fine:

$ cd /path/to/cmake-target-link-libraries-example/tl
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install" \
    ..

but it isn’t:

CMake Error at /path/to/cmake-target-link-libraries-example/prjct/install/share/AnotherLibrary/AnotherLibraryTargets.cmake:60 (set_target_properties):
  The link interface of target "prjct::AnotherLibrary" contains:

    dpndnc::Thingy

  but the target was not found.  Possible reasons include:

    * There is a typo in the target name.
    * A find_package call is missing for an IMPORTED target.
    * An ALIAS target is missing.

That’s strange, but let’s add find_package():

find_package(Thingy CONFIG REQUIRED)
find_package(SomeLibrary CONFIG REQUIRED)
find_package(AnotherLibrary CONFIG REQUIRED)

and try again:

$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install" \
    ..

but that will fail too:

CMake Error at CMakeLists.txt:50 (find_package):
  Could not find a package configuration file provided by "Thingy" with any
  of the following names:

    ThingyConfig.cmake
    thingy-config.cmake

  Add the installation prefix of "Thingy" to CMAKE_PREFIX_PATH or set
  "Thingy_DIR" to a directory containing one of the above files.  If "Thingy"
  provides a separate development package or SDK, be sure it has been
  installed.

Well, fuck. So it wants /path/to/cmake-target-link-libraries-example/dpndnc/install to be also present in CMAKE_PREFIX_PATH. But again, shouldn’t have all traces to Thingy dependency “disappeared” after AnotherLibrary was built, as it’s a STATIC library and it links to Thingy with PRIVATE scope?

Turns out, they should have not. I already mentioned my ignorance, and that’s another evidence to it: I did not know that static libraries do not “bundle” their dependencies into their resulting binary. Moreover, static libraries are not really libraries, they are kind of archives of object files, which are what actually contains the symbols (I’d recommend you to read some more about this, as my understanding is still a bit vague). So AnotherLibrary doesn’t not “contain” Thingy functions, and so for our customer to be able to build his Tool application we need to deliver both our Project package and the Thingy package (otherwise he will have to get it from somewhere else himself).

That is the main reason why I wanted to research this topic and make this experiment. Our project has a lot of 3rd-party dependencies, and I was naively hoping that if we just set PRIVATE scope on linking to them from our SDK, then we won’t need to distribute these dependencies packages in addition to our main package. Well, tough titties, looks like we’ll have to anyway.

Okay then, let’s add the missing path to CMAKE_PREFIX_PATH and finally build the application:

$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install;/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..
$ cmake --build . --target install

While we are here, it is annoying (and error-prone) to add find_package() for every single transitive dependency, so instead you (as maintainer of your project) should use find_dependency() macro in AnotherLibrary’s CMake config like so:

include(CMakeFindDependencyMacro)
# ...keeping in mind, that this is not needed
# when AnotherLibrary is SHARED and links to Thingy with PRIVATE scope
# (there will be more details about that later)
find_dependency(Thingy CONFIG REQUIRED)

Having that (and having re-build/re-installed the Project), we can now delete find_package(Thingy CONFIG REQUIRED) from the Tool project.

Now let’s inspect the resulting Tool binary:

$ nm --demangle ../install/bin/some-tool

0000000100003de0 s GCC_except_table0
0000000100003e78 s GCC_except_table0
0000000100003e8c s GCC_except_table0
0000000100003ea0 s GCC_except_table0
0000000100003e20 s GCC_except_table1
0000000100003e64 s GCC_except_table2
0000000100003c10 t __GLOBAL__sub_I_another.cpp
0000000100003ae0 t __GLOBAL__sub_I_some.cpp
0000000100003d30 t __GLOBAL__sub_I_thingy.cpp
                 U __Unwind_Resume
0000000100008000 b someString
0000000100008030 b thingyString
0000000100008018 b anotherString
0000000100003c00 T prjct::lbrAnother::doTheThing()
0000000100003b30 T prjct::lbrAnother::doAnother()
0000000100003a10 T prjct::lbrSome::doSome()
0000000100003c60 T dpndnc::doThingy()
                 ...

It has all the symbols from the Project’s libraries: AnotherLibrary and SomeLibrary, and we can call these functions:

#include <SomeLibrary/some.h>
#include <AnotherLibrary/another.h>

std::cout << "Direct dependencies:" << std::endl;
prjct::lbrSome::doSome();
prjct::lbrAnother::doAnother();

std::cout << "Transitive dependency:" << std::endl;
std::cout << "via AnotherLibrary | ";
prjct::lbrAnother::doTheThing();

But nm also says that the Tool binary has dpndnc::doThingy() symbol, which comes from the Thingy library. However, if we’ll try to call that function:

std::cout << "directly | ";
dpndnc::doThingy();

It will first fail with undeclared identifier:

/path/to/cmake-target-link-libraries-example/tl/src/main.cpp:27:5: error: use of undeclared identifier 'dpndnc'
    dpndnc::doThingy();
    ^
1 error generated.

and if you’ll try to add #include <Thingy/thingy.h>, then it will fail with missing header:

/path/to/cmake-target-link-libraries-example/tl/src/main.cpp:7:14: fatal error: 'Thingy/thingy.h' file not found
    #include <Thingy/thingy.h>
             ^~~~~~~~~~~~~~~~~
1 error generated.

You can of course copy Thingy/thingy.h from Thingy installation folder so it becomes discoverable for the Tool project (for instance, in Project installation folder), and then you will in fact be able to call that function: the project will compile and will run just fine; but that is not how it was indented to work. The point of this stunt is to demonstrate that this is what the scopes are really about: discoverability of a library’s public headers. Or so I understood it.

If you are curious enough, you can also take a look an Ninja’s build commands in /path/to/cmake-target-link-libraries-example/tl/build/build.ninja:

build CMakeFiles/some-tool.dir/src/main.cpp.o:
  ...
  INCLUDES = -isystem /path/to/cmake-target-link-libraries-example/prjct/install/include
  ...

build some-tool:
  ...
  LINK_LIBRARIES = /path/to/cmake-target-link-libraries-example/prjct/install/lib/libSomeLibrary.a  /path/to/cmake-target-link-libraries-example/prjct/install/lib/libAnotherLibrary.a  /path/to/cmake-target-link-libraries-example/dpndnc/install/lib/libThingy.a
  ...

So indeed, it links to all 3 of them: libSomeLibrary.a, libAnotherLibrary.a (these two are part of the Project) and libThingy.a (this one is part of the Thingy), even though the latter is a PRIVATE dependency of the Project. Thus, the only(?) effect that PRIVATE scope actually has here is that Thingy’s public headers are not added to the Tool’s INCLUDES; but as you saw a bit earlier, manually making these headers available lets the Tool discover dpndnc::doThingy() symbol and still call that function with no problems, as the the library is linked to anyway. So it really is about the headers, isn’t it?

As a side note, that discovery made me suspect that we might be doing something stupid when we merge our SDK installation and our 3rd-party dependencies installations together into a single “bundle” with bin/include/lib/share folders structure (for the delivery convenience), because that roally fucks the PRIVATE scope of our linking to them, as all the public headers become discoverable for consuming projects, don’t they. Goddamn.

INTERFACE

Let’s see what changes with INTERFACE scope:

find_package(Thingy CONFIG REQUIRED)

target_link_libraries(${PROJECT_NAME}
    INTERFACE
        dpndnc::Thingy
)

Configure the project:

$ cd /path/to/cmake-target-link-libraries-example/prjct
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..

If we now try to build the project without changing anything in its sources, it will fail:

/path/to/cmake-target-link-libraries-example/prjct/libraries/AnotherLibrary/src/another.cpp:4:14: fatal error: 'Thingy/thingy.h' file not found
    #include <Thingy/thingy.h>
             ^~~~~~~~~~~~~~~~~
1 error generated.

That’s INTERFACE scope in action: even though AnotherLibrary finds Thingy package and links to its library, that library’s functionality is not available in the project itself. But to be precise, it’s only headers that are not available, so if you’ll copy Thingy/thingy.h into AnotherLibrary’s include or make it discoverable in some other way, then the project will build fine, the symbols inside libAnotherLibrary.a will be the same as with PRIVATE scope, and then the Tool will also be able to call doTheThing(). So once again, the only(?) effect the scopes have is the headers discoverability for targets.

But anyway, as we are not going to manually make Thingy headers available for AnotherLibrary against the INTERFACE scope wishes, we’ll need to comment out (or wrap into #ifdef) the following lines:

//#include <Thingy/thingy.h>

//void doTheThing()
//{
//    dpndnc::doThingy();
//}

Then the project will build and install:

$ cmake --build . --target install

Here’s what is inside install/share/AnotherLibrary/AnotherLibraryTargets.cmake this time:

# Create imported target prjct::AnotherLibrary
add_library(prjct::AnotherLibrary STATIC IMPORTED)

set_target_properties(prjct::AnotherLibrary PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include;${_IMPORT_PREFIX}/include"
  INTERFACE_LINK_LIBRARIES "dpndnc::Thingy"
)

Now there is no LINK_ONLY generator expression, which apparently means that this time projects higher in the dependency chain not only will link to Thingy library binary but will also get its public headers.

And here are the symbols that are available in the resulting libAnotherLibrary.a:

$ nm --demangle ../install/lib/libAnotherLibrary.a

another.cpp.o:
000000000000037c s GCC_except_table0
0000000000000390 s GCC_except_table2
00000000000003d4 s GCC_except_table3
00000000000003f0 s __GLOBAL__sub_I_another.cpp
                 U __Unwind_Resume
00000000000005e0 b anotherString
0000000000000000 T prjct::lbrAnother::doAnother()
                 ...

Quite naturally there is no prjct::lbrAnother::doTheThing() anymore, as we commented it out, but there is also no dpndnc::doThingy(), so AnotherLibrary really is just passing Thingy library linking interface without using the library itself.

If we now try to configure the Tool project, it will fail the same way (first not finding dpndnc::Thingy target and then not finding ThingyConfig.cmake), so let’s configure it with everything provided in the CMAKE_PREFIX_PATH right away:

$ cd /path/to/cmake-target-link-libraries-example/tl
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install;/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..
$ cmake --build . --target install

What symbols does this variant have:

$ nm --demangle ../install/bin/some-tool

0000000100003df0 s GCC_except_table0
0000000100003e88 s GCC_except_table0
0000000100003e9c s GCC_except_table0
0000000100003eb0 s GCC_except_table0
0000000100003e30 s GCC_except_table1
0000000100003e74 s GCC_except_table2
0000000100003c20 t __GLOBAL__sub_I_another.cpp
0000000100003b00 t __GLOBAL__sub_I_some.cpp
0000000100003d40 t __GLOBAL__sub_I_thingy.cpp
                 U __Unwind_Resume
0000000100008000 b someString
0000000100008030 b thingyString
0000000100008018 b anotherString
0000000100003b50 T prjct::lbrAnother::doAnother()
0000000100003a30 T prjct::lbrSome::doSome()
0000000100003c70 T dpndnc::doThingy()
                 ...

This time there is no prjct::lbrAnother::doTheThing(), but there is still dpndnc::doThingy(). What’s also different is that Thingy’s public headers are discoverable now, which means that we have the following set of functions available for Tool to use:

#include <SomeLibrary/some.h>
#include <AnotherLibrary/another.h>

#include <Thingy/thingy.h>

std::cout << "Direct dependencies:" << std::endl;
prjct::lbrSome::doSome();
prjct::lbrAnother::doAnother();
std::cout << std::endl;

std::cout << "Transitive dependency:" << std::endl;
// 
// trying to use that will fail to build with
// error: no member named 'doTheThing' in namespace 'prjct::lbrAnother'
//std::cout << "via AnotherLibrary | ";
//prjct::lbrAnother::doTheThing();
//
// but that one now works
std::cout << "directly | ";
dpndnc::doThingy();

If you are still curious, there is something different in /path/to/cmake-target-link-libraries-example/tl/build/build.ninja too:

build CMakeFiles/some-tool.dir/src/main.cpp.o:
  ...
  INCLUDES = -isystem /path/to/cmake-target-link-libraries-example/prjct/install/include -isystem /path/to/cmake-target-link-libraries-example/dpndnc/install/include
  ...

build some-tool:
  ...
  LINK_LIBRARIES = /path/to/cmake-target-link-libraries-example/prjct/install/lib/libSomeLibrary.a  /path/to/cmake-target-link-libraries-example/prjct/install/lib/libAnotherLibrary.a  /path/to/cmake-target-link-libraries-example/dpndnc/install/lib/libThingy.a
  ...

The set of linking libraries is the same, but INCLUDES now contains the path to Thingy’s headers too.

PUBLIC

Finally, the PUBLIC scope:

find_package(Thingy CONFIG REQUIRED)

target_link_libraries(${PROJECT_NAME}
    PUBLIC
        dpndnc::Thingy
)

Configure and build:

$ cd /path/to/cmake-target-link-libraries-example/prjct
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..
$ cmake --build . --target install

Here everything works the same way as with PRIVATE scope, so AnotherLibrary can call doThingy() function from Thingy library:

#include <Thingy/thingy.h>

void doTheThing()
{
    dpndnc::doThingy();
}

But install/share/AnotherLibrary/AnotherLibraryTargets.cmake is the same as with INTERFACE scope:

# Create imported target prjct::AnotherLibrary
add_library(prjct::AnotherLibrary STATIC IMPORTED)

set_target_properties(prjct::AnotherLibrary PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include;${_IMPORT_PREFIX}/include"
  INTERFACE_LINK_LIBRARIES "dpndnc::Thingy"
)

And symbols inside libAnotherLibrary.a are again the same as with PRIVATE scope:

$ nm --demangle ../install/lib/libAnotherLibrary.a

another.cpp.o:
000000000000038c s GCC_except_table0
00000000000003a0 s GCC_except_table3
00000000000003e4 s GCC_except_table4
0000000000000400 s __GLOBAL__sub_I_another.cpp
                 U __Unwind_Resume
0000000000000638 b anotherString
00000000000000d0 T prjct::lbrAnother::doTheThing()
0000000000000000 T prjct::lbrAnother::doAnother()
                 U dpndnc::doThingy()
                 ...

Configuring the Tool still requires providing paths to both installation folders:

$ cd /path/to/cmake-target-link-libraries-example/tl
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install;/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..
$ cmake --build . --target install

And the Tool executable symbols will be the same as with PRIVATE too:

$ nm --demangle ../install/bin/some-tool

0000000100003dd0 s GCC_except_table0
0000000100003e68 s GCC_except_table0
0000000100003e7c s GCC_except_table0
0000000100003e90 s GCC_except_table0
0000000100003e10 s GCC_except_table1
0000000100003e54 s GCC_except_table2
0000000100003c00 t __GLOBAL__sub_I_another.cpp
0000000100003ad0 t __GLOBAL__sub_I_some.cpp
0000000100003d20 t __GLOBAL__sub_I_thingy.cpp
                 U __Unwind_Resume
0000000100008000 b someString
0000000100008030 b thingyString
0000000100008018 b anotherString
0000000100003bf0 T prjct::lbrAnother::doTheThing()
0000000100003b20 T prjct::lbrAnother::doAnother()
0000000100003a00 T prjct::lbrSome::doSome()
0000000100003c50 T dpndnc::doThingy()
                 ...

All that means that the Tool can now use both doTheThing() function from AnotherLibrary and doThingy() function from Thingy:

#include <SomeLibrary/some.h>
#include <AnotherLibrary/another.h>

#include <Thingy/thingy.h>

std::cout << "Direct dependencies:" << std::endl;
prjct::lbrSome::doSome();
prjct::lbrAnother::doAnother();
std::cout << std::endl;

std::cout << "Transitive dependency:" << std::endl;
// 
// now this one works
std::cout << "via AnotherLibrary | ";
prjct::lbrAnother::doTheThing();
//
// and this one works too
std::cout << "directly | ";
dpndnc::doThingy();

I remember you were curious about this kind of things, so here’s what’s inside /path/to/cmake-target-link-libraries-example/tl/build/build.ninja:

build CMakeFiles/some-tool.dir/src/main.cpp.o:
  ...
  INCLUDES = -isystem /path/to/cmake-target-link-libraries-example/prjct/install/include -isystem /path/to/cmake-target-link-libraries-example/dpndnc/install/include
  ...

build some-tool:
  ...
  LINK_LIBRARIES = /path/to/cmake-target-link-libraries-example/prjct/install/lib/libSomeLibrary.a  /path/to/cmake-target-link-libraries-example/prjct/install/lib/libAnotherLibrary.a  /path/to/cmake-target-link-libraries-example/dpndnc/install/lib/libThingy.a
  ...

As you can see, it’s the same as with INTERFACE scope.

SHARED libraries

Let’s check if anything is different when Project libraries are built as SHARED.

But first, a couple of words about DLLs - shared libraries on Windows. As you probably know, .dll file is what your application would need to be available for its runtime, but in order to link with that library on building your application you would still need a .lib file. That .lib file isn’t produced “by default”, because you need to explicitly mark the library’s symbols for export/import (or add -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=1 to export everything, but that’s not recommended).

With SomeLibrary as an example, to export its symbols for producing .lib you’d need to add __declspec keyword for every symbol in its public header. But as you need to do that only when the library is built as SHARED, then you’d need to add a compile definition and check for it with #ifdef.

Good news here is that you don’t need to set this definition yourself, because CMake already does that for SHARED libraries on its own, in a form of -DSomeLibrary_EXPORTS, where SomeLibrary is the name of the target (you’ll be able to see that for yourself in the resulting build.ninja file, on the DEFINES lines). So you only need to do #ifdef SomeLibrary_EXPORTS in your sources. But even that part can be taken care of by CMake with GenerateExportHeader (as described in this post), although this functionality I haven’t tested myself.

Now back to scopes.

PRIVATE

Having that:

find_package(Thingy CONFIG REQUIRED)

target_link_libraries(${PROJECT_NAME}
    PRIVATE
        dpndnc::Thingy
)

configure libraries as SHARED and build:

$ cd /path/to/cmake-target-link-libraries-example/prjct
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    -DBUILD_SHARED_LIBS=1 \
    ..
$ cmake --build . --target install

And yes, there will be something new, here’s what’s inside install/share/AnotherLibrary/AnotherLibraryTargets.cmake:

# Create imported target prjct::AnotherLibrary
add_library(prjct::AnotherLibrary SHARED IMPORTED)

set_target_properties(prjct::AnotherLibrary PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include;${_IMPORT_PREFIX}/include"
)

Nothing about linking to Thingy!

Let’s look at the symbols:

$ nm --demangle ../install/lib/libAnotherLibrary.dylib

0000000000003ebc s GCC_except_table0
0000000000003f28 s GCC_except_table0
0000000000003ed0 s GCC_except_table3
0000000000003f14 s GCC_except_table4
0000000000003cf0 t __GLOBAL__sub_I_another.cpp
0000000000003e10 t __GLOBAL__sub_I_thingy.cpp
                 U __Unwind_Resume
0000000000008018 b thingyString
0000000000008000 b anotherString
0000000000003a30 T prjct::lbrAnother::doTheThing()
0000000000003960 T prjct::lbrAnother::doAnother()
0000000000003d40 T dpndnc::doThingy()
                 ...

There are differences here too. Unlike PRIVATE scope with STATIC variant, AnotherLibrary’s binary now contains Thingy stuff too: not just the function, as before, but also its string variable, which AnotherLibrary binary did not contain in either of scopes with STATIC variant.

And you guessed it right, this means that Tool no longer needs to find the Thingy CMake package and does not need to link to Thingy’s binary, so there is no need to provide path to Thingy installation in CMAKE_INSTALL_PREFIX:

$ cd /path/to/cmake-target-link-libraries-example/tl
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install" \
    ..
$ cmake --build . --target install

It builds and gets the following symbols:

$ nm --demangle ../install/bin/some-tool

0000000100003e90 s GCC_except_table0
0000000100003ed0 s GCC_except_table1
0000000100003f14 s GCC_except_table2
                 U __Unwind_Resume
                 U prjct::lbrAnother::doTheThing()
                 U prjct::lbrAnother::doAnother()
                 U prjct::lbrSome::doSome()
                 ...

Only the Project’s libraries symbols, no traces of Thingy stuff.

Same story in the /path/to/cmake-target-link-libraries-example/tl/build/build.ninja, no linking to Thingy:

build CMakeFiles/some-tool.dir/src/main.cpp.o:
  ...
  INCLUDES = -isystem /path/to/cmake-target-link-libraries-example/prjct/install/include
  ...

build some-tool:
  ...
  LINK_LIBRARIES = -Wl,-rpath,/path/to/cmake-target-link-libraries-example/prjct/install/lib  /path/to/cmake-target-link-libraries-example/prjct/install/lib/libSomeLibrary.dylib  /path/to/cmake-target-link-libraries-example/prjct/install/lib/libAnotherLibrary.dylib
  ...

What’s new is that LINK_LIBRARIES now also has -Wl,-rpath.

As for the sources, needless to say that calling doThingy() won’t work in this case, same as with PRIVATE scope in STATIC variant:

#include <SomeLibrary/some.h>
#include <AnotherLibrary/another.h>

// not available, or rather not discoverable
//#include <Thingy/thingy.h>

std::cout << "Direct dependencies:" << std::endl;
prjct::lbrSome::doSome();
prjct::lbrAnother::doAnother();
std::cout << std::endl;

std::cout << "Transitive dependency:" << std::endl;
//
// that works
std::cout << "via AnotherLibrary | ";
prjct::lbrAnother::doTheThing();
//
// but that doesn't
//std::cout << "directly | ";
//dpndnc::doThingy();

A slightly interesting detail is that if you’ll make Thingy/thingy.h header discoverable (for example, by copying it to include inside the Project’s installation), then Tool will be able to call dpndnc::doThingy(), just like as it was in the same situation with PRIVATE scope in STATIC variant. But here in SHARED variant it still won’t need the Thingy’s binary for linking, as this symbol will be resolved from AnotherLibrary binary. Cool, huh. Anyway, it’s just a side note; you should definitely not do such things (manipulating headers discovery against scope intentions).

Of course, since the Project libraries are SHARED now, the executable will fail to run:

$ ../install/bin/some-tool

dyld[58538]: Library not loaded: @rpath/libSomeLibrary.dylib
  Referenced from: <SOME-ID-HERE> /path/to/cmake-target-link-libraries-example/tl/install/bin/some-tool
  Reason: tried: '/usr/local/lib/libSomeLibrary.dylib' (no such file), '/usr/lib/libSomeLibrary.dylib' (no such file, not in dyld cache)
Abort trap: 6

$ otool -L ../install/bin/some-tool
    @rpath/libSomeLibrary.dylib (compatibility version 0.0.0, current version 0.0.0)
    @rpath/libAnotherLibrary.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1500.65.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.100.3)

On Windows you’d need to put the libraries DLLs alongside the executable, while on Mac OS (and GNU/Linux) you also need to set DYLD_LIBRARY_PATH (LD_LIBRARY_PATH on GNU/Linux) environment variable:

$ cp ../../prjct/install/lib/*.dylib ../install/bin/

$ DYLD_LIBRARY_PATH="../install/bin" ../install/bin/some-tool
# or
$ cd ../install/bin
$ DYLD_LIBRARY_PATH="." ./some-tool

What’s interesting, although unrelated to scopes effects, is that if you try to install the Tool again, right after just installing it, the installation will have some error message while still returning 0 exit code:

$ cmake --build . --target install
[2/3] Install the project...
-- Install configuration: "Release"
-- Installing: /path/to/cmake-target-link-libraries-example/tl/install/bin/some-tool
$ echo $?
0

$ cmake --build . --target install
[0/1] Install the project...
-- Install configuration: "Release"
-- Up-to-date: /path/to/cmake-target-link-libraries-example/tl/install/bin/some-tool
error: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/install_name_tool: no LC_RPATH load command with path: /path/to/cmake-target-link-libraries-example/prjct/install/lib found in: /path/to/cmake-target-link-libraries-example/tl/install/bin/some-tool (for architecture x86_64), required for specified option "-delete_rpath /path/to/cmake-target-link-libraries-example/prjct/install/lib"
$ echo $?
0

Removing ../install/bin/some-tool file and trying again gets rid of the error message. I have no idea what that is, probably one of the Mac OS things.

Anyway, to summarize, the PRIVATE scope in case of SHARED libraries lets you not to deliver your 3rd-party dependencies to you customer, as he’ll be able to build his Tool without them. Great news, isn’t it. Too bad your customer will likely want to get exactly STATIC variants of your Project libraries, not to mention that he’ll probably want to be able to build your Project from sources himself.

What if transitive dependency is also SHARED

If Thingy library also was a SHARED library, that wouldn’t affect the build and linking:

  • there would be still no linking to it in AnotherLibraryTargets.cmake;
  • Tool configuration would still not require adding path to Thingy’s installation into CMAKE_INSTALL_PREFIX and would not require Thingy’s binary to be available for linking.

But that would certainly affect the final application - the Tool - because it would then require Thingy’s binary for its runtime (in addition to the Project’s libraries, and that obviously applies to other scopes too). So if your goal is to minimize the number of things you have to deliver, then you’d probably prefer your 3rd-party (transitive for your customer) dependencies to remain STATIC (but that might complicate things if some of those are licensed under LGPL or similar).

INTERFACE

Having that:

find_package(Thingy CONFIG REQUIRED)

target_link_libraries(${PROJECT_NAME}
    INTERFACE
        dpndnc::Thingy
)

configure libraries as SHARED and build:

$ cd /path/to/cmake-target-link-libraries-example/prjct
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    -DBUILD_SHARED_LIBS=1 \
    ..
$ cmake --build . --target install

Linking to Thingy will be back in install/share/AnotherLibrary/AnotherLibraryTargets.cmake:

# Create imported target prjct::AnotherLibrary
add_library(prjct::AnotherLibrary SHARED IMPORTED)

set_target_properties(prjct::AnotherLibrary PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include;${_IMPORT_PREFIX}/include"
  INTERFACE_LINK_LIBRARIES "dpndnc::Thingy"
)

Symbols will be the same as with INTERFACE scope in STATIC variant:

$ nm --demangle ../install/lib/libAnotherLibrary.dylib

0000000000003ef0 s GCC_except_table0
0000000000003f04 s GCC_except_table2
0000000000003f48 s GCC_except_table3
0000000000003e40 t __GLOBAL__sub_I_another.cpp
                 U __Unwind_Resume
0000000000008000 b anotherString
0000000000003ac0 T prjct::lbrAnother::doAnother()
                 ...

And again, building the Tool will require providing paths to both Project and Thingy installations:

$ cd /path/to/cmake-target-link-libraries-example/tl
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install;/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..
$ cmake --build . --target install

The Tool’s binary will get the following symbols:

$ nm --demangle ../install/bin/some-tool

0000000100003e64 s GCC_except_table0
0000000100003efc s GCC_except_table0
0000000100003ea4 s GCC_except_table1
0000000100003ee8 s GCC_except_table2
0000000100003db0 t __GLOBAL__sub_I_thingy.cpp
                 U __Unwind_Resume
0000000100008000 b thingyString
                 U prjct::lbrAnother::doAnother()
                 U prjct::lbrSome::doSome()
0000000100003ce0 T dpndnc::doThingy()
                 ...

Unlike its STATIC variant, this time there are no string variables from AnotherLibrary and SomeLibrary, but available functions are the same:

#include <SomeLibrary/some.h>
#include <AnotherLibrary/another.h>

#include <Thingy/thingy.h>

std::cout << "Direct dependencies:" << std::endl;
prjct::lbrSome::doSome();
prjct::lbrAnother::doAnother();
std::cout << std::endl;

std::cout << "Transitive dependency:" << std::endl;
// 
// trying to use that one will fail to build with
// error: no member named 'doTheThing' in namespace 'prjct::lbrAnother'
//std::cout << "via AnotherLibrary | ";
//prjct::lbrAnother::doTheThing();
//
// that one works fine
std::cout << "directly | ";
dpndnc::doThingy();

The /path/to/cmake-target-link-libraries-example/tl/build/build.ninja contents are almost the same as with INTERFACE scope in STATIC variant, except for the -Wl,-rpath again:

build CMakeFiles/some-tool.dir/src/main.cpp.o:
  ...
  INCLUDES = -isystem /path/to/cmake-target-link-libraries-example/prjct/install/include -isystem /path/to/cmake-target-link-libraries-example/dpndnc/install/include
  ...

build some-tool:
  ...
  LINK_LIBRARIES = -Wl,-rpath,/path/to/cmake-target-link-libraries-example/prjct/install/lib  /path/to/cmake-target-link-libraries-example/prjct/install/lib/libSomeLibrary.dylib  /path/to/cmake-target-link-libraries-example/prjct/install/lib/libAnotherLibrary.dylib  /path/to/cmake-target-link-libraries-example/dpndnc/install/lib/libThingy.a
  ...

And the Tool executable will still require Project’s libraries to be available for runtime. But even though it requires Thingy’s binary for linking, it is not needed for runtime:

$ otool -L ../install/bin/some-tool
    @rpath/libSomeLibrary.dylib (compatibility version 0.0.0, current version 0.0.0)
    @rpath/libAnotherLibrary.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1500.65.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.100.3)

$ cp ../../prjct/install/lib/*.dylib ../install/bin/

$ ls -L1 ../install/bin/
libAnotherLibrary.dylib
libSomeLibrary.dylib
some-tool

$ DYLD_LIBRARY_PATH="../install/bin" ../install/bin/some-tool

PUBLIC

Having that:

find_package(Thingy CONFIG REQUIRED)

target_link_libraries(${PROJECT_NAME}
    PUBLIC
        dpndnc::Thingy
)

configure libraries as SHARED and build:

$ cd /path/to/cmake-target-link-libraries-example/prjct
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    -DBUILD_SHARED_LIBS=1 \
    ..
$ cmake --build . --target install

The linking to Thingy is still present in install/share/AnotherLibrary/AnotherLibraryTargets.cmake:

# Create imported target prjct::AnotherLibrary
add_library(prjct::AnotherLibrary SHARED IMPORTED)

set_target_properties(prjct::AnotherLibrary PROPERTIES
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include;${_IMPORT_PREFIX}/include"
  INTERFACE_LINK_LIBRARIES "dpndnc::Thingy"
)

Symbols are the same plus again Thingy’s string variable:

$ nm --demangle ../install/lib/libAnotherLibrary.dylib

0000000000003ebc s GCC_except_table0
0000000000003f28 s GCC_except_table0
0000000000003ed0 s GCC_except_table3
0000000000003f14 s GCC_except_table4
0000000000003cf0 t __GLOBAL__sub_I_another.cpp
0000000000003e10 t __GLOBAL__sub_I_thingy.cpp
                 U __Unwind_Resume
0000000000008018 b thingyString
0000000000008000 b anotherString
0000000000003a30 T prjct::lbrAnother::doTheThing()
0000000000003960 T prjct::lbrAnother::doAnother()
0000000000003d40 T dpndnc::doThingy()
                 ...

Building the Tool of course still requires both installation paths:

$ cd /path/to/cmake-target-link-libraries-example/tl
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="../install" \
    -DCMAKE_PREFIX_PATH="/path/to/cmake-target-link-libraries-example/prjct/install;/path/to/cmake-target-link-libraries-example/dpndnc/install" \
    ..
$ cmake --build . --target install

The symbols it gets are these:

$ nm --demangle ../install/bin/some-tool

0000000100003e84 s GCC_except_table0
0000000100003ec4 s GCC_except_table1
0000000100003f08 s GCC_except_table2
                 U __Unwind_Resume
                 U prjct::lbrAnother::doTheThing()
                 U prjct::lbrAnother::doAnother()
                 U prjct::lbrSome::doSome()
                 U dpndnc::doThingy()
                 ...

And all the functions are available:

#include <SomeLibrary/some.h>
#include <AnotherLibrary/another.h>

#include <Thingy/thingy.h>

std::cout << "Direct dependencies:" << std::endl;
prjct::lbrSome::doSome();
prjct::lbrAnother::doAnother();
std::cout << std::endl;

std::cout << "Transitive dependency:" << std::endl;
// 
// this one works
std::cout << "via AnotherLibrary | ";
prjct::lbrAnother::doTheThing();
//
// and this one works
std::cout << "directly | ";
dpndnc::doThingy();

Requirements for runtime libraries are the same too:

$ otool -L ../install/bin/some-tool
    @rpath/libSomeLibrary.dylib (compatibility version 0.0.0, current version 0.0.0)
    @rpath/libAnotherLibrary.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 1500.65.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1319.100.3)

$ cp ../../prjct/install/lib/*.dylib ../install/bin/

$ ls -L1 ../install/bin/
libAnotherLibrary.dylib
libSomeLibrary.dylib
some-tool

$ DYLD_LIBRARY_PATH="../install/bin" ../install/bin/some-tool

So when do I need to deliver 3rd-party dependencies to my customers

…so they could build their applications with your SDK/framework/library, which is their direct dependency.

Almost always, as it seems.

Assuming that we are comparing the scope of target_link_libraries() from our library to 3rd-party dependencies, with both STATIC and SHARED variants of our library, a summarizing table would be this:

STATIC SHARED
PRIVATE yes no
INTERFACE yes yes
PUBLIC yes yes

But I don’t want to

If you really don’t want to distribute all of your 3rd-party dependencies to customers (one of the reasons could be that they are not using CMake, so they’d need to somehow manage the transitive dependencies linking themselves), then there are still some options (none of which I’ve tried myself).

You could probably bundle/vendor 3rd-party dependencies sources into your SDK/framework/library project and make them OBJECT libraries. But we are all civilized people here, right, we want to use CMake packages and find_package(Thingy CONFIG REQUIRED), and probably with a package manager such as vcpkg, so no vendoring, forget that I even brought this up.

Another way to “hide” 3rd-party dependencies would probably be via making a frankenstein monster of a static mega-library, which would contain everything: object files of your libraries and object files of 3rd-party libraries. Basically, you would “unpack” all the static libraries that you got and “pack” their object files into one big fucking final static library, which is what you’d distribute to your customers. As with the previous option, this doesn’t look like something I would recommend, but if you still would like to do that, here are some links for further reading:

It also wouldn’t hurt to make sure that none of your 3rd-party dependencies license terms would mind against you performing these acrobatic exercises.