paint-brush
Temporary Lifetime Extension: Complicated Casesby@bohdanlab
1,055 reads
1,055 reads

Temporary Lifetime Extension: Complicated Cases

by Bohdan LakatoshJune 14th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This article shows us two, at the first glance, distinct examples of c++ temporary lifetime extension (TLE) feature behavior. The one where we extend temporary lifetime through polymorphic reference conversion and the one where we do it through bounding a field of a temporary directly to a reference. However, if you look deeper you will see that in some context these two examples are similar. And therefore the feature behavior in both cases should be the same. The consistency is an important aspect of any design which helps users use the feature and temporary lifetime extension can show it on its own example.
featured image - Temporary Lifetime Extension: Complicated Cases
Bohdan Lakatosh HackerNoon profile picture

What Is a Temporary Lifetime Extension?

Temporary lifetime extension (TLE) is a C++ language feature that allows you to extend the lifetime of a temporary if it is bound to any but not non-const lvalue reference. Specifically, the temporary value will live for the lifetime of the reference it is bound to. The basic example of utilizing this feature will be the following.

#include <utility>

using T = std::pair<int, int>;

T func(int x) { return T{x, x}; }

int main() {
   const T& myVar = func(7); // temporary object created by func lives until 
   return myVar.first;       // the end of the scope
}  

I have already described some basics and motivations behind this feature in my previous article. There, I tried to stay practical and tried to answer the question “How to use the feature correctly?” Contrary, this article will be more theoretical. We will study a couple of sophisticated examples and try to understand the motivation behind them.


I believe it will help us better understand this particular feature and engineering in general. Since the TLE basics were already discussed in my previous article, let’s jump straight to the complicated stuff!

The Case With Inheritance

In my previous article, we looked at a case where we extend a temporary lifetime by binding it to a reference of type into which it is implicitly convertible. We saw that the TLE works only if the conversion produces a new object. In that article, we deliberately skipped the case, where we tried to extend a temporary lifetime of a derived class by binding it to a parent class reference. Namely this case:

struct P {
   virtual ~P() = default;
   virtual const int& getX() const = 0;
};

struct D : public P {
   explicit D(int t) : x{t} {}
   const int& getX() const override { return x; }
   int x{};
};

D func(int x) { return D{x}; }

int main() {
   const P& local = func(4);  // Binding a derived object to a parent reference
   return local.getX();
}

This case is also about conversion, and we do not create any new object during such conversion. Therefore, based on the previous article, we can assume that this case will end up with a dangling local reference. However, it does not. Polymorphic conversions do not block TLE, and the code above is correct.


It is even more than correct - this case has some nuances. Parent classes can have some virtual methods, and some of them can be abstract, as they are in our example. Since we plan to access all those virtual methods through parent class reference, we expect that polymorphism will work as usual.


Therefore, when we extend D object lifetime, we cannot perform any slicing. The compiler needs to store that D itself, and then provide P reference to a user. Only by storing D object with all its implementation details, we make local.getX() call in the example possible.


This storing happens behind the scenes, and we do not see it directly, but we can observe it through consequences.


The first consequence is that suddenly, the compiler has to store a “bigger” object than the binding reference type implies. I mean, in terms of memory layout, the derived class object encapsulates the parent class object. Thus, in this example, we bind part of an object's memory to a reference, and the compiler extends the life of the entire (bigger) logical chunk of the memory it belongs to.


The second consequence is that since, in this case, the compiler stores the returned object as a D object, it will destroy it as a D object too. When we reach the end of the bound reference scope, even though the bound reference type is P, the compiler will invoke ~D(). This means that effectively, in our example, we get polymorphism without a need to make the destructor of a parent class virtual. Let’s observe it.


Let’s add some logging around objects construction and destruction in the previous example and compare the logs with “usual” polymorphism.


#include <iostream>
#include <memory>

struct P {
    P(){std::cout <<"P()\n";}
    ~P() {std::cout << "~P()\n";}        // NOTE, destructor is not virtual!
    virtual const int& getX() const = 0;
};

struct D : public P {
    explicit D(int t) : x{t} {std::cout << "D(int)\n";}
    ~D(){std::cout << "~D()\n";}
    const int& getX() const override { return x; }
    int x{};
};

D func(int x) { return D{x}; }
std::unique_ptr<D> mem_func(int x){return std::make_unique<D>(x);}

int main() {
    {
        std::cout <<"=== TLE polymorphism ===\n";
        const P& local = func(4);
        std::cout << "polymorphic call returns " << local.getX() << '\n';
    }
    {
        std::cout << "=== Usual polymorphism ===\n";
        std::unique_ptr<P> mem_local = mem_func(5);
        std::cout << "polymorphic call returns " << mem_local->getX() << '\n';
    }
    return 0;
}


If we ignore all the warnings produced by the “Usual polymorphism” block of code and the fact that ~P() is not virtual, we will see the following output:

=== TLE polymorphism ===
P()
D(int)
polymorphic call returns 4
~D()
~P()
=== Usual polymorphism ===
P()
D(int)
polymorphic call returns 5
~P()


