Continuing from our cross-platform project skeleton, let’s try to add a shared library. Windows users may know these as dynamic link libraries (DLLs), while Linux users would recognize them as shared objects (.so). To make things even trickier, most Windows compilers must explicitly export DLL members, while Linux compilers usually export everything by default. Alas, cross-platform is a must so we cannot take any hard dependencies on these Win32/Linux-isms.
Luckily, CMake has us covered. Using GenerateExportHeader, we can easily account for platform specific behaviors without any ugly #ifdef
s (at least none that we have to write personally). This is the desired revised project structure:
- inc (public header file root)
- core (header files for `core` lib)
- lib (header files for shared `lib`)
- src (source code root)
- app (executable target)
- core (static lib target)
- lib (shared lib target)
- test (test code root)
…and this is the CMake project to implement it, with minor additions and edits from the previous version:
cmake_minimum_required(VERSION 3.17) project(sample LANGUAGES CXX) include(GenerateExportHeader) include(GoogleTest) find_package(GTest) add_library(sample-core STATIC src/core/Sample.cpp ) set_property(TARGET sample-core PROPERTY POSITION_INDEPENDENT_CODE ON ) target_include_directories(sample-core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/inc/core ) add_library(sample-lib SHARED src/lib/SampleLib.cpp ) generate_export_header(sample-lib BASE_NAME samplelib EXPORT_MACRO_NAME SAMPLE_LIB_EXPORT) target_include_directories(sample-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/inc/lib ${CMAKE_CURRENT_BINARY_DIR} ) target_link_libraries(sample-lib sample-core ) add_executable(sample-app src/app/main.cpp ) target_link_libraries(sample-app sample-lib ) . . . (remainder omitted as it is unchanged)
The summary of changes is as follows:
- Include GenerateExportHeader to use the export header functionality.
- Add explicit STATIC library type for the existing
sample-core
— just for clarity, not technically required. - Enable POSITION_INDEPENDENT_CODE for
sample-core
; this is required to avoid compilation errors when linking the shared library with the static library. - Add the new shared library
sample-lib
. - Generate an export header for
sample-lib
(with custom settings for nicer symbol names). - Link
sample-lib
withsample-core
. - Link
sample-app
withsample-lib
.
Now we need to implement the code. We’ll start with the public header file, SampleLib.h
:
#ifndef SAMPLE_INC_LIB_SAMPLELIB_H #define SAMPLE_INC_LIB_SAMPLELIB_H #include <samplelib_export.h> extern "C" { SAMPLE_LIB_EXPORT void *SampleInit(const char *name); SAMPLE_LIB_EXPORT const char *SampleGetName(void *p); SAMPLE_LIB_EXPORT void SampleDestroy(void *p); } #endif
You may notice the CMake magic showing up here; instead of using compiler-specific features, the generated samplelib_export.h
header defines symbols like SAMPLE_LIB_EXPORT
to mark public members. Also note the standard practice of flattening the C++ API into a plain C interface which will ensure a stable (enough) ABI.
Now the implementation file, SampleLib.cpp
:
#include <SampleLib.h> #include <Sample.h> #include <memory> using sample::core::Sample; extern "C" { void *SampleInit(const char *name) { return new Sample(name); } const char *SampleGetName(void *p) { return static_cast<Sample *>(p)->get_name().c_str(); } void SampleDestroy(void *p) { delete static_cast<Sample*>(p); } }
Yes, that is a naked new and delete. We’re forced to do this because we are defining explicit memory management functions to avoid leaking any C++ details outside the library. Bad things could happen if we tried to export a function with unique_ptr in its signature.
The final step is using the shared library from the executable in our main.cpp
:
#include <iostream> #include <memory> #include <SampleLib.h> using std::cout; using std::endl; using std::unique_ptr; int main(int argc, const char *argv[]) { if (argc != 2) { cout << "Please pass a string!" << endl; return 1; } unique_ptr<void, void(*)(void*)> p(SampleInit(argv[1]), &SampleDestroy); cout << "Hello, " << SampleGetName(p.get()) << "!" << endl; return 0; }
Here we are at least trying to make things safe by declaring a unique_ptr with custom deleter.
Since our CMake project already includes tests that launch the executable we can even show that this works by running our command line build scripts on Linux:
== ctest Test project /mnt/x/root/CMakeSampleVS/out/build/Linux-Clang-Debug Start 1: SampleTest.GetName 1/3 Test #1: SampleTest.GetName ............... Passed 0.02 sec Start 2: sample-app-UsageError 2/3 Test #2: sample-app-UsageError ............ Passed 0.01 sec Start 3: sample-app-HelloWorld 3/3 Test #3: sample-app-HelloWorld ............ Passed 0.01 sec 100% tests passed, 0 tests failed out of 3 Total Test time (real) = 0.09 sec
…and on Windows:
== ctest.exe Test project X:/root/CMakeSampleVS/out/build/x64-Debug Start 1: SampleTest.GetName 1/3 Test #1: SampleTest.GetName ............... Passed 0.01 sec Start 2: sample-app-UsageError 2/3 Test #2: sample-app-UsageError ............ Passed 0.05 sec Start 3: sample-app-HelloWorld 3/3 Test #3: sample-app-HelloWorld ............ Passed 0.01 sec 100% tests passed, 0 tests failed out of 3 Total Test time (real) = 0.12 sec
GitHub repo with all the changes so far: CMakeSampleVS
As you can see, cross-platform support for C/C++ is possible without any real sacrifice. You can use the VS IDE, you can use command line build scripts, and you can even create shared libraries for each OS from one codebase. Vive la [plate-forme?] différence!
Pingback: Cross-platform without complexity: .NET interop – WriteAsync .NET