Exploring Type Construction and Destruction

Overview

I was recently asked the question, “How do I clean up primitive types?” The reference to primitive types refers to one of the built-in fundamental types in C/C++ (e.g., char, int, long, double) that are used to construct more elaborate compound types (e.g., arrays, structs, classes, unions). The C++ language also nicely allows this to easily explored since the task of reserving/acquiring memory can be separated from the task of constructing an instance of a type. This is the topic of and is explored in this post. One may also be interested in reading, Using The C++ Placement New Operator, where how one might implement a class such as Boost.Optional using placement new is used as its example.


What Are The Fundamental Types?

Clause 3.9.1 Fundamental Types in the ISO C++11 standard defines the fundamental types. Briefly they are:

  • char
  • the five standard signed integer types (i.e., signed char, short int, int, long int, and long long int)
  • the five corresponding unsigned integer types (i.e., unsigned char, unsigned short int, unsigned int, unsigned long int, and unsigned long long int)
  • the extended signed and unsigned integer types
  • wchar_t, char16_t, char32_t
  • bool
  • float, double, long double
  • void (even though it is an incomplete type), and
  • std::nullptr_t (representing the null pointer).

It is helpful to also look at the following clause (§3.9.2) as it defines the compound types:

  • arrays
  • functions
  • pointers (e.g., to void, objects, or functions of a given type; to non-static class members)
  • references
  • classes (which includes structs)
  • unions, and,
  • enumerations.

Storage and Clean Up for the Fundamental Types

When one first learns how to program, one does not normally think about allocating RAM to hold the variables that he/she is creating. The reason is simple: the compiler/interpreter does this for you for all static storage duration (i.e., not thread, dyanmic, or automatic storage duration; e.g., global variables) and automatic storage duration (i.e., variables allocated function call stack) variables.

When one learns about dynamic memory allocation (e.g., C’s malloc, calloc, realloc, and free; C++’s new and delete), one will also learn about sizeof(T). The sizeof(T) operation is a compile-time function that returns the number of bytes used to represent an object of type T. With static and automatic storage declared variables, the compiler will reserve sizeof(T) bytes of space for each variable. For variables on the function call stack, this is done by adjusting the “stack pointer” by the proper number of bytes within each function/scope; with static storage duration objects this is (usually) done by appropriately reserving those bytes in the generated binary (e.g., in the generated EXE or DLL file). So, in essence, the compiler takes care of setting aside and reclaiming the space used for automatic storage duration variables and it takes care of setting aside the space for all static storage duration variables. The actual RAM used to hold all such variables is ultimately allocated by the operating system when it loads the program (e.g., the EXE or DLL) at run-time: the operating system acquires the RAM to hold the executable and reserves a certain amount of stack space for that executable.

ASIDE: C++ also supports thread storage and dynamic storage durations. This is specified in Clause 3.7. Thread storage duration might involve dynamic memory allocation managed by the C++ compiler-generated code for such. Dynamic storage duration is typically managed by the programmer explicitly and increasingly through the use of specialized types, e.g., std::shared_ptr and std::unique_ptr.

So instances of fundamental types themselves don’t need to be cleaned up. The actual data stored in instances of these types are simply bits that don’t need to be acquired, released, or require any “cleaning” operations to be performed to stop using them, to use them for another purpose, etc. This is because their values themselves do not inherently refer to requested resource(s) that must be released later, thus, one does not need to do anything to “clean” them up or to reuse them. Even with the compound types this is generally true. Note that:

  • pointer values (on von Neumann architectures) are also represented by integer values so pointer values (on their own) don’t need to be “cleaned up”, and
  • non-dynamically allocated fixed-size arrays of fundamental types will assume the size (i.e., the size of the array is not stored/used at run-time) so these also on their own will not need to be “cleaned up” either.

Thus there is no need to “clean up” fundamental types and many compound types. This is perhaps why, if you are wondering, your computer science/engineering professor never really mentioned this. :-)

ASIDE: Clearly many pointers do need to be “cleaned up” –but this is not the pointer value itself, rather, the interpretation of that pointer value referring to a previously acquired resource which later needs to be released is the reason why one “cleans up” a pointer value. Clearly a pointer can be set to point to anywhere in the computer’s memory –and that pointer itself does not need to be “cleaned up.”


Separating and Exploring Space Reservation and Type Construction/Destruction

High-level programming languages don’t typically provide the level of control that can be exploited in C++. Even with all of C++’s constructs and abstractions, one can still control how space is reserved/acquired/released separately from object construction and destruction. When these two operations needs to be separated, one normally will use a special form of the new operator called placement new.

If you are new to C++, it is important to first realize that the ISO committee made sure that all types could be treated in a uniform manner syntactically. Specifically, this means that the syntax to default construct, copy construct, move construct, copy assign, move assign, and destruct any type is identical for all types in C++. Further, explicitly using this syntax is always valid whenever the type is represented by a template parameter (that represents a type) regardless of what type it is.

It is meaningful to illustrate that C++, via a template parameter, can even manipulate a fundamental type just like one can do with a user-defined type. Consider this (demonstration-purposes only!) example:

