Objects, their lifetimes and pointers

This post might seem to you, that’s very basic regarding the C++ content. It indeed is basic, but I believe it’s far from being easy. I also believe, that most of the C++ developers will find something surprising and new here, that causes undefined behavior.

The reason, why I chose this topic, is because it’s tricky. Everyone thinks it’s easy and almost everyone gets it wrong. I believe, that the reason for that is education that we mostly get from the C language, which planted within our minds a particular mindset regarding objects and memory, which is not valid in the C++ world. The aim of this post is to fix this up.

We will start a bit theoretical about objects, so that when coming to the practice we already have a good understanding of what is happening. So let’s get started.

Theory of C++ objects

We should start with what objects are. And this is the place, where it starts to get tricky already. We intuitively know, what objects are, but most of us cannot say precisely what an object is.

We could go with Wikipedia (https://en.wikipedia.org/wiki/Object_(computer_science)) for the definition. And what this definition says is:

In computer science, an object can be a variable, a data structure, a function, or a method, and as such, is a value in memory referenced by an identifier.

wikipedia

In the class-based object-oriented programming paradigm, object refers to a particular instance of a class, where the object can be a combination of variables, functions, and data structures.

wikipedia

Both of them are incorrect definitions from the C++ language point of view. So yes, basically it means, that objects in C++ can be something different than objects in other languages (like Java, Rust, Python, etc.).

Definition of objects in C++ language

So let’s get to the point. What are the objects in the C++ language? The definition as a whole C++ standard was evolving and the definition of the object changed a bit as well.

Until (not including) C++17 the object was defined as a region of storage. I am unfortunately not aware of why that definition changed.

Basically C++ standard defines an object as an entity with properties, and possible operations on it. Namely, program can do following with objects:

  • create
  • destroy
  • refer to
  • manipulate
  • access

Objects have following properties:

  • name (this is however optional)
  • storage and its storage-duration
  • type
  • lifetime
  • value

An interesting thing is, how we can create an object, since this is the most common thing, that is done wrong. So: the object can be created by:

  • definition (int x;)
  • new expression (new int)
  • creating temporary (int{})
  • changing active member of a union:
union u{
  int a;
  float b;
} u;
u.a = 5; // creating object u.a
u.b = 10.0f; // creating object u.b

And equally important is how we can destroy an object:

  • by calling a destructor
  • by reusing or releasing storage of an object

technically speaking, not all objects can be destroyed by calling the destructor. Objects of built-in types do not have destructors, but they have got a pseudo-destructor, call to which is a no-op. This is, however, going to change in the future (with C++20 or C++23), so it’s good to assume also now, that destructor ends a lifetime of an object (such assumption won’t cause your program UB).

You might be also thinking, what does it mean to reuse or release the storage. Storage re-usage simply means, that in place of the storage new object is being created. Release of the storage means, that storage ends its duration (by for example performing delete expression, or exiting the scope).

Just to clarify things, references and functions are not objects even though some of their properties can be similar. In C++ we also have got variables. Variable is created by declaring a reference or an object. This means, that variables can denote only some objects. Now it should be clearer, why Wikipedia definition wasn’t correct for the C++ case, but let’s have a look at example:

int x; // x is variable
int& x = //... x is variable
struct Y{int z;}x; // Y and z are not objects, x is.

Objects’ lifetimes

Since we know how to create an object, it’s a good time to find out what’s the lifetime of an object, and what a lifetime is.

It is not strictly defined in the C++ standard, what is a lifetime. Intuitively we refer to it as a time when object lives. The reason for introducing the concept (not confuse with concepts feature) of a lifetime in the language is to know when we can use objects without some limitations.

The actual life of an object has multiple stages:

  • storage allocated, constructor not yet started
  • object under construction (constructor is running)
  • lifetime
  • object under destruction (destructor is running)
  • destructor finished, storage not yet released

Some stages are optional, other stages are possible only for certain types.

We can have std::is_trivially_constructible types, for which 2nd stage cannot take place. Consequently, we can have std::is_trivially_destructible types for which two last stages do not exist. Moreover, since it’s not mandatory (and not a UB) to not call the destructor, two last stages of life are optional for every type.

Under every stage, there are limitations on how we can use the object.

before constructor starts and after destructor ends

In the case of the very first and very last stage (before constructor starts and after destructor ends) there are very limited ways to use such an object. Basically, we can use it as if it was a pointer to the storage only, not as if it was a pointer to an object. What we cannot do with such a pointer is:

  • pass to the delete expression if it points to the object, whose type is not trivially_destructible
  • we cannot access its non-static members (unless its constructor is trivial)
  • we cannot static_cast to any other type than (void*, char*, unsigned char*, std::byte*) and we cannot dynamic_cast it.

If we will try to perform any of the above the behavior is undefined.

Object under construction and destruction

When an object is being constructed and destructed, we can do a little bit more with the object. Namely, we can get to the members of object under construction, that already were initialized. An important thing to note is, that we can do that only via this pointer. Let’s look at what it means by looking at the example from the draft:

struct C;
void no_opt(C*);

struct C {
  int c;
  C() : c(0) { no_opt(this); }
};

const C cobj;

void no_opt(C* cptr) {
  int i = cobj.c * 100;         // value of cobj.c is unspecified
  cptr->c = 1;
  cout << cobj.c * 100          // value of cobj.c is unspecified
       << '\n';
}

in this example inside no_opt using object cobj by its name and not by its pointer reads unspecified values.

Of course, there is also a difference when invoking polymorphic functions and performing typeid and dynamic_cast on an object under construction and destruction.

Differences of typeid and dynamic_cast behaviors are already described in one of my previous posts: https://blog.panicsoftware.com/dynamic_cast-and-typeid-as-non-rtti-tools/

The difference in calling polymorphic functions is, that the most derived object is the base subobject, whose constructor is currently being invoked, this means, that polymorphic call to the function will be a call to the override of the function from the current class. For example:

struct Base{
  Base(){foo();}
  virtual void foo(){std::cout << "Base" << std::endl;}
};

struct Derived : Base{
  void foo() override {std::cout << "Derived" << std::endl;}
};

D d;

In this example, during the construction of d object the “Base” text is going to be printed on standard output. The practical reason for that is, that vptr, that points to vtable is going to be updated at the start of D() constructor, so after Base() constructor.

But there is a more interesting thing about that. It turns out, that you need to be super careful when calling polymorphic functions when an object is under construction or destruction. Let’s have a look at following multi-threaded code:

struct Derived : Base{
  Derived(std::atomic_bool& b){b.store(true);}
  void foo() override {std::cout << "Derived" << std::endl;}
};

struct Derived2 : Derived{
  Derived2(std::atomic_bool& b) : Derived(b){}
  void foo() override {};
}

//...
auto d = (Derived2*)operator new(sizeof(Derived2));
std::atomic_bool d_ctor_running{false};
auto t1 = std::thread([d, &d_ctor_running](){new(d) Derived2(d_ctor_running);});
auto t2 = std::thread([d, &d_ctor_running](){while(d_ctor_running.load()); d->foo();});
    
t1.join();
t2.join();

Even though, the code looks right, calling d->foo in other thread is undefined behavior. What’s the reason for such behavior?

Everything is caused by vptr access. Access to the vptr is unsynchronized, meaning, that simultaneous read and write to it will cause a data race (which is undefined behavior). Writes to the vptr happen at the beginning of the constructors and reads are performed every time we call polymorphic function. In this case, reading is performed by d->foo in the thread t2, and write is potentially performed at the beginning of the Derived2 constructor.

The exact same thing happens, whenever we try to synchronize something in the destructors. In the case of destructors write to the vptr happens at the end of the destructor. So calling virtual function and destructor at the same time possibly causes data race if synchronized improperly.

Converting pointer to the base class turns out to also be limited. We need to assure, whenever we are casting this pointer to the base class, that none of possible “paths” of casting includes an object, whose constructor didn’t start. For example:

struct A{
  A(A*){}
};

struct B : A{
  B() : A(this){}  
};

this example has undefined behavior, we are trying to cast to the base class, which construction didn’t yet start.

Similarly, if we try to create a pointer to the member, which constructor didn’t yet start, we get undefined behavior, for example:

struct C;

struct B{
  B(C*);
};

struct C{
  C(B*);
};

struct D{
  D() : 
    c(&b), // undefined behavior, b's constructor didn't yet start.
    b(&c){}
  C c; // constructed first
  B b;
};

The reason for such undefined behavior is that if we had a virtual inheritance, then casting to the base class needs to use vptr, in which case it won’t be created yet.

All the limitations listed so far do not apply to the objects in which constructor ended and which lifetime still holds.

Since we now understand what is an object and its lifetime, we can slowly proceed to common errors associated with those.

Common errors with objects and lifetimes

Whenever there is any attempt to perform type-punning (inspecting value representation of one object by an object of a different type) in the C++ language, most probably this is a UB.

Why does this happen? Most of the theory behind objects and their lifetime is there to make TBAA (Type Based Alias Analysis) possible. But what does it mean?

Speaking simply and shortly, C++ compilers are free to assume, that two objects of different types are different objects located under different addresses. Such an assumption makes it possible for the compiler to generate assembly with less reading instruction, which directly influences the performance of our applications.

Let’s have a look at the example of generated code:

struct S{
  int a;
};

int test(S& val1, S& val2){
  val1.a = 10;
  val2.a = 2;

  return val1.a+val2.a;
}
test(S&, S&):
  mov r3, #2
  mov r2, #10
  str r2, [r0]
  str r3, [r1]
  ldr r0, [r0]
  add r0, r0, r3
  bx lr

What happens in our ARM assembly is, that in first two lines we set values to our helper registers. Then at the addresses in register r0 and r1 lays the first and the second object. Since it’s possible to call the function test like test(s, s);, so that our function returns 4, after assigning values 2 and 10 to the first and the second objects, the program must re-read value under the first object. Since the value of the r0 register is our return value, addition is done on the first object, and temporary register holding value of the second object.

In this case, the compiler couldn’t perform TBAA optimizations, since types of arguments were the same, thus it was possible, that two arguments reference the same object.

Now it’s enough to just change the type of one of parameters to notice TBAA optimization:

struct S{
  int a;
};

struct T {
  int a;
};

int test(S& val1, T& val2){
  val1.a = 10;
  val2.a = 2;

  return val1.a+val2.a; 
}
test(S&, T&):
  mov r2, #10
  mov r3, #2
  str r2, [r0]
  str r3, [r1]
  mov r0, #12
  bx lr

Now, as you can see, a compiler could assume, that objects come from different places in the memory, and so it was no longer necessary for generated code to re-read the value of the first object, since according to the assumptions of the compiler objects were in a different place in memory. This means, that following call to the function will result in a wrong result value:

S s;
foo(s, reinterpret_cast<T&>(s);

the part, that we read a value of the object, that was never created leads to undefined behavior.

There are of course more examples of undefined behaviors in the C++ world. One of the examples is incorrect usage of a union to do type-punning:

struct rgba{
  uint8_t red;
  uint8_t green;
  uint8_t blue;
  uint8_t alpha;
};

union color{
  rgba color;
  uint32_t as_int;
};

color c = {255, 120, 0, 50};
display(c.as_int);

What is undefined behavior here? We are reading an inactive member of the union. What does inactive mean? An active member of a union is a member to which there was a recent assignment, or which was recently created inside the union. Or in simpler words, the object, that was created most recently in the union.

We created object c with the structure of type rgba. We usually do that, because it’s easier for us to imagine the color, when we separately assign basic colors, rather than we write whole magical integer value. Nonetheless, we almost always never can have two objects at the same storage (except for empty base optimizations and [no_unique_address] attribute since C++20).

Another popular attempt to perform some form of type-punning is reading some buffer, by “pretending”, that it has a different type. For example:

struct T{
  // ...
};

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);

  auto* element = reinterpret_cast<T*>(buff);
  return *element;
}

