Chapter 9 - Notes

Classes

Classes are abstract objects that share common:

  • attributes

  • behaviour (methods)

Declaring a class

struct Human {
    // Attributes, or member variables
    std::string name;
    std::string gender;
    int netWorth;
    
    // Member functions or member methods 
    void introduceSelf();
    void addNetWorth(int amount);
};

Classes provide a way to create our own data types, which allows us to encapsulate data within attributes which can change the behaviour of the corresponding objects.

Encapsulation is the ability to logically group data and functions together.

Objects and Instances

Classes are simply abstract objects, like Human, Country, or Sport. Declaring a class has no effect on the execution of a program.

These classes are simply blueprints for which we can construct one or more instances from. Using the Sport class as an example, we may create objects referring to the sports tennis, running, or golf.

Creating an instance

// Static declaration 
int number;
Human some_human;

// Dynamic declaration
int number_ptr = new int(5);
delete number_ptr;

Human* human_ptr = new Human();
delete human_ptr;

Accessing members

Consider the following class definition:

struct Human {
    std::string name;
    int age;
    
    void introduceSelf() {
        std::cout << "Hi, my name is " << name 
                  << " and I am " << age 
                  << " years old" 
                  << std::endl;
    }
};

Using the Dot Operator

Members of an instance may be accessed using the dot operator (.):

Human some_human;

// Modifying member attributes
some_human.name = "Andrew";
some_human.age = 20;

// Accessing member attributes
std::cout << some_human.name << std::endl;
std::cout << some_human.age << std::endl;

// Calling member functions
some_human.introduceSelf();

Using the Pointer Operator

Members of an instance pointer may be accessed using the pointer operator (->):

Human* human_ptr = new Human;

// Modifying member attributes
(*human_ptr).name = "Andrew";
(*human_ptr).age = 20;

// Accessing member attributes 
std::cout << human_ptr->name << std::endl;
std::cout << human_ptr->age << std::endl;

// Calling member functions
human_ptr->introduceSelf();

delete human_ptr;

The attributes for each class instance is specific only to each instance. That is to say that modifying the attributes for one instance will not affect the attributes of another.

Keywords 'public' and 'private'

Class members, both attributes and methods, can be classified as private or public.

  • public: members that can be accessed and used by anyone in possession of an instance of the class

  • private: members that can only be used within the class

class Human {
private:
    // Everything within this block is private
    int age;
    string name;
    // Everything within this block is public 
public:
    int getAge() { 
        return age; 
    }
    
    void setAge(int newAge) {
        if (newAge > 0) {
            age = newAge;
        }
    }
};
Human adam; 

std::cout << adam.age << std::endl;  // compile error 
std::cout << adam.getAge() << std::endl;  // OK

This prevents anyone from modifying the name and age attributes of an instance of Human.

Classes have private members by default.

Structs have public members by default.

Constructors

A constructor is the function invoked during the instantiation of a class.

Declaring and Implementing a Constructor

class Human {
public:
    Human();  // Declaration
};
class Human {
public:
    Human() {}  // Implementation 
};

This is admittedly not very useful, here's a useful version.

class Human {
public:
    std::string name;
    int age;
    
    Human(std::string some_name, int some_age) {
        name = some_name;
        age = some_age;
    }
};

Which allows us to construct instances in the following manner:

Human some_human = Human("Andrew", 20);

Overloading Constructors

Much like regular functions, constructors can also be overloaded

class Human {
private:
    std::string name;
    int age;

public:    
    Human() {
        name = "unknown";
        age = -1;
    }
    
    Human(int some_age) {
        name = "unknown";
        age = some_age;
    }
    
    Human(std::string some_name, int some_age) {
        name = some_name;
        age = some_age;
    }
};

Which allows us to construct instances in the following ways:

Human zero;  // Constructor 1 
Human first = Human();  // Constructor 1
Human second = Human(20);  // Constructor 2
Human third = Human("Andrew", 20);  // Constructor 3 

Constructor with Default Parameter Values

Much like regular functions, constructor parameters can also be provided with a default value, as an alternative to overloading.

class Human {
private:
    std::string name;
    int age;

public:       
    Human(std::string some_name = "unknown", int some_age = 0) {
        name = some_name;
        age = some_age;
    }
};
Human alice = Human(); // Uses default name "unknown" and default age 0
Human bob = Human("Bob"); // Uses default age 0
Human charlie = Human("Charlie", 20);  // Doesn't use any defaults

Constructor with Initialisation Lists

class Human {
private:
    int age;
    string name;

public:
    Human(string some_name = "unknown", int humansAge = 0) 
          : name(some_name), age(some_age) {}
};

