04 - references

Functions output results by their return value. You might have tried to do something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

void func(int x)
{
	x *= 2;
	std::cout << "x inside function: " << x << "\n";
}

int main()
{
	int x = 10;
	std::cout << "x before function call: " << x << "\n";
	func(x);
	std::cout << "x after function call: " << x << "\n";
}

Surprisingly, the program prints:

x before function call: 10
x inside function: 20
x after function call: 10

The reason for this behavior is that functions do not work directly on objects provided as arguments. They work on their copies. Each frame on the call stack is a new set of objects. Only the return value is transferred to the previous frame.

Isn't this approach inefficient?

At the machine instruction level, everything that the processor does is based on copying data between memory cells and/or registers. These copies are one of the fastest operations.

Lifetime of local objects is related to the existence of current stack frame. If a function would like to modify an object that lives in a different frame, it needs to know where exactly that object is stored. In such case the object will not be copied into the frame but its memory address will be. However the function works, something must be copied - either the data itself or memory address of the data. For lightweight types (described more precisely later) it's more efficient to copy the data than to use any indirect mechanism.

References

References are variables which refer to other objects. Reference types are denoted with &.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

int main()
{
	int x = 10;
	int y = 100;

	// Create and initialize a reference to x.
	// Reference variables do not create any new objects.
	int& ref = x; // A

	// All operations on the references work
	// as if they were perfomed on the bound object.
	++ref;
	std::cout << "x: " << x << "\n";
	std::cout << "x reference: " << ref << "\n";

	// The opposite direction is also true. All changes
	// to the variable are also visible through the reference.
	// Both refer to the same object in memory.
	++x;
	std::cout << "x: " << x << "\n";
	std::cout << "x reference: " << ref << "\n";

	// References can not be rebound. Once the refernece has been
	// initialized, all operations will be performed on the bound
	// object. This does not rebind the reference - it will always
	// refer to x. This statement is equivalent to x = y;
	ref = y; // B
	std::cout << "x: " << x << "\n";
	std::cout << "x reference: " << ref << "\n";
}

Notice the difference between reference initialization (A) and later assignment (B). Both use = but their meaning is very different:

  • A: reference initialization specifies to which object it should refer

  • B: assignment (and any other operation after initialization) work on the specified object

Refereces undergo collapsing. A reference to a reference is equivalent to a single reference:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

int main()
{
	int x = 10;
	int& ref1 = x;
	int& ref2 = ref1;
	int& ref3 = ref2;

	++ref3;

	std::cout << x; // 11

	// syntax error: there is no such thing as nested references
	// int& & ref4 = ref3;

	// this is something different and will be covered later
	// int&& ref5 = ref3;
}

There are actually 2 types of references:

  • lvalue references, denoted with &

  • rvalue references, denoted with &&

rvalue references have different binding (reference initialization) rules and will be covered much later. You can assume that the term reference (with no further context) means lvalue reference (rvalue references are rare - they are used for very specific operations). Apart from initialization, both types of references work the same.

References can be used to modify objects that are defined elsewhere. Creating a reference has a fixed cost (quite cheap) because references copy memory addresses of other objects, not objects themselves. The actual objects can be arbitrarily large but a pointer (memory address) at the machine code level is always a single integer of the architecture size (8 bytes on 64-bit).

Pass by reference

Coming back to the first example, we can now modify the function to use a reference:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

void func(int& x)
{
	x *= 2;
	std::cout << "x inside function: " << x << "\n";
}

int main()
{
	int x = 10;
	std::cout << "x before function call: " << x << "\n";
	func(x);
	std::cout << "x after function call: " << x << "\n";
}

Now the function works on the same object:

x before function call: 10
x inside function: 20
x after function call: 20

Reference binding in detail

Creation of a reference does not create new objects. Instead, a reference is bound to an already existing object (which is done by copying its memory address, not contents). You can not bind non-const lvalue references to temporary objects (rvalues):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

void func(int& x)
{
	x *= 2;
	std::cout << "x inside function: " << x << "\n";
}

