12.7. Immutable classes

The C++ Core Guidelines generally prefer constant data and objects over mutable objects and data when possible. Immutable objects provide several important benefits:

  • Objects that are immutable are easier to reason about. They do not surprise you with unexpected state changes.

  • Immutability can prevent errors when an object changes state unexpectedly.

  • Interfaces that accept constant objects are easier to work with and debug.

  • Although we do not discuss multithreaded programming in this course, immutable objects can safely be shared by concurrent independent threads.

  • Immutability gives the compiler more information to use when reporting errors and making optimizations.

An immutable class does not provide a way to modify an existing object. Its data members are private, and its member functions that inspect the object are const. Operations that appear to change an object instead construct and return a new object.

We will use the same distance value class from the previous section:

namespace length{
  class distance{
    public:
      explicit constexpr distance(double value = 0)
        :m{value}
      {}

      distance(const distance&) = default;
      distance& operator=(const distance&) = delete;
      distance(distance&&) = default;
      distance& operator=(distance&&) = delete;

    private:
      double m; // meters
  };
} // end namespace length

The data member is not const. It does not need to be, because no public member function can modify it. The constructor creates the initial value, and the const member functions below can only inspect that value.

Copy and move construction remain available. Each operation creates a new object and leaves the source object unchanged. Copy and move assignment are deleted because assignment would replace the value stored in an existing object.

The familiar compound-assignment operators are problematic in an immutable class. In a mutable class, operator+= would modify the left-hand object and return *this. An immutable class cannot do that. These overloads are const member functions that would need return a new distance instead:

constexpr distance operator+=(const distance& other) const {
  return distance(m + other.m);
}

The spelling gives the operation an appearance of mutability, but the existing object is never modified. A new value must create a new instance; objects in this example can only be created and viewed.

Using this overload would create surprises since expressions like: a += b would not modify a.

Other binary operators are non-friend, non-member functions that take the both operands as const& and return a new object:

constexpr distance operator+(const distance& lhs, const distance& rhs){
  return distance(lhs.value() + rhs.value());
}

The same pattern applies to subtraction, multiplication, and division.

Adding the complete set of operations gives us this immutable class:

namespace length{
  class distance{
    public:
      explicit
      constexpr distance(double value)
        :m{value}
      {}

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

      explicit constexpr operator int() const {
        return static_cast<int>(m);
      }
      constexpr double value() const {
        return m;
      }
    private:
      double m; // meters
  };

  constexpr distance operator+(const distance& lhs, const distance& rhs){
    return distance(lhs.value() + rhs.value());
  }
  constexpr distance operator-(const distance& lhs, const distance& rhs){
    return distance(lhs.value() - rhs.value());
  }
  constexpr distance operator*(const distance& lhs, const distance& rhs){
    return distance(lhs.value() * rhs.value());
  }
  constexpr distance operator*(double scalar, const distance& rhs){
    return distance(scalar * rhs.value());
  }
  constexpr distance operator/(const distance& lhs, std::size_t denominator){
    return distance(lhs.value() / denominator);
  }
} // end namespace length

Important

The use of constexpr in this class is not what is providing the immutability here. Immutability exists because the class only defines const member functions.

constexpr here allows the class to participate in constexpr compile time evaluation.

Using distance

The average_distance requires some refactoring when the distance class is immutable. The traditional 'accumulating the sum' loop algorithm requires a mutable sum:

auto sum = length::distance{0.0};
for (auto d: distances) sum = sum + d;

The distances are passed to average_distance using a std::span. A span is a small, non-owning view: it remembers where a sequence begins and how many elements it contains, but it does not copy or destroy those elements.

The class cannot use an ordinary running total because assignment is deleted. This function therefore computes the sum recursively. Each recursive call handles one element and asks the next call for the sum of the remaining elements:

sum of [a, b, c]
= a + sum of [b, c]
= a + (b + sum of [c])
= a + (b + (c + sum of []))
= a + (b + (c + 0))

The empty span is the base case and contributes a zero distance. Every operator+ call creates a new distance; no existing distance is copied, assigned, or modified.

