Thursday, September 15, 2011

The Evil Side of Returning a Member as const Reference

Returning a class member (e.g., from a getter function) has negative impact on performance when large objects and copying is involved. In some code I have come across, instead of simply returning a copy, a const reference is used as the return value. Here is an example:
class SomeClass
{
public:
    // ...
    const std::string &someString() const { return m_someStr; }

private:
    std::string m_someStr; 
}
Before elaborating any further on this, let's recall the different ways of passing arguments to functions.

Parameter passing conventions

1. Pass by value

When passing a primitive type or an object of a class that relies on implicit sharing (copy-on-write), copying is inexpensive and we can pass the parameter by value as long as the function is not expected to modify the original object.
void someFunction(int n);
void someFunction(ImplShared obj);
In the first case, n is a primitive type, where a local copy is made within the scope of the function block. This is common for setter methods and more pure functional behaviour, where no side-effects are present. In the second example, implicit sharing is utilized. A shallow copy is made but the internal data is not copied. Instead, it is shared with the original object and the object's internal reference count is incremented.

2. Pass a pointer

void someFunction(Stuff *stuff);
void someFunction(char *s);
When passing a pointer, efficiency is not an issue, but ownership of the object must be carefully considered. A non-const pointer suggests that ownership of the object is passed to the callee, but this is not always the case. The decision is largely dependent on the conventions of the particular library being used, or the broader scenario. In Qt, pointers are passed around rather freely since the QObject-based parent/child ownership strategy ensures proper lifetime management. The documentation is also clear about what to do with pointers for specific functions.

3. Pass a const pointer

void someFunction(const Stuff *stuff);
void someFunction(const char *s);
Passing a const pointer is just as efficient and guarantees that the object pointed to is not changed.

Side note: The pointee is const but the pointer itself can still be modified (to point to a different object). To make the pointer read-only a second const is needed:
const Stuff *const stuff;
Stuff s;
stuff = &s;           // error: assignment of read-only variable 


4. Pass by reference

void someFunction(Stuff &stuff);
Although semantically different from the programmer's point of view – to the compiler, a reference is basically a pointer. A reference, here, indicates that the function will modify the object in some way and, as always in the case of aliasing, care must be taken so that the original object is not destroyed before the reference.

5. Pass by const reference

void someFunction(const std::string &str);
This is the preferred way to pass large objects, since the overhead associated with creating a local copy of the object can be avoided. The constness of the reference makes certain that the original object is left untouched.

Return values

Going back to the topic of return values, here is the example from the beginning of the post again:
class SomeClass
{
public:
    // ...
    const std::string &someString() const { return m_someStr; }

private:
    std::string m_someStr; 
}
This may, or may not be faster than returning a copy. The actual performance benefit (if any) depends on:
  • The class being returned. In this case the specific compiler's implementation of std::string.
  • Whether or not a copy is involved when the returned value is assigned, i.e., if the value is assigned to a std::string or a const std::string&.

This approach, however, raises a number of safety issues. Failure to address these could potentially cause some evil side-effects.

1. Possibility of code that breaks encapsulation

Client code may introduce a const_cast that leads to violations of the encapsulation rules.
class Stuff
{
public:
    Stuff() {}
    ~Stuff() {}

    void setSomeStr(const std::string &str) { m_someStr = str; }
    const std::string &someStr() const { return m_someStr; }

private:
    std::string m_someStr;
};

int main()
{
    Stuff obj;
    obj.setSomeStr("banan");
    std::cout << obj.someStr() << "\n";

    std::string &evil = const_cast<std::string&>(obj.someStr());
    evil = "hello";
    std::cout << obj.someStr() << "\n";     // obj has now changed!
    return 0;
}
Run this example from codepad: http://codepad.org/2mnuXVlJ

Here, the reference is no longer read-only. (Remember that the constness refers to the reference, and not the referenced object itself.) Modifying the local variable now modifies the private data member of the Stuff object at the same time. This is bad.

2. Lifetime/aliasing opaqueness

Keeping a reference (const or not) to a member of a different object could also be asking for trouble:
class Stuff
{
public:
    Stuff() {}
    ~Stuff() {}

    void setStr(const std::string &str) { m_str = str; }
    const std::string &str() const { return m_str; }

private:
    std::string m_str;
};