int main()
{
	func(10);

	int& ref = 10;
	func(ref);
}
main.cpp: In function ‘int main()’:
main.cpp:11:7: error: cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’
  func(10);
       ^~
main.cpp:3:6: note:   initializing argument 1 of ‘void func(int&)’
 void func(int& x)
      ^~~~
main.cpp:13:13: error: cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’
  int& ref = 10;
             ^~

If a function takes an object by non-const reference it means it wants to modify it. If the function got a temporary object, the object would be destroyed just after the function returns which would defeat the purpose of storing a result in the parameter. For this reason, binding temporaries to non-const lvalue references is forbidden.

Bidning temporaries to const lvalue references is fine though. The lifetime of the temporary is extended to the lifetime of the reference:

1
2
3
4
5
6
7
8
9
// error: can not bind rvalue to non-const lvalue reference
int& x = 1;

// ok, lifetime of 1 is extended to the lifetime of y
// (normally object 1 would be destroyed at ;)
const int& y = 1;

// error: can not bind const object to non-const reference
int& z = y;

Const references exist as a consequence of combining const-qualified types and references. There is a big difference in machine code between values and const references:

  • passing by value copies the object

  • passing by const reference copies the memory address of the object

Copying memory address (pointer) results in slightly more complex machine code but it's always cheap (memory address is a fixed-size integer). Copying values varies greatly, it can be computationally expensive for 2 reasons:

  • The type itself is very large (sizeof greater than 1024).

  • The type has complex definition and requires any sort of resource acquisition (especially dynamic memory) in order to copy its contents.

Typical C++ nomenclature will use names like "lightweight type", "heavy type" and "cheap type", "expensive type".

Changing between passing by value and by const reference has practically no difference in semantics but it can have significant difference in performance.

What exactly makes a type expensive is somewhat platform-dependent but generally, any type that dynamically allocates memory is expensive. First expensive types you will learn are std::string (array of characters, optimized for storing text) and std::vector (array of objects of specific type, optimized for read and write operations).

On parameter passing

Depending on how parameters are used we can differentiate 3 kinds:

  • in parameters - data is read by the function; passed by value (T) or by const reference (const T&)

  • out parameters - data is written by the function to the referenced object; the object is passed by non-const reference (T&) (both cheap and expensive types)

  • in-out parameters - like out parameters but the value is additionally read before modification

In practice, out parameters (and in-out parameters) are rare. And they should be - you should use function's return mechanism to return function results. Non-const reference parameters are for specific purposes like in-out parameters (functions which read and update objects) where the object is complex enough that copying it and returning a copy of the new state would be inefficient.

Dangling reference

A dangling reference (or pointer) refers to an object that has been destroyed. Using such reference/pointer invokes undefined behavior.

Without using advanced features, the only case where a dangling reference can be created is returning a reference to function-local object:

1
2
3
4
5
int& square(int x)
{
	int result = x * x;
	return result;
}

When the function returns, the result has already been destroyed. Returned reference (or pointer) will store memory address that is no longer valid.

Shouldn't references then be banned completely from function return types? Why they are allowed?

There are numerous cases when a function can return a reference. Some examples of useful functions that don't return dangling references:

  • An object has static storage duration (basically lifetime of the whole program) and the function gives an access to it by returning a reference (sometimes const reference).

  • A function returns a reference to a subobject of a larger object that has been passed to the function by reference. Could be used for a function that accepts an array of objects and returns a reference to one based on some search criteria.

  • A member function is inherently tied to an object, it can return references to subobjects of this object.

Recommendations

One particular usage can be introduced now - standard library has a function which swaps values between 2 objects of the same type. The function takes both parameters by non-const reference.

1
2
3
4
5
6
7
8
9
10
#include <utility>
#include <iostream>

int main()
{
	int x = 1;
	int y = 2;
	std::swap(x, y); // both parameters taken by non-const lvalue reference
	std::cout << "x = " << x << "\ny = " << y << "\n";
}

Core Guidelines have a cheatsheet for parameter passing in F.15, though it's more complicated than the recommendation in this lesson - it additionally covers move operations which are far from this lesson.

parameter passing