Road to C++ Programmer #3 - Classes

Last Edited: 9/6/2024

The blog post introduces the concept of classes in C++.

C++ Class

Class

A class is like a template or a skelton that defines the attributes and methods of an object. The following shows how we can define a class and initialize instances of objects.

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        // public methods
        void barks () {
            cout << "Woof Woof!" << endl;
        }
};
 
int main () {
 
    Dog dog1;
    dog1.name = "Bell";
    dog2.age = 9;
 
    cout << "Dog's name is " << dog1.name << ", and it is " << dog1.age << " years old." << endl;
    // => Dog's name is Bell, and it is 9 years old. 
 
    dog1.barks();
    // => Woof Woof! 
    
    return 0;
}

The public keyword means that the attributes and methods are accessible from the outside. (will be covered in defail in the next article. ) Although the class may seem like a small feature, it has created a new programming paradigm, and we will explore its impact going forward.

Constructor & Destructor

When declaring and initializing an object with a class, we often want to set default values. We can achieve this by setting up a default constructor, as shown below.

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        // public methods
        void barks () {
            cout << "Woof Woof!" << endl;
        }
 
    Dog () {
        name = "Unnamed";
        age = 0;
    }
};
 
int main () {
    Dog dog;
    cout << "Dog's name: " << dog.name << ", Dog's age: " << dog.age << endl;
    // => Dog's name: Unnamed, Dog's age: 0
    return 0;
}

The default constructor sets the values of the attributes to default. You can also set up a parameterized constructor, which takes parameters and initializes based on them.

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        // public methods
        void barks () {
            cout << "Woof Woof!" << endl;
        }
 
    Dog () {
        name = "Unnamed";
        age = 0;
    }
 
    Dog (string n, int a) {
        name = n;
        age = a;
    }
};
 
int main () {
    Dog dog;
    cout << "Dog's name: " << dog.name << ", Dog's age: " << dog.age << endl;
    // => Dog's name: Unnamed, Dog's age: 0
 
    Dog dog1("Bell", 9);
    cout << "Dog's name: " << dog1.name << ", Dog's age: " << dog1.age << endl;
    // => Dog's name: Bell, Dog's age: 9
    return 0;
}

You can create multiple constructors with different parameters, and the compiler will identify the appropriate one. The constructor is also useful when dynamically allocating memory to an object’s attributes.

class ArrayList {
    public:
        // public attributes
        int size;
        int *array;
 
    ArrayList (int s) {
        size = s;
        *array = new int[s];
    }
};

However, you need to free the memory to prevent memory leaks. You can do that with a destructor, which runs when the object is no longer in use. You can define a destructor as follows:

class ArrayList {
    public:
        // public attributes
        int size;
        int *array;
 
    ArrayList (int s) {
        size = s;
        *array = new int[s];
    }
 
    ~ArrayList () {
        delete array;
    }
};

Member Initializer Lists

If you look closely at the example of the Dog class, you'll notice that the parameter names are set to n and a instead of name and age to prevent ambiguity with the class attributes. However, it would be clearer if we could use the same names. To do so, we can use member initializer lists, as shown below.

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        // public methods
        void barks () {
            cout << "Woof Woof!" << endl;
        }
 
    Dog (string name, int age) : 
        name(name),
        age(age) {}
};

When defining attributes, the member initialization functions are automatically created, allowing you to initialize attributes with the same names as the inputs. Member initializer lists are also useful when initializing const attributes, object attributes without a default constructor, or reference attributes.

this Keyword

The this keyword is a pointer to the object being created. It provides another way to use the same names for parameters and attributes.

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        // public methods
        void barks () {
            cout << "Woof Woof!" << endl;
        }
 
    Dog (string name, int age) {
        this->name = name;
        this->age = age;
    }
};

It can also be useful for creating method chaining, which looks like the following.

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        // public methods
        Dog& barks () {
            cout << "Woof Woof!" << endl;
            return *this; // dereference this pointer and return as reference
        }
 
        Dog& sits () {
            cout << "Sit Down." << endl;
            return *this;
        }
};
 
int main () {
    Dog dog;
    dog.barks().sits(); // Method Chaining 
    // => (dog.barks() => dog).sits()
    return 0;
}

Constructor Delegation & Copy Constructor

When a class has multiple constructors with different numbers of parameters, you can use constructor delegation to shorten the code. (Depending on the C++ version.)

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        Dog (string name) {
            this -> name = name;
        }
 
        Dog (string name, int age) : Dog (name) {
            this -> age = age;
        }
};

You can also pass an object to a constructor to create a copy, as shown below.