int main()
{
    Stuff *obj = new Stuff;
    obj->setStr("banan");
    const std::string &str = obj->str();

    delete obj;          // object gets deleted here
    std::cout << str;    // crash!

    return 0;
}
Here, the problem is obvious but in more complicated code, these kinds of errors can pop up unexpectedly – as a result of objects falling out of scope, etc.

3. Binary compatibility issues

In library code, exposing the the internal data structure of a class member may introduce binary compatibility vulnerabilities.

4. Compiler already performs RVO/NRVO

Most modern compilers have ways of avoiding unnecessary copying. Return Value Optimization aims to eliminate the temporary object created to hold a function’s return value.

The "as-if" rule of the C++ Standard says:

The semantic descriptions in this International Standard define a parameterized nondeterministic abstract machine. This International Standard places no requirement on the structure of conforming implementations. In particular, they need not copy or emulate the structure of the abstract machine. Rather, conforming implementations are required to emulate (only) the observable behavior of the abstract machine as explained below.

The meaning of this is that a compiler may do what it wants as long as the compiled program behaves as if all the requirements of the Standard are fulfilled. In the case of RVO, an implementation is allowed to go even further and change the observable behaviour of the program.

Consider the following program:
class LargeObject
{
    // ...
};

LargeObject largeObjectFactory()
{
    LargeObject localObject;

    // ... populate the object with some data

    return localObject;
}

int main(int argc, char **argv)
{
    LargeObject object = largeObjectFactory();
    return 0;
}
Without any optimizations, a compiler will typically generate code that uses a temp object, hidden to the programmer, to accommodate for the return value. As a result, this causes the object's internal data to be copied twice:


















  1. The hidden object is created to store the return value.
  2. An object is created on the stack in the local scope of largeObjectFactory().
  3. The local object is copied to the hidden object.
  4. The copy constructor is invoked and another copy takes place.

With RVO, one or both of these copy operations may be eliminated, even if it means that no copy constructor is invoked. Ideally, we end up with code equivalent to this:
class LargeObject
{
    // ...
};

void largeObjectFactory(LargeObject &object)
{
    // ... just populate the object
}

int main(int argc, char **argv)
{
    LargeObject object;
    largeObjectFactory(object);

    return 0;
}
There are many circumstances, however, where the compiler is unable to perform RVO.

Side note: The expression A object = someObj() is equivalent to,
A object(someObj());
and, in fact, uses the class' copy constructor, while on the other hand,
A object;
object = someObj();
results in the assignment operator being called.

5. Could we use a smart pointer instead?

A both safe and efficient solution is to return a smart pointer, e.g., boost::shared_ptr as in the example below.
#include <boost/shared_ptr.hpp>

class Stuff
{
public:
    Stuff() : m_str(boost::shared_ptr<std::string>(new std::string))
    {
    }

    ~Stuff() {}

    inline void setStr(const char *str) { m_str->assign(str); }
    inline boost::shared_ptr<const std::string> str_ptr() const 
    { return m_str; }
    inline std::string str() const { return m_str->data(); }

private:    
    boost::shared_ptr<std::string> m_str;
};
The shared_ptr is returned with a const std::string (note the const) template type to avoid issues similar to those mentioned under point 1. Unlike the const reference in the example under point 2, the shared_ptr may outlive the object:
Stuff *obj = new Stuff;
obj->setStr("banan");
boost::shared_ptr<const std::string> str = obj->str_ptr();
delete obj;
std::cout << str->data();         // banan
Run this example from codepad: http://codepad.org/YCI4Eawk

The pointee is automatically destroyed when there are no more users of the instance.

6. C++11 move semantics is your friend

C++11 introduces the concept of rvalue references. This makes it possible for a class to, in addition to the "three amigos", also define a move constructor and a move assignment operator which, in effect, solves the problem described here. I will write more about C++11 move semantics in a separate post. More information is available on MSDN: http://msdn.microsoft.com/en-us/library/dd293665.aspx

Conclusion

  • C++11 solves this with rvalue references
  • Examine the class which is returned to identify the best solution
  • Is passing a reference/return void a better alternative?
  • boost::shared_ptr provides both efficiency and safety
  • Returning by value is ok if the class uses implicit sharing
  • Optimization is not everything

0 comments:

Post a Comment