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 usuallybool
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
orhas
- 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
- settersimplify
- actionprint
- 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.