Building multiple versions of a library

I’m looking for a best practice to build a library multiple times with different sets of defines.

This is an old legacy library recently converted to cmake. The project is a single library and builds on multiple platforms. It consists of dozen of directories. Dozens of support utilities, and hundreds of tests of various kinds. Its millions of lines of code. The library code is littered with #ifdef’s. These might control, say, how or if shared memory is used, or whether a utility can read or write a certain type of file (i.e. there might be utilities which can create a data file, but client applications can only read the files). These days, the best way might have been to use a runtime design to control these options, but as there are thousands of files and refactoring this is a long task.

I’ll try to boil this down to something more simple. Let’s just say we have 3 libraries, and 3 different defines (or 4 since no defines at all is also an option).

LibTop, LibMiddle and LibBottom are the libs.

LibTop can build with DEF1, or DEF2, or none at all.
LibMiddle can build with DEF1 or none at all.
LibBottom can build with DEF2, or DEF3, or none at all.

The libs depend on each other like this:

LibTop depends on LibMiddle and LibBottom.
LibMiddle depends on LibBottom.
LibBottom depends on nothing.

When you build LibTop with DEF1, you also build LibMiddle with DEF1, but LibBottom with none (since it does not support DEF1).

Some examples are a utility or test which requires just LibBottown and DEF3. Or a utility requiring LibTop with no defines (and, thus, Middle and Bottom with no defines).

Or a more complex example is a test for LibTop where we want to test all 3 variations. So it must build one version which links against DEF1. Another against DEF2, and one with no defines. The DEF1 version would require the DEF1 version of Middle and the none version of Bottom.

None of this is terribly difficult until you want to build everything at once. You end up with 3 versions of LibHigh, 2 of LibMiddle, and 3 of LibBottom (but not the same 3 set of defines used for LibHigh). Some tests or utilities you just build with 1 define. Some you might build more than one, but you don’t want any of the output to clash.

How we handle this now is build use different isolated sets of output trees (driven by a script). This means output trees for DEF1, DEF2, DEF3, and none. Once a test or utility is building DEF1 it uses DEF1 output for everything. This means building LibTop, LibMiddle, and LibBottom in each tree once you’ve built everything. We ended up LibMiddle 4 times, when there are only 2 versions of it. The approach is safe, but slow.

What would be a better way to handle this? One which builds the minimal amount of libraries required. Never has the same lib or object files build in the same dir with different defines, and is (preferable) all done with cmake (preferably simple and maintainable came).

My thought is that we have LibHigh-DEF1, LibHigh-DEF2, LibHigh-None, LibMiddle-DEF1, LibMiddle-None, etc. That there is a directory for each of these in the output dir, but how to handle this in the CMakeLists.txt for the lib, and then to express the dependency in the utility or test cmake is still not crystal clear to me.

Some preliminary questions:

  • Are defines additive? That is, adding DEFx only adds new available APIs and does not remove or alter any versus a build without DEFx.
  • If not, there’s no easy way to do it with any build system.
  • If so, I recommend splitting up the targets along these boundaries.
# libbottom
add_library(libbottom bottom-common.c)
add_library(libbottom-def2 bottom-def2.c)
target_link_libraries(libbottom-def2 PUBLIC libbottom)
add_library(libbottom-def3 bottom-def3.c)
target_link_libraries(libbottom-def3 PUBLIC libbottom)

# libmiddle
add_library(libmiddle middle-common.c)
target_link_libraries(libmiddle PUBLIC libbottom)
add_library(libmiddle-def1 middle-def1.c)
target_link_libraries(libmiddle-def1 PUBLIC libmiddle)

# libtop
add_library(libtop top-common.c)
target_link_libraries(libtop PUBLIC libmiddle)
add_library(libtop-def1 top-def1.c)
target_link_libraries(libtop-def1 PUBLIC libtop libmiddle-def1)
add_library(libtop-def2 top-def2.c)
target_link_libraries(libtop-def2 PUBLIC libtop libmiddle-def2)

# executables for each def
add_executable(exe-plain exe.c)
target_link_libraries(exe-plain PUBLIC libtop)
add_executable(exe-def1 exe.c)
target_link_libraries(exe-def1 PUBLIC libtop-def1)
add_executable(exe-def2 exe.c)
target_link_libraries(exe-def2 PUBLIC libtop-def2)
add_executable(exe-def3 exe.c)
target_link_libraries(exe-def3 PUBLIC libtop libbottom-def3)

CMake does not allow consumers to “impose” on the targets it consumes. To do so would invite unsolvable situations (e.g., what if consumer-a said "add -Dx=1 to consumed-x" while consumer-b said "add -Dx=2 to consumed-x"). So libtop cannot “dictate” anything to libbottom; all it can do is check for agreement.