Great Expectations

Previously, we’ve looked at the basic concepts of function parameter passing, and we’ve looked at the mechanics of how parameters are passed at the Application Binary Interface (ABI) level.

Far too often we focus on the mechanisms and efficiency of parameter passing, with the goal: if it’s efficient then it’s good; that’s all there is to it.  In this article I want to move past simple mechanics and start to explore function parameter design intent – that is, what can I expect (to change) about the objects I use as function arguments; and what can I expect to be able to do with an object as a function implementer.

To that end, we’ll take a look at parameter passing from the perspective of the mutability (ability to be modified) of the parameters from both a caller’s and a function’s (callee’s) point of view.

Before we start

In this article I’m going to assume you’re familiar with the concept of function input parameters, output parameters and input-output parameters.  The problem in C++ is that these concepts are not explicitly identified, so we need to use idiomatic declaration syntax to capture them.

If you’re not familiar with the idioms I’d highly recommend you read this article before carrying on.

Reference and referant mutability

When passing a parameter there are two objects that can potentially be modified during the call:

  • The argument, either a pointer or reference type
  • The object the pointer or reference refers to

The table below summarises the parameter declarations and what that signifies for mutability.

Great Expectations Slide1

Pointers

It’s easiest to start with pointer declarations as they are the most explicit.

In the case of a simple pointer (top left quadrant) both the pointer itself and the pointed-to object may be modified in the call.

void func(int* ptr)
{
  *ptr = 0;  // Object mutable
  ++ptr;     // Pointer mutable
}

A pointer-to-const parameter (top right quadrant) allows the function to modify the pointer but not modify the object pointer-to.

void func(const int* ptr)
{
  *ptr = 0;  // FAIL
  ++ptr;     // Pointer mutable
}

A const-pointer (bottom left quadrant) allows the pointer-to object to be modified, but not the pointer.

void func(int* const ptr)
{
  *ptr = 0;  // Object mutable
  ++ptr;     // FAIL
}

Finally, a const-pointer-to-const does not allow either the pointer or the pointed-to object to be modified.

void func(const int* const ptr)
{
  *ptr = 0;  // FAIL
  ++ptr;     // FAIL
}

l-value references

References were introduced to C++ to replace pointers for simulating pass-by-reference semantics in C.

Once a reference is initialised it cannot be re-seated.  This makes references effectively const.  Therefore, either the object being referenced can be modified; or not.

void func(const int& a, int& b)
{
  a = 10;   // FAIL
  b = 100;  // b is mutable

  int x;
  b = x;   // Not re-seating; assignment
}

r-value references

r-value references were added to C++ to support move semantics.  The programming model for move semantics is the receiver will take ownership of (‘pilfer’) the resources of the object being referenced.  So, even though an r-value reference may be const-qualified this has no practical application.

class Movable
{
  // ...
};


void func(Movable&& param)
{
  Movable local { std::move(param) };  // Pilfer resources from param.
}

Furthermore, given a function overloaded for both const-r-value reference and r-value reference the compiler will always choose the r-value overload.  For the purposes of our discussion the const-r-value reference can therefore be excluded from the set.

void func(const int&& param);
void func(int&& param);


int main()
{
  int i { 0 };

  func(std::move(i));  // => func(int&&) as expected.
  func(17);            // => func(int&&) as well.
}

(I am ignoring Meyers’ ‘Universal References’ here as they are an emergent property of templates; and will ultimately yield either a l-value or r-value reference)

Expectations of mutability

When an object is passed to a function the declaration sets an expectation on the mutability of the parameter – that is, whether the argument will be changed by the function or not.  This mutability may be different depending on the point of view – either the caller or callee.

Usually a parameter may either be classified as an input, output or input-output.  The expectations of mutability are shown below.

Great Expectations Slide2

An input parameter should be considered immutable from both the caller’s perspective and the callee’s – the callee can only read the argument and the caller should not expect the argument object to change.

An input-output parameter may be modified by the callee; therefore both caller and callee can expect the object to be mutable.

An output parameter is generated by the callee and should be considered immutable from its perspective.  From the caller’s perspective however the object may or may not be mutable (depending on whether it is a const object)

C++ muddies these waters somewhat.  The mutability of objects depends not only on the declaration signature but also on whether the argument supports moving or (only) copying

Object supports copying

Great Expectations Slide3

Passing by l-value reference (top left quadrant) sets the expectation that, from the caller’s perspective, the object may be modified.  Similarly, the callee (function) can expect the object to be mutable.

This holds true for pointers, also.

class Copyable
{
public:
  Copyable()  = default;
  ~Copyable();

  void read() const;
  void write();

  Copyable(const Copyable&);
  Copyable& operator=(const Copyable&);

  Copyable(Copyable&&)            = delete;
  Copyable& operator=(Copyable&&) = delete;
};


void use(Copyable& c)
{
  c.read();   // OK => object is mutable
  c.write();  // OK
}


int main()
{
  Copyable copyable { };
  
  copyable.read();       // OK
  copyable.write();      // OK
  use(copyable);         // Expect copyable *may* be modified
}

