Can a C++ function directly return an object?

Nov 16, 2018 · 1434 words

Memory and resource management is one of C++‘s strongest capabilities, as well as its most complex and thought-provoking area. When writing Java, we simply new every object without a second thought. After all, all objects are restricted to the heap, and a garbage collector manages memory for us. However, in C++, we must consider whether to place an object on the stack or use new to place it on the heap. By default, objects are placed on the stack, which has the advantage of preventing memory leaks since we won’t forget to release the object’s memory. But if we place a large object on the stack and pass it as a parameter or return value, we must consider the overhead of object copying. Because C++ is compatible with C, parameters are passed by value (call by value) by default, meaning objects are copied during both parameter passing and when returning values. For parameters, we can at least declare them as reference types T& to avoid copying. However, the copy overhead of return values cannot be solved by simply declaring the return type as a reference.

When You Must Return an Object

Suppose we want to write a range function:

cpp
vector<int> range(int begin, int end, int step=1) {
    vector<int> res;
    for (int i = begin; i < end; i += step) {
        res.push_back(i);
    }
    return res;
}

This code returns a vector<int> object, which is exactly what we want to avoid: a large object placed on the stack. Calling this function creates a temporary object for the return value, necessitating a copy of all elements in the list. Obviously, you cannot simply change the return type to vector<int>& to avoid copying—the compiler will generate a warning: warning: reference to local variable ‘res’ returned. You would be returning a reference to a local variable, which points to a stack address that could be reclaimed at any time. This is a common mistake for C++ beginners. Since returning a reference is not an option and we want to avoid the overhead of copying, many “old-school” C++ programmers perform a manual optimization: passing the return value in as a reference parameter. Using this method, the range() function can be rewritten as follows:

cpp
vector<int> range(vector<int>& out, int begin, int end, int step=1) {
    for (int i = begin; i < end; i += step) {
        out.push_back(i);
    }
}

// Caller
vector<int> r;
range(r, 0, 10);

C/C++ programmers might be very accustomed to writing code this way. However, it must be admitted that this is an ugly syntax. So, what actually happens if we directly return a vector<int> object? Can the overhead of object copying be avoided?

Temporary Objects and Return Value Optimization

According to Section 2.3 “Program Transformation Semantics” in Inside the C++ Object Model, a function’s return value undergoes the following transformation:

  1. A temporary variable __result is added.
  2. When the function returns, the copy constructor of __result is called, using the return value x as an argument.
  3. Subsequent operations are performed on __result (if any).

Note that subsequent operations can include even more constructors. For example, if we call the range function to initialize the variable r1:

cpp
vector<int> r1;
r1 = range(1, 10); // 调用 r1 的 copy assignment operator (即 operator=)

As you can see, even for a simple function call, the temporary object is copied twice. The more numbers range generates, the more content needs to be copied, and the greater the impact on performance.

To solve this problem, many C++ compilers implement Return Value Optimization (RVO) to eliminate multiple copies of return value temporary objects.

An Example

To verify the difference before and after the compiler performs return value optimization, let’s run a complete example. We define a Blob class to store raw binary data blocks. Blob needs to allocate its own space to store data, so it needs to implement a destructor, copy constructor, and copy assignment operator. All constructors and destructors will call a logging function so we can see their calling order.

cpp
class Blob {
public:
    Blob()
    : data_(nullptr), size_(0) {
        log("Blob's default constructor");
    }

    explicit Blob(size_t size)
    : data_(new char[size]), size_(size) {
        log("Blob's parameter constructor");
    }

    ~Blob() {
        log("Blob's destructor");
        delete[] data_;
    }

    Blob(const Blob& other) {
        log("Blob's copy constructor");
        data_ = new char[other.size_];
        memcpy(data_, other.data_, other.size_);
        size_ = other.size_;
    }

    Blob& operator=(const Blob& other) {
        log("Blob's copy assignment operator");
        if (this == &other) {
            return *this;
        }
        delete[] data_;
        data_ = new char[other.size_];
        memcpy(data_, other.data_, other.size_);
        size_ = other.size_;
        return *this;
    }

    void set(size_t offset, size_t len, const void* src) {
        len = min(len, size_ - offset);
        memcpy(data_ + offset, src, len);
    }

private:
    char* data_;
    size_t size_;

    void log(const char* msg) {
        cout << "[" << this << "] " << msg << endl;
    }
};