We can see that non-virtual ~P() breaks the destruction procedure in the “Usual polymorphism” case. When we destroy mem_local, the unique pointer destructor invokes ~P() and expects that it will be a virtual call which will lead to ~D() call.


If we make ~P() non-virtual, the unique pointer destructor just calls ~P(), and that is it. ~D() is not invoked at all, so the cleanup of the created object is not complete. Note, that according to C++ standards, it is an undefined behavior.


But, in the “TLE polymorphism” case, we see that both ~D() and ~P() are invoked. The created object destruction is executed completely, even though ~P() is not virtual. It happens because, when we destroy the derived object, we invoke ~D() directly and it then invokes ~P() (see the documentation for details on why).


It is an interesting nuance around the TLE feature. Its existence does not mean that everyone needs to use it. However, there are use cases for it too. This particular feature is used in a ScopeGuard implementation. This guard class implements a utility to defer some logical cleanup until the end of the scope, where it was initialized.


This class does not need any methods; its idea is in its destructor, and in the fact that it will be called in the correct moment. Thus, for this class, making a destructor call non-virtual is similar to removing runtime polymorphism completely. For more information about the implementation and TLE usage, see Andrei Alexandrescu and Petru Marginean's article.

The Peculiar Case

Here is another peculiar example of TLE.

#include <iostream>

struct P {
    explicit P(int l) : x{l} { std::cout << "P(int)\n"; }
    P(const P&) { std::cout << "const P&\n"; }
    P& operator=(const P&) {std::cout << "operator=\n"; return *this;}
    ~P() { std::cout << "~P()\n"; }

    int x;
};

struct S {
    S(int l, int r) : x{l}, y{r} {}

    P x;
    P y;
};

S calc(int x, int y) { return S{x + y, x * y}; }

int main() {
    const P& local = calc(4, 6).y; // Directly bind only part of the new object
    return local.x;
}

Here, we create a new object of the type S by calling calc but we bind only a field of that object (here y) to the reference. The question now is: will TLE work here or will we get a dangling local reference? It works! This code is correct and local is not a dangling reference.


In my previous article, we looked at similar examples and saw that TLE works only when we bind newly created standalone objects. And it does not work if we assign a reference to some field in that object to the target reference. This case feels like, here we also assign y field reference to local. Especially if we check what this code prints:

P(int)
P(int)
~P()
~P()

S contains two fields of type P, therefore, we see that we created two P objects and then destroy them. There is no copying or other ways to create new P objects are created. However, in this case, we do not assign a reference to the field to local. We bind local to the field directly.


And it is important. In this particular case, the compiler deduces that y is a part of S object, and it stores the entire object even though the user is bound only to they field of it.


This behavior is a consistent behavior. I talk about consistency here because the example above is effectively the same as the example with parent and derived class. Parent class data fields take only part of the memory space in the derived class object.


Thus, creating a new derived class object and binding it to a corresponding parent class reference is effectively the same as creating a new struct object and binding only one of its fields to a reference. If we extend the lifetime of the “bigger” derived object in the first case, why should we not extend the lifetime of the “bigger” entire struct object in the second case?

The Case to Impress Your Friends

In the end, I would like to leave you with a strange example that works because of what is described above. As I understand, the example was created by Nicolai Josuttis, but I found it here.


#include <iostream>
#include <string_view>
#include <cstring>

struct Example {
    char data[6] = "hello";
    std::string_view sv = data;
    ~Example() { strcpy(data, "bye"); }
};

int main() {
    auto&& local = Example().sv;  // Here we extend lifetime of entire Example
    std::cout << local << '\n';
}

This code works and in the end, prints hello. It is strange because on the one hand, sv depends on the data field lifetime. The data has to outlive sv, otherwise, the latter will contain a dangling pointer. On the other hand, the fact that we bind sv field to local reference extends the lifetime of the entire Example object. Thus, effectively sv extends the lifetime of the data because of TLE.


This code works, but it does not mean that it is a good code 🙂.

Conclusion

For me, this part of TLE implementation in C++ standard is a good example of how complicated a feature design is.


On one side, there is a bunch of stuff that was created before you and your solution needs to fit their behavior. In this story specifically, the authors tried to implement the TLE of a derived class object. And now, you need to care about invoking virtual methods through parent class reference only because TLE is abounding binding to a reference.


On the other side, there are a lot of other examples that, depending on the context, can become those “similar situations” where the feature behavior should be the same. Like the fact that in the case of TLE, binding a struct field to its reference is similar to binding a derived class to a parent class reference.


And finally, you need to make everyone happy without producing many “examples to impress your friends.”


What do we gain with this consistency of TLE behavior? The main benefit on the user side is the avoidance of additional rule exceptions. It may sound like not much, but specifically, the absence of rule exceptions gives us that feeling of “it just works as you expect.” One cannot expect much in a world full of exceptions. Also, I think that, for the same reason, the consistency of TLE behavior between the first two examples in this article simplifies the life of a compiler developer, but this, I do not know for sure.