This is the same as the previous example, but will be useful for invoking base class constructors, which we will learn about later.

Destructors

Destructors are functions that are called when an instance is destroyed. This will occur in two cases:

  • When an statically created object goes out of scope

  • When delete is called on a dynamically allocated object

Declaring and Implementing a Destructor

The syntax for a destructor is very similar to that of a constructor, except prefixed with a tilde (~):

struct Human {
    ~Human();  // Declaration 
}
struct Human {
    ~Human() {};  // Implementation
}

When to use a destructor

Since the destructor is invoked when an object of a class is destroyed, it is the ideal place to reset any variables, or release any memory dynamically allocated during the lifetime of the object.

struct Human {
    int* age_ptr;
    
    Human() {
         // Each `Human` object dynamically allocates an integer
        age_ptr = new int(0); 
    }
    
    ~Human() {
        // Which should be cleaned up in the destructor
        delete age_ptr;
    }
}

Copy Constructors

Arguments passed into a function like the below are copied:

double foo(int a, int b); 

Arguments sent as parameters a and b are copied when foo() is invoked. This applies to objects or instances of classes as well.

Shallow copying and associated problems

Classes such as Human in the previous example contain a member variable age_ptr that points to dynamically allocated memory. When an instance of this class is copied, the pointer member is copied, but not the underlying memory that it references, resulting in two objects pointing to the same dynamically allocated integer in memory.

When the first object is destructed, delete deallocates the memory in the destructor, therefore invalidating the pointer held by the original object. This will cause a double-free error when the original object is later destructed.

Such copies are shallow and threaten the stability of the program.

Ensuring deep copies with the copy constructor

Copy constructors are special constructors that are invoked every time an object of the class is copied.

struct Human {
    // ...
    Human(const Human& original); // copy constructor 
}

The copy constructor takes an object of the same class, by reference, as a parameter. This parameter will be the source object that you will be copying from.

You would use the reference to this object to ensure a deep copy of all necessary in the source.

struct Human {
    int* age_ptr;
    Human() {
        age_ptr = new int(5);
    }
    
    Human(const Human& original) {
        if (original.age_ptr != nullptr) {
            age_ptr = new int;  // Create a new int ourselves
            *age_ptr = *(original.age_ptr);  // Copy the value
        }
    }
}

Using const in the copy constructor ensures that the copy constructor does not modify the source object that is being referred to.

The parameter in the copy constructor has to be passed by a reference, otherwise the copy constructor will invoke itself to copy the parameter, which will invoke itself again to copy the parameter, which will invoke itself again... you get the point.

Ensuring deep copies with the copy assignment constructor

The copy constructor ensures deep copies in the case of pass-by-copy function calls, but does not fix copies via assignment:

Human first;
Human second;
second = first; // `first` is copied into `second`!
// double-free happens when the program exits 

Therefore, we also need to implement the copy assignment constructor:

struct Human {
    int* age_ptr;
    Human() {
        age_ptr = new int(5);
    }
    
    Human(const Human& original) {
        if (original.age_ptr != nullptr) {
            age_ptr = new int;  // Create a new int ourselves
            *age_ptr = *(original.age_ptr);  // Copy the value
        }
    }
    
    Human& operator=(const Human& original) {
        if (original.age_ptr != nullptr) {
            age_ptr = new int;  // Create a new int ourselves
            *age_ptr = *(original.age_ptr);  // Copy the value
        }
        return *this;
    }
}

Move Constructors

Static Class Members and Functions

When the keyword static is used on a class' data member, it ensures that the member is shared across all instances.

When static is used on a local variable declared within the scope of a function, it ensures that the variable retains its value between function calls.

When static is used on a member function, the method is shared across all instances of the class. Additionally static class methods do not require an instance to be invoked.

Different Uses of Constructors and Destructors

Singleton Classes

Singletons are classes that only permit one instance to exist, the creation of additional instances are prohibited.

class Singleton {
private:
    Singleton() {};  // private default constructor
    Singleton(const Singleton&); // private copy constructor 
    const Singleton& operator=(const Singleton&); // private copy assignment operator
    
    std::string name;
public:
    static Singleton& GetInstance() 
    {
        static Singleton instance;
        return instance;
    }
    
    std::string GetName() 
    {
        return name;
    }
    
    void SetName(string name)
    {
        this.name = name;
    }
}

No-Copy Classes

No copy classes are classes that prohibit being copied:

  • through a pass-by-copy function parameter

  • through an assignment operator

This is done by making both the copy constructor and copy assignment operator private.

