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.

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

社区洞察

其他会员也浏览了