So we are reading the content of the stream, by filling buff with proper values. We might know, that content inside the stream could be represented with an object of type T. Once it’s filled we reinterpret_cast the pointer to the buff to the pointer to type T. That kind of thing works well in C language, but not in C++. And you already know, that the reason for UB here is reading an object, that never existed.

Once knowing what’s the issue you might be tempted to write process_element function in the following manner:

struct T{ // POD
  // ...
};

T process_element(Stream& s){
  alignas(T) char buff[sizeof(T)];
  read_stream(s, buff);

  T* element = new(buff) T;
  return *element;
}

Don’t do that. Now we created the missing T object in the buffer buff. So the missing object is no longer an issue. The issue is now the value of an object.

In the object’s theory, we can read, that one of the properties of an object is its value. The value is also a property of an object, also meaning, that the value of an object is valid as long as the object is valid (it is in its lifetime).

The second thing is, that placement new performed on the storage reuses the storage, because it creates a new object in place of the storage. As mentioned in the theory section. Re-usage of the storage ends a lifetime of an object, in this case, it also makes old value gone.

Since old value does not exist, and the constructor of the trivially constructed object doesn’t do anything the created object remains uninitialized. Uninitialized objects have indeterminate values and reading such values (except for some cases like reading such indeterminate values with std::byte, unsigned char and char) ends with undefined behavior.