constexpr
length::distance sum_distances(std::span<const length::distance> distances,
                               std::size_t index = 0)
{
  if (index == distances.size()) {
    return length::distance{0.0};
  }
  return distances[index] + sum_distances(distances, index + 1);
}

constexpr
length::distance average_distance(std::span<const length::distance> distances)
{
  return sum_distances(distances) / distances.size();
}

The std::array below is constructed from distance values produced by the arithmetic expressions. The array stores those values, and the span lets the averaging function work with any array size without exposing the array's ownership or representation.

The const in std::span<const length::distance> is important. It means that this function can inspect the distances through the span, but cannot modify them.

int main(){
  using namespace length::unit;

  constexpr auto work = 63.0_km;
  constexpr auto commute = 2 * work;
  constexpr auto gym = 2 * 1600.0_m;
  constexpr auto shopping = 2 * 1200.0_m;

  constexpr std::array weeks{
    4 * commute + gym + shopping,
    4 * commute + 2 * gym,
    4 * gym + 2 * shopping,
    5 * gym + shopping
  };

  constexpr auto avg_travel = average_distance(weeks);

  static_assert(static_cast<int>(avg_travel) == 264000);
  return static_cast<int>(avg_travel); // 264000m
}

Run It

This example does not print a value, but returns the final value from main. To inspect the generated code, copy it into the online Compiler explorer.

 1#include <cstddef>
 2#include <array>
 3#include <span>
 4
 5namespace length{
 6  class distance{
 7    public:
 8      explicit
 9      constexpr distance(double value)
10        :m{value}
11      {}
12
13      distance(const distance&) = delete;
14      distance& operator=(const distance&) = delete;
15      distance(distance&&) = delete;
16      distance& operator=(distance&&) = delete;
17
18      explicit constexpr operator int() const {
19        return static_cast<int>(m);
20      }
21      constexpr double value() const {
22        return m;
23      }
24    private:
25      double m; // meters
26  };
27
28  constexpr distance operator+(const distance& lhs, const distance& rhs){
29    return distance(lhs.value() + rhs.value());
30  }
31  constexpr distance operator-(const distance& lhs, const distance& rhs){
32    return distance(lhs.value() - rhs.value());
33  }
34  constexpr distance operator*(const distance& lhs, const distance& rhs){
35    return distance(lhs.value() * rhs.value());
36  }
37  constexpr distance operator*(double scalar, const distance& rhs){
38    return distance(scalar * rhs.value());
39  }
40  constexpr distance operator/(const distance& lhs, std::size_t denominator){
41    return distance(lhs.value() / denominator);
42  }
43} // end namespace length
44
45namespace length::unit {
46  constexpr distance operator""_km(long double d){
47   return distance(1000 * d);
48  }
49  constexpr distance operator""_m(long double m){
50    return distance(m);
51  }
52} // end namespace length::unit
53
54constexpr
55length::distance sum_distances(std::span<const length::distance> distances,
56                               std::size_t index = 0)
57{
58  if (index == distances.size()) {
59    return length::distance{0.0};
60  }
61  return distances[index] + sum_distances(distances, index + 1);
62}
63
64constexpr
65length::distance average_distance(std::span<const length::distance> distances)
66{
67  return sum_distances(distances) / distances.size();
68}
69
70int main(){
71  using namespace length::unit;
72
73  constexpr auto work = 63.0_km;
74  constexpr auto commute = 2 * work;
75  constexpr auto gym = 2 * 1600.0_m;
76  constexpr auto shopping = 2 * 1200.0_m;
77
78  constexpr std::array weeks{
79    4 * commute + gym + shopping,
80    4 * commute + 2 * gym,
81    4 * gym + 2 * shopping,
82    5 * gym + shopping
83  };
84
85  constexpr auto avg_travel = average_distance(weeks);
86
87  static_assert(static_cast<int>(avg_travel) == 264000);
88  return static_cast<int>(avg_travel); // 264000m
89}

12.7.1. Template metaprogramming approach

