Should functions declared with `= default` only go in the header file

An explicitly-defaulted function is not necessarily not user-provided

What would be the best practice in this case?

I would recommend, as a rule of thumb, unless you explicitly and wantonly know what you are getting into, to always define explicitly-defaulted functions at their (first) declaration; i.e., placing = default at the (first) declaration, meaning in (your case) the header (specifically, the class definition), as there are subtle but essential differences between the two w.r.t. whether a constructor is considered to be user-provided or not.

From [dcl.fct.def.default]/5 [extract, emphasis mine]:

[...] A function is user-provided if it is user-declared and not explicitly defaulted or deleted on its first declaration. [...]

Thus:

struct A {
    A() = default; // NOT user-provided.
    int a;
};


struct B {
    B(); // user-provided.
    int b;
};

// A user-provided explicitly-defaulted constructor.
B::B() = default;

Whether a constructor is user-provided or not does, in turn, affect the rules for which objects of the type are initialized. Particularly, a class type T, when value-initialized, will first zero-initialize the object if T's default constructor is not user-provided. Thus, this guarantee holds for A above, but not for B, and it can be quite surprising that a value-initialization of an object with a (user-provided!) defaulted constructor leaves data members of the object in an uninitialized state.

Quoting from cppreference [extract, emphasis mine]:

Value initialization

Value initialization is performed in these situations:

  • [...]
  • (4) when a named variable (automatic, static, or thread-local) is declared with the initializer consisting of a pair of braces.

The effects of value initialization are:

  • (1) if T is a class type with no default constructor or with a user-provided or deleted default constructor, the object is default-initialized;

  • (2) if T is a class type with a default constructor that is neither user-provided nor deleted (that is, it may be a class with an implicitly-defined or defaulted default constructor), the object is zero-initialized and then it is default-initialized if it has a non-trivial default constructor;

  • ...

Let's apply this on the class types A and B above:

A a{};
// Empty brace direct-list-init:
// -> A has no user-provided constructor
// -> aggregate initialization
// -> data member 'a' is value-initialized
// -> data member 'a' is zero-initialized

B b{};
// Empty brace direct-list-init:
// -> B has a user-provided constructor
// -> value-initialization
// -> default-initialization
// -> the explicitly-defaulted constructor will
//    not initialize the data member 'b'
// -> data member 'b' is left in an unititialized state

a.a = b.b; // reading uninitialized b.b: UB!

Thus, even for use cases where you will not end up shooting yourself in the foot, just the presence of a pattern in your code base where explicitly defaulted (special member) functions are not being defined at their (first) declarations may lead to other developers, unknowingly of the subtleties of this pattern, blindly following it and subsequently shooting themselves in their feet instead.


Functions declared with = default; should go in the header file, and the compiler will automatically know when to mark them noexcept. We can actually observe this behavior, and prove that it happens.

Let's say that we have two classes, Foo and Bar. The first class, Foo, contains an int, and the second class, Bar, contains a string. These are the definitions:

struct Foo {
    int x;
    Foo() = default;
    Foo(Foo const&) = default;
    Foo(Foo&&) = default;
};

struct Bar {
    std::string s;
    Bar() = default;
    Bar(Bar const&) = default;
    Bar(Bar&&) = default;
};

For Foo, everything is noexcept because creating, copying, and moving an integer is noexcept. For Bar on the other hand, creating and moving strings are noexcept, but copy construction is not because it might require allocating memory, which might result in an exception if there is no more memory.

We can check if a function is noexcept by using noexcept:

std::cout << noexcept(Foo()) << '\n'; // Prints true, because `Foo()` is noexcept

Lets do this for all constructors in Foo and Bar:

// In C++, # will get a string representation of a macro argument
// So #x gets a string representation of x
#define IS_NOEXCEPT(x) \
  std::cout << "noexcept(" #x ") = \t" << noexcept(x) << '\n';
  
int main() {
    Foo f;
    IS_NOEXCEPT(Foo()); // Prints true
    IS_NOEXCEPT(Foo(f)) // Prints true
    IS_NOEXCEPT(Foo(std::move(f))); // Prints true
    
    Bar b;
    IS_NOEXCEPT(Bar()); // Prints true
    IS_NOEXCEPT(Bar(b)) // Copy constructor prints false
    IS_NOEXCEPT(Bar(std::move(b))); // Prints true
}

This shows us that the compiler will automatically deduce whether or not a defaulted function is noexcept. You can run the code for yourself here

Tags:

C++

C++11