Solutions to common errors

Last section we have seen popular kinds of errors. Let’s have a look on how to fix them.

Type punning on union

Let’s start with type punning using unions. The reason why unions are in the C++ standard is to be able to save memory, when we know, that object of only one of the set of types will be stored in a given region of memory.

The way to solve an issue from the example with a union is to create a proper abstraction over what we want to achieve. Just to remind ourselves, we wanted to be able to easily set values of each and every basic color and alpha channel to achieve the final color we want. We can as well create following structure, that allows us to do the same:

struct color{
  uint8_t red(){
    return as_int>>(8*3);
  }

  void red(const uint8_t value){
    const uint32_t red32 = static_cast<uint32_t>(red()) << (8*3);
    as_int ^= red32; // clear red byte in as_int
    as_int |= (static_cast<uint32_t>(value) << (8*3)); // set red byte to the red value
  }

  // other functions

  uint32_t as_int;
};

No we have got proper abstraction created over the low-level color manipulation. Let’s see what’s happening here.

We have created functions to set and read a value from the proper byte of the member. Bonus points for us, that doing everything on bit operations made our code correct regardless of the endianness of our architecture. That’s a big benefit.

The function for returning the value of the red byte is quite straightforward. We simply shift the value of an integer, so that the last byte becomes the first one (the assumption is made, that red byte is the last one inside the as_int member).