The immutable class above stores its value in an object. Another approach is to store the value in the type itself. This is the technique used by template metaprograms: a template argument supplies metadata, and template specializations or instantiations perform work while the program is compiled.

The classic example is a factorial calculation. The general template recursively refers to a smaller instantiation, and the specialization ends the recursion at 1:

 1#include <iostream>
 2
 3template <int N>
 4struct factorial{
 5  static int const value = N * factorial<N-1>::value;
 6};
 7
 8template <>
 9struct factorial<1>{
10  static int const value = 1;
11};
12
13int main() {
14   std::cout << "5! = " << factorial<5>::value;
15}

For a distance, the template argument can hold the number of meters. This requires C++20 because earlier C++ standards did not allow floating-point values as non-type template parameters:

template <double M>
struct distance{
  static constexpr double value = M;
};

Now each arithmetic result can have its own type. For example, adding two distances creates a distance whose template argument is the sum of the two input arguments:

template <double LHS, double RHS>
constexpr auto operator+(distance<LHS>, distance<RHS>)
  -> distance<LHS + RHS>{
  return {};
}

The same idea works for subtraction, multiplication, and division. A scalar also has to be compile-time metadata if the result type is to include the scaled value. An ordinary function parameter such as int scalar cannot be used in a return type, so we represent an integer scalar with std::integral_constant:

template <int N>
inline constexpr std::integral_constant<int, N> constant{};

template <int N, double M>
constexpr auto operator*(std::integral_constant<int, N>, distance<M>)
  -> distance<N * M>{
  return {};
}

This gives us the intended compile-time calculation while making the template metadata visible in the expression:

auto work = distance<63.0>{};
auto commute = constant<2> * work;

The braces are required because distance<63.0> names a type. The constant<2> wrapper is also intentional. Writing 2 * work would look simpler, but the value 2 would be an ordinary function argument and could not determine the result type distance<126.0>.

Here is the immutable distance class converted to use template metadata:

 1#include <type_traits>
 2
 3namespace length{
 4  template <double M>
 5  struct distance{
 6    static constexpr double value = M;
 7
 8    constexpr distance() = default;
 9    distance(const distance&) = default;
10    distance& operator=(const distance&) = delete;
11    distance(distance&&) = default;
12    distance& operator=(distance&&) = delete;
13  };
14
15  template <int N>
16  inline constexpr std::integral_constant<int, N> constant{};
17
18  template <double LHS, double RHS>
19  constexpr auto operator+(const distance<LHS>&, const distance<RHS>&)
20    -> distance<LHS + RHS>{
21    return {};
22  }
23
24  template <double LHS, double RHS>
25  constexpr auto operator-(const distance<LHS>&, const distance<RHS>&)
26    -> distance<LHS - RHS>{
27    return {};
28  }
29
30  template <int N, double M>
31  constexpr auto operator*(std::integral_constant<int, N>,
32                           const distance<M>&)
33    -> distance<N * M>{
34    return {};
35  }
36
37  template <double M, int N>
38  constexpr auto operator*(const distance<M>&,
39                           std::integral_constant<int, N>)
40    -> distance<M * N>{
41    return {};
42  }
43
44  template <int N, double M>
45  constexpr auto operator/(const distance<M>&,
46                           std::integral_constant<int, N>)
47    -> distance<M / N>{
48    return {};
49  }
50} // end namespace length
51
52int main(){
53  using namespace length;
54
55  constexpr auto work = distance<63.0>{};
56  constexpr auto commute = constant<2> * work;
57  constexpr auto total = commute + constant<2> * distance<1600.0>{};
58
59  static_assert(decltype(work)::value == 63.0);
60  static_assert(decltype(commute)::value == 126.0);
61  static_assert(decltype(total)::value == 3326.0);
62}

This technique makes immutability structural: an object has no data member that can change, and a different distance is represented by a different type. The trade-off is a more restrictive interface. Every value that must appear in a result type has to be available as template metadata, which is why this approach is useful for demonstrating metaprogramming but is not a general replacement for the previous immutable value class.

More to Explore