OpenMethod interoperates with shared libraries on Linux, other POSIX-like platforms, and Windows.

OpenMethod uses global data to keep track of methods, overriders and classes, all managed by static constructors and destructors. initialize uses that information to set up dispatch tables. For a program and its shared libraries to contribute classes, methods and overriders to the same registry, that data must be truly global: a single copy, shared by all the modules.

All of a registry’s mutable state lives in a single variable. Sharing a registry across modules therefore means sharing that one symbol: the owning module exports it, and every client module imports it. Three macros do this. Each takes the registry as an argument, so they can be used to manage default_registry, indirect_registry and any custom registry:

Macro Where

BOOST_OPENMETHOD_IMPORT_REGISTRY

header; every translation unit of a client module

BOOST_OPENMETHOD_EXPORT_REGISTRY

header; every translation unit of the owning module

BOOST_OPENMETHOD_INSTANTIATE_REGISTRY

exactly one .cpp of the owning module

The owning module uses two of them: EXPORT in the header its translation units share, and INSTANTIATE in exactly one of them.

// header, every translation unit of a client module
BOOST_OPENMETHOD_IMPORT_REGISTRY(boost::openmethod::default_registry);
// header, every translation unit of the owning module
BOOST_OPENMETHOD_EXPORT_REGISTRY(boost::openmethod::default_registry);
// exactly one .cpp of the owning module
BOOST_OPENMETHOD_INSTANTIATE_REGISTRY(boost::openmethod::default_registry);

Use them at namespace scope, after <boost/openmethod.hpp> has been included and after the registry class is defined - they expand to registry_state<REGISTRY::registry_type>, which needs a complete type. Everything they emit is fully qualified, so nothing need be added to namespace boost::openmethod.

The macros exist because the underlying explicit instantiations are not portable - the two ABIs want opposite things, and getting it wrong fails on one platform while compiling silently on the other:

  • On Windows, Cygwin and MinGW, __declspec(dllexport) and extern are incompatible on an explicit instantiation, and MSVC rejects the combination outright. Nor is such a declaration needed there: visibility is not a PE concept, so the owning module’s other translation units may instantiate the state implicitly. EXPORT expands to nothing and INSTANTIATE carries the dllexport.

  • On ELF and Mach-O the attribute must be on the declaration, so that every translation unit of the owning module pins the symbol to default visibility. Repeating it on the definition is an error on GCC, so INSTANTIATE carries no attribute there.

on ELF, EXPORT is not decoration. A translation unit of the owning module that has neither it nor the instantiation instantiates the state implicitly, and under -fvisibility=hidden that copy is module-local. Because ELF merges COMDATs at the most restrictive visibility, the merged symbol then becomes local: the module builds, exports nothing, and clients fail to link with an undefined reference to registry_state<…​>::st.

Nothing else needs decoration. In particular, methods declared with BOOST_OPENMETHOD require no export or import: method objects are consolidated across modules when initialize runs (see Implementation Notes).

If a library uses open-methods as an implementation detail, and does not make them part of its public API, it is not necessary to export the registry state.

Implicit Linking

When a program links against a shared library at build time, all the modules are loaded before main starts, their static constructors run, and a single call to initialize sets up the dispatch tables. The only requirement is the one stated above: one module owns the registry state, the others import it.

The natural arrangement is for the shared library to own the registry: the program already links against the library, so, on Windows, the import library dependency runs in the usual direction. The implicit_linking example demonstrates this arrangement.

The following header is shared between the program and the library. It defines the class hierarchy and the meet method, and it is also where each module says how it relates to the registry state:

// animals.hpp

#include <string>
#include <boost/openmethod.hpp>

#ifdef OWNS_REGISTRY_STATE
BOOST_OPENMETHOD_EXPORT_REGISTRY(boost::openmethod::default_registry);
#else
BOOST_OPENMETHOD_IMPORT_REGISTRY(boost::openmethod::default_registry);
#endif

struct Animal { virtual ~Animal() {} };
struct Herbivore : Animal {};
struct Carnivore : Animal {};

struct Cow : Herbivore {};
struct Wolf : Carnivore {};

BOOST_OPENMETHOD(
    meet, (
        boost::openmethod::virtual_ptr<Animal>,
        boost::openmethod::virtual_ptr<Animal>),
    std::string);

The module that owns the state defines OWNS_REGISTRY_STATE before including this header, which selects EXPORT; every other module gets IMPORT. The owner also uses INSTANTIATE, in one of its .cpp files.