We define a createBlob function that creates a blob from a string and calls this function:

cpp
Blob createBlob(const char* str) {
    size_t len = strlen(str);
    Blob blob(len);
    blob.set(0, len, str);
    return blob;
}

int main() {

    Blob blob;

    cout << "Start assigning value..." << endl;
    blob = createBlob("A very very very long string representing serialized data");
    cout << "End assigning value" << endl;

    return 0;
}

The createBlob function returns a Blob object, creating a temporary object that is then assigned to the blob variable. We should expect to observe many constructor and destructor calls. However, modern compilers generally perform return value optimization by default, eliminating many unnecessary constructor calls. To observe the constructor calls in the worst-case scenario, we use the -fno-elide-constructors compilation flag to disable return value optimization.

Results without return value optimization:

text
[0x7ffd220ada20] Blob's default constructor
Start assigning value...
[0x7ffd220ad9e0] Blob's parameter constructor
[0x7ffd220ada30] Blob's copy constructor
[0x7ffd220ad9e0] Blob's destructor
[0x7ffd220ada20] Blob's copy assignment operator
[0x7ffd220ada30] Blob's destructor
End assigning value
[0x7ffd220ada20] Blob's destructor

As we can see, the compiler generated a temporary object at address 0x7ffd220ada30, which required one copy constructor and one destructor call. The copy constructor involves copying the data within the blob, which is a significant overhead.

Results with return value optimization:

text
[0x7ffdd52c7d50] Blob's default constructor
Start assigning value...
[0x7ffdd52c7d60] Blob's parameter constructor
[0x7ffdd52c7d50] Blob's copy assignment operator
[0x7ffdd52c7d60] Blob's destructor
End assigning value
[0x7ffdd52c7d50] Blob's destructor

The compiler helped us optimize away that unnecessary temporary object. However, in complex real-world scenarios (such as when two branches of an if statement in a function return different objects), the compiler may be unable to perform RVO. In such cases, we need a better way to eliminate the negative impact of temporary objects. The move semantics introduced in C++11 solve this problem effectively.

Move Semantics and Move Constructors

Move semantics is a crucial concept introduced in the C++11 standard. It is similar to “ownership transfer” in Rust. A move constructor accepts an “rvalue reference.” Generally, since an rvalue is just a temporary variable, we can “steal” the contents of the rvalue object without causing other side effects. In C++11, move semantics are what truly matter. The move constructor is defined as follows:

cpp
Blob(Blob&& other) {
    log("Blob's move constructor");
    swap(data_, other.data_);
    swap(size_, other.size_);
}

Blob& operator=(Blob&& other) {
    log("Blob's move assignment operator");
    if (this == &other) {
        return *this;
    }
    swap(data_, other.data_);
    swap(size_, other.size_);
}

Let’s run the code again to observe the constructor calls.

Results without return value optimization:

text
[0x7ffef7172f70] Blob's default constructor
Start assigning value...
[0x7ffef7172f30] Blob's parameter constructor
[0x7ffef7172f80] Blob's move constructor
[0x7ffef7172f30] Blob's destructor
[0x7ffef7172f70] Blob's move assignment operator
[0x7ffef7172f80] Blob's destructor
End assigning value
[0x7ffef7172f70] Blob's destructor

Results with return value optimization:

text
[0x7ffc18a6e2a0] Blob's default constructor
Start assigning value...
[0x7ffc18a6e2b0] Blob's parameter constructor
[0x7ffc18a6e2a0] Blob's move assignment operator
[0x7ffc18a6e2b0] Blob's destructor
End assigning value
[0x7ffc18a6e2a0] Blob's destructor

We can see that the results this time are equivalent to replacing all copy constructors / copy assignment operators from the previous run with move constructors / move assignment operators. It’s clear that the performance optimization brought by move semantics is actually orthogonal to RVO: RVO is responsible for eliminating redundant temporary variables and constructors, while move semantics are responsible for replacing expensive copy constructors with move constructors.

Finally, many modern compilers can now automatically add move constructors, and compiler RVO is becoming increasingly effective. The coding style of old-school C++ programmers seems more like a “manual compiler” approach. One thing we must realize is that compilers will only get better, and their capabilities often far exceed our imagination. Therefore, a better approach for us is to write more elegant and readable code; issues like return value copy overhead should be left to the compiler to handle.