C++ and the Complexity of Return Values

Posted on 2nd of April 2024 | 550 words

In the domain of C++ optimization, we encounter two key players: Return Value Optimization (RVO) and Named Return Value Optimization (NRVO). These techniques excel in simplifying function returns by avoiding unnecessary object copies or moves, akin to a direct transfer from temporary to destination.

Looking at the example:

class Object {
public:
  Object() { std::cout << "Construct at " << this << "\n"; };

  ~Object() { std::cout << "Destruct at " << this << "\n"; };

  Object(const Object &) { std::cout << "Const Copy at " << this << "\n"; };

  Object(Object &&) { std::cout << "Move at " << this << "\n"; };

  Object &operator=(const Object &) {
    std::cout << "Const Copy Assignment at " << this << "\n";
    return *this;
  };

  Object &operator=(Object &&) {
    std::cout << "Move Assignment at " << this << "\n";
    return *this;
  };
};
Object TestRVO() { return Object{}; }

Object TestNRVO() {
  Object obj;
  return obj;
}

Object obj = TestRVO();
Object obj2 = TestNRVO();

Since C++17, Return Value Optimization (RVO) has been governed, and the moment when a constructor is invoked is termed “materialization”. C++17 specifies that materialization should be delayed as much as possible, typically until it binds to references or until a class’s member is accessed using the dot operator, or until an array is subscripted using square brackets or converted to a pointer. Alternatively, materialization occurs when the value is ultimately discarded, ensuring that at least one temporary is created. On the other hand, Named Return Value Optimization (NRVO) lacks regulation but is commonly implemented by proficient compilers.

RVO and NRVO can be disabled under certain circumstances, such as conditional returns (if(xx) return x; else return y;), returning a function parameter or global variable instead of a local variable, or returning a non-id-expression. This explains why using std::move(id) is discouraged when NRVO is available. In scenarios where RVO/NRVO is applicable, no move operations are triggered. Conversely, in their absence, move operations become inevitable. Another optimization step occurs where the compiler attempts to interpret an id-expression as an xvalue. Consequently, return x; is equivalent to return std::move(x);, obviating the need for copying.

For example:

struct S {
  S() = default;

  S(const S &) = delete;

  S(S &&) { std::cout << "here;"; };

  ~S() = default;
};

// NRVO happens, no move at all.
S foo() {
  S s;
  return s;
}

// BAD, NRVO can happen, but it's disabled, so move ctor is called.
S bar() {
  S s;
  return std::move(s);
}

// NRVO is disabled due to conditional return; however, the subsequent
// optimization ensures no unnecessary copy is made.
S foofoo(bool use) {
  S obj1, obj2;

  if (use)
    return obj1;

  return obj2;
}

// Similar to the previous function, but redundant std::move calls are added
// unnecessarily.
S barbar(bool use) {
  S obj1, obj2;

  if (use)
    return std::move(obj1);

  return std::move(obj2);
}

When NRVO and RVO optimizations fail, a copy operation ensues. In such cases, manual move operations are required if moving is preferred. Note that t.s isn’t considered an id-expression, hence both optimizations fail, necessitating explicit move calls like std::move(t.s) or std::move(t).s.

The subsequent optimization, officially known as implicit move, is a feature anticipated in C++23. Before its introduction, implicit moves are subject to stricter constraints compared to id-expressions.

Complexity of C++ never ceases to amaze me.