Special functions and binary sizes

Sandor Dargo - Feb 1 '23 - - Dev Community

These months, I try to better understand how our code affects binary sizes. Last week, we had a look into storage durations and memory allocations. This week, let's have a look into special member functions.

We are going to discuss whether it matters or not if we make our special functions default or if we provide empty implementations. We are also going to see if we should have the implementations in the header or in the cpp files. Or maybe not having them all is the best?

The simplest cases

For my first tests, I used this very simple piece of code as a basis:

#include <array>

class C {
public:
    C() = default;
    ~C() = default;

    C(const C&) = default;
    C(C&&) = default;
    C& operator=(const C&) = default;
    C& operator=(C&&) = default;

};

std::array<C, 10000> a{};

int main() {

}
Enter fullscreen mode Exit fullscreen mode

I took this simple class and modified it in various ways. I compiled 6 different versions:

  • special functions with defaulted at declaration time
  • special functions with empty implementation in the class declaration
  • no special functions at all, abiding to the rule of 0
  • I tried all these both with virtual and with non-virtual destructors

The results are here:

Version Binary size
default non-virtual 16K
default virtual 116K
empty non-virtual 17-34K
empty virtual 33-34K
missing non-virtual 16K
missing virtual 116K

I found no huge differences in compile time and runtime, even though my tests here were not very thorough. I created classes with a virtual destructor, but I haven't used them in a polymorphic way. That's not for today.

I made here two interesting observations. If our class doesn't have a virtual destructor, then either defaulting the special functions or completely omitting them potentially leaves us with a smaller binary than if we provide empty implementations. With an empty implementation, it depends on the optimization level. I think it's because if we let the compiler generate these functions, it can optimize better. We also have to mention that a defaulted special function can be implicitly noexcept. But it won't be noexcept if you provide an empty implementation and you don't declare it explicitly noexcept

Another interesting observation was that for classes with a virtual destructor and an empty implementation always generated smaller code than the default implementations.

While it's clear that we shouldn't add a virtual destructor to a class if it was not meant to be used polymorphically, this latter observation bothers me a bit.

Then I realized that while the first tests gave me an interesting start, I should clearly be closer to real usage.

Classes are rarely declared at the same translation unit with main(), they are often used by different translation units and classes are very often separated into a different header and implementation file.

Besides, classes more often than not have member variables.

So I decided to run more experiments.

The results of the next tests

I incrementally implemented the missing items to see how the numbers change.

main and the non-virtual class in different files

First of all, I separated the main() and the class into different files. As a first step, I kept the implementation in the header file.

// c.h
class C {
public:
    C() = default;
    ~C() = default;

    C(const C&) = default;
    C(C&&) = default;
    C& operator=(const C&) = default;
    C& operator=(C&&) = default;

};

// main.cpp
#include "c.h"
#include <array>

std::array<C, 10000> a;

int main() {

}
Enter fullscreen mode Exit fullscreen mode

Then I also ran some tests where I defaulted the implementations in the cpp file.
Then I did the same experiments with empty implementations. Something we can so often see in old code.

I ended up with these numbers:

Version Binary size
default non-virtual header/only 16K
default non-virtual separated 34K
empty non-virtual header-only 15-34K
empty non-virtual separated 34K

I skipped run-times and compile-times here, because the differences are so little, that I don't think it makes sense to include those.

So what seems interesting to see is that for these non-polymorphic classes, the header-only versions produced a smaller binary both when they are defaulted and also when the implementations are left empty.

main and the virtual class in different files

Let's see what the numbers say for a virtual class.

Version Binary size
default virtual header/only. 116K
default virtual separated 34K
empty virtual header-only 33-34K
empty virtual separated 17-34

Polymorphic and non-polymorphic classes seemingly behave the opposite way. While for non-virtual classes header-only implementations resulted in a smaller binary, for polymorphic classes moving the implementations to the cpp file was a clear win and the empty implementation was even smaller than the defaulted one.

Don't get me wrong, I'm not advocating for an empty implementation over a default one, the default has several advantages, I'm simply sharing what I found while I was implementing.

In the defaulted version's assembly, we can see the line .quad __ZTV1C+16 exactly 10,000 times. For the empty implementation, instead of that, we can observe once again a .zero fill. For some reason, the compiler was able to deduce something for the empty implementation that it could not for the defaulted one.

So how should we implement special functions?

First of all, we should not. Whenever possible, we should follow the rule of zero. By all means, that's the best.

But assuming that you have to implement your special functions what to do is not obvious. In our examples, we saw that empty implementations ({}) might produce smaller binaries than defaulted ones in case you have a virtual destructor. Still, I wouldn't go for empty implementations. The ones generated by the compiler might not be completely the same, and the compiler knows it better than us. A defaulted special function might be noexcept, while an empty implementation will only be noexcept if you explicitly state so.

Some time ago, I posted a question on Twitter and I got some answers from Jason Turner. His opinion was that I should certainly default in the header. "If you forward declare an explicitly defaulted constructor then you're going to tell the compiler "Ooooh, I have something important for you, you'll find it later" Then later you say "haha, just kidding, it's really just a default constructor!"

Later he also mentioned that this can be a technique if you explicitly want to out-of-line a defaulted constructor because of compile times. And I have to add that eventually for binary size too.

At the same time, it should be documented somewhere and widely known in the organization, because it's not intuitive and someone will come to move those definitions back to the header where it belongs. Jason is right. I've been there, I've done that myself. No, not the documentation part...

For classes with non-virtual destructors, we should definitely avoid out-of-line destructors because of three reasons:

  • as said, it decreases readability
  • it might increase the binary size
  • a default outside of the class declaration is considered a user-provided constructor/destructor and therefore the class is not trivial anymore

For polymorphic classes, it's different though. First of all, they are not trivial. Second, the binary size would actually decrease by moving things out of line.

So it boils down to binary size and probably a bit of compile-time vs readability.

Then the answer is it depends. If you optimize for binary size, then I think you should move virtual destructors out of line. And if you do it for the destructor, I'd take the others too so that it looks less strange. But don't forget to measure to see if it really has an impact. The class has to be used with lots of instances.

Conclusion

Today we were looking for understanding what to do with special member functions of a class if we want to walk away with the smallest binary. We did different experiments and we saw that the outcomes and suggestions are different based on whether the destructor is virtual or not.

For classes with virtual destructors, it's worth considering moving the implementation (even if it's the default) out of line, out of the header file. On the other hand for classes with non-virtual destructors, there is no reason to provide special members out of line.

Next time we'll keep discovering how having a virtual destructor is affecting our binaries.

Connect deeper

If you liked this article, please

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .