>
>
>
Lifetime extension of temporary objects…

Guest
Articles: 23

Lifetime extension of temporary objects in C++: common recommendations and pitfalls

After reading this article, you will learn the following: ways to extend the lifetime of a temporary object in C++, various tips and tricks; pitfalls of the lifetime extension that a C++ programmer may face, and that I have already faced myself.

This article may be useful not only for beginners but also for advanced developers.

If interested, grab a cup of tea and let's go find out where the references are dangling.

We published and translated this article with the copyright holder's permission. The author is Evgeny Neruchek (cheshirwr@mail.ru). The article was originally published on Habr.

They're in the trees, Johnny!

To begin with, let's identify a range of possible problems with references:

  • Dangling references.
  • That's all.

You may enumerate much more problems with the references, but any other issues pale in comparison with dangling references. Dangling references are the most frequent of any production problems.

For those who do not know, a dangling reference is a reference to memory that contains an object which no longer exists. It happens, when the lifetime of an object expires before the lifetime of the reference that points to it expires. The reference becomes dangling when the compiler destroys the object (calls a destructor for the object and then releases the storage space allocated for this object), while the reference to this object still exists.

And if someone uses this dangling reference anything can happen. The C++ standard describes this 'anything' as undefined behavior or UB.

The behavior is called 'undefined' as to the C++ standard. It means that the C++ language cannot determine what exactly should happen during the program's execution. In fact, undefined behavior depends on the program and the environment in which it runs. Thus, UB depends on:

  • the algorithm;
  • the complier; operations supported by the complier;
  • the optimization level;
  • a standard library implementation;
  • OS.

And so on...

That's why we can only guess what would happen in any particular case (maybe nothing bad, or maybe your program would crash).

Although we still can theorize what will actually happen in case of UB, but let me draw an analogy — what's better: to investigate a murder or to prevent it? Since any program containing undefined behavior is a potential problem for a customer, it's also our pain in the neck. That's why I think avoiding and preventing undefined behavior is vital.

One of the reasons for dangling references is the peculiarities of the temporary objects' lifetime extension. You can extend the lifetime of a temporary object since C++03 (the extension is made through the const lvalue references). In C++11 the extension mechanism was modified (rvalue references were added):

If you receive a temporary object by const lvalue reference or rvalue reference, then its lifetime is extended to the lifetime of the reference.

So, the lifetime extension is only available for referenced temporary objects. And yet, if we add storing by value to the proposition above, we get the following results:

  • A const lvalue reference to the value of a temporary object.
  • An rvalue reference to the value of a temporary object.
  • Storing by value.

Although, from the C++ viewpoint, storing by value is not a specific mechanism for the lifetime extension, since the value of a temporary object must be copied or moved (the lifetime of the original temporary value is not extended). But let us go ahead:

  • Copy elision optimization leads to the externally similar effects, as when storing by reference.
  • Storing by value also has some details related to the lifetime extension through references.

We will take a closer look at each of these two options. But first let's inspect the Xray class. The Xray class enables us to track the order of constructors' and destructors' calls.

struct Xray
{
  Xray(std::string value)
    : mValue(std::move(value))
  {
      std::cout << "Xray ctor, value is " << mValue << std::endl;
  }
  
  Xray(Xray&& other)
    : mValue(std::move(other.mValue))
  {
      std::cout << "Xray&& ctor, value is " << mValue << std::endl;
  }
  
  Xray(const Xray& other)
    : mValue(other.mValue)
  {
      std::cout << "Xray const& ctor, value is " << mValue << std::endl;
  }
  
  ~Xray()
  {
      std::cout << "~Xray dtor, value is " << mValue << std::endl;
  }
  
  std::string mValue;
};

1. Ways to extend the lifetime and to store temporary objects

To warm up, let's start with the simplest option: when we don't store the value of a temporary object but only create it.

In this case, the compiler calls the destructor in the same line. Here's the example on godbolt:

void main()
{
  // Output: Xray ctor, value is 1
  // Output: Xray dtor, value is 1
  Xray{"1"};
  
  std::cout << "Wait a sec" << std::endl;
}

1.1 Const lvalue reference