class NoCopy {
private:
    NoCopy(const NoCopy&); // private copy constructor 
    NoCopy& operator= (const NoCopy&);  // private copy assignment operator
}

Heap-Only Classes

Heap only classes are classes that can only be instantiated dynamically (on the heap) and never statically (on the stack).

This is done by declaring a private destructor, and exposing the destructor through a static member function.

class VeryLargeStructure {
private:
    ~VeryLargeStructure();  // private destructor

public:
    static void Destroy(VeryLargeStructure* instance) {
        delete instance;  // can be invoked here since it's within the class
    }
}

This is not a very common pattern, and is a remnant of older days where stack memory is limited, and expensive to unwind, compared to heap memory.

`this` Pointer

this is a reserved keyword applicable anywhere within the scope of a class, and contains the address of the object itself.

This does not have many applications, other than being able to disambiguate between local variables and member methods with the same name:

struct Human {
    std::string name;
    
    Human(std::string name) {
        this->name = name;  // This is ok!
    }
};

Note that this is not available for static class methods, since static methods do not require an instance to invoke.

Size of a class

The operator sizeof() is used to determine the amount of memory, in bytes, used by a specific type.

This operator can be used for classes too, and report the sum of bytes consumed by all the attributes contained within the class definition.

Word Padding

Let's use a worked example for this.

Friend Classes

Member functions and attributes classified as private are not accessible from functions outside of the class.

This rule can be waived for classes or functions declared as friend classes or functions using the keyword friend.

class Human {
    std::string name;
    int age;
    friend void Describe(const Human& human);  // Friend function
public:
    Human(std::string name, int age) : name(name), age(age) {};
}

void Describe(const Human& human) {
    std::cout << human.name << " is " << human.age << " years old." << std::endl;
}

Friend functions are defined using the keyword friend followed by the signature of the function that is to be friended.

Friend classes are defined using the keyword friend class followed by the name of the class that is to be friended.

Unions

A union is a special class type where only one of its data members is active at a time. Thus, a union can accommodate multiple types of data members, but is actually only one of them.

Declaring a Union

union DayInfoValue {
    char day;
    bool isRaining;
    float temperature;
}

When to use a union

A union is often used as a member of a struct or class to model a complex data type.

The benefits of using a union is that it saves space as opposed to storing each possible data as a separate attribute.

enum DayInfoType {
    DAY,
    RAIN,
    TEMPERATURE,
}

struct DayInfo {
    DayInfoType type;
    DayInfoValue value;
    
    void display() {
        switch (type):
            case DayInfoType::DAY:
                std::cout << "The day is " << value.day << std::endl;
                break;
            case DayInfoType::RAIN:
                std::cout << "Today " << (value.isRaining ? "is" : "is not") << " raining" << std::endl;
                break;
            case DayInfoType::TEMPERATURE:
                std::cout << "The temperature today is " << value.temperature << " celcius" << std::endl;
                break;
    }
}

Aggregate Initialisation

Aggregate initialisation is initialisation with curly braces.

// With value types
int a {3};
double b {5};
float b {a};

// With array types 
int nums[] = {5, 6, 10};
char word[6] = {'h', 'e', 'l', 'l', 'o', '\0'};

Aggregate initialisation can be applied to any aggregate types.

Classes, structs, and unions are aggregates too.

There are some restrictions imposed by the C++ standard on the specification of structs or classes that can be aggregates. But a safe definition:

  • Classes and structs that comprise of only public and non-static data members

  • Contains no private or protected data members

  • Contain no virtual member functions

  • Feature no or only public inheritance

  • No user-defined constructors

can be aggregates.

The following are valid aggregates:

struct Foo {
    int integer;
    double decimal;
}

Foo myFoo {10, 10.2};
struct Bar {
    int num;
    char word[6];
    int numbers[5];
}

Bar myBar {42, {'h', 'e', 'l', 'l', 'o', '\0'}, {2, 4, 6, 8, 10}};

Aggregate initialisation should be avoided for unions, as it will only initialise the first member defined.

Forward Declaring Classes

So far we've only defined classes. If we only wanted to declare a class for forward declaration purposes, we can do that.

class Foo {
    int x;
    Foo(int x);  // Delcaring a constructor (not implementing!)
    void bar();  // Declaring a member function (not implementing!) 
};

Foo::Foo(int x): x(x) {}; // Defining

void Foo::bar() {   // Defining
    std::cout << x << std::endl;
}

This is similar to declaring a function using its prototype, then defining it later.

int sum(int x, int y);  // Declaring 

// ... 

int sum(int x, int y) {  // Implementing / defining 
    return x + y; 
}

Last updated

Was this helpful?