Contents

Why I avoid using `default` and inline functions in my class declarations

Sure, = default is great and convenient especially for destructors and other special member functions but when declaring a class that’s meant to be a part of a shared library’s interface I try to avoid using it and here’s my reasons why.

TLDR

Tip
When writing an interface class, containing virtual functions, don’t use = default or include inline definitions in class’s header file to avoid vtable/symbols duplication across compilation targets that use the header.

Problem definition

Note
The repo for the testcode discussed here can be found on my gitlab.

Let’s start with a simple abstract class declared in a header file (calc.hpp):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#ifndef __CALC_HPP__
#define __CALC_HPP__

class Calc {
public:
    virtual ~Calc() = default;

    virtual int mul(int, int) = 0;
};

#endif

There’s no calc.cpp file as this is an abstract interface. By using the = default keyword, I’m instructing the compiler to generate a public inline destructor with an empty body. Additionally, the compiler will generate typeinfo (RTTI) and a vtable for Calc class in all translation units which include the header.

Let’s assume now, that I wish to implement this interface in two separate libraries: libsimplecalc.so and libfastcalc.so.

Here’s the header for libsimplecalc.so (simple_calc.hpp):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#ifndef __SIMPLE_CALC_HPP__
#define __SIMPLE_CALC_HPP__

#include "calc.hpp"

#include <memory>

extern "C" std::unique_ptr<Calc> createSimpleCalc();

#endif

The implementation of SimpleCalc looks like so (simple_calc.cpp):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include "simple_calc.hpp"

class SimpleCalc : public Calc {
public:
    int mul(int a, int b) override {
        return a * b;
    }
};

std::unique_ptr<Calc> createSimpleCalc() {
    return std::make_unique<SimpleCalc>();
}

Similarly, for libfastcalc.so, the header file (fast_calc.hpp):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#ifndef __FAST_CALC_HPP__
#define __FAST_CALC_HPP__

#include "calc.hpp"

#include <memory>

extern "C" std::unique_ptr<Calc> createFastCalc();

#endif

… and the corresponding implementation file (fast_calc.cpp):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include "fast_calc.hpp"

class FastCalc : public Calc {
public:
    int mul(int a, int b) override {
        int result = 0;
        while (b) {
            if (b & 1) {
                result += a;
            }
            a <<= 1;
            b >>= 1;
        }
        return result;
    }
};

std::unique_ptr<Calc> createFastCalc() {
    return std::make_unique<FastCalc>();
}

The code in mul doesn’t really matter. Ironically FastCalc::mul is most likely slower than SimpleCalc::mul. The important thing is that both libraries rely on calc.hpp defining the Calc interface containing a default destructor.

Having the libraries compiled, let’s inspect the symbols:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ nm -A -C lib*calc.so | grep -E '(vtable|typeinfo)'
libfastcalc.so:0000000000005df0 V typeinfo for Calc
libfastcalc.so:0000000000005dd8 V typeinfo for FastCalc
libfastcalc.so:000000000000402a V typeinfo name for Calc
libfastcalc.so:0000000000004020 V typeinfo name for FastCalc
libfastcalc.so:0000000000005db0 V vtable for Calc
libfastcalc.so:0000000000005d88 V vtable for FastCalc
libfastcalc.so:                 U vtable for __cxxabiv1::__class_type_info@CXXABI_1.3
libfastcalc.so:                 U vtable for __cxxabiv1::__si_class_type_info@CXXABI_1.3
libsimplecalc.so:0000000000005dd8 V typeinfo for SimpleCalc
libsimplecalc.so:0000000000005df0 V typeinfo for Calc
libsimplecalc.so:0000000000004020 V typeinfo name for SimpleCalc
libsimplecalc.so:000000000000402d V typeinfo name for Calc
libsimplecalc.so:0000000000005d88 V vtable for SimpleCalc
libsimplecalc.so:0000000000005db0 V vtable for Calc
libsimplecalc.so:                 U vtable for __cxxabiv1::__class_type_info@CXXABI_1.3
libsimplecalc.so:                 U vtable for __cxxabiv1::__si_class_type_info@CXXABI_1.3

Both vtable and typeinfo for Calc are duplicated in both libraries.

ODR violation?

Let’s assume that there’s an application code linking both libraries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "simple_calc.hpp"
#include "fast_calc.hpp"

#include <iostream>
#include <memory>