The lvalue references are references of the Xray& or const Xray& types (but the reference doesn't have to always be of the Xray type, it can also be of the int type, for example). Lvalue reference always points out to named (or constant) values (we will discuss it in more detail in comparison to rvalue references).

It's easy to recognize a reference as an lvalue reference by its type (const T&, T&). Moreover, the values it refers to are on the left side of = when the variable is declared:

int i = 3;
int& iRef = i;

In this case, i refers to lvalue, that's why iRef is called lvalue reference.

Now let's take a look at the second option of creating a constant lvalue reference to a temporary object:

void main()
{
  // Output: Xray ctor, value is 1
  const Xray& xrayRef = Xray{"1"}; 
  
  // Output: xrayRef value is 1
  std::cout << "xrayRef value is " << xrayRef.mValue << std::endl;
  
} // Output: Xray dtor, value is 1

Everything is simple: we create a value of a temporary object through Xray{"1"}  and then store the const reference to this object in xrayRef. After that, a value of a temporary object gets destroyed when the main function is exited (after the program's execution flow encounters the end of the function body — the brace } ).

The following case is similar, but here the implicit type conversion from std::string to Xray occurs when creating a temporary object:

void main()
{
  // Output: Xray ctor, value is 1
  const Xray& xrayRef = std::string("1"); 
} // Output: Xray dtor, value is 1

The reason for the successful compilation is that the constructor, which takes the t std::string, is declared in Xray. All constructors support implicit conversion by default.

If necessary, we can block implicit type conversion by marking the Xray(const std::string&) constructor as explicit. Then we should call the Xray{std::string("1")} constructor explicitly.

The main advantage of this approach is that there are no extra copy constructor calls. But this approach is not the only one that has this advantage.

1.2 rvalue reference

The rvalue reference is a reference of the Xray&& type (but the reference doesn't have to always be the Xray type, it can also be the int type, for example). The rvalue reference points only at temporary objects. The rvalue is so called because it can be used only on the righthand side of an assignment.

int&& iRvalueRef = 3;

Here (int)3 refers to rvalue (the value of a temporary object), that's why iRvalueRef is called lvalue reference.

Difference between rvalue and lvalue

Let's look at the example below to make the differences clear:

int three = 3; 
int& threeLvalueRef = three;
int&& fourRvalueRef = 4;

In this case:

1. (int)3 and (int)4 are rvalue, temporary objects. These temporary objects don't have any names to be called.

2. int three is lvalue. The lvalue reference has the name 'three' by which it can be called.

3. int& threeLvalueRef is an lvalue reference. It references to lvalue three.

4. int&& fourRvalueRef is an rvalue reference. It references rvalue (int)4.

Read this article to learn more about reference types and their differences: Understanding lvalues, rvalues and their references [EN] (fluentcpp).

You can also recieve an rvalue reference by moving the object (if a move constructor is implemented in a class, as in Xray::Xray(Xray&&)). To do it, call the std::move function:

Xray xray = Xray{′123″};
Xray&& xrayRef = std::move(xray);

The std::move function does not make any magic, it only casts the type of the Xray parameter to the class type of rvalue reference — Xray&&. The std::move function thereby calls a constructor, whose parameter is Xray&&. This constructor is called the move constructor. Then developers need to consider the logic of the move constructor: which class fields are to be moved and in what way.

There is a reason for that. There are heavy types whose values are better to move than to copy.

Example: copying the value of the Xray::mValue string is not always the best option, since copying requires:

  • Memory allocation for mValue.size() bytes.
  • Bitwise copying.

Such copying can take a long time, since the string may easily be of 10,000 characters long (or more). That's why moving the string value is much faster than copying it. Thus, the data pointer (const char*), that is under the hood of std::string, is passed to another std::string class, and no additional memory allocation or copying is performed.

Here's the simplified implementation of the std::move for lvalue references:

template<typename T>
T&& move(T& value) 
{
return (T&&)value;
}

The rvalue reference is also used to point out at the value of a temporary object without any additional copy or move constructors being called.

void main()
{
  // Output: Xray ctor, value is 1
  Xray&& xrayRef = Xray{"1"};
} // Output: Xray dtor, value is 1

1.3 Storing by value

Storing by value looks as follows:

void main()
{
  // Output: Xray ctor, value is 1
  Xray xray = Xray{"1"}; 
} // Output: Xray dtor, value is 1

"Won't the copy constructor be called?" — you may wonder looking at the case above.

But the copy elision optimization prevents redundant copying, so the copy/move constructor is not called. And moreover, even in this case only 1 constructor (which creates the Xray object) is called:

void main()
{
  // Output: Xray ctor, value is 1
  Xray xray = Xray{Xray{Xray{"1"}}}; 
} // Output: Xray dtor, value is 1

Since C++98, the copy elision optimization was introduced, but not all compliers supported it. Since C++17, all compilers are required to support the copy elision optimization.

2. Type deduction

In C++ type deduction refers to the automatic or half-automatic detection of the type of a variable. In this case, the compiler is able to deduce the type through the initializer (of the assigned expression).

The type-deduction mechanism determines only the way in which the type is deduced — as a reference or as a non-reference type. So, we still end up with one of the options from the first paragraph as a result of the type deduction: either passing by reference, or passing by value. We're going to briefly discuss the ways of the type deduction to get the full picture.

I used cppinsights to check what type the complier deduced.

2.1 auto

Since C++11, we're able to use the auto keyword to deduce the type of a variable through its initializer. If we use the auto keyword without any additional qualifiers and & (as is) during the type deduction of the variable type, then a reference declaration and the const and volatile type qualifiers are ignored. In this case storing by value occurs. Check the type deduction on cppinsights:

void main()
{
  // Output: Xray ctor, value is 1
  auto xray = Xray{"1"}; // type = Xray
} // Output: Xray dtor, value is 1

Check the type deduction on cppinsights:

int i = 0;
int& iRef = i;
const int& iConstRef = i;
volatile int& iVolatileRef = i;
const volatile int& iCVRef = i;
int* iPtr = &i;

auto _1 = i;            // type = int
auto _2 = iRef;         // type = int
auto _3 = iConstRef;    // type = int
auto _4 = iVolatileRef; // type = int
auto _5 = iCVRef;       // type = int
auto _6 = iPtr;         // type = int*

To add qualifiers and a reference declaration (or to save them during the type deduction), you need to specify them next to the auto keyword. When qualifiers and reference types are added, the lifetime is extended through a const lvalue reference (cppinsights):

void main()
{
  // Output: Xray ctor, value is 1
  const auto& xray = Xray{"1"}; // type = const Xray&
} // Output: Xray dtor, value is 1

You can also specify auto&& so the type deduction mechanism would be quite similar to perfect forwarding. When you use auto&&, the lifetime is extended through an rvalue reference (cppinsights):

void main()
{
  // Output: Xray ctor, value is 1
  auto&& xray = Xray{"1"}; // type = Xray&&
} // Output: Xray dtor, value is 1

Learn more about perfect forwarding in the 2.4 template paragraph.

Check the type deduction on cppinsights:

int i = 0;
const int& iConstRef = i;

auto&& _ = i;           // type = int&
auto&& _1 = iConstRef;  // type = const int&
auto&& _2 = 4;          // type = int&&

2.2 decltype

Since C++11 with the introduction of the decltype keyword, we're able to obtain the type of an expression at compile time. The examples of the type deduction with the decltype keyword on cppinsights:

int i = 0;
const int& iConstRef = i;
int&& iRvalueRef = 1;

decltype(i) _1 = i;                              // type = int
decltype(iConstRef ) _2 = iConstRef;             // type = const int&
decltype(iRvalueRef) _3 = std::move(iRvalueRef); // type = int&&
decltype(3) _4 = 3;                              // type = int

So, if you use the decltype keyword, then storing by value occurs:

void main()
{
  // Output: Xray ctor, value is 1
  decltype(Xray{"1"}) xray = Xray{"1"}; // type = Xray
} // Output: Xray dtor, value is 1

If you need to deduce the type of an object that does not have the required constructor (in particular, the default constructor), then you can use std::declval:

decltype(std::declval<Xray>()) xray = Xray{"1"}; // type = Xray&&

The type deduction at compile time means that an expression passed to decltype is processed not during program execution, but during compilation. The compiler only checks which type is the result of the expression and then substitutes it.

Example: in the decltype(2+2) expression, the result of the addition (2+2) is not processed, since the compiler sees this expression only in terms of types: (int)+(int), so the result is int.

You may have noticed that using decltype in this way is not very convenient:

1. You get a lot of extra information that completely or partially duplicates the expression being assigned.

2. Once in a while you have to use various workarounds (such as std::declval) to deduce the type.

That's why decltype was modified into decltype(auto) in the next standard.

2.3 decltype(auto)

Since C++14, we're able to pass the auto keyword to decltype as a parameter. With decltype(auto) we can deduce the same type as the one of the expressions assigned, thus the qualifiers and a reference declaration are saved.

The examples of the type deduction with decltype(auto) on cppinsights:

int i = 2;
const int& iConstRef = 0;

decltype(auto) _1 = 1;            // type = int
decltype(auto) _2 = iConstRef;    // type = const int&
decltype(auto) _3 = std::move(i); // type = int&&

Thus, if you use decltype(auto), then storing by value occurs (cppinsights):

void main()
{
  // Output: Xray ctor, value is 1
  decltype(auto) xray = Xray{"1"}; // type = Xray
} // Output: Xray dtor, value is 1

2.4 template

The template type deduction is pretty similar to the auto type deduction.

If we specify the template type T (from template<typename T>) without any additional qualifiers and & (as is), during the template argument deduction in a function template, then a reference declaration and the const and volatile qualifiers are ignored. In this case storing by value occurs (on cppinsights you can see all the types instantiated with a template):

template<typename T> 
void foo(T param)
{}

void main()
{
  // Output: Xray ctor, value is 1
  foo(Xray{"1"}); // type = Xray
} // Output: Xray dtor, value is 1

Check the type deduction on cppinsights:

template<typename T> 
void foo(T param)
{}

int i = 0;
int& iRef = i;
const int& iConstRef = i;
volatile int& iVolatileRef = i;
const volatile int& iCVRef = i;
int* iPtr = &i;

foo(i);            // type = int
foo(iRef);         // type = int
foo(iConstRef);    // type = int
foo(iVolatileRef); // type = int
foo(iCVRef);       // type = int
foo(iPtr);         // type = int*

To add qualifiers and a reference declaration (or to save them during the type deduction), you need to specify them next to the name of a template parameter.

In this case, the lifetime is extended via a const lvalue reference (cppinsights):

template<typename T> 
void foo(const T& param)
{}

void main()
{
  // Output: Xray ctor, value is 1
  foo(Xray{"1"}); // type = const Xray&
} // Output: Xray dtor, value is 1

You can also specify T&& (forwarding reference) so the type deduction and the value passing mechanisms would be quite similar to perfect forwarding.

In this case, the lifetime is extended via an rvalue reference (cppinsights):

template<typename T> 
void foo(T&& param)
{}
void main()
{
  // Output: Xray ctor, value is 1
  foo(Xray{"1"}); // type = Xray&&
} // Output: Xray dtor, value is 1

Check the type deduction on cppinsights:

template<typename T> 
void foo(T&& param)
{}

int i = 0;
const int& iConstRef = i;

foo(i);            // type = int&
foo(4);            // type = int&&
foo(std::move(i)); // type = int&&
foo(iConstRef);    // type = const int&

Learn more about various templates in other articles:

3. Tips and tricks

3.1 Before referencing another reference, make sure that this 'another' reference doesn't point out at a temporary object

When you return a reference from a function you don't extend the reference's lifetime. This is a typical problem for beginners — they return a reference to a temporary value from a function. Check the type deduction on cppinsights:

const Xray& foo() 
{ 
  return Xray(′′1′′); 
}

// All cases below are incorrect. Lifetime is not extended.
const Xray& _1 = foo(); // Dangling reference

auto _2 = foo(); // the Xray type. Undefined value
                 // content in Xray::mValue.
const auto& _3 = foo(); // the const Xray& type, dangling reference
auto&& _4 = foo();      // the const Xray& type, dangling reference
  
decltype(auto) _5 = foo();  // the const Xray& type, dangling reference
decltype(foo()) _6 = foo(); // the const Xray& type, dangling reference

In this case the temporary object gets destroyed when the function exits, so the returning reference is dangling. As a result, the compiler issues a warning (run a program on godbolt):

warning: returning reference to local temporary object

But this warning is useless if you have thousands of them (it's easy to miss), or if you just don't read warnings. You can only be aware of this nuance and be attentive, or to use a sanitizer tool.

3.2 Do not use std::move when NRVO can be used

NRVO (named return value optimization) is the variant of copy elision that allows you to avoid copying/moving a named value that is returned from a function.

In this case, str is a named value (since it has a 'str' name). If the compiler is able to use NRVO, an object is placed directly into value and no additional copying or moving is needed:

std::string foo() 
{
    std::string str;
    // .. str change
    return str; 
}

std::string value = foo();

There is another complier optimization — RVO (return value optimization). RVO is a more simplified optimization that also allows to avoid creating a name for return values.

In the following case, std::string{"1"} is not a named value (because, unlike str in the case above, it doesn't have a name). If the cmpiler is able to use RVO, no copying or moving is performed, since an object is placed directly into value:

std::string foo() 
{
    return std::string{"1"}; 
}

std::string value = foo();

You can read this article to learn more about NRVO and RVO: RVO and NRVO in C++17 [RU] @BykoIanko.

To optimize performance, you may think of writing something like this:

std::string&& foo() 
{
    std::string str;
    // .. str change
    return std::move(str); 
}

In the above case, the returning reference is dangling because it points at the local object that gets destroyed when the function is exited. So, the compiler issues a warning which can easily be missed among other warnings (run a program on godbolt):

warning: returning address of local variable or temporary: str

You can return by value just in case you're worried that NRVO would not be performed (but it would, since all modern compilers are able to perform it):

std::string foo() 
{
    std::string str;
    // .. str change
    return std::move(str);
}

But it's always better to rely on NRVO and not use move.

3.3 Make sure that the value is not xvalue (Xray&&) before extending the lifetime of temporary objects

In C++03, the lifetime of temporary objects was extended through saving them by a constant reference. Since C++11, xvalues were introduced whose lifetime couldn't be extended. We're not going to analyze all value categories in detail. So, long story short, there are several value categories: prvalue (that was previously described as rvalue), lvalue and xvalue.

In this particular case we speak about xvalue. In overloads of the called functions, xvalues are quite similar to rvalues. Thus, xvalue is passed into the function as an rvalue reference T&& (if there's an overload of the called function).

Example: the move constructor is called, if the class has one (the copy constructor is called when an lvalue reference is passed):

Xray& lvalue();
Xray prvalue();
Xray&& xvalue();

Xray _1 = lvalue();  // Xray(Xray) copying
Xray _2 = prvalue(); // copy elision
Xray _3 = xvalue();  // Xray(Xray&&) moving

You cannot extend the lifetime of xvalue (the same is true for lvalue). The lifetime extension of xvalue results in dangling references:

Xray const& _1 = prvalue(); // the lifetime of a reference
Xray&& _2 = prvalue();      // the lifetime of a reference
    
Xray& _3 = lvalue();       // dangling reference, does not extend the lifetime
const Xray& _4 = lvalue(); // dangling reference, does not extend the lifetime
Xray&& _5 = xvalue();      // dangling reference, does not extend the lifetime
const Xray& _6 = xvalue(); // dangling reference, does not extend the lifetime

3.4 Always save the value of the RAII object created into the variable, or declare a reference to it

It would be better to pay special attention to the lifetime of the RAII object. For example, don't declare std::lock_guard as a temporary object, when you don't save a reference to it (or don't store the object by value). It leads to the early mutex release, which creates a race condition:

void main()
{
  // Do this
  std::lock_guard<std::mutex> lock{someMutex};
  
  /* Don't do that:
     std::lock_guard<std::mutex>{mutex};
     The complier destroys lock_guard before it leaves main,
 which creates race conditions (that we are trying to avoid with mutex)
  */ 

  // .. multi-threaded safe calls
  // .. changing mutex-protected values
}

You may have thought that the creation of a temporary object is canceled by the optimizer, but it is not. In this case, constructor and destructor have side effects. In some other cases, the optimizer may inline the constructor and destructor code, but the functionality of the program is the same.

3.5 Before referencing another reference, make sure that this 'another' reference doesn't point out at a temporary object

You can extend the lifetime of a temporary object once, when binding to a reference for the first time. When you do '&-points to>&-points to>temporary object', it does not extend the lifetime repeatedly, but it leads to a dangling reference and undefined behavior:

template<class T> 
const T& foo(const T& in) 
{ 
  return in; 
}

const Xray& ref1 = X(1); // True, the lifetime is extended.

Xray& ref2 = foo(X(2)); // False, the lifetime is not extended, 
                        // ref2 — dangling reference.
std::cout << ref2.mValue; // Undefined behavior

3.6 Do not extend the lifetime of a temporary object via the ternary operator ?:

If you save a reference to an expression obtained with a ternary operator, the lifetime of one of the temporary objects extends depending on the condition:

Xray&& rvalRef = cond 
                ? Xray{′′1′′}  // One of the temporary objects
                : Xray{"2"}; // will have a lifetime of rvalRef

const Xray& constLvalRef = cond 
                ? Xray{′′1′′}  // One of the temporary objects
                : Xray{"2"}; // will have a lifetime of constLvalRef

Since this mechanism is not so easy-to-use, I would not recommend you to use it in your code.

3.7 Do not use references in class fields (if they point to temporary objects especially) and do not use std::reference_wrapper to extend the lifetime of a temporary object

The first reason is that when you create references in class fields to objects whose lifetime is not controlled by the class, this is more likely to result in dangling references (compared to passing by value).

The second reason — this mechanism is unstable. Although the standard specifies that if a temporary object has a reference field initialized by another temporary object, then the lifetime extension is recursively applied to the initializer of this field:

struct X 
{ 
  const int& lvalRef; 
};

const X& lvalRef = X{1}; // Temporary objects X and (int)1
                         // will have a lifetime of lvalRef 
X&& rvalRef = X{1};      // Temporary objects X and (int)1
                         // will have a lifetime of rvalRef 

auto&& _1 = X{1};         // It's also ok, the type is Xray&&  
                          // (extension with an rvalue reference)
decltype(auto) _2 = X{1}; // It's also ok, the type is Xray 
                          // (storing by value)

But it seems like it works only if there is an aggregate-initialization (when Xray x{1}) is called), or if the compiler supports copy elision (when Xray x = Xray{1} is called). So, returning to our case, the constructor added to initialize the value is unstable on different compilers:

struct X 
{ 
  template<typename T>
  X(T&& l)
    : val(l)
  {}
  
const int& val;
};

const X& lvalRef = X{1}; // Dangling reference, the lvalRef.val value == 0
X&& rvalRef = X{1};      // Dangling reference, the rvalRef .val value == 0
auto&& _1 = X{1};        // Dangling reference, the _1 .val value == 0,
                         // the X&& type (has a lifetime of rvalue)
decltype(auto) _2 = X{1};  // X type (storing by value),
                           // On msvc(trunk) the _2 .val value == 1,
                           // but on gcc(trunk) it's dangling,  
                           // the _2 .val value == 0

The decltype(auto) _2 = X{1} part, which is compiled into X _2 = X{1}, and its results seem to be the most curious. The following description of the expressions with the lifetime extended is pretty much obscure:

the initializer expression is used to initialize the destination object

But I think that in case of decltype(auto) _2 =  Xray{1}, the lifetime is extended, since the temporary object is used in the initializer expression of the Xray field. So, I think that this 'non-extension' of the lifetime is a compiler bug.

That's why I wouldn't recommend you to reference temporary objects in class fields, because:

  • This mechanism depends on the compiler and may be unstable;
  • You can only guess the result, since the description of the mechanism is unclear.

3.8 When passing temporary objects to new, make sure that none of them are stored by reference

Temporary objects were passed as parameters when initializing to new. Thus, the lifetime of these objects lasts until the new call is executed. It means that storing references to temporary objects in class fields (that are created through new) results in dangling references:

struct S 
{ 
  int i; const std::pair<int,int>& pair; 
};

S a { 1, {2,3} };         // true, but it's unstable (see 3.7)
S* p = new S{ 1, {2,3} }; // false, p->pair — dangling reference

3.9 Do not extend the lifetime of a temporary array through a reference to its elements

You can extend the lifetime of a temporary array by referencing one of its elements. C++ has a special mechanism for this. But I would recommend you using it only in the disputes with your co-workers (and maybe in metaprogramming with the old C++ standards), since this mechanism is not so easy-to-use.

However, using this mechanism does not result in dangling references, and the lifetime of the temporary array is extended to the lifetime of the reference to its element:

int id = 0;
int&& a = int[2]{1, 2}[id];

Fortunately, the code in its current form is not compiled. You need to create an array in a less obvious way to make this code work (cppinsights):

template<typename T>
using dummy = T;

int main()
{
  int i = 1;
  const int& a = dummy<int[2]>{1, 2}[i]; // the const int& type
}

You can also extend the lifetime of a temporary object through an rvalue reference:

int&& a = dummy<int[3]>{1, 2, 3}[i];

However, the lifetime extension through an rvalue reference is sometimes unstable on different compilers:

But everything is ok on gcc (godbolt).

There is a compilation error on msvc: cannot convert from 'int' to 'int &&' (godbolt).

It's likely to be a msvc's bug. But we still can get around this bug, if we take the value via auto&& (godboltcppinsights):

auto&& a = dummy<int[3]>{1, 2, 3}[i]; // the int&& type

Conclusion

Saving the value by a constant reference is stressful for anyone who has ever faced a dangling reference. It's important to monitor an object's lifetime. It would be better not to put it on the compiler.

Please, have mercy on a developer who is going to read your code, they may not know all the shades of the object's lifetime extension. And if they do know something about it, they may not be able to keep an eye on everything at once. Even the most educated computer science expert may doubt when reading your code whether a reference is dangling or not.

It would be better to use most of these mechanisms only in theoretical research or to neutralize dangerous code.

Stay away from any over-thinking stuff and HolyHandGrenade. KISS.