cpp

Introduction

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:

The C++ memory organization has to respect the basic requirements of an operating system, but the rest is up to C++.

Basic requirements of an operating system

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 and 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 the read-only memory – please uncomment some lines. The code compiles, but is killed by the operating system with the SIGSEGV (segment violation) signal.

#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.

What is up to C++

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. For instance, such languages allow allocation of data on the heap only, which deteriorates performance and flexibility, but enables easy implementation of garbage collection. Some garbage collectors are even further 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.

Move semantics, mentioned in passing

We need to mention here the move semantics, something that we’ll look into later. For now, it’s enough to know 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).

In the examples that follow we use structure A that lets us see when an object is constructed, copied, moved, accessed (by calling function hello) and destroyed. Please note (take a look at the file GNUmakefile) that file A.cpp is separately compiled, and then linked with the examples.

Click here to see A.hpp and A.cpp

Data and their location

The read-write memory stores:

Global and static data

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 its 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:

Local data

Data local to a function or a block scope are created on the stack. The local data is automatically destroyed when it goes 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

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. In contrast, using raw pointers is error-prone (often resulting in vexing heisenbugs) but easy. Since smart pointers are the C++11 functionality, modern code uses the smart pointers, and the legacy code the raw pointers.

The following example uses the low-level new and delete operators, which is not recommended, but suitable 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;
}

Emplacement

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);
}

Local vs dynamic data

Allocation on the stack is the fastest: it’s only necessary to increase (or decrease, depending on the processor architecture) the stack pointer (a.k.a. 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 an 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.

Function calls

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 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 instruction.

Accepting arguments

If a function parameter is of a non-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++, that copying might be gone (because of the materialization) or replaced with moving.

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"));
}

Returning a result

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.

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();
}

Returning by value

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 always copied the result twice. First, from a local variable of the function to a temporary place on the stack for the return value. Second, from the temporary place to the destination, e.g., a variable that was initialized with the result.

To understand how returning by value became efficient, and to be aware of exceptions, we need to understand the call conventions, the constructor elision, and the materialization.

Function call convention

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.

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 evolotion, C++ must be binary compatible with C (so that C++ can call C functions, most notably of the operating system), yet capable to implement new C++ functionality (like the constructor elision).

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):

Parameters are created on the stack because a processor might be unable to store them (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 accepted or returned in processor registers, e.g, an integer in EAX for x86, Linux, and GCC.

We differentiate between two call conventions:

The legacy call convention required a function to return its result in a temporary place at the top of the stack, which was easy to locate with the stack register – that was an advantage. A disadvantage it was to copy the result from that temporary place to a destination, e.g., a variable that was initialized with the result.

The 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. We don’t need a temporary place anymore. For instance, for x86, Linux, and GCC, the address is passed to a function on the stack last, while to a constructor in the RDI register.

The following example demonstrates that a result can be returned anywhere (now that we have the modern call convention), and not only on the stack (as the legacy convention stipulated in the past): the function returns its value (an object) by creating it in the place for the global variable a (allowed by the modern call convention), without copy-initializing a from a temporary object on the stack (required by the legacy call convention).

#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 of a function and a local variable of a function look the same, but they differ in what code controls (creates, initializes and destroys) them:

Bottom line: a parameter is out of control of the function.

Copy/move elision

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, and not a compiler optimization) efficient returning by value, and so this functionality gained in importance, but under the standardized term of elision.

The copy/move elision ([class.copy.elision]) elides the copy and move constructors only, and no other constructors or assignment operators. It’s also known as the copy elision or the constructor elision. We distinguish between two versions of the elision: named and unnamed.

Named elision

The following example demonstrates a use case for the named elision (that also used to be known as the named RVO): returning a non-static local variable that is not a parameter, where the copy constructor was elided. It is still elision according to the newest standard (C++23).

#include "A.hpp"

A f()
{
  A a("f");
  return a;
}

int main()
{
  A a = f();
}

To see the legacy behviour, 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.

Even if constructors are elided, they must be defined because depending on the type of the returned value, 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 further below, elision does not apply.

Bottom line: elision is optional.

struct X
{
  X() = default;
  // X(const X &) = delete;
};

X f()
{
  X x;
  return x;
}

int main()
{
  X x = f();
}

Exceptions

Elision cannot always take place, because of technical reasons. First, because we return data, which has to be created prior to deciding which data exactly to return:

#include "A.hpp"

A foo(bool flag)
{
  // These objects have to be created, and since we don't know which
  // is going to be returned, both of them have to be created locally.
  A a("a"), b("b");

  // The returned value must be copied.
  return flag ? a : b;
}

int main()
{
  foo(true);
}

Second, because 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"));
}

Finally, because we return global or static data, which has to be available after the function returns, and so the function can only copy the value from the global or static data:

#include "A.hpp"

// Global data.
A a("global");

A foo()
{
  // static A a("static");
  return a;
}

int main()
{
  foo();
}

Unnamed elision

The following example demonstrates a use case for the unnamed elision (that also used to be known as the unnamed RVO): returning a temporary object, where the move (or copy) constructor is elided.

#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:

#include "A.hpp"

#pragma clang diagnostic ignored "-Wvexing-parse"

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{}};
}

Parameter initialization

An example below shows the initialization of a function parameter. 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 C++17, the unnamed elision became the materialization.

Materialization

Materialization is just creation of a datum according to an expression that defines:

