Object Lifetime
Last time we discussed Value Semantics. However, I missed one topic that is super important for a better understanding of basic building blocks of C++. Today, we are going to talk about an object. Without further ado, let’s dive deeper!
Object
What is an object? According to the C++ standard, part 3.9.8 under the name of [basic.types]
An object type is a (possibly cv-qualified) type that is not a function type, not a reference type, and not a void type.
Now is int i
an object? Yes.
Is void* p
an object? Still yes, because pointers are types themselves, they are not references.
As we said, references are not types, but what if we declare something like struct S{ int& ref;};
would that be an object type? Put your answers down below, I’d like to see your opinion on this. 🙂
Lifetime
When we talk about objects, the one idea that always follows is lifetime.
Lifetime is a period in which an object is fully initialized. It begins with
- storage with the proper alignment,
- obtaining size for type T, and
- if the object has non-trivial initialization, its initialization is complete.
The lifetime of an object of type T ends when:
-
T is a class type with a non-trivial destructor,
-
the destructor call starts, or
- the storage which the object occupies is reused or released
This is an extraction from [basic.life] paragraph of C++ standard. In easy words, complex objects live from the end of the constructor, until the beginning of a destructor, and the trivial objects, like int
live from their storage allocation, until the end of the scope. Seems pretty easy, right?
So, if you see something like:
auto& GetData(){
const int i = 5;
return i;
}
you’d probably say that there is something fishy about it, and you’d be correct. Although i
is returned from a function, notice the auto&
here. It says that we’re actually returning a reference to a local variable. After the function returns, the stack is cleaned. Hence, you invoke a good old UB.
But what if we change the function to return a string instead of an int?
auto* GetData(){
const char* i = "Hello";
return i;
}
Now we get a normal “Hello” string from a function. This is due to a simple rule of a language, that states that all strings are valid throughout the entire program because they have a static storage.
But let’s change the story again:
auto* GetData(){
const char[] i = "Hello";
return i;
}
Notice that I’ve changed only one character and suddenly everything breaks. The behavior is undefined, everything crashes and burns. What happened here? We actually initialized the array with a copy of static data and returned a pointer to it.
Strings live longer, than you think they will, except when they don’t – Jason Turner @ CppCon 2018
Complex objects and their lifetimes
As we already discussed, lifetime of a complex object begins with a constructor and ends with a destructor. Although there are objects that have implicit destructors and constructors, there still may be things that may surprise you.
Firstly, the lifetime begins with the ending of the constructor execution.
That means a lot. Let’s have a look at an example:
struct S {
S(){}
~S(){}
};
int main(){S s;}
I think everyone will agree that the call order is constructor, and then destructor. But what if I present exceptions to the equation? What if S()
may throw?
Well, the answer is: the destructor does not start! Why? Because the lifetime of an object did not start, since the constructor didn’t finish its execution.
Here lies a menace:
struct S {
S(int a){
a = new MyWidgetNothrow(a);
b = new MyWidgetThrows(a);
}
~S(){
delete a;
delete b;
}
MyWidget* a;
MyWidget* b;
};
int main(){S s;}
Are widgets deleted with the unwinding if the second object throws? No, the memory will leak. Hence, this is why the usage of new
operator is a bad code smell and we should avoid it whenever possible. Here is an example on Godbolt.
However, a good thing to mention is that the stack unwinder actually destroys all initialized objects from within the class on throw. So, the pointer being an object type is trivially destroyed, but not the memory behind it.
What can we do to make this code work? Delegating constructors comes into play!
struct S {
S() = default;
S(int a):S{} {
a = new MyWidgetNothrow;
b = new MyWidgetThrows;
}
~S(){
delete a;
delete b;
}
MyWidget* a = nullptr;
MyWidget* b = nullptr;
};
int main(){S s;}
Here, the delegation constructor finished its execution, hence the object is alive, and the destructor finishes successfully on throw. This information brings us to the point of smart pointers. Since in many cases you can’t have an additional constructor to delegate to, or even a constructor at all, you need smart pointers to have robust code and ensure correct ownership of the objects.
Conclusion
Today we have touched a very basic yet complex topic of object lifetimes. Of course, there is a lot more to say and we will continue basics in the next post tackling move semantics with lifetime implications and finishing with a sprinkle of ownership semantics of smart pointers.
I hope you enjoyed the read. There is a plan for a video series that tackles modern C++ 20/23 topics and use the concepts in practice. Stay tuned for the announcement. 🙂
Be careful when returning references and wrapper objects from a function, and use new
as rarely as possible! Enjoy object lifetime!
Sincerely,
Ilya “Agrael” Doroshenko
If you like this article and want to read similar material, consider subscribing via our RSS feed.
Subscribe to KDAB TV for similar informative short video content.
KDAB provides market leading software consulting and development services and training in Qt, C++ and 3D/OpenGL. Contact us.