int main() {
    auto fcalc = createFastCalc();
    auto scalc = createSimpleCalc();

    for (int a = 0; a < 100; a++) {
        for (int b = 0; b < 100; b++) {
            const auto fastRes = fcalc->mul(a, b);
            const auto simpleRes = scalc->mul(a, b);

            if (fastRes != simpleRes) {
              std::cerr << "[DISCREPANCY]: fcalc(" << fastRes << ") != scalc("
                        << simpleRes << ") for a: " << a << ", b: " << b
                        << std::endl;
              return -1;
            }
        }
    }

    std::cout << "OK" << std::endl;
    return 0;
}

Would that violate One Definition Rule (ODR)?

There are no duplicates during linking (and even if there were - the linker is able to eliminate duplicate inline functions). Both definitions of vtable and typeinfo for Calc are contained in shared libraries. The application code doesn’t use Calc directly so, the vtable and typeinfo won’t be instantiated there at all.

What about runtime? Technically, there will be two definitions of vtable and typeinfo for Calc in the process memory. The application code will use vtable and typeinfo from both libsimplecalc.so and libfastcalc.so but that’s not really problem since both definitions are identical. The dynamic linker is able to handle that as well. This can be verified:

1
2
3
4
5
6
7
8
9
$ LD_DEBUG=symbols,bindings ./app |& c++filt
     ...
     20839:	symbol=vtable for Calc;  lookup in file=./app [0]
     20839:	symbol=vtable for Calc;  lookup in file=/home/tomasz/calc/libfastcalc.so [0]
     20839:	binding file /home/tomasz/calc/libsimplecalc.so [0] to /home/tomasz/calc/libfastcalc.so [0]: normal symbol `vtable for Calc'
     ...
     20839:	symbol=vtable for Calc;  lookup in file=./app [0]
     20839:	symbol=vtable for Calc;  lookup in file=/home/tomasz/calc/libfastcalc.so [0]
     20839:	binding file /home/tomasz/calc/libfastcalc.so [0] to /home/tomasz/calc/libfastcalc.so [0]: normal symbol `vtable for Calc'

The log shows that the vtable for Calc from libfastcalc.so has been chosen. So, from the practical standpoint, there’s no ODR violation.

What about dlopen? Would that be any different? The test application can be rewritten like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include "calc.hpp"

#include <iostream>
#include <sstream>
#include <memory>

#include <dlfcn.h>

using CalcCtor = std::unique_ptr<Calc>(*)();

auto loadLib(std::string path) {
    // wrapper to ignore dlclose's return value
    auto handleDeleter = [](void* handle) {
        ::dlclose(handle);
    };

    std::unique_ptr<void, decltype(handleDeleter)> handle{nullptr, handleDeleter};
    handle.reset(::dlopen(path.c_str(), RTLD_NOW));
    if (!handle) {
        std::stringstream ss;
        ss << "Failed to load library: " << path << ", error: " << ::dlerror() << std::endl;
        throw std::runtime_error(ss.str());
    }

    return handle;
}

template <typename Handle>
CalcCtor loadSym(Handle&& h, std::string symName) {
    auto sym = reinterpret_cast<CalcCtor>(::dlsym(h.get(), symName.c_str()));
    if (!sym) {
        std::stringstream ss;
        ss << "Failed to load symbol: " << symName << ", error: " << ::dlerror() << std::endl;
        throw std::runtime_error(ss.str());
    }
    return sym;
}

int main() {
    auto fastCalcHandle = loadLib("libfastcalc.so");
    auto simpleCalcHandle = loadLib("libsimplecalc.so");

    auto fcalc = loadSym(fastCalcHandle, "createFastCalc")();
    auto scalc = loadSym(simpleCalcHandle, "createSimpleCalc")();

    for (int a = 0; a < 100; a++) {
        for (int b = 0; b < 100; b++) {
            const auto fastRes = fcalc->mul(a, b);
            const auto simpleRes = scalc->mul(a, b);
            if (fastRes != simpleRes) {
              std::cerr << "[DISCREPANCY]: fcalc(" << fastRes << ") != scalc("
                        << simpleRes << ") for a: " << a << ", b: " << b
                        << std::endl;
              return -1;
            }
        }
    }

    std::cout << "OK" << std::endl;
    return 0;
}

