Skip to content

Latest commit

 

History

History
383 lines (323 loc) · 17.2 KB

const_correctness.md

File metadata and controls

383 lines (323 loc) · 17.2 KB

Const correctness

Video

Const correctness is a paradigm of how and when to use const with our objects and functions "correctly" to simplify the process of writing and reading of the code while using the compiler to protect us from changing data that should stay constant.

Modeling an example of passing the phone

So let's say our friend wants to borrow our phone, how would we represent it in C++?

I'd argue that both we and our friend would be objects of some classes, say GoodPerson and MehPerson. A GoodPerson would own a phone, i.e., the Phone object would be part of the data owned by the GoodPerson. And, being a GoodPerson open to the world, we will start by modelling a GoodPerson as a struct (see the lecture on classes for more on struct vs class). The MehPerson would have a function DoStuff that takes a reference to a phone:

struct GoodPerson {
  Phone phone;
};

class MehPerson {
 public:
  // Imagine the implementation is hidden in a library - no way for us to know
  // what this function actually does with the phone it gets.
  void DoStuff(Phone &phone);
};

We can then model the situation of passing over the phone by creating an object of each of these classes and passing the phone object from one to another to do stuff with it:

int main() {
  GoodPerson me{};
  MehPerson my_friend{};
  my_friend.DoStuff(me.phone);
  return 0;
}

Rule 1: pass by const reference

If you followed me talking about functions before, then you will instantly see an issue with the DoStuff function: it takes a non-const Phone object. Which allows the MehPerson class to modify the Phone object in any way it wants. Even worse, the implementation of MehPerson can be hidden away into a pre-compiled library from us so we can't know for sure what is happening there!

Which leads us to rule #1 of const correctness:

Rule 1️⃣: Always pass big objects you don't intend to change by a const reference to any function. Pass small objects by copy.

This is nothing new to us as we have talked about it before when we talked about functions. However, it does not help us much here, does it? The function DoStuff does not belong to us. For all we know, it belongs to some other library. But they are interested in our Phone object, right? So we do have some form of control.

Rule 2: create const objects

One way to enforce const correctness here would be to not have a mutable Phone object in the first place. And a way to achieve this would be to become an extremely stable person and create the object that represents us in the previous example as a const object in the first place:

int main() {
  const GoodPerson me{};
  MehPerson my_friend{};
  my_friend.DoStuff(me.phone);  // ❌ Won't compile unless DoStuff takes a const reference or a copy of the Phone
}

Which leads us to our rule #2 of const correctness:

Rule 2️⃣: Make every object const unless it explicitly needs to be changed. If you can design an object that does not need to change throughout its lifetime - do so.

The bad news here is that it is hard to achieve at all times. Think about it, while I believe that I am a very stable person, in no way I could model myself as a const object. Not even talking about the physiological or moral things, what if I want to buy a new Phone and replace my instance with this new one? So the GoodPerson object cannot be const here and such cases are quite common.

Rules 3 and 4: return data by const reference, mark functions that don't change the object as const

So how else can we make sure that the DoStuff function must take a constant Phone reference?

We do have another trick up our sleeves. It's time for the GoodPerson struct to close up a little and become a class. This allows us a lot more control over how the others get access to our data.

In our case, we would make GoodPerson a class that takes a reference to a temporary Phone (an rref) object in its constructor. We would then move the underlying Phone object into its private data and hold it there.

We also have to think of how to expose this internal Phone object to the world, so we implement a "getter" function. This function would return a const reference to our internal Phone object. Furthermore, this function is not supposed to change the underlying object in any way and we have a mechanism to indicate this to the compiler: we mark the whole function const too!

#include <utility>  // For std::move

class GoodPerson {
 public:
  explicit GoodPerson(Phone &&phone) : phone_{std::move(phone)} {}
  const Phone &phone() const { return phone_; }

 private:
  Phone phone_;
};

int main() {
  GoodPerson me{Phone{}};
  MehPerson my_friend{};
  my_friend.DoStuff(me.phone());
}

Which allows us to formulate rules 3 and 4 of const correctness:

Rule 3️⃣: Prefer returning a const reference to the private data of complex types of any object if you need to expose them to the user of your class. Return a copy for simple types instead. Only return a non-const reference if your class implements a data-agnostic container, e.g., a std::vector or alike.

Rule 4️⃣: Mark every class method as const unless it is explicitly supposed to change the underlying object. Prefer const methods when designing a class.

The const class methods can be confusing to beginners

It is important to note here, that if a class method is not marked as const the compiler assumes that this method can change the underlying object.

I want to note that in my experience, the const class functions are the reason most of the beginners struggle with const correctness in C++. Let's illustrate why.