Let’s temporarily call such an expression a “recipe” for a value of a given type. It differs from a variable initilization in that we don’t give it a name. Here are some example recipes:

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:

#include "A.hpp"

#pragma clang diagnostic ignored "-Wunused-value"

int main()
{
  int();
  int(1);
  A("main");
}

When a recipe is not standalone, then it’s an argument of a call to a function, a constructor or an operator. Prior to C++17, a recipe used as a call argument also created a temporary that was used to initialize a parameter of the call, regardless of whether the parameter was of a reference type or not. A temporary was destroyed before the evaluation of the complete (whole) expression was complete. When passing by value, however, that temporary didn’t exist because its value was created directly in a parameter in accordance with the unnamed elision.

Bottom line: prior to C++17, a recipe always created a temporary object, that could have been 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.

An evaluation of a prvalue materializes the result (the result object, the result value) directly into a destination, voiding the need for the 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();
}

Materialization is mandatory, while elision is not. The returned value is never (no exceptions) moved (or copied), and so its type doesn’t need to have the move (or copy) constructors, as shown in the example below.

struct X
{
  X() = default;
  X(const X &) = delete;
  X(X &&) = delete;
};

X f()
{
  return X();
}

int main()
{
  X x = f();
}

Compile the above example with:

Sample implementation

Here’s a sample implementation of the constructor elision and the materialization. We are not returning anything, but pass a pointer where we want to have the return value created. We need to call a destructor right after the variable initialization (so that we have the memory allocated, and so that destructor is called right before the main function returns), but a compiler doesn’t do it in its implementation.

#include "A.hpp"

void f(A *p)
{
  new (p) A("g");
}

int main()
{
  A a("main");
  a.~A();
  f(&a);
}

Lifetime and identity

Lifetime and identity are two notions that in C++ became technical terms because they are fundamental to how C++ processes data. A lifetime of 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 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.

More elaborate examples

As a drill, we study the examples below and analyze them to understand whether the copy or move constructors are called or not, or whether they are elided. These example are simple and minimalistic, and they cover almost all use cases of daily programming. To see the differences, we compile the examples with C++11 and C++17, and ask the compiler not to elide constructors.

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 behaviour of the legacy call convention, 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 bahaviour. If we really wanted to see the C++03 behaviour, we should use some really old system.

I used an online compiler (Compiler Explorer) to quickly compile the below examples with various compiler versions and compilation flags. For this reason, I created a separate file hack.hpp, so that in an online compiler you can replace #include "hack.hpp" with the contents of this file. It’s a hack, a handy one. This file defines the same struct A as files A.hpp and A.cpp. You can find it in ../lib.

Example 1

Returning a prvalue by value is a special use case in C++17, that of materialization: the prvalue is materialized directly in the destination, no copy or move constructor is needed. The result is returned by value twice, no copy or move 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 instruction. That first temporary is used to move-initialize the result of function g, i.e., the second temporary object that becomes the argument of the return instruction of function f. That second temporary is used to move-initialize the result of function f, i.e., the third temporary object that becomes the argument of the move-initialization of variable a. These move constructors are elided, but you can ask the compiler not to do it.

#include "hack.hpp"

A g()
{
  return A("Hello World!");
}

A f()
{
  return g();
}

int main()
{
  A a = f();
}

Example 2

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 or move constructor is called.

The prvalue returned by g is materialized into the local variable of function f. Function f returns a prvalue that is move-initialized (materialized) from the local variable and into the local variable of function main. Move-initialization is more efficient than copy-initialization because the local variable (that is the argument of the return instruction) will soon be destroyed – it’s called the implicit move (we’ll get to it). Function f is in full control of its local variable, and so it can elide the move initialization (i.e., calling the move constructor, where the source object is the local variable of function f, and the target object is the local variable of the main function) by passing the address for the return value (passed by the main function) to function g. Ask your compiler to use C++17 and not to elide constructors to see a move constructor called once.

For C++11, the output is the same because of the elision. 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 move constructor called four times. Each function call results in the move constructor called twice: first the value of a return expression is moved into a temporary of the call expression, and next to a local variable.

#include "hack.hpp"

A g()
{
  return A("Hello World!");
}

A f()
{
  A a = g();
  return a;
}

int main()
{
  A a = f();
}

Example 3

In this example, function f returns the value of its parameter. The main function passes the result of g to f. The output shows a move constructor was called. No way, no how could this constructor be elided.

The main function allocates 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 there. Next, the main function passes the address of its local variable to function f, so that function f can materialize its prvalue there by the move initialization (it’s also the implicit move) from its parameter. Ask your compiler to use C++17 and not to elide constructors to see no difference in output, because no elision was used.

In C++11, the output is the same because of the elision. Function g creates a temporary, but the move constructor is elided, so the result is created in the parameter of function f. Ask your compiler to use C++11 and not to elide constructors to see the move constructor called four times, as in the example above, with the same explanation.

#include "hack.hpp"

A g()
{
  return A("Hello World!");
}

A f(A a)
{
  return a;
}

int main()
{
  A a = f(g());
}

Example 4

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 efect, because elision is not used. In C++11 without elision, a temporary move-initializes the constructor parameter.

#include "hack.hpp"

struct X
{
  A m_a;

  X(A a): m_a(a)
  {
  }
};

int main()
{
  X(A("main"));
}

Conclusion

Quiz

Acknowledgement

Partly funded under the Regional Excellence Initiative Program of the Minister of Science and Higher Education, project number 020/RID/2018/19, 2019 - 2022.