Here’s the dynamic linker log

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ LD_DEBUG=symbols,bindings ./appdl |& c++filt
     ...
     21128:	symbol=vtable for Calc;  lookup in file=./appdl [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libstdc++.so.6 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libgcc_s.so.1 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libm.so.6 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib64/ld-linux-x86-64.so.2 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/home/tomasz/calc/libfastcalc.so [0]
     21128:	binding file /home/tomasz/calc/libfastcalc.so [0] to /home/tomasz/calc/libfastcalc.so [0]: normal symbol `vtable for Calc'
     ...
     21128:	symbol=vtable for Calc;  lookup in file=./appdl [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libstdc++.so.6 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libgcc_s.so.1 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib/x86_64-linux-gnu/libm.so.6 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/lib64/ld-linux-x86-64.so.2 [0]
     21128:	symbol=vtable for Calc;  lookup in file=/home/tomasz/calc/libsimplecalc.so [0]
     21128:	binding file /home/tomasz/calc/libsimplecalc.so [0] to /home/tomasz/calc/libsimplecalc.so [0]: normal symbol `vtable for Calc'
     ...

The situation is slightly different now. libfastcalc.so uses its own definition. Similarly, libsimplecalc.so. Theoretically this might be interpreted as an ODR violation but in my understanding this is still fine as both definitions are the same. Duplicates will be handled differently, depending on the flags in dlopen call (RTLD_LOCAL, RTLD_GLOBAL, RTLD_FIRST). But in general, the dynamic linker is even able to handle discrepant duplicates. This is of course, implementation specific and really outside of C++ standard as such.

What’s the problem then?

Bloat

From the practical standpoint, ODR will not be broken. The main disadvantage of having inline or = default functions in the header is the bloat generated in all translation units that include the header. This has an impact both during build and runtime.

This was causing problems long time ago with older toolchains leading to missed exceptions (like reported here) and other undefined behaviour but this is no longer the case and modern compilers can handle duplicates fine now.

Library ABI

When writing a shared library you want to define its API and ABI at the same time. Without having all symbols explicitly instantiated within your library the ABI is pretty much incomplete, spread across client code and a bit unstable.

Solution: RTTI anchoring

This is an old technique. It’s even mentioned in clang’s documentation.

The idea is to implement at least one (usually it’s the virtual destructor) virtual function in library’s cpp file (even if the library has no other code) so, the RTTI is contained within the library and no duplicates will ever be created anywhere else.

If I modify the Calc interface like so:

1
2
3
4
5
6
class Calc {
public:
    virtual ~Calc();

    virtual int mul(int, int) = 0;
};

… and introduce libcalc.so - just for the sake of implementing the destructor:

1
2
3
#include "calc.hpp"

Calc::~Calc() {}

The situation is greatly improved. Firstly, the vtable and typeinfo for Calc is now defined in libcalc.so only:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ nm -A -C lib*calc.so | grep -E '(vtable|typeinfo)'
libcalc.so:0000000000003e08 V typeinfo for Calc
libcalc.so:0000000000002000 V typeinfo name for Calc
libcalc.so:0000000000003de0 V vtable for Calc
libcalc.so:                 U vtable for __cxxabiv1::__class_type_info@CXXABI_1.3
libfastcalc.so:                 U typeinfo for Calc
libfastcalc.so:0000000000005dd8 V typeinfo for FastCalc
libfastcalc.so:0000000000004020 V typeinfo name for FastCalc
libfastcalc.so:                 U vtable for Calc
libfastcalc.so:0000000000005db0 V vtable for FastCalc
libfastcalc.so:                 U vtable for __cxxabiv1::__si_class_type_info@CXXABI_1.3
libsimplecalc.so:0000000000005dd8 V typeinfo for SimpleCalc
libsimplecalc.so:                 U typeinfo for Calc
libsimplecalc.so:0000000000004020 V typeinfo name for SimpleCalc
libsimplecalc.so:0000000000005db0 V vtable for SimpleCalc
libsimplecalc.so:                 U vtable for Calc
libsimplecalc.so:                 U vtable for __cxxabiv1::__si_class_type_info@CXXABI_1.3

Another example of this technique can be found in recent (well 2018) proposal pr1263r0 to C++ standard.

1
2
3
4
5
6
7
8
9
// in header
struct Foo {
    virtual void a() { }
    virtual void b() { }
    virtual void anchor();
};

// in exactly one TU
void Foo::anchor() { }

The proposal suggests to extend the language to be able to explicitly control where RTTI should be instantiated. Unfortunately the proposal was closed - needing more work.

Conclusion

The = default and inline functions in class declarations are very convenient but they can lead to bloat in shared libraries defining abstract classes. It’s better to stick with “RTTI anchoring” as a mitigation to avoid generating unnecessary function instantiations across multiple translation units.

The test code discussed here can be found here.