Object oriented programming in C++ — basic classes and inheritance

Written by:

Last updated: 16 June 2022


1 C++ classes overview

At the end of the previous lecture, we introduced structs, which define a new type containing some fields. That abstraction is known as “composition” since we are combining (composing) a type from a few other types.

Like most programming languages, we can go one step further and give the struct some member functions (aka. methods). In this example below, we add two constructors (functions that create and return an object) and a member function walk.

// animal.h

#pragma once



struct Animal {

  double age;

  int num_legs;

  int energy;



  Animal();

  Animal(double starting_age, int num_legs = 4);



  bool walk(int distance);

};
Snippet 1: animal.h

Notice that the member functions above are only declarations, as this is a header file. Similar to before, we put the definitions in a .cpp file in order to satisfy the One Definition Rule (as the header file may be included by more than one translation unit):

// animal.cpp

#include "animal.h"



#include <iostream>



Animal::Animal() : age(0), num_legs(4), energy(100) {

}



Animal::Animal(double starting_age, int num_legs)

    : age(starting_age), num_legs(num_legs), energy(100) {

}



bool Animal::walk(int distance) {

  std::cout << "Trying to walk..." << std::endl;

  if (distance <= energy) {

    energy -= distance;

    std::cout << "Walked for " << distance << " metres." << std::endl;

    return true;

  } else {

    std::cout << "Not enough energy!" << std::endl;

    return false;

  }

}
Snippet 2: animal.cpp
Member initializer list

The line

 : age(0), num_legs(4), energy(100)

that follows the head of the function definition is called the member initializer list. This specifies how each of the fields of Animal should be initialized. In this example, we are setting age to 0, num_legs to 4, and energy to 100.

We finally use this struct in the following way:

// main.cpp

#include <iostream>



#include "animal.h"



int main() {

  // Construct an animal using the second constructor

  Animal anim(10.0);



  // Call the `walk()` member function

  bool success = anim.walk(30);



  // Output whether the walk succeeded

  std::cout << (success ? "Success" : "Failure") << std::endl;

}
Snippet 3: main.cpp

1.1 Inline definitions of member functions

Just like in free functions, member functions can be defined inline by placing the function definitions in the header file and adding the inline keyword. However, functions with definitions inside the struct definition are implicitly inline, so the inline keyword is optional:

struct Animal {
  double age;
  int num_legs;
  int energy;

  // same as `inline Animal() : ...`
  Animal() : age(0), num_legs(4), energy(100) {
  }
  Animal(double starting_age, int num_legs = 4)
      : age(starting_age), num_legs(num_legs), energy(100) {
  }

  // same as `inline bool walk(int distance) { ...`
  bool walk(int distance) {
    std::cout << "Trying to walk..." << std::endl;
    if (distance <= energy) {
      energy -= distance;
      std::cout << "Walked for " << distance << " metres." << std::endl;
      return true;
    } else {
      std::cout << "Not enough energy!" << std::endl;
      return false;
    }
  }
};
Snippet 4: Same Animal struct but with inline function definitions

1.2 The this pointer

The this pointer is used when referring to fields or member functions from the current instance of the struct. For example, in the walk member function, energy is actually a shorthand for this->energy (this can be implicit most of the time).

We can rewrite walk to make this explicit:

  bool walk(int distance) {
    std::cout << "Trying to walk..." << std::endl;
    if (distance <= this->energy) {
      this->energy -= distance;
      std::cout << "Walked for " << distance << " metres." << std::endl;
      return true;
    } else {
      std::cout << "Not enough energy!" << std::endl;
      return false;
    }
  }
Snippet 5: walk member function in Animal, explicitly qualifying member fields with this
When do we need to write this explicitly?

Most of the time, this does not need to be written explicitly when accessing a member.

The most common situation requiring explicit this is when there is a local variable of the same name. Explicit this is also required when the base class comes from a template parameter (templates will be covered in a later lecture) and so the compiler cannot figure out if there is a member of the given name.

Note that this is a pointer to the current instance. This is why we use the -> operator to access a field from it. Later in this lecture, we will see an example where we use *this to refer to the current instance.

1.3 Implementation of member functions

We’ve talked about functions and calling conventions in the previous lecture, so it is clear how functions work at the assembly level. However, how do member functions work? How does the walk function access the energy of the correct Animal?

A hint comes from the concept of the this pointer.

Although not guaranteed by the C++ standard, every major compiler today implements member functions in a similar way — they add an additional “parameter” to the function call to pass a pointer to the current object (i.e. the this pointer).

For example, the member function

bool walk(int distance)

of Animal is equivalent to a free function

bool walk(Animal* this, int distance)

and the member function call anim.walk(30) is equivalent to a normal function call walk(&anim, 30). Note that this is “equivalent” in the sense that the this pointer is passed into the function as an additional parameter, however you cannot call a member function using the normal function call syntax or vice versa, and the names of a member function and the equivalent free function are mangled differently so you cannot import declarations using the incorrect syntax. In other words, this is an implementation detail that should not be directly visible to the programmer.

Because of the way member functions are implemented, while technically undefined behaviour, it is possible on almost all platforms to reinterpret a member function pointer as a normal function pointer (with an additional pointer argument at the front).