Function to set the value of red is a bit more complex though, but we will get through that. What we are doing here is (let’s assume, that current red value is 0xff, we want to change it to 0xaa, and the rest of the colors are 0xbb:

0xff // value returned from red()
0x000000ff // static_cast<uint32_t>
0xff000000 // << 8*3 // it's a value of red32 var

0xffbbbbbb // as_int
0xff000000 // red32
0x00bbbbbb // after xor ^ operation

0xaa // new value, we want red to be
0x000000aa // static_cast<uint32_t>
0xaa000000 // << 8*3

0x00bbbbbb // as int summed with
0xaa000000 // shifted and casted new red value

0xaabbbbbb // result

that’s quite a lot of operations, but finally, we managed to set the oldest byte to the desired value. You might fear, that so many operations are far from being optimal code. But the compiler will manage to handle that. All those operations are compiled into one assembly instruction (with -O2 optimizations):

 strb    r1, [r0, #3]

we can add similar functions for other base colors in color structure, but we will avoid that for brevity.

So not only we did avoid undefined behavior, but also we avoided the issue with the endianness of our architecture.

Reading from the stream

We also had an issue with reading from a stream. Namely, we had an issue with the following code:

struct T{
  // ...
};

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);

  auto* element = reinterpret_cast<T*>(buff);
  return *element;
}

and also with this one:

struct T{ // POD
  // ...
};

T process_element(Stream& s){
  alignas(T) char buff[sizeof(T)];
  read_stream(s, buff);

  T* element = new(buff) T;
  return *element;
}

The solution for that issue is there in the C++ language and is named trivially copyable types.

Assuming, that T is a trivially copyable data type, we have a benefit, that copy of the value of an object of that type is as simple as memcpy or memmove. This means, that we could do the following trick to give our program a defined behavior:

struct T{
  // ...
};
static_assert(std::is_trivially_copyable_v<T>);

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);

  T read_element;
  std::memcpy(&read_element, buff, sizeof(T));
  return read_element;
}

So what we changed here is, instead of doing reinterpret_cast, we created an uninitialized object of type T, we copied some data into that element, giving it a value we wanted to, and at last, we returned this read_element.

Since C++20, we got a function called std::bit_cast what does it do? Basically, it’s the same as what we did in the example above, but encapsulated in the function, which is constexpr. (neither memcpy nor memove is a constexpr function). That’s how usage of this function looks like:

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);
  return std::bit_cast<T>(buff);
}

In this way, our code gets a bit clearer on the intention. It’s also shorter.

C++ is of course full of options. There is also a possibility of doing this a bit differently:

T process_element(Stream& s){
  T element;
  read_stream(s, reinterpret_cast<unsigned char*>(&element));
  return element;
}

You might be surprised, that this is a valid C++ code. After all, the object of type unsigned char has never been created. And this is true. An object of this type was not created, but there is another C++ rule, that says, that we can inspect the value of storage of any type as long as it is accessed by any of:

  • char
  • unsigned char
  • std::byte (since C++17)

and our program won’t have undefined behavior. Compiler basically, when encounters pointer to any of the above types, or reference to those, cannot assume, that there actually is no other object in this storage.

std::launder and pointer values

If you were reading some C++ news, especially regarding C++17, you might have heard about std::launder. My personal opinion, is it’s difficult to understand what std::launder does. And unfortunately reading cppreference is of little help if you do not know pointers in C++ in very detail. Before we start explaining pointer values, let’s all agree, that we will be talking about relaxed pointer safety (model of pointers in C++ language, which is not designed to support garbage collector – yes garbage collector – and we do not need to worry about pointer reachability). Let’s now have a look at motivating example for introducing std::launder in C++17.

alignas(T) char buff[sizeof(T)];
new(buff) T;
T* ptr = reinterpret_cast<T*>(buff);
use_object_under(ptr);

surprise surprise, there is UB in this example as well. It’s not because of any of the rules we read so far. It’s because of how pointers in C++ work. Basically, the idea behind why it’s UB is, because ptr can have address, where the T object is created, but it might not point to this object.

It sounds strange indeed. So how those pointers even work in C++?

First of all, pointers can have one of the following set of values:

  • pointer to an object (it’s a set of values and representations)
  • pointer past an object ( it’s a set of values and representations)
  • invalid pointer (single value, multiple representations)
  • the null pointer (single value, one representation)

The difference between value, value representation and object representation is, that value is logical and abstract while it’s representations lays in machine space. We could say, that object representation is an implementation detail of value and value is just this part of object implementation, that contributes to the value. For example, when we have structure, then padding between members does not count as its value, but obviously contribute to its object representation, at the same time padding does not contribute to the value representation.

Pointer to an object is a set of values, because it can point to different objects (pointers then won’t be equal to each other) and addresses to most of the objects will be different, so their object representations will be different as well. The same story goes about pointer past an object.

Then we have got a null pointer. This is a single value and every null pointer is equal to another null pointer. They also have one object representation ( usually it’s 0 and since there is no padding it also has one value representation).

The most interesting case is probably with an invalid pointer, having one value and multiple value representations. The values may or may not compare equal to other invalid pointers, since comparison operator usually compares stored addresses. It’s very similar to the floating-point types – we can have one NaN value, but there are multiple value representations of that value.

And here magic comes in. Usually, value representation determines the value of an object. Meaning, that if you change the value, it’s value representation changes as well. Pointers are an exception to that rule! You can change the value without changing its value representation.

The value representations of pointers to objects and invalid pointers may overlap. This means, that whether or not the pointer is valid may only be known at compile time by the compiler. It’s also possible, that the compiler cannot deduce that from the context it compiles – then compiler needs to assume, that pointer has a pointer to object value.

Pointers are also trivial types. meaning, that their value can be copied by simple memcpy back and forth and its value will be preserved. Let’s have a look at some examples:

T t;
T* tptr = &t; // tptr value: pointer to t
X* xptr = reinterpret_cast<X*>(tptr); // xptr value: invalid ptr 
tptr = reinterpret_cast<T*>(xptr); // tptr value: pointer to t

