C++ tidbit #8: Damaging Default Destructor

C++ tidbit #8: Damaging Default Destructor

Special member functions are implicitly generated by the compiler if the user didn't provide them - constructors, destructors, assignments, and moves. Here , for example, a move ctor for C is used although it was never declared or defined in code:


struct C {
  std::string s;
};

void f() {
? ? C c1;
? ? c1.s = "asdf";
? ? C c2 = std::move(c1);  // <-- calls an auto-generated C(C&&)
}
        

Kinda-Reasonable, Part I

Auto generation of move ctors - as well as of some other special member functions - is dependent on the compiler being certain that it doesn't need to do anything fancy. This does not entail any real fancy-detection logic: per the standard, if the user has provided some (for example) copy ctor - copy operations aren't trivial and it would be a mistake to let the compiler implicitly generate move, that could be missing needed functionality. Similarly, if the code has a user-given dtor, something non-automatic probably needs to be done to the object that is the potential move target - and implicit generation of moves (ctor and assignment) is inhibited.

Note that 'user given' could mean an empty definition like `{}`- no analysis whatsoever is applied to the special members in question:



struct C  
 std::string s;
 ~C() {}   // Prevents implicitly generating C::C(C&&)
};

void f() {?
 ? C c1;?
 ? c1.s = "asdf";?
 ? C c2 = std::move(c1);  // <-- now calls C::C(C const&) 
}        

Let it be said already that this reasoning isn't entirely sound. The line `C c2 = ...` does not involve any destruction of previous `c2` contents, and one could imagine a C++ standard in which an auto-generated move would apply here.

Still-Mostly-Reasonable, Part II

`default constructor` was a very unfortunate historical choice of term for a ctor with no parameters. In C++11 true `defaults` joined in:


C() = default;
~C() = default;
C(C&) = default;
C(C&&) = default;
...        

In C++ jargon these are called 'defaulted' to distinguish them from 'default', and confusing idioms like 'defaulted default constructor' started to appear. The motivation for these was mostly to re-enable implicit special members that were inhibited by the logic in part I:


struct C {
? ? std::string s;
? ? ~C() {};          // <-- prevents implicit move ctor
? ? C(C&&) = default; // <-- restores move-ctor functionality
                      //     but inhibits default (=no-param) ctor 
? ? C() = default;    // <-- restores defaulted-default ctor
};


void f() {
? ? C c1;
? ? c1.s = "asdf";
? ? C c2 = std::move(c1);  <-- Huzzah! move is called again
}        

Another minor motivation for `=default` was to easily change the access specifier of a special member function without touching its implementation, and you sometimes see 'better code documentation' as an added value. Some good discussion is available in MS docs .

Utter Madness, Part III

Here's some more exact wording for the implicit-move inhibition rules, quoting cppreference :

" If no user-defined move assignment operators are provided ..., and all of the following is true:

  • there are no user-declared copy constructors;
  • there are no user-declared move constructors;
  • there are no user-declared copy assignment operators;
  • there is no user-declared destructor,

then the compiler will declare a move assignment operator... ."

This phrasing contains a small but potent landmine: user-declared.

Not provided, declared.

For reasons unbeknownst to me, `=default` counts as user-declared.

In this code, implicit move is inhibited:


struct C 
? ? std::string s;
? ? ~C() = default;  // <-- !
};


void f() {
? ? C c1;
? ? c1.s = "asdf";
? ? C c2 = std::move(c1);  // <-- no move.   !? 
}        

You might have reasonably assumed that spreading `=default`s around is at worst meaningless. It isn't - at worst it's a (potentially substantial) pessimization.

<sigh/>

There might be some real use cases lurking out of my sight where this is in fact the desired impact of `=default` (do tell me if you have one!) - but I suspect this is just another accidental side effect of the standard complexity. If this was really a design choice by the committee - it certainly brought about more confusion then benefit.

Kris van Rens

Software architect at ViNotion / Owner at Kris van Rens / Trainer at High Tech Institute

1 年

Yes, you're right. The "defaulted default constructor" is confusing. But honestly I usually just refer to it as "user-declared ..." in general to avoid this confusion. User-declared either means: 1) custom implementation, 2) '=default;', (request a default implementation), or 3) '=delete;'; yes, this also counts as user-declared, and means something like: "declared, but marked as unusable". This means "user-declared" always participates in overload resolution! Officially the behavior to implicitly generate the copy operations in case of a user-declared destructor (or another copy operation) is deprecated. But consider this case: you have pre-C++11 code in use in your post-C++11 code base, and there is a user-declared destructor (which means the move ops will not be declared as per the rules of the standard). Then, if this type is "moved" (e.g. using std::move) in some context, it is nice that the copy-fallback is actually in place (so the copy ops *are* generated in practice!). Otherwise legacy pre-C++11 without user-declared move operations for this type would fail in this context. Just sprinkling '= default;' around is never useful. Use the rule of zero or all, or a more intelligent guideline for specific categories of types.

Roman Gershman

Co-Founder & CTO at DragonflyDB

2 年

The fact that there is no visibility to which c'tor is called as a result of some action is super confusing in c++. The same applies to hidden allocations in lambdas.

要查看或添加评论,请登录

社区洞察

其他会员也浏览了