class Dog {
    public:
        // public attributes
        string name;
        int age;
 
        Dog (string name) {
            this -> name = name;
        }
 
        Dog (string name, int age) : Dog (name) {
            this -> age = age;
        }
 
        Dog (const Dog& dog) {
            this -> name = dog.name;
            this -> age = dog.age;
        }
};
 
int main () {
    Dog dog("Bell", 9);
    Dog dogCpy = dog;
    cout << "Copied name: " << dogCpy.name << endl;
    // => Copied name: Bell
    return 0;
}

The copy constructor above is the default copy constructor that is made available by default. The reason for introducing it here is that you need to be careful when copying objects that have pointers as attributes.

class ArrayList {
    public:
        int* array;
        int size;
 
    ArrayList (int size) {
        this -> size = size;
        array = (int *) malloc(size * sizeof(int));
    }
 
    ~ArrayList () {
        free(array);
    }
};
 
int main () {
    ArrayList list(5);
    ArrayList copy = list;
 
    cout << "Original Address: " << list.array << ", Copied Address: " << copy.array << endl;
    // => same address!
    exit(0);
    return 0;
}

When you run the above code, you'll observe that the addresses of the pointers in list and copy are the same. This is problematic because changes to list.array will also affect copy.array. Additionally, if you remove exit from the code, the program will crash because the same memory is freed twice. To avoid this, we use a deep copy constructor like the one shown below.

class ArrayList {
    public:
        int* array;
        int size;
 
    ArrayList (int size) {
        this -> size = size;
        array = (int *) malloc(size * sizeof(int));
    }
 
    ArrayList (ArrayList& list) {
        this -> size = list.size;
        array = (int *) malloc(list.size * sizeof(int));
    }
 
    ~ArrayList () {
        free(array);
    }
};
 
int main () {
    ArrayList list(5);
    ArrayList copy = list;
 
    cout << "Original Address: " << list.array << ", Copied Address: " << copy.array << endl;
    // => different address!
 
    return 0;
}

Operator Overloading

In some cases, it makes sense to apply algebraic or comparison operators to objects.

class ArrayList {
    public:
        int* array;
        int size;
 
    void print() {
        cout << "[";
        for (int i = 0; i < size-1; i++) {
            cout << array[i] << ", ";
        }
        cout << array[size-1] << "]" << endl;
    }
 
    ArrayList (int array[], int size) {
        this -> size = size;
        this -> array = (int *) malloc(size * sizeof(int));
 
        for (int i = 0; i < size; i++) {
            this -> array[i] = array[i];
        }
    }
 
    ~ArrayList () {
        free(array);
    }
};
 
int main () {
    int array1[5] = {1,2,3,4,5};
    int array2[5] = {1,2,3,4,5};
    ArrayList list1(array1, 5);
    ArrayList list2(array2, 5);
 
    list1.print();
    list2.print();
 
    if (list1 == list2) {
        cout << "List 1 is the same as List 2!" << endl;
    }
 
    ArrayList concat = list1 + list2;
    concat.print();
 
    return 0;
}

However, the above code does not compile because the operators for the object are not defined. To fix this, we can define how operators behave for the object, as shown below.

class ArrayList {
    public:
        int* array;
        int size;
 
    void print() {
        cout << "[";
        for (int i = 0; i < size-1; i++) {
            cout << array[i] << ", ";
        }
        cout << array[size-1] << "]" << endl;
    }
 
    ArrayList (int array[], int size) {
        this -> size = size;
        this -> array = (int *) malloc(size * sizeof(int));
 
        for (int i = 0; i < size; i++) {
            this -> array[i] = array[i];
        }
    }
 
    ~ArrayList () {
        free(array);
    }
 
    ArrayList operator+(const ArrayList& array_list) {
        int final_size = this -> size + array_list.size;
        int final_array[final_size];
        for (int i = 0; i < this -> size; i++) {
            final_array[i] = this -> array[i];
        }
        for (int i = size; i < final_size; i++) {
            final_array[i] = array_list.array[i - this -> size];
        }
        return ArrayList(final_array, final_size);
    }
 
    int operator==(const ArrayList& array_list) {
        if (this -> size != array_list.size) {
            return 0;
        }
        for (int i = 0; i < this -> size; i++) {
            if (this -> array[i] != array_list.array[i]) {
                return 0;
            }
        }
        return 1;
    }
};

Once the operators are defined, concatenation and comparison can be performed using the + and == operators.

Exercises

From this article, there will be an exercise section where you can test your understanding of the material introduced in the article. I highly recommend solving these questions by yourself after reading the main part of the article. You can click on each question to see its answer.

Resources