You have a class Foo with a function bar that is a "getter" and does not change the content of the Foo object:

class Foo {
 public:
  int bar() { return bar_; }

 private:
  int bar_{};
};

Now the Foo object is passed by a const reference to some function called Whatever that calls bar() on it:

void Whatever(const Foo& foo) {
  foo.bar();
}

int main() {
  Foo foo{};
  Whatever(foo);
  return 0;
}

What will happen if we try to compile this code? The compiler will complain:

main.cpp:4:3: error: 'this' argument to member function 'bar' has type 'const Foo', but function is not marked const
   foo.bar();
   ^~~

At this point a lot of beginners will become frustrated: they know that they don't change the foo object, they pass a const reference to the Whatever function and seem to be doing everything right. But the compiler sees that the foo.bar() method is not marked as const and assumes the worst. So it will complain that calling a non-const method on a const reference to an object might change the underlying object, which is forbidden. I've seen many frustrated students struggle with this concept but I, for one, like how it's implemented. Anyway, if you follow rules 3 and 4 that we just introduced, you should be fine 😉

Rule 5: don't mark class data as const unless you're implementing a view

Finally, there is just one more place where const can be used and that I have to mention here. We can actually have const data within a class. So, coming back to our example, we could make the Phone object constant within our GoodPerson structure:

struct GoodPerson {
  const Phone phone;  // 😱 not the best idea.
};

However, this is nearly never a good idea.

Essentially by having const data any object of such a class is doomed to live and die within a single scope with no way to be moved to any different scope.

This is rarely useful with one significant outlier - the view paradigm. As one typical example consider this: say, a certain class has its interface but we would want it to have a different interface when we work with it. One way to achieve this is to introduce a thin wrapper around the class in question that holds a const reference to the object of interest and introduces new interface to working with this object. Feels a bit hand-wavy, right? Let's think of a concrete example then.

Let's say, our friend and us from the previous example figure that the MehPerson class does not need the whole Phone object. They just need the weather! So they change the DoStuff function to take a const reference to the Weather object instead, which the Phone object readily provides:

int main() {
  GoodPerson me{Phone{}};
  MehPerson my_friend{};
  my_friend.DoStuff(me.phone().weather());
  return 0;
}

However, the Weather object only has a function to get a forecast by GNSS coordinate:

class Weather {
 public:
  Forecast GetWeatherForLocation(const Latitude& latitude, const Longitude& longitude) const;
  // Other stuff in the Weather object
};

This is convenient for a very precise forecast but our friend wants to know the weather in Interlaken, remember? So they wrap the constant Weather reference into a view object, that uses some other functions and provides a better interface, calling the weather.GetWeatherForLocation(lat, lon) under the hood:

#include <string>

// Get latitude and longitude provided a city name
LatLon GetGnssCoordinatesForCity(const std::string& city_name);

class CityWeatherView {
 public:
  explicit CityWeatherView(const Weather &weather) : weather_{weather} {}

  Forecast GetWeatherForCity(const std::string &city_name) const {
    const auto lat_lon = GetGnssCoordinatesForCity(city_name);
    // ❓Question: should GetWeatherForLocation be a const method?
    return weather_.GetWeatherForLocation(lat_lon.latitude, lat_lon.longitude);
  }

 private:
  const Weather &weather_;
};

Such a CityWeatherView is not movable and exists for the sole purpose of simplifying the interface to the Weather object. This class is then typically used locally within some scope, say the DoStuff method of the MehPerson class:

void MehPerson::DoStuff(const Weather &weather) {
  const CityWeatherView weather_view{weather};
  const auto forecast = weather_view.GetWeatherForCity("interlaken");
  // Do smth with the forecast.
}

While you might argue that in this example we don't need the view class as we could've just called the GetGnssCoordinatesForCity function directly from the DoStuff function, imagine what would happen if this would happen in many parts of the code base? And what if the view helper function was longer than a couple of lines? These lines of code would then get copied in all the places that they would be needed, requiring us to repeat ourselves all the time, making changes that will inevitably come next much harder.

Anyway, this leads us to our last rule of const correctness:

Rule 5️⃣: Never make class data const unless it is a const reference to other object when you are implementing a view over that object.

💡 There is a slight caveat to that: we can and should mark the class static data const, but we'll talk about it some other time.

Summary

Ok, we're done now! If you follow these 5 rules you should have no problem with const in C++. Not only that, you will actually employ the compiler as your friend who is able to look over your shoulder and find the mistakes in your logic. In the end, if you're trying to call a function not marked as const on a const object - you probably didn't really mean it! If the compiler does not catch this, you will have to spend time searching for the logic bug, which, in my experience, is much harder. With time, using these rules will become second nature and will help you writing high-quality code that ends up saving you and the others time and nerves.