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.