07 - const

So far we have talked about member functions and how they can shield the class from undesirable use. This lesson extends the topic, covering few very common kinds of member functions.

Setters:

  • primary purpose: change data members (they set things)

  • secure class invariants.

  • function names often start with set

  • such functions almost always return void (if not, it's usually bool to indicate whether operation succeeded)

Getters:

  • primary purpose: obtain information (they get things)

  • function names often start with get (return value of a private data member or compute something from them)

  • almost always are read-only operations that do not change data members

Question-like functions (a subset of getters):

  • very often return bool

  • names usually start with is or has - for example: is_ready, is_full, is_open, has_completed,

  • almost always are read-only operations that do not change data members

Action-like functions:

  • primary purpose: modify the object to complete specific task

  • names are formed like orders - for example: next_item, load_file, refresh

  • typically return one of:

    • void

    • bool (to inform if the operation succeeded)

    • specific data type that holds operation result and/or detailed error information

Action-like functions are the most broad group and usually they will contain most important code for any given class.

Getters and setters do not always come in pairs - getters may combine information from multiple members and setters (and action functions) may change multiple fields. This all depends on class invariants.

Exercise

Recall fraction class from previous lessons. Can you assign each of its member functions a specific category?

Answer
  • set - setter

  • simplify - action

  • print - getter (although instead of returning it prints the values)

Member function qualifiers

In C++ member functions can have certain qualifiers:

  • const

  • volatile

  • & - the lvalue reference qualifier or && - the rvalue reference qualifier

In this lesson you will learn about the simplest of them - the const qualifier.

When applied to a variable, const prevents its modification. When applied to a member function, it prevents that function from modifying fields - it's as if all fields were const for the code inside the function. You can still do everything else in such function, the only restriction is on modifying member variables.

The fraction class already has a function that could use it - you probably already know which one.

Const-qualified member functions follow const-correctness:

  • they can be called on const-qualified objects

  • they can not call non-const-qualified member functions

Let's have an example (with improved print function - now it also supports other streams):

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <iostream>

// (greatest common divisor)
// if you have C++17, you can remove this function and use std::gcd from <numeric>
int gcd(int a, int b)
{
	if (b == 0)
		return a;
	else
		return gcd(b, a % b);
}

int make_valid_denominator(int value)
{
	if (value == 0)
		return 1;
	else
		return value;
}

class fraction
{
private:
	int numerator;
	int denominator;

public:
	fraction(int numerator = 0, int denominator = 1)
	: numerator(numerator)
	, denominator(make_valid_denominator(denominator))
	{}

	void simplify()
	{
		const int n = gcd(numerator, denominator);
		numerator /= n;
		denominator /= n;
	}

	// note where the const keyword is placed
	// "const double approx()" would affect return type, not the function
	double approx() const
	{
		return static_cast<double>(numerator) / denominator;
	}

	// std::cout is a global object of type std::ostream
	void print(std::ostream& os = std::cout) const
	{
		os << numerator << "/" << denominator;
	}
};

/*
 * Because fraction class is small and inexpensive to copy,
 * it should be passed by value instead of const reference.
 * Const reference is used here to demonstrate potential
 * errors of calling non-const methods on const objects.
 */
void print_details(const fraction& fr)
{
	fr.print();
	std::cout << ", approx. " << fr.approx() << "\n";

	// fr.simplify(); // error: can not call non-const member function on const-qualified object
}

void test(fraction fr)
{
	print_details(fr);
	fr.simplify();
	std::cout << "after simplification:\n";
	print_details(fr);
	std::cout << "\n";
}

int main()
{
	test(fraction(8, 12));
	test(fraction(8, -12));
	test(fraction(-8, -12));
}

Don't get it wrong - do not const-qualify a function just becase it can be. Think what is the function's purpose and only then add const if it's a getter. Action-like functions should not be const-qualified even if they can (for whatever reason). If you make this mistake, there is a chance that the function implementation will change at some point in a way that prevents applying const. This can cause compilation issues in other code which was (incorrectly) using the class by relying on the action constness.

Does const-qualifying a function help in optimization?

Generally no. const does not help the compiler except in few corner cases. It's much more of a help for the programmer to catch bugs related to object misuse.

Overloading on qualification

Const-qualifying a function changes its type. This in turn allows overloading based on constness of the object. The following style of getters and setters is very popular in C++ (and often the recommended one):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class button
{
public:
	      std::string& text()       { return m_text; }
	const std::string& text() const { return m_text; }

	// [...] other methods

private:
	std::string m_text;
	// [...] other fields
};

// example use
// if the object is const-qualified, const-qualified overload is called
// the non-const overload returns non-const reference to the string
button btn1(/* ... */);
btn1.text() = "Exit";

const button btn2(/* ... */);
btn2.text() = "Exit"; // error: std::string::operator= is not const-qualified

This example presents multiple conventions, common in C++ code:

  • Member variables are named with some prefix (usually m_ or _):

    • This avoids name clashes with method names.

    • This improves code readability of method implementations (member variables can be easily distinguished from function-local variables).

    • This helps with tooling (e.g. IDE autocomplete feature)

  • Functions should generally be named as verbs but here they are named as nouns - they only return references to fields.

  • There are 2 overloads which differ in const qualification and analogically their return type.

Which overload is choosen when a method is called? It depends on the constness of the object on which it is done.

  • For const objects, the const-qualified overload is choosen which acts only as a getter.

  • For non-const objects, the non-const-qualified overload is choosen which can be used both as a getter and as a setter.

The tradeoffs of this style:

  • Such functions expose an implementation detail - the return type must match member type. If the class is later refactored to contain fields of different types, code which was using the class may also need to be changed.

  • Since the setter does not take the value as a parameter but returns a reference to a field:

    • ...it no longer can control what is actually written to it. This makes the style undesirable if the class has invariants to enforce. For the fraction class, this style should not be used because the denominator has to be checked against zero.

    • ...the calling code can access field's methods, which allows significant code reuse. Example above accesses std::string::operator=.

In other words, the approach of returning a reference to the field offers code reuse (access to methods of the field) at the cost of coupling external code to the implementation (the type of the field).

Selecting desired overload

If an object is const-qualified, only const-qualified methods can be called. But in the opposite situation, both const and non-const overloads can be called. For a non-const object, the compiler doesn't check how the function is used and what is done with it's return type (if non-void) - it simply picks non-const overload for consistency.

In some situations, calling const-qualified overload on a non-const object is beneficial. This often happens for types which use COW (copy-on-write) implementation as an optimization.

For a type that implements COW, specific data is shared across multiple objects. Each object holds some form of access to a shared state (e.g. a pointer) and only such pointer is copied. This allows read operations for actual data from multiple places (potentially multiple threads) while not wasting memory by duplicating the data for each thread. If at any point in time, there is a need for modification, the object will create a new copy of the data and refer to this new copy. Many file systems use this optimization - copied files are not actually copied but only their metadata, a real copy is made only when one of users attempts to edit the file. Thus "copy-on-write" name. This approach of sharing identical copies is also known as shallow copying and is a part of flyweight design pattern.

In C++ COW can be used whenever there is a resource which is expensive to obtain (simplest example is dynamically allocated memory, such as buffers for strings). String types in many libraries (but not std::string in C++11 and later) are implemented with COW. Below is a hypothetical excerpt from such class:

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
class cow_string
{
public:
	// always cheap to use this overload
	char operator[](int index) const
	{
		// directly return character
		return buffer->data[index];
	}

	// potentially expensive overload
	char& operator[](int index)
	{
		// if multiple string objects refer to the data...
		if (buffer->use_count > 1)
		{
			// ...make a copy of the data to support independent changes
			// (other string objects should remain unaffected)
			--buffer->use_count;
			buffer = allocate_buffer(size());
		}

		// now it's sure that returned non-const reference
		// is to a string that has exactly 1 use
		return buffer->data[index];
	}

private:
	shared_buffer* buffer;
};

In such situation, there is a big difference between calling const-qualified overload and non-const-qualified overload. For this reason, C++17 added a helper function:

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

char get_first_char()
{
	cow_string str = returns_str_from_somewhere();

	if (str.empty())
		return '\0';

	// bad: potentially expensive call
	// return str[0];

	// good: std::as_const returns const reference
	// now a const-qualified overload of operator[] will be used
	return std::as_const(str)[0];
}

std::as_const is a very simple function, it just returns a const reference to the passed object. If you don't have C++17 you can implement this function in C++11 compatible code:

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

template <typename T> constexpr
typename std::add_const<T>::type& as_const(T& t) noexcept
{
	return t;
}

template <typename T>
void as_const(const T&&) = delete; // disallows temporary objects

Later you will also learn about std::shared_ptr which can be used to implement types with COW behavior.

Setters for classes with invariants

The style of const + non-const overloads is quite popular but it's not appropriate when a class has some invariants - returning a non-const reference makes external code totally unconstrained. For something like the fraction class, the following implementation can be used:

  • the const-qualified overload returns const reference

  • the non-const-qualified overload, instead of retuning a non-const reference, takes the value to set as a parameter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int numerator() const
{
	return m_numerator;
}

void numerator(int value)
{
	m_numerator = value;
}

int denominator() const
{
	return m_denominator;
}

void denominator(int value)
{
	m_denominator = make_valid_denominator(value);
}

Data member names were changed to avoid name conflicts with function names.