#include <type_traits>

template <typename T>
void destroy(T&& t)
{
  using type = typename std::remove_reference<T>::type;
  t.~type(); // call T's destructor on t
}

struct Empty { };

int main()
{
  // Demonstration code only. Don't do this!
  int i;
  destroy(i);
  Empty e;
  destroy(e);
}

i.e., notice that the destructor can be invoked on the user-defined type as well as the built-in fundamental type. Clearly there is no “real” destructor for any of the fundamental types but the C++ language permits the syntax and semantics so such things can be expressed and used with all (complete) types. This is important because this allows one to focus on functionality –not what type the parameter T actually represents. This facilitates generic programming in C++.

If you are new to C++, know that destructors are actually functions –so they can be directly called but you should never do so unless you are using placement new. Constructors, on the other hand, are defined to not be functions. One implication of this is constructors cannot be directly called. Constructors are invoked either through a variable declaration or by using one of the new operator overloads.

The new operator has a number of overloads that are defined in the language and, like the other operators, can be overloaded. The new operator even allows one to define an overload that permits a user-defined set of function arguments passed to it! The C++ standard defines these forms of the new operator (e.g., see http://en.cppreference.com/w/cpp/memory/new/operator_new):

  • Four global overloads: two for scalars, two for arrays. One of each of these pairs is a non-throwing version (i.e., it returns nullptr instead of throwing std::bad_alloc).
  • Placement new overloads: one is defined for scalars, the other for arrays. Both of these accept a void* address argument representing the memory allocated for the object. (NOTE: Programmers can define additional overloads with custom arguments. Such definitions are considered to be placement new forms.)
  • Four class-based overloads: two for scalars, two for arrays. One of each of these pairs behaves just like the throwing global form and the other has user-defined arguments. (NOTE: All such overloads are considered to be static class members in C++ –even if the static keyword is not used.)

In C++, there are three rules concerning how these are used:

  1. If a non-placement scalar form of new is used to obtain memory, then the delete keyword must be used to release that memory.
  2. If a non-placement array form of new is used to obtain memory, then the delete[] keyword must be used to release that memory.
  3. If a placement form of new is used, then there is no corresponding call to any form of the delete keyword, i.e., it must not be used. Instead, the programmer must manually invoke any destructors and appropriately release any resources as required should that be necessary.

If the third rule above sounds like it can be dangerous, know that it can be! The third rule essentially requires the programmer to completely manage everything manually. This is a good thing: enabling the programmer to control things when needed are what both the C and C++ programming languages are all about!

To understand how placement new and the aforementioned direct invocation of a destructor can be properly and safely used it is best to consider a minimal example:

#include <new>

template <typename T>
class A1
{
private:
  alignas(T) char data_[sizeof(T)];

public:
  T& get()
  {
    return *reinterpret_cast<T*>(data_);
  }

  A1()
  {
    new(data_) T{};  // Use data_'s memory.
  }

  ~A1()
  {
    get().~T();  // Manual destruction.
  }
};

int main()
{
  A1<int> a;
  a.get() = 34;
}

In the above definition of A1, the instance of T is stored where data_ is in memory. This is why data_ is an array of char having sizeof(T) elements properly aligned in memory (i.e., so that it can be used to hold an instance of type T). The actual construction of T‘s instance is done in the default constructor using one of the placement new forms. Clearly visible is the passing of data_, which is effectively a char * address, which is passed in as the void* argument to placement new. Because the memory address is passed to a placement new form, the new operator does not try to acquire any memory to hold the object at all: it assumes the address passed to it is valid to hold the object and it constructs the type at that address. To undo this, the A1 destructor needs to manually invoke T‘s destructor. Since the memory was reserved by the struct‘s data_ member declaration itself, no memory actually needs to be released, i.e., nothing further needs to be done.

A1‘s constructor, however, does not actually need to construct T using placement new. One could defer that until a time (should it ever occur) the actual object is needed. The following program modifies class A1 so that unless the get() member function is invoked, the type stored in data_ is never constructed:

#include <new>

template <typename T>
class A2
{
private:
  alignas(T) char data_[sizeof(T)];
  bool constructed_;

public:
  T& get()
  {
    if (!constructed_)
    {
      new(data_) T{};  // Construct T
      constructed_ = true;  // Remember this fact
    }
    return *reinterpret_cast<T*>(data_);
  }

  A2() :
    constructed_{false}
  {
  }

  ~A2()
  {
    if (constructed_)
      get().~T();
  }
};

int main()
{
  A2<int> a;
  a.get() = 34;
}

Wow! This is lazy construction! Of course, there are some trade-offs:

  • A2 requires an additional bool member to track whether or not the object is constructed.
  • A2‘s get() member may throw exceptions from the construction of the object whereas A1‘s get() member function would have never thrown an exception.

but these trade-offs may be worthwhile, even mandated, under certain conditions. For example, if one needed to store an array of 1,000,000 T objects using A2 instead of A1 then this would mean:

  • only those instances of T actually accessed would incur run-time costs of construction, and
  • upon construction of the array only the memory would be acquired –no instances of T would be constructed.

Just how powerful and useful placement new is can be demonstrated with a very practical and simple example. In the code below, the MO (“move-only”) type cannot be default- or copy-constructed and copy-assigned: it can only be move-constructed, move-assigned, and destroyed. Clearly trying to create lvalue scalars or arrays of MO will fail, but, std::vector<MO> doesn’t fail to compile! It, in fact, has no problem using MO:

#include <vector>

struct MO
{
  MO() = default;
  MO(MO const&) = delete;
  MO(MO&&) = default;
  MO& operator =(MO const&) = delete;
  MO& operator =(MO&&) = default;
  ~MO() = default;
};

int main()
{
  // The next 3 lines fail to compile
  // as there is no default constuctor...
  MO array[50];
  MO *p = new MO;
  MO *p = new MO[1000000];

  // But this works since std::vector
  // uses placement new internally...
  std::vector<MO> v;
  v.reserve(1000000);
  v.emplace_back(MO{});
}

Isn’t this cool, considering that v reserves the space in std::vector for 1,000,000 MO objects?!!

The existence of placement new allows one to separate the two distinct operations of reserving space for an instance of some type T versus actually instantiating an instance of type T. In the above program, the reservation of a million objects did not have the inefficiency of impossibly default constructing those objects. Only two MO objects are constructed: the one passed to emplace_back and the one at index 0 in std::vector. This works because std::vector internally uses placement new.

Finally, one should also print out when things are being created, destroyed, etc. This way one can confirm exactly how many instances of a type are being created and destroyed:

#include <new>
#include <vector>
#include <utility>
#include <iostream>

template <typename T>
class A3
{
private:
  alignas(T) char data_[sizeof(T)];

public:
  T& get() &
  {
    return *reinterpret_cast<T*>(data_);
  }

  T const& get() const &
  {
    return *reinterpret_cast<T const*>(data_);
  }

  T&& get() &&
  {
    return std::move(*reinterpret_cast<T*>(data_));
  }

  A3()
  {
    new(data_) T{};
    std::cout
      << "A3(); this = "
      << this
      << std::endl
    ;
  }

  A3(A3 const& a)
  {
    new(data_) T{a.get()};
    std::cout
      << "A3(A3 const&); this = "
      << this
      << std::endl
    ;
  }
 
  A3(A3&& a)
  {
    new(data_) T{a.get()};
    std::cout
      << "A3(A3&&); this = "
      << this
      << std::endl
    ;
  }

  A3& operator =(A3 const& a)
  {
    A3 tmp{a};
    swap(*this, tmp);
    std::cout
      << "A3::operator =(A3 const&); this = "
      << this
      << std::endl
    ;
    return *this;
  }

  A3& operator =(A3&& a)
  {
    swap(*this, a);
    std::cout
      << "A3::operator =(A3&&); this = "
      << this
      << std::endl
    ;
    return *this;
  }

  ~A3()
  {
    get().~T();
    std::cout
      << "~A3(); this = "
      << this
      << std::endl
    ;
  }
};

int main()
{
  using namespace std;

  cout << "TEST 1:" << endl;
  {
    vector< A3<int> > v;
    v.reserve(15);
    cout
      << "v.size() = "
      << v.size()
      << endl
    ;
  }

  cout << "\nTEST 2:" << endl;
  {
    vector< A3<int> > v;
    v.reserve(15);
    cout
      << "v.size() = "
      << v.size()
      << endl
    ;
    v.emplace_back(A3<int>{});
    cout
      << "v.size() = "
      << v.size()
      << endl
    ;
  }

  cout
    << "\nDONE TESTS."
    << endl
  ;
}

When run the above program produces output similar to:

$ ./a.out
TEST 1:
v.size() = 0

TEST 2:
v.size() = 0
A3(); this = 0x7fff54118050
A3(A3&&); this = 0x604010
~A3(); this = 0x7fff54118050
v.size() = 1
~A3(); this = 0x604010

DONE TESTS.
$

Even though 15 array locations are reserved, absolutely no objects are instantiated in TEST 1. When an element is added in TEST 2, you can see the instantiations: the object on the call stack 0x7fff54118050 being moved constructed into the newly created object in the array 0×604010. So even though there are 14 more locations of RAM reserved for A<int> objects, there is only 1 A<int> object actually constructed in v and it was constructed only when it was needed to be!

Clearly this has obvious benefits for std::vector. One benefit is that elements that are not used don’t consume time being initialized. Another benefit is the initialization can occur when it is needed, e.g., push_back(), emplace_back(), etc. and those initializations can actually be constructor calls –not assignments.


Closing Words

Hopefully this post helps (i) demystify any misconceptions about new and placement new and (ii) improve your understanding concerning how type instances can be constructed and destructed (independently of how one obtains the memory to hold those types). Placement new is an important part of C++ and it is a vital part of all std::allocator-styled classes. When it is used, it is hidden inside of classes to avoid its misuse and errors that would otherwise likely occur. Containers such as std::vector rely on placement new to give it the abilities it does (e.g., the ability to store move-only objects, objects without a default constructor, etc.) that cannot be done using traditional C-style arrays of the same type.

Happy Coding!

thoughts from noise