C++ processes data of fundamental types (aka built-in types,
e.g., int, long double) or user-defined types (e.g., struct A,
class B, enum C, union D). The C++ standard describes:
when data is created and destroyed,
where (i.e., what part of memory, what data structure) data is located.
The C++ memory organization has to respect the basic requirements of an operating system, but the rest is up to C++.
When we run a program, it becomes a process of an operating system, and a task a processor is executing. A process manages its memory within the limits imposed by the operating system. An operating system offers a process two types of memory: read-only and read-write.
The read-only memory stores the code of the program (i.e., the processor instructions), and the const data known at compile time, e.g., string literals. This memory is shared by all processes of the same program, which can be a substantial saving for a large program run in a large number (e.g., a web server).
An unprivileged task (a privileged task is a kernel task, i.e., a task of an operating system) cannot do anything that could disturb the operating system or other processes. For instance, an unprivileged task cannot write to its read-only memory. Every process is an unprivileged task.
In the following example we try to write to const data that may be allocated in the read-only memory – please uncomment some lines. The code compiles, but is killed (on x86 under Linux and OpenBSD) by the operating system with the SIGSEGV (segment violation) signal. Let’s stress that we don’t code like this, it’s for experimentation only.
#include <iostream>
using namespace std;
const int test1 = 1;
int main()
{
// Is the global variable in the read-only memory?
// *const_cast<int *>(&test1) = 10;
// const_cast<int &>(test1) = 10;
// Is this local variable in the read-only memory?
const volatile int test2 = 2;
*const_cast<int *>(&test2) = 20;
cout << test2 << endl;
const_cast<int &>(test2) = 200;
cout << test2 << endl;
// Is this local static variable in the read-only memory?
static const int test3 = 3;
// *const_cast<int *>(&test3) = 30;
// const_cast<int &>(test3) = 30;
// "Hello!" is a string literal in the read-only memory.
// *static_cast<char *>("Hello!") = 'Y';
// A string literal is of type "const char *", and that's why we had
// to static-cast it to "char *". This would not compile:
// *"Hello!" = 'Y';
}
We can check the location of the variables with the command below. Notice the ‘r’ in the output for the read-only memory:
nm ./sigsegv | c++filt | grep test
All other data is located in the read-write memory, because it can be changed by a process. Every process has its own read-write memory, even when there are many processes of the same program.
C++ strives for time and memory performance, and that is reflected in the memory organization by, e.g., using pointers (C++ keeps close to hardware). Furthermore, C++ also strives for a flexible control over data management by, e.g., allowing a programmer to allocate data globally, statically, locally or dynamically. Finally, the C++ memory organization is also deterministic: we know exactly when and where the data are destroyed (so that they are destroyed as soon as no longer needed).
C++ is in stark contrast with other languages, such as Java or C#, where data management is simplified at the cost of degraded performance, and constrained management of data. In such languages, a prime example is the garbage collection which deteriorates performance. Some garbage collectors are even more inefficient, because they are nondeterministic, i.e., it is undefined when data are destroyed.
The C++ Standard Committee considered the garbage collection support, but dropped it for performance reasons. Nowadays, C++ requires no garbage collection since it offers advanced container and smart pointer support, which could be considered a form of garbage collection.
In the examples that follow we use structure A that lets us see when
an object is constructed, copied, accessed (by calling function
hello) and destroyed. Note that there is no default constructor,
and so nowhere in the following examples do we default-construct
objects of type A: an object is either directly initialized (with a
string) or copy constructed. Please note (take a look at a make file)
that file A.cpp is separately compiled, and then linked with the
examples.
File A.hpp:
#ifndef A_HPP
#define A_HPP
#include <string>
struct A
{
std::string m_name;
// For a direct-initialization with a string.
A(const std::string &);
// The copy constructor.
A(const A &);
void hello() const;
~A();
};
#endif // A_HPP
File A.cpp:
#include "A.hpp"
#include <iostream>
using namespace std;
A::A(const string &name): m_name(name)
{
cout << "ctor: " << m_name << endl;
}
A::A(const A &a): m_name(a.m_name + " copied")
{
cout << "copy-ctor: " << m_name << endl;
}
void A::hello() const
{
cout << "Hello from " << m_name << endl;
}
A::~A()
{
cout << "dtor: " << m_name << endl;
}
We need to mention here the move semantics. For now, it’s enough to say that moving sometimes replaces copying. A value is copied when copy-construcing (or copy-assigning to) some other (target) object by using the copy constructor (or the copy-assignment operator). A value is moved when move-construcing (or move-assigning to) some other (target) object by using the move constructor (or the move-assignment operator).
Below we deliberately don’t discuss moving, because what follows is
already a full mouth to swallow. We’ll look into moving in detail
later, in another lecture. For this reason we disable moving for type
A by declaring its copy constructor.
The read-write memory stores:
global and static data in a location of fixed size,
local data on a stack (more specifically, a stack per thread of the process),
dynamic data on the heap (aka the free-store).
Global data are initialized before entering the main function, and are available everywhere in the program:
#include <iostream>
// A table of characters initialized with a string literal.
char t[] = "Hello!";
int main()
{
t[0] = 'Y';
std::cout << t << std::endl;
}
Static data are initialized before their first use, and are local to a function (i.e., unavailable elsewhere):
#include "A.hpp"
#include <iostream>
void foo(bool flag)
{
std::cout << "foo\n";
if (flag)
static A a("static");
}
int main()
{
std::cout << "main\n";
foo(false);
foo(true);
foo(true);
}
In the example above remove static, and notice the changes in the
program output.
The global and static variables seem very similar in that they keep data between calls to a function. However, there are two reasons for using a static over a global variable:
a static variable is initialized only when needed (when we call a function), while a global variable is always initialized, which may be a performance hit if the variable is unused,
keep the code tidy and less error-prone.
Data local to a function or a block scope are created on the stack. The local data are automatically destroyed when they go out of scope. It’s not only a great property you can rely on to have your data destroyed, but also a necessity since the stack has to grow smaller when a scope ends.
Data created locally are destroyed in the reverse order of their creation, because the stack is a FILO (first in, last out) structure.
#include "A.hpp"
#include <iostream>
int main()
{
A a("a, function scope");
A b("b, function scope");
// Block scope.
{
A a("a, block scope");
A b("b, block scope");
}
std::cout << "Bye!" << std::endl;
}
Dynamic data are created on the heap, and should be managed by smart
pointers, which in turn use the low-level functionality of raw
pointers, most notably the new and delete operators.
Data created with the new operator has to be eventually destroyed by
the delete operator to avoid a memory leak. We cannot destroy the
same data twice, otherwise we get undefined behavior (e.g., a
segmentation fault, bugs).
A programmer should use the smart pointers, which is error-safe but hard to learn. In contrast, using raw pointers is error-prone (often resulting in vexing heisenbugs) but easy to learn. Since smart pointers are the C++11 functionality, modern code should rely on the smart pointers, while the legacy code used the raw pointers.
The following example uses the low-level new and delete operators,
which is not recommended, but suitable now to demonstrate the dynamic
allocation.
#include "A.hpp"
#include <iostream>
A * factory()
{
return new A("factory");
}
int main()
{
A *p = factory();
delete p;
std::cout << "Bye!" << std::endl;
}
The placement new operator creates an object (or a value of some
non-class type) “in place”, i.e., in the place pointed to with a
pointer that we pass in parentheses right after new. That version
of the operator does not allocate memory, so it has nothing to do with
the dynamic data. This operation is called emplacement.
#include "A.hpp"
#include <cassert>
A g("global1");
A &singleton()
{
static A a("singleton1");
return a;
}
int main()
{
g.~A();
new(&g) A("global2");
auto &s = singleton();
s.~A();
new(&s) A("singleton2");
A l("local1");
l.~A();
new(&l) A("local2");
int i = 0;
new(&i) int(1);
assert(i == 1);
new(&i) int();
assert(i == 0);
}
Allocation on the stack is the fastest: it’s only necessary to increase (or decrease, depending on the processor architecture) the stack pointer (aka the stack register) by the size of the memory needed.
A stack can be of fixed size or it can grow automatically: more memory can be allocated for the stack without the process requesting it explicitly, if an operating system can do this. If not, the process is killed with an error in the case of stack overflow.
The following code tests how big a stack is, and whether an operating system automatically allocates more memory for the stack. A function calls itself and prints the number of how many times the function was recursively called. If we see small numbers (below a million) when the process is terminated, the operating system does not automatically allocate more memory for the stack. If we see large numbers (above a million or far more), then the operating system most likely automatically allocates more memory for the stack.
#include <iostream>
using namespace std;
void
foo(long int x)
{
int y = x;
cout << y << endl;
foo(++y);
}
int main()
{
foo(0);
}
Allocation on the heap is slow, because it’s a complex data structure which not only allocates and deallocates memory of an arbitrary size, but also deals with defragmentation, and so several memory reads and writes are necessary for a single allocation.
An operating system allocates more memory for the heap, when the
process (i.e., the library which allocates memory) requests it. A
heap can grow to any size, only limited by an operating system. When
finally an operating system refuses to allocate more memory, the new
operator throws std::bad_alloc. Here’s an example:
#include <cassert>
#include <iostream>
int main()
{
for(unsigned x = 1; true; ++x)
{
// Allocate 1 GiB.
std::byte *p = new std::byte [1024 * 1024 * 1024];
assert(p);
std::cout << "Allocated " << x << "GiBs." << std::endl;
}
}
Data on the stack are packed together according to when the data was created, and so data that are related are close to each other. This is called data collocation. And collocation is good, because the data that a process (more specifically, some function of the process) needs at a given time is most likely already in the processor memory cache (which caches memory pages), speeding up the memory access manyfold.
Data allocated on the heap are less collocated (in comparison with the stack): they are more likely to be spread all over the heap memory, which slows down memory access, as quite likely the data is not in the processor memory cache.
A function accepts an argument by either value or reference. Also, a function returns its result by either value or reference. There are no other ways of accepting an argument or returning a value. A pointer can be accepted or returned either by value or by reference.
A function can have parameters, and then we call a function with arguments. A function parameter is local to a function body, like a local variable. A parameter has a type and a name given in the function declaration or definition. An argument is an expression that is part of a call expression.
A parameter is initialized with an argument. A result is initialized with the expression of the return statement.
If a function parameter is of a non-reference type (any type except a reference type), we say that a function accepts (or takes) an argument by value, or that we pass an argument to a function by value. In legacy C++, a non-reference parameter was initialized always by copying the argument value into the parameter. In modern C++, depending on the category of the argument, that copying might be gone, because of materialization.
If a function parameter is of a reference type, we say that a function accepts an argument by reference, or that we pass an argument to a function by reference. Initialization makes the parameter a name (an alias) for the argument data.
The example below shows how to accept an argument either by value or by reference.
#include "A.hpp"
void foo(A a)
{
a.hello();
}
void goo(const A &a)
{
a.hello();
}
int main()
{
foo(A("foo"));
goo(A("goo"));
}
If the return type is a non-reference type, we say that a function
returns the result by value. If the return type is a reference type,
we say that a function returns the result by reference. The reference
should be initialized with data that will exist when the function
returns (i.e., the data should outlive the function). For instance,
containers (e.g., std::vector) offer functions (e.g., operator[]
or front) that return references to dynamically-allocated data. As
a side note, returning is passing from a function.
The example below shows how to return a result either by value or by reference.
#include "A.hpp"
A foo()
{
A a("foo");
return a;
}
A & goo()
{
static A a("goo");
return a;
}
int main()
{
foo().hello();
goo().hello();
A f = foo();
f.hello();
A g = goo();
g.hello();
}
The call convention are the technical details on how exactly a function is called, which depend on the platform (the system architecture, the operating system, and the compiler). C++ does not specify a call convention (that’s the operating system business), but some C++ functionality (like the constructor elision) depends on a call convention. Also, C++ tries not to make guarantees that could get in the way of some specific call convention, because a compiler would have to work around the call convention to keep such guarantees: e.g., C++ does not guarantee the order in which arguments of a function call are evaluated, or the order in which temporary objects of a complex expression are destroyed.
A regular C++ programmer maybe doesn’t have to know such details, but knowing them is worthwhile not only to use C++ better, but also to understand that C++ is a part of computer systems that evolve. In that evolution, C++ must be binary compatible with C (so that C++ can call C functions, most notably of the operating system), yet able to implement new C++ functionality (like materialization).
There are many call conventions that depend mostly on the processor capabilities. Typically, a call convention requires that the caller of the function (i.e., the code that calls the function):
creates the function parameters on the stack,
allocates memory for the return value.
Parameters are created on the stack because a processor might be unable to store them (e.g., in registers). The caller allocates memory for the return value because it may not fit in a processor, yet it should be available to the caller once the function returns. Small data may be passed (to and from a function) in processor registers, e.g, an integer in EAX for x86, Linux, and GCC.
A modern call convention allows the place for the return value be allocated anywhere in memory (not only on the stack, but also on the heap, or in the memory for the global and static data), and passing to a function the address of the place, so that the function can create the return value at the destination pointed. For instance, for x86, Linux, and GCC, the address is passed to a function on the stack, while to a constructor in the RDI register.
The following example demonstrates that a result can be returned
anywhere: the function returns its value (an object) by creating it in
the place for the global variable a. We can see the returned value
is definitely not returned on the stack, because then variable a
would be copy-initialized from the value on the stack.
#include "A.hpp"
A foo()
{
return A("foo");
}
A a = foo();
int main()
{
}
As a corollary of these call conventions, let’s notice that a parameter and a local variable of a function seem similar, but they differ in what code controls (creates, initializes and destroys) them:
a parameter is controlled by the caller of the function,
a local variable is controlled by the function.
Bottom line: a parameter is out of control of the function.
In modern C++, returing by value is fast, does not impose any unnecessary overhead, and therefore is recommended. It’s not what it used to be in the deep past, before C++ was standardized. Back then returning by value usually copied the result twice, which was required by a legacy call convention. First, from a local variable of the function to an intermediate place (usually on the stack) for the return value. Second, from that intermediate place to the place on the stack for a temporary object. That temporary object was used next as the returned value to continue with the evaluation of the expression that called the function.
To understand how returning an object by value became efficient, and to be aware of exceptions, we need to understand the constructor elision and materialization.
For a historical review, the functionality that is now called elision was mentioned for copying in C++98, termed elision in C++03, and extended for moving in C++11. This functionality was commonly known as the return value optimization (RVO), but it wasn’t called like this in the standard. Around the time of C++11, the modern call convention started to spread that enabled (with the official support of an operating system, specifically the system compiler) efficient returning by value, and so this functionality gained in importance, but under the standardized term of elision.
The copy elision ([class.copy.elision]) elides the copy constructor, and not the direct-initialization constructors or assignment operators. It’s also known as the constructor elision. We distinguish between two versions of the elision: named and unnamed. The terms of the named and unnamed elision are not standard: the standard treats them just as elision, but we differentiate between them for better understanding, and to emphasise the transition from the unnamed elision to materialization.
Elision applies to returning by value. Whether the elision is named or unnamed depends on the return argument, i.e., the expression of the return statement. A single elision applies to a single return, but if there are multiple functions called in a chain, then the corresponding elisions “may be combined to eliminate multiple copies” (as the standard puts it), regardless of whether they are named, unnamed or both.
The following example demonstrates a use case for the named elision (that also used to be known as the named RVO): the return argument is a non-static local variable that is not a parameter. It is still elision according to the newest standard (C++26).
#include "A.hpp"
A f()
{
A a("f");
return a;
}
int main()
{
A a = f();
}
To see the legacy behavior (i.e., without the elision), compile the
above example with the compiler flag -fno-elide-constructors (modify
CXXFLAGS in GNUmakefile). Notice the differences at run-time, that
with constructor elision, objects are not copied unnecessarily.
There are two exceptions specific to the named elision. First, we return a function parameter, which was created by the caller, not the function, and so the function cannot create the parameter in the location for the return value:
#include "A.hpp"
A foo(A a)
{
return a;
}
int main()
{
A a = foo(A("main"));
}
Second, we return a global or static variable, which has to be available after the function returns, and so the function can only copy its value:
#include "A.hpp"
// Global data.
A a("global");
A foo()
{
// static A a("static");
return a;
}
int main()
{
foo();
}
Third, we return a value which has to be created (for a local variable) prior to deciding which value to return:
#include "A.hpp"
A foo(bool flag)
{
// This object is created here. We refer to it by name later.
A a("true");
// Named elision cannot be uses when returning the value of a.
return flag ? a : A("false");
}
int main()
{
foo(true);
foo(false);
}
The following example demonstrates a use case for the unnamed elision (that also used to be known as the unnamed RVO): the return argument is a temporary object.
#include "A.hpp"
A f()
{
return A("f");
}
int main()
{
A a = f();
}
There is another use case of the unnamed elision: initialization with a temporary object, because a constructor is a special function that returns by value the created object. The example below shows the variable initialization:
#pragma GCC diagnostic ignored "-Wvexing-parse"
#include "A.hpp"
int main()
{
// The equivalent ways of direct (with arguments) initialization.
A a("a");
A b{"b"};
A c = A("c");
A d = A{"d"};
A e(A{"e"});
A f{A("f")};
// Acceptable and interesting, but we don't code like that.
A g = A(A("e"));
A h = A{A{"f"}};
// That's a function declaration, though in the legacy C++ it used
// to mean the default initialization of object x.
A x();
// The equivalent ways of default initialization. To make it
// compile, add a default constructor to type A.
// A a;
// A b{};
// A c = A();
// A d = A{};
// A e(A{});
// A f{A()};
// Acceptable and interesting, but we don't code like that.
// A g = A(A());
// A h = A(A{});
// A i = A{A()};
// A j = A{A{}};
}
An example below shows the initialization of a function parameter with a temporary object, just like above a variable was initialized with a temporary object. The parameter is initialized (controlled) by the caller, and so the temporary object can be created in the parameter in accordance with the unnamed elision.
#include "A.hpp"
void f(A)
{
}
int main()
{
// Constructor elided for a parameter initialization.
f(A("Hello!"));
}
In the example below, those uses cases would be of the named and
unnamed elision if not for their return types: the functions return by
value the base objects (of type A) of the return argument (of type
B). Elision is unable to squeeze a value of derived type B into
the space for a value of base type A. The compiler has to resort to
copying.
#include "A.hpp"
#include <string>
struct B: A
{
B(const std::string &name): A(name)
{
}
};
A named()
{
B b("named");
return b;
}
A unnamed()
{
return B("unnamed");
}
int main()
{
named();
unnamed();
}
Even if constructors are elided, they must be defined because depending on some minor technical details, the standard allows a compiler not to elide constructors. In the example below, delete the copy constructor by removing the comment to see the compilation fail. Also, in the exceptional use cases discussed before, elision does not apply, and so constructors must be defined, because they are used.
struct X
{
X() = default;
// X(const X &) = delete;
};
X f()
{
X x;
return x; // for named elision
// return X(); // for unnamed elision
}
int main()
{
f();
}
In C++17, the use cases of the unnamed elision became the use cases of materialization. Materialization makes the unnamed elision mandatory, while before it was optional – that nifty wording, however, describes a new concept (of materialization) with the defunct terminology (of the unnamed elision) that is not even standard. Since C++17, elision includes only the use cases that we termed ‘named’.
The C++17 introduced a new take on the problem of returning by value: the copy constructor is not elided, it’s unneeded by design. Why should the return value be copied when it’s materialized? The returned value is never (no exceptions) copied, and so its type doesn’t need to have the copy constructor, as shown in the example below.
struct X
{
X() = default;
X(const X &) = delete;
};
X f()
{
return X();
}
int main()
{
X x = f();
}
Compile the above example with:
-std=c++14 to see the compilation fail: in C++14 it was a use case
of the unnamed elision,
-std=c++17 to see that -fno-elide-constructors has no effect: in
C++17 it is not an elision use case any longer.
Materialization generalizes creation: materialization is just creation of an object when it’s returned by value. A constructor creates an object and returns it by value too, so creation is materialization too, but limited to a single return.
So we have functions that return by value the results returned by value by functions called – in this call chain, we are interested in the return statement at the end of the chain, because it creates the object that then crosses the function boundaries without being copied. The function called last is a constructor, but constructors have no return statement, and so we are interested in the return expression that calls the constructor. That return statement creates an object with a new value, as opposed to the named elision that elides copying the value of an existing object.
The return expression creates an object with a new value by defining:
the type of data to create,
the arguments for the creation.
Let’s temporarily call such an expression a “recipe” for a value of a given type. The expression differs from a variable initilization in that we don’t give it a name. Here are some example recipes:
std::string(): type std::string, no arguments,
int() is really int(0): type int, argument 0,
std::string("Hello"): type std::string, argument "Hello",
1 is int(1): type int, argument 1.
When a recipe is a standalone expression, then its evaluation creates a temporary (a temporary value) that is next destroyed, and so it is a discarded-value expression. A temporary object is created on the stack. Here’s an example:
#pragma GCC diagnostic ignored "-Wunused-value"
#include "A.hpp"
int main()
{
int();
int(1);
A("main");
}
When a recipe is not standalone, then it’s an argument of a return statement or a call (to a function, a constructor or an operator). Prior to C++17, a recipe used as an argument created a temporary object (or just a temporary) that was used to initialize a return value or a parameter (of a function, a constructor or an operator). That temporary:
was destroyed before the evaluation of the complete (whole) expression was complete,
usually didn’t exist because its value was created directly in a destination (a return value or a parameter) in accordance with the unnamed elision.
Furthermore, a result returned by value had to be available to a caller, and so it was a temporary of that call expression. However, if that temporary wasn’t standalone, then it was usually elided (as described above). i.e., its value was created directly into the destination.
Bottom line: prior to C++17, a recipe always created a temporary object that usually was elided.
Since C++17 these recipes are called expressions of the prvalue category, prvalue expressions or just prvalues. We’ll look into the expression categories later, but we start refering to these expressions as prvalues. Along with the prvalues, there came a new semantics: evaluation of a prvalue materializes the result (the result object, the result value) directly into a destination, voiding the need for elision.
That destination doesn’t have to be a temporary, it can also be a variable. A temporary materialization is the materialization into a temporary, and not a materialization that is temporary. Here’s an example:
#include "A.hpp"
A f()
{
return A("f");
}
int main()
{
// A prvalue is materialized into a temporary.
f().hello();
// A prvalue is materialized into variable a.
A a = f();
// A prvalue is materialized into a temporary.
a = f();
}
In the above example, the last call to f creates a temporary that
becomes the argument (the source object) of the assignment expression.
This temporary can’t be elided (specifically, the copy-initialization
from the temporary to variable a can’t be elided), because a
already exists. We need to differentiate between initialization and
assignment.
Here’s a test implementation of the unnamed elision and the materialization. We are not returning anything, but pass a pointer to a location where we want to have the return value created.
#include "hack.hpp"
void f(A *p)
{
new (p) A("f");
}
int main()
{
A a;
f(&a);
}
The following code falls short of a test implementation of the named elision, because a copy constructor is called anyway.
#include "hack.hpp"
void f(A *p)
{
A a("f");
a.hello();
// The implementation of the return instruction.
new (p) A(a);
}
int main()
{
A a;
f(&a);
}
The following is a test implementation of the named elision: we create the return object, and then refer to it with a reference, like to a local variable.
#include "hack.hpp"
void f(A *p)
{
// Create the result object.
new (p) A("f");
A &a = *p;
a.hello();
}
int main()
{
A a;
f(&a);
}
Let’s keep in mind that the above implementations are supposed to help us understand how the elision and materialization work, but we don’t program like this: we don’t call a constructor again (as we do with the placement new) after an object has been created. We could legitimately call the constructor again, if the destructor was called first.
In the implementations above we include the hack.hpp file. It’s a
hack, a handy one. This file defines the same struct A as files
A.hpp and A.cpp, except for the default constructor that
hack.hpp requests to be default implemented.
In the implementation above, we default initialize the local variable,
and pretend it has never happened because it writes nothing to cout,
but we need that initialization to have the memory allocated and the
destructor called. We don’t call the destructor before the placement
new, even though we should, because that would obfuscate the output of
the program. We play this underhanded trickery just to get the output
right.
Lifetime and identity are two notions that in C++ became technical
terms because they are fundamental to how C++ processes data. A
lifetime of a datum is the period of runtime the datum exists.
The lifetime of some datum (a variable, a temporary) starts when it is
constructed, and ends when it is destroyed. The datum during its
lifetime has identity, i.e., it exists somewhere. We can take the
address of a datum in existance with the & operator.
The location of a datum (the place where the datum is, lives, resides) is usually RAM, but a compiler can also make it a processor register to boost performance. If so, what happens when we take addresses of data in registers? Then, it’s a problem of the compiler that has to work around our request for the address with less wiggle room for optimization.
Below there’s an example with integer variables. Their lifetimes
begin with their declaration statements that can also initialize them.
The initializing 1 has no identity: it’s not created anywhere and
then copy constructed into the variable. Instead, the value of 1
(which may be an operand of a processor instruction) is written into
the location of the variable – we could say that the value (1) was
materialized.
int main()
{
// Uninitialized.
int x;
// Default-initialized.
int y = {};
// Expression {} doesn't have identity.
// &{}; // Error.
// Initialized with 1.
int z = 1;
// Expression 1 doesn't have identity.
// &1; // Error.
}
Here’s the example with std::string variables. Since, they are
objects, they always have to be initialized. A compiler interprets
{} as string{}, which has no identity. The value of this
expression is not copy constructed or move constructed into the
variable. Instead, the value of string{} is created directly in the
location of the variable – we say that a value (string{}) was
materialized.
#include <string>
int main()
{
// Default-constucted.
std::string x;
// Equivalent to the above.
std::string y = {};
std::string z = std::string("Hello!");
// Expression string("Hello!") doesn't have identity.
// &string("Hello!"); // Error!
}
In the example above, even expression string("Hello!") has no
identity! This is surprising because "Hello!" surely has to be
somewhere in memory, i.e., it must have an identity. Well, a string
literal is a singular datum: it is never created nor destroyed, and it
always exists. Yes, a string literal has an identity, but
string("Hello!") still doesn’t.
In deep C++ past, a temporary was destroyed in the scope it was created in. With RVO, elision and materialization that changed: a temporary (and even a local variable) can be created in a function, and then destroyed in the calling code. Prior to C++17, we can ask the compiler not to elide constructors to see that temporary objects are destroyed in the scope they are created in.
As a drill, we study the examples below and analyze them to understand whether the copy constructors are called or not, or whether they are elided. These examples are simple and minimalistic, and they cover almost all use cases of daily programming. To see the differences, we compile the examples the usual way (with at least C++17), and then with C++11, asking the compiler to elide constructors or not.
I tried to compile with C++03 to see the copying instead of moving. However, the move semantics (of C++11) was still used. Also, I couldn’t get the behavior of the legacy call convention (where a temporary for a return value is always created), which is not surprising, because I’m using a modern system that offers the modern call convention only. I tried the same with an online compiler with the same effect. It’s legacy, so we shouldn’t be bothered. In a few years, we can expect to have problems replicating the C++11 behavior. If we really wanted to see the C++03 behavior, we should use some really old system, e.g., Debian 4.
We include the hack.hpp file in the examples below, so that we can
use an online compiler (e.g., Compiler Explorer) to quickly compile
the examples with various compiler versions and compilation flags:
there we replace #include "hack.hpp" with the contents of this file,
without having to use another compilation unit (A.cpp). Let’s recall
that this file defines type A with the move semantics disabled, and
so no moving takes place, only copying.
We also include the frame.hpp file to use the frame struct. We
create local variable x of this type at the beginning of a function
for two reasons. First, to see when a function starts execution.
Second, to see whether the return statement calls the copy
constructor: if it does, we should see its output right before the
output of the destructor of local variable x. Local variable x is
destroyed only after the return expression has been evaluated.
The result is returned by value twice, no copy constructor is called.
The lifetime of the object starts in function g, and ends in the
main function. What? A temporary object is not destroyed in the
same scope it was created in? Since C++17, it’s not a temporary, but
a prvalue. Asking a compiler to elide constructors makes no
difference.
In C++11, a temporary is created in function g as an argument of the
return statement. That first temporary is used to copy-initialize the
result of function g, i.e., the second temporary object that becomes
the return argument of function f. That second temporary is used to
copy-initialize the result of function f, i.e., the third temporary
object that becomes the argument of the copy-initialization of
variable a. These constructors are elided, but you can ask the
compiler not to do it.
#include "frame.hpp"
#include "hack.hpp"
A g()
{
frame x(__PRETTY_FUNCTION__);
return A("g");
}
A f()
{
frame x(__PRETTY_FUNCTION__);
return g();
}
int main()
{
frame x(__PRETTY_FUNCTION__);
A a = f();
}
With this example we can see the difference between elision and
materialization. Compile the example with C++17, and ask the compiler
not to elide constructors. We see that the named elision goes only
one level deep: the value of the local variable of function g is
copied to the return value of function g, i.e., the copy constructor
is called right before function g returns.
Next, the return value of g is a prvalue that is materialized in the
destination, i.e. the local variable of the main function. It’s the
materialization that provides the address of the destination to
function g, and goes two levels: from f to main, and from the
return value of f to the local variable of main.
When the copy constructor is elided (i.e., the named elision takes
place), function ‘g’ creates the variable a in the destination
pointed to by the caller. Variable a, even though local, is not
created on the stack, and is not destroyed before the function
returns.
#include "frame.hpp"
#include "hack.hpp"
A g()
{
frame x(__PRETTY_FUNCTION__);
A a("g");
a.hello();
return a;
}
A f()
{
frame x(__PRETTY_FUNCTION__);
return g();
}
int main()
{
frame x(__PRETTY_FUNCTION__);
A a = f();
}
In this example, function f doesn’t return right away the result of
g as in the example above, but initializes local variable a with
it, and then returns a. The output of this example is the same as
of the above, because no copy constructor is called.
The prvalue returned by g is materialized into the local variable of
function f. The result of function f is a prvalue that is
materialized from the local variable of function f and into the
local variable of function main by calling the copy constructor,
where the source object is the local variable of function f and the
target object is the local variable of function main.
Function f is in full control of its local variable, and so it can
elide the copy initialization by passing the address for its return
value (passed by the main function) to function g. Ask your
compiler to use C++17 and not to elide constructors to see the copy
constructor called once.
For C++11, the output is the same because of the elision, i.e., no copy constructor called. There is no materialization yet, but there is unnamed elision. Ask your compiler to use C++11 and not to elide constructors to see the copy constructor called four times. Each function call (and there are two) results in the copy constructor called twice: first the value of a return expression is copied into a temporary of the call expression, and next to a local variable.
#include "frame.hpp"
#include "hack.hpp"
A g()
{
frame x(__PRETTY_FUNCTION__);
return A("g");
}
A f()
{
frame x(__PRETTY_FUNCTION__);
A a = g();
a.hello();
return a;
}
int main()
{
frame x(__PRETTY_FUNCTION__);
A a = f();
}
In this example, like in the examples above, function g is called
first, then function f returns its result to function main. Here,
however, function f returns the value of its parameter (which is
missing in examples above). The output shows a copy constructor
called. No way, no how could this constructor be elided, because it’s
an exception we already know.
The main function:
allocates the memory for the parameter of function f and passes
its address to function g as the address for the return value, so
that function g can materialize its prvalue (i.e., the result)
there, thus initializing the parameter of function f,
passes the address of its local variable to function f, so that
function f can materialize its result (which is returned by
value, and so it’s a prvalue) by copy-constructing from its
parameter.
Ask your compiler to use C++17 and not to elide constructors to see no difference in output.
In C++11, the output is the same (one copy-constructor called), as the unnamed elision is used three times:
twice when function g returns a temporary whose value is created
in the parameter of function f, because the following
copy-initializations are elided:
first, from a temporary object of function g to the result
object (a temporary object of the call expression to g),
second, from that result object to the parameter of function f,
once when function f returns its parameter, because the copy
initialization:
from the parameter to the result object cannot be elided,
from the result object to the local variable of the main function is elided.
Ask your compiler to use C++11 and not to elide constructors to see the copy constructor called four times.
#include "frame.hpp"
#include "hack.hpp"
A g()
{
frame x(__PRETTY_FUNCTION__);
return A("g");
}
A f(A a)
{
frame x(__PRETTY_FUNCTION__);
return a;
}
int main()
{
frame x(__PRETTY_FUNCTION__);
A a = f(g());
}
In the example below, the prvalue argument of the constructor is materialized into the parameter of the constructor, and then its value is copied into the member field. In C++17, asking a compiler not to elide constructors has no effect, because elision is not used. In C++11 without elision, a temporary copy-initializes the constructor parameter.
#include "hack.hpp"
struct X
{
A m_a;
X(A a): m_a(a)
{
}
};
int main()
{
X(A("main"));
}
Data can be allocated globally, statically, locally or dynamically.
Allocating memory on the stack is ultra fast, while on the heap is much slower.
Don’t use the dynamically-allocated data, if local data are good enough.
Nowadays passing parameters or returning results by value is efficient, because the value is not copied (nor moved) unnecessarily.
Is memory allocation on the stack faster than on the heap?
Where can we allocate objects?
What is the return value optimization?
Partly funded under the Regional Excellence Initiative Program of the Minister of Science and Higher Education, project number 020/RID/2018/19, 2019 - 2022.