Downsides of omitting trivial destructor calls

Via the std-proposals mailing list. Which of these two functions — foo or bar — do you expect to give better codegen?

struct Integer {
    int value;
    ~Integer() {} // deliberately non-trivial
};

void foo(std::vector<int>& v) {
    v.back() *= 0xDEADBEEF;
    v.pop_back();
}

void bar(std::vector<Integer>& v) {
    v.back().value *= 0xDEADBEEF;
    v.pop_back();
}

Compile both with GCC and libstdc++. Did you guess correctly?

foo:
  movq   8(%rdi), %rax
  imull  $-559038737, -4(%rax), %edx
  subq   $4, %rax
  movl   %edx, (%rax)
  movq   %rax, 8(%rdi)
  ret
bar:
  subq   $4, 8(%rdi)
  ret

What’s going on here is that GCC is smart enough to understand that when you run a destructor on a piece of memory, you end its lifetime, which renders all preceding writes to that piece of memory “dead”. But GCC is also smart enough to understand that a trivial destructor (such as the pseudo-destructor ~int()) is a no-op with no effects whatsoever.

So, bar calls pop_back, which runs ~Integer(), which marks vec.back() as “dead”, and GCC eliminates the multiplication by 0xDEADBEEF entirely.

On the other hand, foo calls pop_back, which runs the pseudo-destructor ~int() (it might choose to omit the call altogether, but it doesn’t). GCC observes that this is a no-op and forgets about it. Therefore GCC does not observe that vec.back() is dead, and cannot eliminate the multiplication by 0xDEADBEEF.

This happens for all trivial destructors, not just for pseudo-destructors like ~int(). Replace our ~Integer() {} with ~Integer() = default; and watch the imull instruction reappear!


UPDATE, March 2021: Trivially destructible objects’ lifetimes are now correctly ended by pseudo-destructor calls. This was one of the effects of Richard Smith’s P0593 “Implicit creation of objects for low-level object manipulation,” adopted into C++20 (but implemented by GCC 11 in all language modes, thank goodness).

Posted 2018-04-17