For example, this works in gcc on x86-64, and is equivalent to doing anim.walk(30);:

bool (*fptr)(Animal*, int) =
    reinterpret_cast<bool (*)(Animal*, int)>(&Animal::walk);
bool success = fptr(&anim, 30);
Snippet 6: Casting member function pointer to normal function pointer

1.4 const member functions

If the this pointer is really just another parameter passed into the function, it follows that we should be able to make it a const parameter (i.e. the difference between Animal* and const Animal*) if we do not intend to modify the current object. A simple getter is a good candidate for being const:

  int get_energy() const {
      return energy;
  }
Snippet 7: get_energy member function in Animal, which we mark as const because it does not need to modify the current object

We can then call get_energy with an Animal (or a reference of it) that is const:

Animal anim(10.0);

// Take a const reference to `anim`
const Animal& anim_ref = anim;

// Call a const member function
int energy = anim_ref.get_energy();
Snippet 8: Calling get_energy with a const Animal&

Try removing the const from get_energy — it will cause a compile error because you cannot call a non-const member function using a const reference. This shows that if your function does not modify any fields in the current object, you can choose whether or not to make it a const function. As a non-const object may be used to call a const function, marking a function as const whenever possible means that the function can be called on as many objects as possible.

Should we always make a member function const whenever no fields are modified?

For simple classes, this is usually desired. However, for classes that hold pointers to other objects, the class may semantically contain those objects even though they don’t syntactically contain them. Functions that modify those objects can technically be const (since they are only held as pointers), but since the class semantically contains those external objects, these functions should not be const. This is related to value semantics, and we will see some examples of such classes in later lectures.

1.5 Encapsulation, public and private access modifiers

When we add member functions to structs, we are grouping behaviour (the member functions) with state (the fields). Most of the time, the fields should then not be accessed directly by users of the object. This abstraction of state and behaviour ensures that the object can only be interacted with in a few fixed ways, freeing the user from thinking about the internal implementation of the object. This form of abstraction is known as “encapsulation”, and is one of the fundamental abstractions of object-oriented programming (OOP).

To limit the access of certain fields and member functions, C++ has access modifiers public and private (as well as protected and friend, which we will explain later), like most other OOP languages. For example, we could make the energy field private:

struct Animal {

  double age;

  int num_legs;



private:

  int energy;



public:

  Animal();

  Animal(double starting_age, int num_legs = 4);



  bool walk(int distance);



  int get_energy() const;

};
Snippet 9: Animal struct with a private field

Note that the private: makes all fields after it private until the next access modifier is encountered — this is slightly different from Java and C♯ where each field or method needs its own access modifier.

We could also make all the fields private (as is common for encapsulation), as well as add private member functions:

struct Animal {

private:

  double age;

  int num_legs;

  int energy;



public:

  Animal();

  Animal(double starting_age, int num_legs = 4);



  bool walk(int distance);



  int get_energy() const;



private:

  do_something_privately();

};
Snippet 10: Animal struct with many private fields

There is no need to place the private fields or member functions in any particular order — you can simply sprinkle more public: and private: in the struct definition. (Note that the order of fields is still important, since it affects the struct layout and padding.) Coding conventions may however prefer a certain order (e.g. public member functions go on top, followed by private member functions, and followed lastly by fields (which are almost always all private)).

1.5.1 struct vs class

In C++, there is a class keyword, that can be used almost interchangeably with the struct keyword, save for one difference: By default, fields and memeber functions in a struct are public, while those in a class are private. However, by most coding conventions, the struct keyword is used for composition abstractions, while the class keyword is used for encapsulation abstractions. We will use this convention for the rest of this course.

2 Inheritance

Inheritance allows us to implement a struct that shares some fields and member functions with another class (i.e. a “base” class). In this lecture, we will only talk about inheritance as a way to extend the functionality of a class. We will cover runtime polymorphism (i.e. virtual functions) in a later lecture.

Let’s start with a cleaned-up version of the Animal class from the previous section. For brevity, we use inline definitions here, but they can be placed out-of-line if desired.

class Animal {
private:
  double age;
  int num_legs;
  int energy;

public:
  Animal() : age(0), num_legs(4), energy(100) {
  }
  Animal(double starting_age, int num_legs = 4)
      : age(starting_age), num_legs(num_legs), energy(100) {
  }

  bool walk(int distance) {
    /* do stuff */
  }

  int get_energy() const {
    return energy;
  }
};
Snippet 11: The base class

We can inherit from this class like this:

class Cat : public Animal {
private:
  bool is_sleeping;
  int cuddliness;

public:
  Cat() : Animal(), is_sleeping(false) {
  }
  Cat(double starting_age) : Animal(starting_age), is_sleeping(false) {
  }

  void sleep() {
    is_sleeping = true;
  }

  void wake_up() {
    is_sleeping = false;
    energy = 100;
  }
};
Snippet 12: The derived class

This creates a new class with additional fields and member functions. These additional fields are tacked onto the end of the base class (Animal) like this (for this and all following examples we assume that int is 4 bytes long):