the reason, why tptr at very end restores its original value is just because of pointers being trivial types. Xptr has invalid ptr value, but its value representation (of invalid ptr value) is the same as the value representation of tptr, therefore casting it back to tptr restores its original value (pointer to t).

Coming back to motivation example:

alignas(T) char buff[sizeof(T)];
new(buff) T; // buff object's lifetime ends
T* ptr = reinterpret_cast<T*>(buff); // invalid ptr cast to still invalid ptr (no "pointer to object" value was there first)
use_object_under(ptr); // UB

We have got a pointer buff, that has a pointer to char array value (pointer to actual object). After applying placement new, the lifetime of array ends, pointer buff will now have a value of the invalid pointer (with the same value representation as before). Now we know, that this invalid pointer has the same value representation as a pointer to object of type T, that we placement newed in the buffer, but reinterpret_cast will “not work“. For the reinterpret cast to work we first have to have a pointer to an object, that was cast to another type. Nothing like that happened in this case. This is where std::launder comes in.

The std::launder looks like:

template <class T>
constexpr T* launder(T* p) noexcept;

and it’s brief description is:

Obtains a pointer to the object located at the address represented by p.

cppreference

This means, that if we know that we have got a pointer with correct value representation (here meaning address) we can get a pointer with value “pointer to object” if and only if the object exists in the given address.

Fixing motivation example will be like:

alignas(T) char buff[sizeof(T)];
new(buff) T; // buff object's lifetime ends
T* ptr = reinterpret_cast<T*>(buff); // invalid ptr cast to still invalid ptr (no "pointer to object" value was there first)
use_object_under(std::launder(ptr)); // OK! value of ptr changed to pointer to object, without changing value representation

Before C++17 there was no way to avoid that kind of UBs, but fear not. Compiler vendors knew that fact, and were kind enough to not do any optimizations based on this UB, so in practice, before C++17 this UB works “as expected”.

There is one more case, where we can use std::launder – it’s something that I call – assigning unassignable.

Imagine type T, that is not assignable. At some point you might run into the following issue:

T a;
T b;

if(condition){
  // I want to assign b to a so badly:
  new(a) auto(b);
}

Now the question is if we can use the old name a to refer to newly created object. And the answer is: it depends…

Regarding rules we know so far, placement new creates a new object and ends the lifetime of an old one. This still holds true. Also the name a is a name that refers to the old object, so we might think we cannot use that name. That’s not entirely true, because of another rule, that helps us in this case:

If the newly created object is of the same type as old one, and is created in the storage associated with an old object, then all references and pointers to the old object are updated to point to new object if the type of objects doesn’t contain any non-static const member and any non-static references. In the case of C++20, the rule was updated, so that we can use the old name and old pointers/references even if the types have const members and references.

A new hope – implicit object creation

If you made it so far, congratulations! Your mind is probably broken now and you deserve a treat. That treat is an implicit object creation proposal, that is going to make it into C++20, which will make our lives easier.

Consider the C code:

