Skip to content

Latest commit

 

History

History
432 lines (363 loc) · 19.5 KB

classes_intro.md

File metadata and controls

432 lines (363 loc) · 19.5 KB

Simple classes in C++

Video

By now we all should be quite comfortable using functions, but there are some things for which using just the functions alone doesn't really provide enough abstraction. The solution to this is to introduce the custom types, otherwise known as classes.

The cat's life example

Let me illustrate what I'm talking about with an example. Imagine that we want to write a game about a cat roaming the world looking for fun adventures.

The cat starts off as pretty grumpy and get's its happiness up by doing some fun mischief (like throwing a vase from a table). It's not all fun though and some actions are dangerous and sometimes (rarely) the cat will lose 1 of its 9 lives. We win the game once the cat's happiness reaches 100% or it has explored every corner of our world. If our cat reaches 0 lives, we lose the game.

Sounds good? So how can we model this in code taking into account everything we know until now?

Let's model this using just the functions first

Before we jump into coding this, we first would want to model our world and the cat using some variables.

For simplicity, we will assume a line-like world, which we then can represent as a vector of fun and dangerous events randomly generated for each game. The cat then can be represented by its position in the world, number of lives, and happiness. The coordinate increases and the cat "finds" more events which update the cat's parameters.

💡 I suggest you to pause here and to try to model this game on your own. How would you write it using functions? Give it a try - it's a good exercise!

One way or another, we will probably come up with something like this:

#include <iostream>
#include <vector>

namespace {
constexpr auto kMaxHappiness = 100;
constexpr auto kMaxLives = 9;
} // namespace

// Feel free to have more events here of course
enum class Event {
  kNone,
  kFun,
  kDanger,
  kWorldExplored,
};

// Generate a random world to explore
std::vector<Event> GenerateWorld();

// Move the cat in some way
std::size_t MoveCat(std::size_t cat_position);

// Return a random event in the simplest case
Event FindNextAdventure(const std::vector<Event>& world, std::size_t cat_position);

// Update number of lives depending on what happened
int UpdateLives(Event event, int current_lives);

// Update happiness depending on what happened
int UpdateHappiness(Event event, int current_happiness);

int main() {
  const auto world = GenerateWorld();
  std::size_t cat_position{};
  int cat_lives_counter{kMaxLives};
  int cat_happiness{0};
  while (true) {
    cat_position = MoveCat(cat_position);
    const auto event = FindNextAdventure(world, cat_position);
    if (event == Event::kWorldExplored) {
      std::cout << "The world is fully explored. We won!\n";
      break;
    }
    cat_lives_counter = UpdateLives(event, cat_lives_counter);
    if (cat_lives_counter < 1) {
      std::cout << "Cat ran out of lives. We lost...\n";
      break;
    }
    cat_happiness = UpdateHappiness(event, cat_happiness);
    if (cat_happiness >= kMaxHappiness) {
      std::cout << "Cat fully happy! We won!\n";
      break;
    }
  }
  return 0;
}

💡 Note how I did not implement the functions and just declared them - it is usually enough when designing something to get a feeling for the resulting system. We can fill the implementation once we're happy with the overall design.

Once we actually implement the functions, the code works and is fun to play with (you can add more events and print the ones your cat encounters during the exploration for example) but we quickly start seeing limitations of this approach once we try extending it, for example to multiple cats.

Extension to more cats

We would need to store all the cat properties in vectors. So we'll have a vector for the cat_lives_counter, cat_happiness, and for cat_position so that there is one for each cat. Not only this is not neat, this opens us up to the famous "off by one" bugs as we now have to process multiple vectors in unison and given that the code will inevitably change later, this will lead to errors.

Model this nicer with custom types

Wouldn't it be nice if we could capture our game setup in a more abstract way to keep the code readable and the logic clear?

And of course there is such a way (otherwise there would be no lecture about it 😉), and this way involves custom types.

Before we go into the mechanics of the custom types, let's first see how we could theoretically use them on an abstract level. The idea is that we bundle our data along with the methods to process them into the new Cat and World types:

#include <iostream>
#include <vector>

// Magically define the Cat and the World types here

int main() {
  World world{};
  Cat cat{};
  while(true) {
    cat.Move();
    const auto event = cat.FindNextAdventure(world);
    if (event == Event::kWorldExplored) {
      std::cout << "The world is fully explored. We won!\n";
      break;
    }
    cat.UpdateLives(event);
    if (!cat.IsAlive()) {
      std::cout << "Cat ran out of lives. We lost...\n";
      break;
    }
    cat.UpdateHappiness(event);
    if (cat.IsTotallyHappy()) {
      std::cout << "Cat fully happy! We won!\n";
      break;
    }
  }
  return 0;
}

Or, if we think more about it, when we interact with our cat, the Move, UpdateLives and the UpdateHappiness functions look more like an implementation detail so we could hide them into our existing FindNextAdventure function, further simplifying the cat interface!

#include <iostream>
#include <vector>

// Magically define the Cat and the World types here

