Trivially copyable does not mean trivially copy constructible
About a month ago, I got an interesting pull request for lexy, my new parser combinator library. It fixed a seemingly weird issue relating trivially copyable types and special member function of classes containing unions. While digging into it, I learned a lot about trivial special member functions and made a somewhat surprising realization:
Just because a class is std::is_trivially_copyable
does not mean the class is actually std::is_trivially_copy_constructible
or even std::is_copy_constructible
:
you can have classes that you can’t copy, but they’re still trivially copyable,
and classes where the copy constructor can do arbitrary amounts of non-trivial work, but they’re nonetheless trivially copyable!
Let me explain.
Special member function
The default constructor, copy constructor, move constructor, copy assignment operator, move assignment operator, and destructor of a class are called special member function. They’re special, because the compiler can and will implement them for us in certain situations. The exact rules are complicated, but luckily we don’t need to bother with them here (nor ever).
A default constructor of a class T
is a constructor that can be called without arguments:
T(); // ok, no arguments
T(int i = 42, float f = 3.14); // ok, all arguments defaulted
template <typename ... Args>
T(const Args&... args); // ok, can be called with no arguments
A copy constructor of a class T
is a non-templated (!) constructor whose first argument is of type T&
, const T&
, volatile T&
, or const volatile T&
, and all other parameters (if there are any) have default arguments.
Likewise, a move constructor of a class T
is a non-templated (!) constructor whose first argument is of type T&&
, const T&&
, volatile T&&
or const volatile T&&
, and all other parameters (if there are any) have default arguments.
T(const T& other); // traditional copy constructor
T(T&& other); // traditional move constructor
T(const T& other, int i = 42); // copy constructor, second argument defaulted
T(T& other); // copy constructor
template <typename Arg>
T(Arg&& other); // not a copy/move constructor, templated
A copy assignment operator of a class T
is a non-templated (!) operator=
overload whose only argument is of type T&
, const T&
, volatile T&
, or const volatile T&
.
Likewise, a move assignment operator of a class T
is a non-templated (!) operator=
overload whose only argument is of type T&&
, const T&&
, volatile T&&
, or const volatile T&&
.
Note that the return type or member function cv/ref qualifier don’t matter.
T& operator=(const T& other); // traditional copy assignment
T& operator=(T&& other); // traditional move assignment
int operator=(const T& other) volatile &&; // copy assignment
template <typename Arg>
T& operator=(Arg&& other); // not a copy/move assignment, templated
A destructor is the weird member function with the ~
.
Keep those rules in mind, they’ll become important later.
Type traits for special member functions
Each special member function has a type trait std::is_[default/copy/move]_[constructible/assignable]
that allows you to query its existence.
So if a class has a copy constructor, std::is_copy_constructible<T>
is std::true_type
.
Except this is not what those traits actually do!
The traits query whether an expression is well-formed:
std::is_default_constructible<T>
checks whetherT()
is well-formed.std::is_copy_constructible<T>
checks whetherT(std::declval<const T&>())
is well-formed.std::is_move_constructible<T>
checks whetherT(std::declval<T&&>())
is well-formed.std::is_copy_assignable<T>
checks whetherstd::declval<T&>() = std::declval<const T&>()
is well-formed.std::is_move_assignable<T>
checks whetherstd::declval<T&>() = std::declval<T&&>()
is well-formed.std::is_destructible<T>
checks whether~T()
is well-formed.
This means that the type traits can report a different results from a hypothetical “does the class have this special member function?” trait.
For starters, they ignore access specifiers: if you have a private copy constructor, std::is_copy_constructible<T>
is std::false_type
.
But there are also more nuances in some situations:
struct weird
{
weird& operator=(const volatile weird&) = delete; // (1)
template <int Dummy = 0>
weird& operator=(const weird&) // (2)
{
return *this;
}
};
static_assert(std::is_copy_assignable_v<weird>); // ok
weird w;
w = w; // invokes (2)
Note that overload (2) takes a defaulted template parameter. Using operator syntax to call that function, there is no way to actually specify that template parameter. However, as it is defaulted, there is no need to.
The operator overload (1) is a copy assignment operator, which is deleted.
The operator overload (2) is not considered to be an assignment operator, as it is a template.
However, overload resolution of w = w
doesn’t care about what exactly is a “copy assignment operator”, it just works as normal.
As such, it will find the templated overload (which is a better match than the one taking a const volatile
),
and happily “copy assign” the object, even though it technically has no copy assignment operator.
This is also what std::is_copy_assignable
checks, so the assertion passes.
I’m focusing on assignment here and the following for dramatic reasons, but the same applies to every other special member function.
The rules that determine whether something is a special member function and the rules that determine which constructor/assignment operator is actually invoked are completely different!
To determine whether something is special member function, look for a member with the signatures given above. To determine what is called, do regular overload resolution.
This is especially problematic when combining it with the rules for implicit generation of special member functions. Consider a class that defines only a templated assignment operator, but not one that is considered to be a copy assignment operator. The compiler might generate a copy assignment operator in addition to the templated one, which is then preferred during overload resolution! (Unlike
weird
, the generated copy assignment operator takesconst weird&
, which is then preferred).
Note that the type traits, which do overload resolution, give you the correct result.
Something like std::has_copy_assignment_operator<T>
wouldn’t be very useful, as you want to query whether you can invoke something that looks like one,
not whether there is the corresponding function somewhere.
See also: Arthur O’Dwyer’s post about a concept that checks whether there exists a member function with a given signature.
Trivial special member function
Special member functions can be trivial (not the topic, the actual member function can have this property).
They are trivial, if they’re not user provided (i.e. they use = default
or are implicitly generated),
and the corresponding function of all the members/base classes are also trivial.
Trivial default constructors and destructors do nothing, whereas trivial copy/move constructors/assignment operator do essentially a std::memcpy
.
struct foo
{
int a;
float f;
foo() = default; // trivial
// implicitly declared copy constructor is trivial
~foo() {} // not-trivial, user provided
};
The actual rules are bit more nuanced, but you’ll get the idea.
Type traits for trivial special member functions
Each of the six type traits from above also come in a is_trivially_XXX
flavor.
And again, they don’t check whether the type has a trivial special member function,
but whether the corresponding expression invokes only trivial functions.
struct weird
{
weird& operator=(const volatile weird&) = delete; // (1)
template <int Dummy = 0>
weird& operator=(const weird&) // (2)
{
return *this;
}
};
static_assert(std::is_copy_assignable_v<weird>); // ok
// not ok, (2) is non-trivial
static_assert(std::is_trivially_copy_assignable_v<weird>);
Again, this is what is useful:
you want to check whether a = b
invokes a non-trivial function, not whether there is a non-trivial function in the class.
std::is_trivially_copyable
This brings me to std::is_trivially_copyable
, which does something completely different from std::is_trivially_copy_constructible
!
std::is_trivially_copyable<T>
checks whether T
is a trivially copyable type (duh).
A trivially copyable type is either a fundamental type, or a class where:
- the destructor is trivial and not deleted,
- every copy/move constructor/assignment operator is either deleted or trivial (or doesn’t exist at all),
- and there is a non-deleted copy constructor, move constructor, copy assignment operator, or move assignment operator.
Condition 1 should be straightforward: the destructor of the type must not do anything. Condition 2 says that if the type has a special member function, it must be trivial. Finally, condition 3 says that there must be some way to relocate an object from one location to another; types that are completely immovable are not trivially copyable.
Again, the actual rules are bit more nuanced.
Note that std::is_trivially_copyable_v<T>
can be true
, but std::is_trivially_copy_constructible_v<T>
can be false
:
T
does not need to be copy constructible to be trivially copyable, std::is_copy_constructible_v<T>
can be false
.
There is also
std::is_trivial
which isstd::is_trivially_copyable
plus trivial default constructor. However, this type trait is neither interesting nor really useful compared to trivially copyability, so I won’t be mentioning it again.
Got all that? Because now it gets interesting.
Based on the definition above, you might be tempted to implement std::is_trivially_copyable_v<T>
as follows:
template <typename T>
constexpr bool is_trivially_copyable_v
// condition 1
= std::is_trivially_destructible_v<T>
// condition 2
&& (!std::is_copy_constructible_v<T> || std::is_trivially_copy_constructible_v<T>)
&& (!std::is_move_constructible_v<T> || std::is_trivially_move_constructible_v<T>)
&& (!std::is_copy_assignable_v<T> || std::is_trivially_copy_assignable_v<T>)
&& (!std::is_move_assignable_v<T> || std::is_trivially_move_assignable_v<T>)
// condition 3
&& (std::is_copy_constructible_v<T> || std::is_move_constructible_v<T>
|| std::is_copy_assignable_v<T> || std::is_move_assignable_v<T>);
In fact, this is basically how clang implements std::is_trivially_copyable
currently.
But this implementation is wrong!
Unlike std::is_trivially_[copy/move]_[constructible/assignable]
, std::is_trivially_copyable
does not use overload resolution to check expressions.
It actually goes ahead and looks for the existence of a special member function!
This can create funny situations:
struct weird
{
weird() = default;
weird(const weird&) = default;
weird(weird&&) = default;
~weird() = default;
weird& operator=(const volatile weird&) = delete; // (1)
template <int Dummy = 0>
weird& operator=(const weird&) // (2)
{
return *this;
}
};
static_assert(std::is_copy_assignable_v<weird>); // (a)
static_assert(!std::is_trivially_copy_assignable_v<weird>); // (b)
static_assert(std::is_trivially_copyable_v<weird>); // (c)
Assertion (a) passes because overload resolution finds the templated overload (2). Assertion (b) does not pass because overload resolution checks the templated overload (2), which is not trivial.
However, assertion (c) passes (if you don’t use clang, that is):
std::is_trivially_copyable_v<weird>
checks the special member functions without doing overload resolution.
It has a trivial non-deleted destructor and copy/move constructor, and a deleted copy assignment operator.
As such, it is trivially copyable.
That the actual copy assignment a = b
might invoke arbitrary non-trivial code does not matter, the type is still trivially copyable!
Just because a type is copy assignable and trivially copyable, does not mean that the type is trivially copy assignable, likewise for all the other special member functions.
Okay, that’s a bit weird.
But surely nobody writes types such as weird
and the important type trait is either std::is_trivially_copyable
or one of the std::is_trivially_[copy/move]_[constructible/assignable]
and not a mix between the two depending on the situation.
… you know what is coming?
weird
is known as Microsoft’s std::pair
and the standard absolutely requires sometimes std::is_trivially_copyable
and sometimes std::is_trivially_[copy/move]_[constructible/assignable]
depending on the situation!
Trivially copyability vs. calls trivial function
The standard requires that a type is std::is_trivially_copyable
in the following situations:
- When passing/returning objects to from function calls that are trivially copyable, they may be passed/returned in registers as an optimization.
std::memcpy()
must only be used with trivially copyable types and is guaranteed to work.std::bit_cast()
must only be used with trivially copyable types.
On the other hand, the standard requires that overload resolution invokes only trivial special member functions (std::is_trivially_[copy/move]_[constructible/assignable]
)
- when determining whether the defaulted implementation of a special member function is trivial,
- when the active member of a union is changed via direct assignment,
- and when determining whether or not a union has a non-deleted special member function.
The union cases are interesting:
Copying a union is defined to copy the object representation, which essentially does std::memcpy
.
std::memcpy
is only allowed for trivially copyable types.
However, the union only has a non-deleted copy constructor if overload resolution finds a trivial copy constructor for all variants, which is not guaranteed to exist for trivially copyable types!
This means that it is not enough to put std::is_trivially_copyable
types into a union, they need to actually be std::is_trivially_[copy/move]_[constructible/assignable]
– even though the actual copy operation requires only std::is_trivially_copyable
:
// As above.
struct weird
{
weird() = default;
weird(const weird&) = default;
weird(weird&&) = default;
~weird() = default;
weird& operator=(const volatile weird&) = delete;
template <int Dummy = 0>
weird& operator=(const weird&)
{
return *this;
}
};
static_assert(std::is_copy_assignable_v<weird>);
static_assert(!std::is_trivially_copy_assignable_v<weird>);
static_assert(std::is_trivially_copyable_v<weird>);
union weird_union
{
int i;
weird w;
} u;
u = u; // error: weird_union has deleted copy assignment
And remember: weird
is more commonly known as std::pair
.
This was exactly the cause of lexy’s initial bug.
I’ll just leave you with the tweet I wrote after I figured it all out:
That moment when you discover confusing C++ behavior and are not sure whether to file a bug report against the standard, the STL implementation or the compiler...
— Jonathan Müller (@foonathan) March 1, 2021
Probably all three.
(the standard’s behavior is a bit surprising, MSVC’s std::pair
is not trivially copy assignable, and clang doesn’t do std::is_trivially_copyable
correctly)
Conclusion
There are two different categories of type traits regarding trivial special member function:
std::is_trivially_[copy/move]_[constructible/assignable]
and std::is_trivially_copyable
.
The first category do overload resolution to evaluate some expression and determine whether the called function is trivial,
the second category look whether the class defines functions matching a given signature.
This makes them fundamentally incompatible.
The type traits you actually want most of the time are in the first category: you actually type some expression in your code and want to check whether that is trivial. Use them to constrain your special member functions or select between a trivial and non-trivial union based implementation.
std::is_trivially_copyable
should only be used when you need to call std::memcpy()
or std::bit_cast()
(or functions that are built on top).
In particular, do not use them as a shorthand for “all special member functions are trivial”, because that is not what it actually does!
Always remember: a type can be std::is_trivially_copyable
without being std::is_trivially_copy_constructible
or std::is_copy_constructible
:
types with deleted copy constructor can be trivially copyable, and types where overload resolution selects a non-trivial constructor during copy can still have a trivial copy constructor.