each module here has a single translation unit, so on ELF the owning module could get away with INSTANTIATE alone. EXPORT is shown because it becomes required the moment that module gains a second translation unit - see the warning above. Putting it in the shared header from the start makes the module safe to grow.

Here the library owns the state. It registers the classes, provides the default behaviour, and emits the definition:

// extensions.cpp

#define OWNS_REGISTRY_STATE

#include "animals.hpp"

using namespace boost::openmethod;

BOOST_OPENMETHOD_INSTANTIATE_REGISTRY(boost::openmethod::default_registry);

BOOST_OPENMETHOD_CLASSES(Animal, Herbivore, Cow, Carnivore, Wolf);

BOOST_OPENMETHOD_OVERRIDE(
    meet, (virtual_ptr<Animal>, virtual_ptr<Animal>), std::string) {
    return "greet";
}

The program extends it. It adds a class the library has never heard of, two overriders that specialise meet, and imports the state through the header. It also registers the library’s classes a second time - see below:

// main.cpp

#include "animals.hpp"
#include <boost/openmethod/initialize.hpp>
#include <iostream>
#include <memory>

using namespace boost::openmethod::aliases;

struct Tiger : Carnivore {};

BOOST_OPENMETHOD_CLASSES(Tiger, Carnivore);
BOOST_OPENMETHOD_CLASSES(Animal, Herbivore, Cow, Carnivore, Wolf);

BOOST_OPENMETHOD_OVERRIDE(
    meet, (virtual_ptr<Herbivore> a, virtual_ptr<Carnivore> b), std::string) {
    auto base = next(a, b);
    return "do not " + base + ", run";
}

BOOST_OPENMETHOD_OVERRIDE(
    meet, (virtual_ptr<Carnivore>, virtual_ptr<Herbivore>), std::string) {
    return "hunt";
}

auto main() -> int {
    boost::openmethod::initialize();

    std::unique_ptr<Animal> gracie(new Cow());
    std::unique_ptr<Animal> willy(new Wolf());
    std::unique_ptr<Animal> hobbes(new Tiger());

    std::cout << "cow meets wolf -> " << meet(*gracie, *willy)
              << "\n"; // do not greet, run
    std::cout << "wolf meets cow -> " << meet(*willy, *gracie) << "\n"; // hunt
    std::cout << "cow meets tiger -> " << meet(*gracie, *hobbes)
              << "\n"; // do not greet, run

    return 0;
}

Note what next does here: the program’s Herbivore, Carnivore overrider calls the library’s Animal, Animal overrider, so control crosses the module boundary in the middle of a single dispatch, and cow meets wolf prints do not greet, run.

The program registers Animal, Herbivore, Cow, Carnivore and Wolf, although the library has registered them already. That is the rule use_classes states: a class must be registered in as many translation units as it has type ids. On ELF and Mach-O it has one - the dynamic linker merges the type_info objects, and the second registration is redundant but harmless. Windows keeps one type_info per module, so the program’s Wolf and the library’s Wolf are two different type ids, and the program creates its Wolf with new. Without the second registration the program’s id is unknown to the registry, and the first dispatch on it fails with unknown class Wolf. Registering in both modules is what makes the example portable, and costs nothing where it is not needed.

Dynamic Linking

By "dynamic linking", we mean a program loading a shared library after it has started, and accessing its content. A common application of dynamic linking is to implement plugin architectures.

A dynamically loaded library can add classes, methods and overriders to an existing registry. initialize must be called to rebuild the dispatch tables after loading or unloading a shared library.

Here the program is the natural owner of the registry state: it must be able to run before the library is loaded, and regardless of whether it is loaded at all.

Let’s look at an example. It shares the same animals.hpp as the implicit-linking example above - same classes, same meet method, same OWNS_REGISTRY_STATE convention - but ownership is reversed.

Here the main program owns the registry state, so it is main.cpp that defines OWNS_REGISTRY_STATE and emits the EXPORT definition, in its only translation unit, satisfying the exactly-one rule. It provides a catch-all overrider, then calls the meet method:

// main.cpp

#define OWNS_REGISTRY_STATE

#include "animals.hpp"

#include <boost/openmethod.hpp>
#include <boost/openmethod/initialize.hpp>
#include <boost/dll/shared_library.hpp>
#include <iostream>
#include <memory>

using namespace boost::openmethod;

