Game Development by Sean

Zero-Trivial Constructors and Destructors

Table of Contents

The C++ specification has an explicit definition of a trivial constructor (as well as a trivial copy constructor, move construct, assignment operators, and destructors). Triviality helps with a number of optimizations. Unfortunately, there are times when a constructor is “almost trivial” or when a destructor is trivial under certain circumstances. The C++ standard does not deal with this issues. I propose some simple traits that can be added to a custom framework to support these concepts and which could be added to C++ with some work. A trivial constructor is one that does nothing. Namely, it means that the object is in a valid state even if the constructor is not run. Memory can be allocated for the object and it is immediately valid (though its members are in an undetermined state). Built-in types, for example, are trivial. Simply declaring an integer requires no constructor. It can be assigned to or other operated on with no problems, albeit dereferencing it is a bad idea. The same can be true for many user-defined types, especially numerical types like linear algebra vectors and the like.

Default Constructors

Some types do need to be initialized, but only to a zero value. An example is a smart pointer (and most containers) which requires that its internal pointer be null in order to be in a valid state. If the smart pointer were completely unitialized, then upon first assignment the type wouldn’t have any way to know if its internal pointer was unitialized garbage or pointing to a legal object, and it would try to free an invalid object (or decrement a reference count or whatever is appropriate) at an invalid memory location.

I call constructors that simple set all members to zero (or some members to zero and leaving other members unitialized) and which only invoke other constructors for bases or members that follow the same rules “zero-trivial constructors.” Yes, a better name could likely by found.

The key to such types is that initializing all their memory to zero puts them in a valid state. When allocating a contiguous chunk of memory to contain such types (like an array or std::vector) zeroing out the memory is sufficient; there is no need to execute the constructor for each object. Most runtimes offer an API to allocate zero-initialized memory, and for others a standard routine like memset() is typically must faster than iterating over each element and setting it to zero. In these cases, zeroing the memory for the collection removes the need to ever run the constructor.

Optimizing compilers typically detect these situations. If a loop sets all memory locations in an array to zero (or another byte value) then the compiler can replace the whole loop with an inlined intrinsic version of memset(). Unfortunately, these optimizations are generally disabled in debug builds, making debuggable binaries slower than they could be if the containers explicitly know not to insert a loop.

Rather simply, a container could use a custom type trait like so:

if (ext::is_zero_trivial_constructible::value) std::memset(array, 0, size); else for (size_t i = 0; i < size; ++i) new (array + i) Type;</code>

Release builds will typically use dead code elimation to only emit code for the appropriate branch and their optimization of the loop will typically be identical to any optimizations they perform on std::memset. Notably, even if the array members are set after the loop, optimizing compilers often can either remove both the memset and the loop or neither (it’s worth testing on your target compilers to be sure, of course). Overall, the above code produces faster results when the necessary optimizations are unavailable (in debug builds or with less capable compilers) an produces identical results when the optimizations are available.

If a standard type trait is defined, it becomes possible for the programmer to declare that a user-defined type use zero-trivial constructor. It would be reasonable for a compiler to automatically detect these as well, if such a concept were desired to be added to C++ proper. A sample user-defined trait setup might look like:

namespace ext { // default template struct is_zero_trivially_constructible { static constexpr bool value = false; };

// override for type Foo template <> struct is_zero_trivially_constructible { static constexpr bool value = true; }; }</code>

It’s also trivial to think up implementations that would look for typename tags inside of types to make it easier to declare a type as zero-trivial constructible. Such tricks are generally avoid in good C++ code, but are quite doable.

Destructors

Trivial destructors are those which do nothing. Namely, they have no side effects. It’s safe to never call the destructor for types with trivial destructors. An example is again built-in types like ints. Simple deallocating its backing store is all that needs to be done.

A similar zero-based optimization can be made for some destructors. For many types, including most smart pointers and containers, destructing instances only has a side effect of any kind if members are non-zero. For example, a simplified std::unique_ptr destructor might simply look like:

delete ptr;

Remember that deleting a null pointer is legal and does nothing.

For these types, we know that we don’t need to call the destructor if we know that the memory allocated to the type contains all zeros. This in itself is rarely applicable with a concept for zero-move constructors and assignments, which we’ll cover shortly.

If this concept where added to C++ proper, the compiler could find these cases only for simpler types reasonably. If a destructor only contains calls to delete, for instance. It may be possible to allow calls to other functions and method if they’re marked with a particular attribute, which would be necessary to support most allocator wrappers (including STL allocators). Overall, it may just be easier to require a particular attribute or contextual keyword on the destructor definition, especially as that makes developer intent clear (this affects a type’s semantics and ABI) and doesn’t allow semantics to automatically change based on implementation details.

Move Constructors and Assignment

A trivial move constructor is, like a trivial copy constructor, one with no side effects. This is rarely useful; it essentially means that their is no difference between a trivial copy constructor and a trivial move constructor. In either case, it is safe to copy instances using functions like std::memcpy(). A trivial move constructor hence is one that doesn’t really move, it just copies.

This is unfortunate because in many cases a move constructor does have side effects, but those are to simple zero-out the source memory, so that a call to the source instance’s destructor has no effect. THis is what I would call a zero-trivial move constructor or assignment.

In effect, it means that it would be sufficient to do the following to move an object type that uses zero-move and then “forget” the source instance:

std::memcpy(&dest, &source, sizeof(Type)); std::memset(&source, 0, sizeof(Type)); source.~Type();

It’s hero that a zero-trivial destructor makes sense. Notice that the last two lines above effectively cancel out for such a type: there’s no need to call the destructor if the memory backing the instance is zerod out, and there’s no need to zero out the memory of an instance that’s no longer dereferenced because the instance is gone. Hence, for an object that has both zero-trivial move and destruct, it is legal to move an entire array of objects from one location to another to do so just with memcpy and then deallocating the source memory. A simplified std::vector might grow by doing the following:

new_mem = new Type[new_size]; if (std::is_trivially_move_constructible::value || std::is_zero_trivially_move_constructible::value) std::memcpy(new_mem, old_mem, sizeof(Type) * new_size); else for (size_t i = 0; i < old_size) new (new_mem + i) Type(std::move(old_mem[i])); if (!std::is_zero_trivially_move_constructible::value || !std::is_zero_trivially_destructible::value) for (size_t i = 0; i < old_size) old_mem[i].~Type(); delete[] old_mem;</code>

The entire loop can be ellided (at run-time even for non-optimizing compilers) since we know that after moving the instances we have no need to call their destructors. If the type is also zero-trivial move constructible, there’s no need to loop to copy the instances, either.

Again, this could be supported in C++ proper (with the addition of a new contextual keyword, if desired). So long as only zero-values are written to source members, we have a zero-move constructor or assignment.

Without C++ support, this is also again easily supported with the addition of a custom type trait.

Alternative Approaches

There have been some proposals to add a destructed-after-move trait to C++ proper. While a lot simpler overall, I don’t believe such a trait is sufficient.

First, it only handles one specific case. It does not allow using memset to initialize objects that only need to be zeroed out to be valid, which removes one of the optimizations presented above.

Second, it becomes harder for the compiler to differentiate move constructors that follow the zero-trivial semantics above; such a trait would ignore the fact that such move constructors and assignments are safe to implement as a copy and zero memset, which can be a lot faster in some circumstances. On its own, a move constructor that simply zeroes out its source is non-trivial, so a loop would be required to move all elements in a container.

Finally, there’s a big difference between destructed-after-move and zero-trivial move. In the former case, the source object is always considered to be an unusable state (it becomes illegal to call any method on it, even the destructor or assignment). In the zero-trivial case, if the object if zero-trivial constructible then the object is in a valid, usable state.

Take the following code for example:

Foo first{ /* data */ }, second;

second = std::move(first);

// legal with zero-trivial move or current C++11 move semantics, illegal with destructed-after-move first = { /* another value */ };</code>

The current semantics for move are that the object, if explicitly moved, must still be in a valid state. Zero-trivial move, if only allowed to be used with zero-trivial construction, maintains this property.

The only alternative to zero-trivial move that maintains compatibility with current semantics would be a destruction-optional-after-move, meaning that the move operation must leave the source in a valid state where the destructor will have no side effects. This would be simpler than the full specification for zero-trivial move and destruct, but is harder to implement in such a way that the compiler can give diagnostics to the developer if the rules are broken. It also still ignores the (admittedly rarer) case where a block of elements is moved and then the original source needs to be reset to a default state; zero-trivial move with zero-trivial construct guarantees this, while destruction-optional-after-move could conceivably leave the object in a non-default-constructed state that just happens to be safe not to destruct. A trivial stupid example would be:

struct Foo { int value; Foo() : value{0} {}; [ignore_destruct_after_move] Foo(Foo&& src) : value(src.value) { src.value = 7; } ~Foo() { if (value != 0 && value != 7) std::cout << "Destructing Foo(" << value << ")\n"; } };

In such a crazy case, the struct’s destructor does nothing after a move, but moving from the object does not reset the object to its default-constructed state. This would be undesirable for a “page-flipping” container.

Yet another approach might be to instead have a defaulted-after-move trait. This would make code like the above more obviously incorrect to a programmer and allow for some container operations, but it still requires the addition of a zero-trivial default constructor and a ignore-destructor-if-defaulted trait; on their own, that’s as much new semantics as what I proposed above, but becomes nearly impossible to enforce or provide diagnostics for. It also still bars various memcpy and memset optimizations from being applied unless the type has a zero-trivial default constructor, so its apparent “looseness” doesn’t really buy anything.

Summary

The zero-trivial set of traits could be added to C++, but I wouldn’t hold my breath. In the mean time, for those working on custom frameworks that need to maintain a particular performance profile even when debugging (cough games cough), it may be worth-while to add custom traits.

The nice thing is that it is possible to use the template approach above to add the trait to existing types, so if a more-efficient std::vector<> replacement is desired but it needs to Just Work with std::unique_ptr<> or other types, they can be “extended” if necessary (and after ensuring your STL implementation actually has the target traits, of course, since the compiler can’t check and enforce anything here).</div>