Immutability in C++ (2/2): Immutability through Interfaces
In the last article we looked at creating immutable objects in C++ using immutable member variables. Here we will look at immutability through interfaces that let us take advantage of move semantics.
Immutable interfaces
Assume we had a movable data structure inside our game state class. Maybe we replaced the array with a std::vector
to set the number of players dynamically. Now that we have a std::vector
instead of an array, we could take advantage of move semantics. As explained at the end of the previous article that means that we cannot make this data member const
because that prevents us from moving from it. So we have to take a different approach and implement an interface that provides immutability to an outside user of the class:
class game_state
{
public:
static constexpr int MAX_STEPS = 20;
//creates new game state for n players
//with all players at zero steps
game_state(int n);
//NEW: l-value ref qualifier
game_state add_steps(int player, int amount) const &;
//NEW: r-value ref qualifier
game_state add_steps(int player, int amount) &&;
std::optional<int> get_winner_index() const;
//NEW: getter method
int get_steps(int player_index) const
{return steps.at(player_index);};
private:
//NEW: mutable & private member
std::vector<int> steps;
};
Let’s have a look at what has changed compared to the previous implementation. The constructor is self explanatory, so let’s focus on the interesting bits.
Member Variables and Getters
The member variable is mutable and has a const
-qualified getter method now. There is still no setter method. So from the outside this member behaves as if immutable.
L-Value and R-Value Overloads for State Transitions
I have declared two overloads of the add_steps
function, which differ by their ref-qualifiers. You can read more on ref-qualifiers here. In short, the &
-qualified function works on lvalues and the &&
-qualified method on rvalue instances of the class.
Lvalue and Rvalue Semantics
As a quick recap that helps us to differentiate rvalues and lvalues we follow Scott Meyers advice in Effective Modern C++:
A useful heuristic to determine whether an expression is an lvalue is to ask if you can take its address. If you can, it typically is. If you can’t it’s usually an rvalue.
So let’s say we have the following code:
game_state game;
game_state game2 = game.add_steps(0,6);
Here we know game
is an lvalue because we can take its adress. So the call to add_steps
operates on an lvalue. The temporary object returned from this function call is an rvalue. We cannot take its address. Although we initialize the lvalue game2
with the result of the function call, it is important to note that the right hand side of the =
sign is an rvalue. Now consider this:
game_state game;
game_state game2 = game.add_steps(0,6).add_steps(1,4);
So we know now that the second call of add_steps
operates on an rvalue. To the user of the class it looks all the same, but not to us implementors. We can take advantage of the call to the rvalue qualified method by avoiding unneccessary copies.
Implementations
The implementation for lvalues should not look suprising:
game_state game_state::add_steps(int player_index, int amount) const &
{
//do some sanity checks here
game_state next_game(*this);
next_game.steps.at(player_index) += amount;
return next_game;
}
So we create a copy of the game by calling the copy constructor, which is implicitly defined for this class. Then we mutate the data of the copy and return the mutated copy. Only we as implementors can mutate the data of this instance because the fields are declared private. Now for the rvalue overload.
game_state game_state::add_steps(int player_index, int amount) &&
{
//do some sanity checks here
steps.at(player_index) += amount;
return std::move(*this);
}
Here we mutate the objects own data and return a copy of the object which is move constructed. This is why we need the call to std::move
before returning *this
. The move constructor is implicitly defined for us. The move constructor avoids an unneccessary copy of a vector for temporary objects. If we had declared the member vector const
then we could not have moved from it.
Conclusions and Further Reading
We have seen how to implement immutability in C++ two different ways. The first way of using public const
members has the advantage of simplicity. One caveat is that we have to implement a constructor taking all member fields for initialization. The second way is to provide an interface that enforces immutability and takes advantage of move semantics. The added complexity might lead to a performance increase. However, not all types have cheap move operations. For further reading I heartily recommend Functional Programming in C++ where the author presents immutable data structures for more sophisticated use cases.
Comments
You can comment on this post using your GitHub account.
Join the discussion for this article on this ticket. Comments appear on this page instantly.