BOOST_OPENMETHOD_INSTANTIATE_REGISTRY(boost::openmethod::default_registry);

BOOST_OPENMETHOD_CLASSES(Herbivore, Cow, Carnivore, Wolf);

BOOST_OPENMETHOD_OVERRIDE(
    meet, (virtual_ptr<Animal>, virtual_ptr<Animal>), std::string) {
    return "greet";
}

int main() {

    try {
        std::cout << "Before loading the shared library.\n";

        boost::openmethod::initialize(trace::from_env());

        std::cout << "cow meets wolf -> "
                  << meet(*std::make_unique<Cow>(), *std::make_unique<Wolf>())
                  << "\n"; // greet
        std::cout << "wolf meets cow -> "
                  << meet(*std::make_unique<Wolf>(), *std::make_unique<Cow>())
                  << "\n"; // greet

        // to be continued...

The shared library does not define OWNS_REGISTRY_STATE, so the header imported the state for it. It adds two overriders, a new class, Tiger, and a factory function:

// extensions.cpp

#include "animals.hpp"

using namespace boost::openmethod;

BOOST_OPENMETHOD_OVERRIDE(
    meet, (virtual_ptr<Herbivore> a, virtual_ptr<Carnivore> b), std::string) {
    auto base = next(a, b);
    return "do not " + base + ", run";
}

BOOST_OPENMETHOD_OVERRIDE(
    meet, (virtual_ptr<Carnivore>, virtual_ptr<Herbivore>), std::string) {
    return "hunt";
}

struct Tiger : Carnivore {};

BOOST_OPENMETHOD_CLASSES(Tiger, Carnivore);

extern "C" {
BOOST_SYMBOL_EXPORT auto make_tiger() -> Animal* {
    return new Tiger;
}
}

We load the shared library using Boost.DLL. After calling initialize, the new overriders are installed. The main program can also use Tiger objects, even though it has no knowledge of that class at compile time.

int main() {
        // ...

        std::cout << "\nLoading shared object / DLL.\n";

        boost::dll::shared_library lib(
            "boost_openmethod-shared",
            boost::dll::load_mode::rtld_global |
                boost::dll::load_mode::append_decorations);

        boost::openmethod::initialize(trace::from_env());

        std::cout << "cow meets wolf -> "
                  << meet(*std::make_unique<Cow>(), *std::make_unique<Wolf>())
                  << "\n"; // do not greet, run
        std::cout << "wolf meets cow -> "
                  << meet(*std::make_unique<Wolf>(), *std::make_unique<Cow>())
                  << "\n"; // hunt

        auto make_tiger = lib.get<Animal*()>("make_tiger");
        std::cout << "cow meets tiger -> "
                  << meet(
                         *std::make_unique<Cow>(),
                         *std::unique_ptr<Animal>(make_tiger()))
                  << "\n"; // do not greet, run

Finally, we unload the shared library and call initialize again. The overriders provided by the shared library are removed from the method.

int main() {
        // ...

        std::cout << "\nAfter unloading the shared library.\n";

        lib.unload();
        boost::openmethod::initialize(trace::from_env());

        std::cout << "cow meets wolf -> "
                  << meet(*std::make_unique<Cow>(), *std::make_unique<Wolf>())
                  << "\n"; // greet
        std::cout << "wolf meets cow -> "
                  << meet(*std::make_unique<Wolf>(), *std::make_unique<Cow>())
                  << "\n"; // greet
dlclose does not necessarily unload the library when using gcc’s -rdynamic option. The library may remain loaded until the program exits, so the output of the last meet call may still be the same as the previous one.
if a shared library installs an error handler (see error_handling.adoc) via the registry’s error_handler::set, reset it (e.g. to the previous handler) before the library is unloaded. The handler is part of the registry’s shared state, so it stays installed after dlclose; calling it afterwards invokes code in the unloaded library.

CMake Setup

Since the executable owns the registry state, the shared library must link against the executable to resolve the imported symbol. This is the reverse of the typical linking direction:

add_executable(my_app main.cpp)
set_target_properties(my_app PROPERTIES ENABLE_EXPORTS ON)
target_link_libraries(my_app Boost::openmethod Boost::dll)

add_library(my_plugin SHARED extensions.cpp)
target_link_libraries(my_plugin PRIVATE Boost::openmethod my_app)

On Windows, ENABLE_EXPORTS ON on the executable tells CMake to generate an import library (.lib on MSVC) that the DLL links against. On POSIX, ENABLE_EXPORTS adds -rdynamic (or the platform’s equivalent), which makes the executable’s symbols visible to the libraries it loads.

BOOST_OPENMETHOD_INSTANTIATE_REGISTRY produces an explicit instantiation definition, which may appear only once in the program. Use it in exactly one translation unit of the owning module; that module’s other translation units use EXPORT, and client modules IMPORT.
a translation unit that uses neither macro compiles cleanly - it just silently gets its own private copy of the registry state instead of an error. That module then stops sharing classes, methods and overriders with the rest of the program (symptoms: missing_class or unexpected bad_call errors). Guard against this by putting the applicable macro in a project header that every translation unit includes, as in the examples, rather than repeating it in individual .cpp files.

Type Ids Across Modules

Everything above is about sharing the registry’s state. There is a second, quieter question: where the type ids themselves come from, and how far apart they end up. It decides how well the registry’s type_hash policy can do its job, and it is the one place where dlopen behaves differently from ordinary linking.

Under std_rtti, a type id is &typeid(X) - the address of a std::type_info object. The Itanium ABI requires that identity to be pointer identity across modules, and the linker delivers it for an implicitly linked shared library with a copy relocation: the record is copied into the executable’s image, and the library’s references are redirected to that copy. So a program and the libraries it links against present one compact set of type ids, however many modules there are.

dlopen does not get that. A plugin’s own classes are not named by the executable, so nothing unifies them; their records stay in the plugin’s own mapping, which the loader places wherever it likes - and with address-space randomization, somewhere different on every run. The distance between a program’s type ids and its plugin’s is routinely measured in terabytes, and it moves from run to run.

RTTI has to keep default visibility for ids to unify at all. Under -fvisibility=hidden one class can end up with a different type_info object in each module; initialize copes - it treats them as several ids for the same class - but they are extra ids for the hash to separate. This is why the library’s own shared-library tests mark their classes BOOST_SYMBOL_VISIBLE.

What it costs

fast_perfect_hash, the default, searches for a multiplier M and a shift S such that (M * x) >> S is collision-free over the registered type ids. It is fast and compact when the ids are evenly spread, and degrades when they are not - and a program plus a few dlopened modules is as uneven as it gets: several tight clusters, very far apart. Two things follow:

  • the search gets dramatically more expensive, and on a large enough set it fails - it gives up after half a million attempts and the error handler is called with a search_error, which by default terminates the program;

  • vptr_vector sizes its table from the hash’s range, so a hash that is working hard costs memory as well as time.

A program that loads plugins and registers more than a few hundred classes is the one most likely to meet both.

The alternatives

Three other type_hash policies trade that away, and a fourth option removes the hash from the picture entirely. They are all drop-in: with replaces a policy with the one of the same category, in place, so the new hash still precedes vptr_vector in the list.

policy what it does

One slot per type id, whatever the addresses are, and a search whose cost depends only on how many classes there are. The table size can be stated before seeing an address. Costs a second dependent load on every dispatch - a nanosecond or two per call. The one to reach for in a plugin host.

The same idea with the final reduction replaced by a shift. Cheaper per call than minimal_perfect_hash where the compiler hoists the shift amount out of the dispatch loop, at the price of a table that rounds up to a power of two - between one and two slots per type id, depending on the class count.

Indexes by the smallest set of bit positions that still separates the type ids. As fast per call as the default, and it finds its table deterministically in milliseconds. Needs BMI2, for every translation unit of the program - see its documentation before choosing it.

Not a hash at all: a map keyed on the type id, so there is no table to size and no search to fail. Slower per dispatch than any of the above, and the only option that asks nothing of the type ids.

Switching is one declaration. The registry is then a custom registry, so it needs the treatment in Custom Registries to be shared across modules:

struct plugin_registry :
    boost::openmethod::default_registry::with<
        boost::openmethod::policies::minimal_perfect_hash<>> {};

vptr_map replaces the vptr policy rather than the hash, and the hash is then dead weight, so drop it:

struct plugin_registry :
    boost::openmethod::default_registry::with<
        boost::openmethod::policies::vptr_map<>>::without<
        boost::openmethod::policies::type_hash> {};
none of this arises until a module registers classes of its own. A plugin that only adds overriders for classes the program already registered contributes no new type ids, and the default policies are as good there as anywhere.

Indirect Vptrs

initialize rebuilds the v-tables in the registry. This invalidates all the virtual_ptrs, and also the v-table pointers stored in objects by inplace_vptr_base, related to that registry. This is seldom an issue, as most programs that dynamically load shared libraries do so at the very beginning of their execution.

Otherwise, indirect v-table pointers must be used. This is achieved by using a registry that contains the indirect_vptr policy. The library provides an indirect_registry that has the same policies as default_registry, plus indirect_vptr. Make it the registry the BOOST_OPENMETHOD macros use by defining BOOST_OPENMETHOD_DEFAULT_REGISTRY before including <boost/openmethod/core.hpp>, or any header that includes it, like <boost/openmethod.hpp>.

The indirect_vptr example does that in the header both modules share, rather than passing a compiler switch from the build system. One place then settles both questions - which registry the program uses, and how its state is shared - and no translation unit can accidentally disagree:

// animals.hpp

#include <string>

#define BOOST_OPENMETHOD_DEFAULT_REGISTRY boost::openmethod::indirect_registry
#include <boost/openmethod.hpp>

#ifdef OWNS_REGISTRY_STATE
BOOST_OPENMETHOD_EXPORT_REGISTRY(boost::openmethod::indirect_registry);
#else
BOOST_OPENMETHOD_IMPORT_REGISTRY(boost::openmethod::indirect_registry);
#endif

struct Animal { virtual ~Animal() {} };
struct Herbivore : Animal {};
struct Carnivore : Animal {};

struct Cow : Herbivore {};
struct Wolf : Carnivore {};

BOOST_OPENMETHOD_CLASSES(Animal, Herbivore, Cow, Carnivore, Wolf);

BOOST_OPENMETHOD(
    meet, (
        boost::openmethod::virtual_ptr<Animal>,
        boost::openmethod::virtual_ptr<Animal>),
    std::string);

indirect_registry has its own state, separate from that of default_registry, shared with the same three macros: just name indirect_registry, as above.

Here is a program that carries virtual_ptrs across initialize calls. It owns the state, so it defines OWNS_REGISTRY_STATE and emits the definition:

// main.cpp

#define OWNS_REGISTRY_STATE

#include "animals.hpp"

#include <boost/openmethod.hpp>
#include <boost/openmethod/initialize.hpp>
#include <boost/openmethod/interop/std_unique_ptr.hpp>
#include <boost/dll/shared_library.hpp>
#include <iostream>

using namespace boost::openmethod::aliases;

BOOST_OPENMETHOD_INSTANTIATE_REGISTRY(boost::openmethod::indirect_registry);

BOOST_OPENMETHOD_OVERRIDE(
    meet, (virtual_ptr<Animal>, virtual_ptr<Animal>), std::string) {
    return "greet";
}

auto main() -> int {
    std::cout << "Before loading the shared library.\n";
    boost::openmethod::initialize();

    auto gracie = make_unique_virtual<Cow>();
    auto willy = make_unique_virtual<Wolf>();

    std::cout << "cow meets wolf -> " << meet(*gracie, *willy) << "\n"; // greet
    std::cout << "wolf meets cow -> " << meet(*willy, *gracie) << "\n"; // greet

    std::cout << "\nAfter loading the shared library.\n";

    boost::dll::shared_library lib(
        "boost_openmethod-indirect_shared",
        boost::dll::load_mode::rtld_global |
            boost::dll::load_mode::append_decorations);

    boost::openmethod::initialize();

    // The virtual_ptrs made before the reload still dispatch correctly:
    std::cout << "cow meets wolf -> " << meet(*gracie, *willy)
              << "\n"; // do not greet, run
    std::cout << "wolf meets cow -> " << meet(*willy, *gracie) << "\n"; // hunt

    return 0;
}

The shared library it loads includes the same header, so it uses indirect_registry too and imports the state. The complete example is in the indirect_vptr directory.

Custom Registries

A custom registry is shared exactly the same way - name it instead of default_registry. Nothing above is specific to the predefined registries:

// my_registry.hpp
#ifndef MY_REGISTRY_DEFINED
#define MY_REGISTRY_DEFINED

struct my_registry;
#define BOOST_OPENMETHOD_DEFAULT_REGISTRY my_registry

#include <boost/openmethod.hpp>

struct my_registry : boost::openmethod::registry</* policies... */> {};

#ifdef OWNS_REGISTRY_STATE
BOOST_OPENMETHOD_EXPORT_REGISTRY(my_registry);
#else
BOOST_OPENMETHOD_IMPORT_REGISTRY(my_registry);
#endif

#endif

The #define makes my_registry the registry the BOOST_OPENMETHOD macros use, exactly as indirect_vptr/animals.hpp does above; drop it if the methods name their registry explicitly. The sharing macros come last, after the definition.

// registry.cpp - exactly one translation unit of the owning module
#define OWNS_REGISTRY_STATE
#include "my_registry.hpp"

BOOST_OPENMETHOD_INSTANTIATE_REGISTRY(my_registry);

Implementation Notes

One state variable per registry

Everything mutable in a registry is agglomerated into a single variable. The registry’s class and method lists, the dispatch tables, and the state of every stateful policy (for example, the hash function parameters of fast_perfect_hash, or the vector of v-table pointers of vptr_vector) are all members of one object, of which there is exactly one instance per registry. Policies do not keep their own globals: their state is gathered, by template machinery, into a tuple inside that same object.

This is what makes sharing a registry across modules tractable: there is exactly one symbol to export and import, whatever the registry’s policies. The variable is the sole member of registry_state, a deliberately thin, function-free class template. That form is dictated by MSVC: dllexport/dllimport are only honored on a whole-class explicit instantiation - not on a variable template, nor on the instantiation of a lone static data member - and exporting a class wholesale also decorates its member functions and nested types, which the state-holding class itself could not tolerate. A one-member wrapper class with no functions is the one form that can be exported and imported on every platform.

Registrars are consolidated, not shared

The export/import mechanism is deliberately limited to that single state variable. No attempt is made to share the registrars - the static objects generated by BOOST_OPENMETHOD, BOOST_OPENMETHOD_OVERRIDE and BOOST_OPENMETHOD_CLASSES - across modules. Each module keeps its own copies of the method objects, of the registration records, and of the per-class v-table pointers used for dispatch.

Instead, the copies are consolidated at runtime. When a module is loaded, its static constructors append its class and method records to the lists in the shared registry state, each record pointing back into the module that registered it. When initialize runs, records that describe the same method or class - typically, one per module, keyed by type - are grouped together. The dispatch tables are computed once, from the consolidated information; then the resulting slots and strides are copied back into every module’s copy of each method object, and every module’s v-table pointers are updated through the pointers captured at registration time. Each module thus dispatches through its own statics, at full speed, but all the copies agree because they were derived from the same computation.

This is why method declarations need no dllexport/dllimport arguments, and why modules do not need to agree on who "owns" a method: ownership only exists for the registry state.

Platform Details

On Windows, by default, each module (executable or DLL) receives its own copy of global variables. Without special measures, the registry state, dispatch tables, and method function pointers are duplicated: when a DLL’s static constructors register classes and overriders, they populate the DLL’s own copy of the registry, invisible to the main program. The EXPORT and IMPORT macros expand to __declspec(dllexport) and __declspec(dllimport) decorations on an explicit instantiation of the registry state, making the owner’s copy the only one.

On Linux and other ELF platforms, the macros can sometimes be omitted. Without them, the registry state is an implicitly instantiated template variable with ordinary external linkage, and the dynamic linker collapses all the modules' copies into one - provided the symbol is visible, which is what -rdynamic (ENABLE_EXPORTS ON in CMake) ensures for an executable. However, if the program is built with -fvisibility=hidden, each module’s copy is internalized to a module-local symbol, and the modules end up with separate registries, just like on Windows. The macros handle this case: EXPORT emits an explicit instantiation with default visibility; IMPORT emits a plain extern template declaration, which suppresses the client’s own instantiation, forcing it to reference the owner’s symbol. Since the macros are harmless when they are not needed, portable code should simply always use them.

The platforms also differ in when the imported symbol must be resolved. On ELF, a shared library may be linked with unresolved references, which the dynamic linker satisfies at load time; a plugin can thus import the registry state from the executable that will load it. Mach-O (macOS) and PE (Windows) require every reference to be satisfied at link time: a client library must link against the owning module - hence the reverse link in the CMake setup above.

on Windows, every module that shares a registry must be linked against the same dynamic C++ runtime (the /MD or /MDd family). The registry state contains standard containers (std::vector, std::function, etc.) allocated by whichever module runs initialize; if a module links the static runtime (/MT) instead, or links a different runtime version, that module’s CRT frees memory it did not allocate when the state is destroyed at exit, corrupting the heap.