struct T{};
//...
struct T*ptr = (struct T*)malloc(sizeof(struct T);

using ptr to get to the T object would be UB (no such object was created in allocated space).

The adoption of the proposal would give access to the object defined behavior. But let’s start with what is implicit object creation.

Implicit object creation is there already in C++ language for one case only: assignment to the union member, provided, that member is of trivially copyable type with defaulted, trivial assignment operator. Consider the following code:

struct T{
  int a;
  char b;
};

union U{
  T first;
  int second;
};

U u;
u.first = T{1,2}; // ?
u.second = 5;     // ?

This code has defined behavior, but if you think closely, there is one interesting thing going on there: you are doing assignments to the inactive members of union! How is that, that it has defined behavior? The answer is implicit object creation. Such an assignment implicitly creates an object that we are assigning to, just before the assignment, so that the program has defined behavior.

Implicit object creations will do the same for us, additionally, we won’t be forced to do the laundering, since it will also be done for us. In C++20 we can expect the following operations to do implicit object creations as well:

  • malloc-like functions
  • operator new
  • std::allocator<T>::allocate
  • std::memcpy and std::memove
  • creation of arrays of :
    • char
    • unsigned char
    • std::byte

In practice, this means, that we will almost never need to use the std::launder tool (we weren’t using it often anyways). The only case that left, where we still need to use std::launder tool is when we know there is a pointer of one type, and you know that under its address there, in fact, lives object of a different type.

Summary

If I want you to remember something from this post that would be:

  • Think in terms of objects, their values and type not objects and their memory
  • Don’t try type-punning in C++ -it’s likely it won’t end well
  • If you use object outside its lifetime be super careful about it

Bibliography and thank you!

I would love to say many many thanks to all the people on CppLang, that helped me to understand all the subtleties of the C++ standard wording regarding objects and lifetimes (if you are that person you know it’s about you ๐Ÿ˜‰ ). Also big thank you goes to the authors of implicit object creation proposals.

Bibliography


8 responses to “Objects, their lifetimes and pointers”

  1. This is wrong:

    And equally important is how we can destroy an object:

    * by calling a destructor

    You can’t destroy an object by calling its destructor. It’s the other way around. The destructor is called when object is destroyed. Calling the destructor just executes a function (which may delete some members, but will not destroy an object).
    Because of such misinformation you see people trying to free objects by calling “T.~T()” and wonder why they have memory leaks…

    • Hi, thank you for your comment ๐Ÿ™‚

      You are wrong, though.

      You are treating destruction and deallocation as the same thing, but they are not.

      Destructor, as the name suggests, destroys an object. But it doesn’t mean, that storage of that object is going to be released.
      You can have a look into this link: http://eel.is/c++draft/expr.prim.id.dtor to have a look into an example.
      Of course, t.~T() will not free the storage, but this is not what I was saying.

      Objects have their storage and that storage has its duration. Objects can be destroyed before storage ends its duration. Dynamic storage is released when proper deallocation function is called.

      Let me know, if that makes things clear ๐Ÿ™‚

  2. “since itโ€™s not mandatory (and not a UB) to not call the destructor”

    Did you mean “since itโ€™s not mandatory to call the destructor (and not a UB if destructor is not called)”?

  3. Great article Dawid,

    Reading through this and few articles about object lifetime and type aliasing rules for C++, it’s still quite confusing if certain code triggers UB or not.

    I’m wondering if following code triggers UB,

    struct T{
    int a;
    char b;
    };

    union U{
    T first;
    int second;
    };

    void read_and_do_something(int arg)
    {
    // reads value from arg and does some work.
    }

    U u;
    u.first = T{1, 2};
    read_and_do_something(u.second); /* Accessing inactive member of the union but T is trivial and first member of T is int, which should be the same as reading "u.first.a". I'm guessing this should be legal but is it? */

    • Great question.

      It is UB. The reason for that is, that `second` is not an active member of the union, even though both reads would read the same thing. But there is more strangeness in that.

      If your union was defined like:


      struct T{
      int a;
      char b;
      };

      struct S{
      int a;
      };

      union U{
      T a;
      S b;
      };

      You could do something like following:


      U u;
      u.a = T{1, 2};
      read_and_do_something(u.b.a);

      Description of that can be found here: http://eel.is/c++draft/class.union#2.note-1

      What’s more in your case, what you can do, is following:

      U u;
      u.first = T{1, 2};
      read_and_do_something(reinterpret_cast(u.first));

      The reason, that it’s allowed is the fact, that T and int are pointer-interconvertible:
      http://eel.is/c++draft/basic.compound#4.3 and pointer-interconvertible types have some superpowers (can be reinterpret-casted):

      If two objects are pointer-interconvertible, then they have the same address, and it is possible to obtain a pointer to one from a pointer to the other via a reinterpret_ยญcast.

      • Damn, that’s a hard pill to swallow.

        There’s pitfalls everywhere when communicating with legacy C-Api (sockaddr type punning, low level i/o, etc) with these aliasing rules that I need to take extreme care of when building an abstraction layer on top.

        Can’t wait for std::bit_cast and std::bless to end this nonsensical abomination already.

        Oh and keep up the good work. You and Baker’s article are the only materials that convinced me to bother with coroutines. Props to that.

Leave a Reply to [email protected] Cancel reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.