int main() {
  World world{};
  Cat cat{};
  while(true) {
    const auto event = cat.FindNextAdventure(world);
    if (event == Event::kWorldExplored) {
      std::cout << "The world is fully explored. We won!\n";
      break;
    }
    if (!cat.IsAlive()) {
      std::cout << "Cat ran out of lives. We lost...\n";
      break;
    }
    if (cat.IsTotallyHappy()) {
      std::cout << "Cat fully happy! We won!\n";
      break;
    }
  }
  return 0;
}

Now this looks much better than before! The new code is more readable for humans and is going to be easier to maintain!

💡 This is just one of the ways to design this game. If you have the time, I encourage you to think about the alternatives. Can you think of any? Write what you thought about in the description! Also, once you're at it, think how this can be extended to multiple cats 😉

Time for formalities

Now that we know at least a little about what we need the custom types for, we can talk about how to write them!

How to call things

First of all, let's get the language out of the way:

Cat grumpy_uninitialized_cat;
Cat cute_initialized_cat{};  // ✅ Force the initialization of all Cat  data

🚨 Here, Cat (with the capital C) is called a class or a user-defined type. The grumpy_uninitialized_cat and the cute_initialized_cat are variables of type Cat. They are also often called instances or an object of type Cat.

💡 Note that in this example grumpy_uninitialized_cat might remain uninitialized, which might lead to undefined behavior. Unless you have a good reason for it, always initialize variables, also of your custom types.

🎨 In this course we will name the custom types in CamelCase and the variables in snake_case just as any other variable.

What does custom type hold?

A custom type consists of 2 things:

  • The data it holds
  • The methods to process these data

💡 This is usually referred to as encapsulation, which is a fancy word that just means that we put data that belongs together in one place along with the methods to process them.

Let's define our own type Cat!

First of all, there are two C++ keywords that indicate to the compiler that we are defining our own custom type:

  • class
  • struct

We're going to use class for now and talk about struct at the end of this lecture.

In the beginning of this lecture, we kinda already designed our Cat class methods without even thinking about the data it has to store.

In other words, what a class does is more important than how it does it. The class methods form its public interface answering the what question, while the data the class holds answers the how question and is just an implementation detail.

That's why the methods always come before the data! If we change the class data without changing its public interface nobody should notice!

class Cat {
public:
  bool IsAlive() const { return number_of_lives_ > 0; }
  bool IsTotallyHappy() const { return happiness_ >= kMaxHappiness; }

  Event FindNextAdventure(const World &world) {
    Move();
    if (position_ >= world.size()) {
      return Event::kWorldExplored;
    }
    const auto event = world[position_];
    UpdateNumberOfLives(event);
    UpdateHappiness(event);
    return event;
  }

private:
  void Move() { position_++; }
  void UpdateHappiness(Event event) {
    if (event == Event::kDanger) {
      number_of_lives_--;
    }
  }
  void UpdateNumberOfLives(Event event) {
    if (event == Event::kFun) {
      happiness_ += kHappinessIncrement;
    }
  }

  int number_of_lives_{kMaxLives};
  int happiness_{};
  std::size_t position_{};
}; // <- Note a semicolon here!

There is a whole lot of new stuff going on here! Let's unpack it all one by one.

Access modifiers public and private

These define how the data and methods of the class are accessed through an instance of this class.

  • Everything under public: can be accessed directly from the outside, i.e., cat.IsAlive() will compile.
  • Everything under private: can only be accessed from within the methods of the class itself, i.e., cat.Move() won't compile.

💡 There is also the protected access modifier but we will talk about it later.

🚨 This is the fundamental principle behind encapsulation: the data of our class is only reachable from within the methods of our class, so every instance of such a class has full control over its data.

🎨 Also note that we always start with public and private part follows later. Reason being that we read code from the top and care about what we actually can call more than about some obscure implementation details.

Let's look at the class data

All the data in a class must live under private: part.

🎨 Name private variables of a class in snake_case_ with the trailing underscore _. It helps differentiate which variable we work with in the class methods.

💡 Note that we don't leave variables uninitialized as discussed in the lecture on variable. This helps us avoid the hard-to-debug undefined behavior.

What's with the const after the function?

Essentially, this means that these functions guarantee that the object on which this function is called will not be changed. It works by the compiler basically assuming that this object is const while in the scope of such a function.

🚨 If a method is not marked as const the compiler assumes that it might need to change the object data, so if we call a non-const method on a const object, the code won't compile!

But what about struct?

Ok, I Nearly forgot to talk about struct. Structs are exactly the same as classes with just one difference. If you leave a class without access modifiers, everything will be private. If you do the same with a struct, everything will be public. Other than that they are exactly the same. Go ahead and try to change our cat class into a struct to experiment with this!

💡 Having everything public though breaks encapsulation. When everybody has access to your internal data you don't really control it anymore. So a rule of thumb is to use struct only for pure data storage, if you need any methods use a class instead.

That's about it for now

Now we know the basics of what custom types are, why we would care about them and how to write our own ones! This is an important topic to understand as a lot of what modern C++ is builds on top of a solid understanding of what custom types are. In the following lectures we are going to extend what we learned here with further details and learn how to use classes and structs to achieve great things!