Passing by value (top right quadrant) implies that, from the caller’s perspective, the (original) object will not be modified (since a copy is made).  The callee’s perspective, however, is that the object is mutable; although this will have no effect outside the function.

void use(Copyable c)
{
  c.read();   // OK => object is mutable
  c.write();  // OK
}


int main()
{
  Copyable copyable { };

  copyable.read();       // OK
  copyable.write();      // OK

  use(copyable);         // Copy made. copyable unchanged.
}

Passing by const l-value reference – or by const value – sets the expectation from the caller’s perspective that the object will not be modified.  Similarly, the callee must treat the object as immutable (read-only)

void use(const Copyable& c)
{
  c.read();   // OK
  c.write();  // FAIL => object is immutable
}


int main()
{
  Copyable copyable { };

  copyable.read();       // OK
  copyable.write();      // OK

  use(copyable);         // Expect copyable to be unmodified.
}

Clearly, passing by const-l-value-reference will be more efficient since it doesn’t require a copy to be made; and yields exactly the same results.

Object supports moving

Great Expectations Slide4

The table for objects supporting move is simpler.  As mentioned above, the expectation is that an r-value object will be going out of scope and therefore the only practical course of action is to move from it into some other object.  After the call the object is expected to be in an ‘empty’ or ‘expired’ state (known as an x-value).

class Movable
{
public:
  Movable()  = default;
  ~Movable();

  void read() const;
  void write();

  Movable(const Movable&)            = delete;
  Movable& operator=(const Movable&) = delete;

  Movable(Movable&&);
  Movable& operator=(Movable&&);
};


void use(Movable&& c)
{
  c.read();   // OK => object is mutable
  c.write();  // OK
}


int main()
{
  Movable movable { };

  movable.read();          // OK
  movable.write();         // OK

  use(Movable { });        // r-value will not be used
                           // outside call.
  use(std::move(movable)); // Expect movable to be 'empty'
                           // after call.
}

This also holds for passing by value.  The object’s move constructor will be called, leaving the original object in an expired state.

In both the above cases the callee perceives the object as mutable.

void use(Movable c)
{
  c.read();   // OK => object is mutable
  c.write();  // OK
}


int main()
{
  Movable movable { };

  movable.read();          // OK
  movable.write();         // OK

  use(std::move(movable)); // Expect movable to be 'empty'
                           // after call
}

Note, since our Movable type does not support copying we must explicitly move (via std::move) the object on call; making it explicit to the reader that the argument will be left ‘empty’ after the call.

If your type supports both move and copy semantics the use of std::move will dictate the mutability expectations of the passed object.

void use(MoveCopy mc);


int main()
{
  MoveCopy moveCopy { };

  use(moveCopy);            // Expect moveCopy to be unchanged.
  use(std::move(moveCopy)); // Expect moveCopy to be 'empty'
                            // after call
}

What about output parameters?

The C++ implementation of an output parameter is the function return value.  Return values, using the C mechanism, are returned by copy.

For built-in types this has little overhead.

For user-defined types the return value will be copied into the receiving object.  If the object supports moving the return value object will be moved into the receiver. In both cases the compiler may apply the Named Return Value optimisation to circumvent this copying/moving to construct directly into the receiver object.

Movable make()
{
  // Return value object is effectively
  // immutable to this function, since
  // there's no way to modify the object.
  //
  return Movable { };
}


int main()
{
  auto       m1 = make();  // Looks like copy, but NRV optimisation
  const auto m2 = make();  // leads to copy-elision.  No copy or
                           // move constructors called.

  m1.write();              // OK   – m1 is mutable
  m2.write();              // FAIL – m2 is immutable
}

So, in all cases, the return signature is the same: return by value.

Conclusion

Combining the above mechanisms leads to a familiar set of parameter-passing idioms, as shown in the table below.

Great Expectations Slide5

If you want to learn more about how Feabhas can help you improve your C++ skills, have a look here.

Glennan Carnie
Dislike (1)
Website | + posts

Glennan is an embedded systems and software engineer with over 20 years experience, mostly in high-integrity systems for the defence and aerospace industry.

He specialises in C++, UML, software modelling, Systems Engineering and process development.

About Glennan Carnie

Glennan is an embedded systems and software engineer with over 20 years experience, mostly in high-integrity systems for the defence and aerospace industry. He specialises in C++, UML, software modelling, Systems Engineering and process development.
This entry was posted in C/C++ Programming, Design Issues and tagged , , , , , , , , . Bookmark the permalink.

2 Responses to Great Expectations

  1. The section entitled "Reference and referant mutability", subsection "Pointers" contains a sentence that starts with "A const-pointer (bottom right quadrant)". I believe that const-pointer is in the bottom-left quadrant. Thanks!

    Like (0)
    Dislike (0)
  2. You are, of course, quite correct. I've fixed that silly mistake in the text.

    I always struggled with my lefts and rights!

    Thanks! 🙂

    Like (0)
    Dislike (0)

Leave a Reply