23. Classes – Introduction – Modern C++ for Absolute Beginners: A Friendly Introduction to C++ Programming Language and C++11 to C++20 Standards

© Slobodan Dmitrović 2020
S. DmitrovićModern C++ for Absolute Beginnershttps://doi.org/10.1007/978-1-4842-6047-0_23

23. Classes - Introduction

Slobodan Dmitrović1 
(1)
Belgrade, Serbia
 
Class is a user-defined type. A class consists of members. The members are data members and member functions. A class can be described as data and some functionality on that data, wrapped into one. An instance of a class is called an object. To only declare a class name, we write:
class MyClass;
To define an empty class, we add a class body marked by braces {}:
class MyClass{};
To create an instance of the class, an object, we use:
class MyClass{};
int main()
{
    MyClass o;
}

Explanation

We defined a class called MyClass. Then we created an object o of type MyClass. It is said that o is an object, a class instance.

23.1 Data Member Fields

A class can have a set of some data in it. These are called member fields. Let us add one member field to our class and make it of type char:
class MyClass
{
    char c;
};
Now our class has one data member field of type char called c. Let us now add two more fields of type int and double:
class MyClass
{
    char c;
    int x;
    double d;
};

Now our class has three member fields, and each member field has its name.

23.2 Member Functions

Similarly, a class can store functions. These are called member functions. They are mostly used to perform some operations on data fields. To declare a member function of type void called dosomething(), we write:
class MyClass
{
    void dosomething();
};
There are two ways to define this member function. The first is to define it inside the class:
class MyClass
{
    void dosomething()
    {
        std::cout << "Hello World from a class.";
    }
};
The second one is to define it outside the class. In that case, we write the function type first, followed by a class name, followed by a scope resolution :: operator followed by a function name, list of parameters if any and a function body:
class MyClass
{
    void dosomething();
};
void MyClass::dosomething()
{
    std::cout << "Hello World from a class.";
}

Here we declared a member function inside the class and defined it outside the class.

We can have multiple members functions in a class. To define them inside a class, we would write:
class MyClass
{
    void dosomething()
    {
        std::cout << "Hello World from a class.";
    }
    void dosomethingelse()
    {
        std::cout << "Hello Universe from a class.";
    }
};
To declare members functions inside a class and define them outside the class, we would write:
class MyClass
{
    void dosomething();
    void dosomethingelse();
};
void MyClass::dosomething()
{
    std::cout << "Hello World from a class.";
}
void MyClass::dosomethingelse()
{
    std::cout << "Hello Universe from a class.";
}
Now we can create a simple class that has both a data member field and a member function:
class MyClass
{
    int x;
    void printx()
    {
        std::cout << "The value of x is:" << x;
    }
};

This class has one data field of type int called x, and it has a member function called printx(). This member function reads the value of x and prints it out. This example is an introduction to member access specifiers or class member visibility.

23.3 Access Specifiers

Wouldn’t it be convenient if there was a way we could disable access to member fields but allow access to member functions for our object and other entities accessing our class members? And that is what access specifiers are for. They specify access for class members. There are three access specifiers/labels : public, protected, and private:
class MyClass
{
public:
    // everything in here
    // has public access level
protected:
    // everything in here
    // has protected access level
private:
    // everything in here
    // has private access level
};
Default visibility/access specifier for a class is private if none of the access specifiers is present:
class MyClass
{
    // everything in here
    // has private access by default
};
Another way to write a class is to write a struct. A struct is also a class in which members have public access by default. So, a struct is the same thing as a class but with a public access specifier by default:
struct MyStruct
{
    // everything in here
    // is public by default
};

For now, we will focus only on public and private access specifiers. Public access members are accessible anywhere. For example, they are accessible to other class members and to objects of our class. To access a class member from an object, we use the dot . operator.

Let’s define a class where all the members have public access. To define a class with public access specifier, we can write:
class MyClass
{
public:
    int x;
    void printx()
    {
        std::cout << "The value of x is:" << x;
    }
};
Let us instantiate this class and use it in our main program:
#include <iostream>
class MyClass
{
public:
    int x;
    void printx()
    {
        std::cout << "The value of data member x is: " << x;
    }
};
int main()
{
    MyClass o;
    o.x = 123;    // x is accessible to object o
    o.printx();   // printx() is accessible to object o
}

Our object o now has direct access to all member fields as they are all marked public. Member fields always have access to each other regardless of the access specifier. That is why the member function printx() can access the member field x and print or change its value.

Private access members are accessible only to other class members, not objects. Example with full commentary:
#include <iostream>
class MyClass
{
private:
    int x; // x now has private access
public:
    void printx()
    {
        std::cout << "The value of x is:" << x; // x is accessible to // printx()
    }
};
int main()
{
    MyClass o;    // Create an object
    o.x = 123;    // Error, x has private access and is not accessible to // object o
    o.printx();   // printx() is accessible from object o
}

Our object o now only has access to a member function printx() in the public section of the class. It cannot access members in the private section of the class.

If we want the class members to be accessible to our object, then we will put them inside the public: area. If we want the class members not to be accessible to our object, then we will put them into the private: area.

We want the data members to have private access and function members to have public access. This way, our object can access the member functions directly but not the member fields. There is another access specifier called protected: which we will talk about later in the book when we learn about inheritance.

23.4 Constructors

A constructor is a member function that has the same name as the class. To initialize an object of a class, we use constructors. Constructor's purpose is to initialize an object of a class. It constructs an object and can set values to data members. If a class has a constructor, all objects of that class will be initialized by a constructor call.

23.4.1 Default Constructor

A constructor without parameters or with default parameters set is called a default constructor. It is a constructor which can be called without arguments:
#include <iostream>
class MyClass
{
public:
    MyClass()
    {
        std::cout << "Default constructor invoked." << '\n';
    }
};
int main()
{
    MyClass o; // invoke a default constructor
}
Another example of a default constructor, the one with the default arguments:
#include <iostream>
class MyClass
{
public:
    MyClass(int x = 123, int y = 456)
    {
        std::cout << "Default constructor invoked." << '\n';
    }
};
int main()
{
    MyClass o; // invoke a default constructor
}

If a default constructor is not explicitly defined in the code, the compiler will generate a default constructor. But when we define a constructor of our own, the one that needs parameters, the default constructor gets removed and is not generated by a compiler.

Constructors are invoked when object initialization takes place. They can’t be invoked directly.

Constructors can have arbitrary parameters; in which case we can call them user-provided constructors:
#include <iostream>
class MyClass
{
public:
    int x, y;
    MyClass(int xx, int yy)
    {
        x = xx;
        y = yy;
    }
};
int main()
{
    MyClass o{ 1, 2 }; // invoke a user-provided constructor
    std::cout << "User-provided constructor invoked." << '\n';
    std::cout << o.x << ' ' << o.y;
}

In this example, our class has two data fields of type int and a constructor. The constructor accepts two parameters and assigns them to data members. We invoke the constructor with by providing arguments in the initializer list with MyClass o{ 1, 2 };

Constructors do not have a return type, and their purposes are to initialize the object of its class.

23.4.2 Member Initialization

In our previous example, we used a constructor body and assignments to assign value to each class member. A better, more efficient way to initialize an object of a class is to use the constructor’s member initializer list in the definition of the constructor:
#include <iostream>
class MyClass
{
public:
    int x, y;
    MyClass(int xx, int yy)
        : x{ xx }, y{ yy } // member initializer list
    {
    }
};
int main()
{
    MyClass o{ 1, 2 }; // invoke a user-defined constructor
    std::cout << o.x << ' ' << o.y;
}

A member initializer list starts with a colon, followed by member names and their initializers, where each initialization expression is separated by a comma. This is the preferred way of initializing class data members.

23.4.3 Copy Constructor

When we initialize an object with another object of the same class, we invoke a copy constructor. If we do not supply our copy constructor, the compiler generates a default copy constructor that performs the so-called shallow copy. Example:
#include <iostream>
class MyClass
{
private:
    int x, y;
public:
    MyClass(int xx, int yy) : x{ xx }, y{ yy }
    {
    }
};
int main()
{
    MyClass o1{ 1, 2 };
    MyClass o2 = o1; // default copy constructor invoked
}

In this example, we initialize the object o2 with the object o1 of the same type. This invokes the default copy constructor.

We can provide our own copy constructor. The copy constructor has a special parameter signature of MyClass(const MyClass& rhs). Example of a user-defined copy constructor:
#include <iostream>
class MyClass
{
private:
    int x, y;
public:
    MyClass(int xx, int yy) : x{ xx }, y{ yy }
    {
    }
    // user defined copy constructor
    MyClass(const MyClass& rhs)
        : x{ rhs.x }, y{ rhs.y } // initialize members with other object's // members
    {
        std::cout << "User defined copy constructor invoked.";
    }
};
int main()
{
    MyClass o1{ 1, 2 };
    MyClass o2 = o1; // user defined copy constructor invoked
}

Here we defined our own copy constructor in which we explicitly initialized data members with other objects data members, and we print out a simple message in the console / standard output.

Please note that the default copy constructor does not correctly copy members of some types, such as pointers, arrays, etc. In order to properly make copies, we need to define our own copy logic inside the copy constructor. This is referred to as a deep copy. For pointers, for example, we need both to create a pointer and assign a value to the object it points to in our user-defined copy constructor:
#include <iostream>
class MyClass
{
private:
    int x;
    int* p;
public:
    MyClass(int xx, int pp)
        : x{ xx }, p{ new int{pp} }
    {
    }
MyClass(const MyClass& rhs)
        : x{ rhs.x }, p{ new int {*rhs.p} }
    {
        std::cout << "User defined copy constructor invoked.";
    }
};
int main()
{
    MyClass o1{ 1, 2 };
    MyClass o2 = o1; // user defined copy constructor invoked
}

Here we have two constructors, one is a user-provided regular constructor, and the other is a user-defined copy constructor. The first constructor initializes an object and is invoked here: MyClass o1{ 1, 2 }; in our main function.

The second, the user-defined copy constructor is invoked here: MyClass o2 = o1; This constructor now properly copies the values from both int and int* member fields.

In this example, we have pointers as member fields. If we had left out the user-defined copy constructor, and relied on a default copy constructor only the int member field would be properly copied, the pointer would not. In this example, we rectified that.

In addition to copying, there is also a move semantic, where data is moved from one object to the other. This semantic is represented through a move constructor and a move assignment operator.

23.4.4 Copy Assignment

So far, we have used copy constructors to initialize one object with another object. We can also copy the values to an object after it has been initialized/created. We use a copy assignment for that. Simply, when we initialize an object with another object using the = operator on the same line, then the copy operation uses the copy constructor:
MyClass copyfrom;
MyClass copyto = copyfrom; // on the same line, uses a copy constructor
When an object is created on one line and then assigned to in the next line, it then uses the copy assignment operator to copy the data from another object:
MyClass copyfrom;
MyClass copyto;
copyto = copyfrom; // uses a copy assignment operator
A copy assignment operator is of the following signature:
MyClass& operator=(const MyClass& rhs)
To define a user-defined copy assignment operator inside a class we use:
class MyClass
{
public:
    MyClass& operator=(const MyClass& rhs)
    {
        // implement the copy logic here
        return *this;
    }
};
Notice that the overloaded = operators must return a dereferenced this pointer at the end. To define a user-defined copy assignment operator outside the class, we use:
class MyClass
{
public:
    MyClass& operator=(const MyClass& rhs);
};
MyClass& MyClass::operator=(const MyClass& rhs)
{
    // implement the copy logic here
    return *this;
}

Similarly, there is a move assignment operator, which we will discuss later in the book. More on operator overloading in the following chapters.

23.4.5 Move Constructor

In addition to copying, we can also move the data from one object to the other. We call it a move semantics. Move semantics is achieved through a move constructor and move assignment operator. The object from which the data was moved, is left in some valid but unspecified state. The move operation is efficient in terms of speed of execution, as we do not have to make copies.

Move constructor accepts something called rvalue reference as an argument.

Every expression can find itself on the left-hand side or the right-hand side of the assignment operator. The expressions that can be used on the left-hand side are called lvalues, such as variables, function calls, class members, etc. The expressions that can be used on the right-hand side of an assignment operator are called rvalues, such as literals, and other expressions.

Now the move semantics accepts a reference to that rvalue. The signature of an rvalue reference type is T&&, with double reference symbols. So, the signature of a move constructor is:
MyClass (MyClass&& rhs)
To cast something to an rvalue reference, we use the std::move function. This function casts the object to an rvalue reference. It does not move anything. An example where a move constructor is invoked:
#include <iostream>
class MyClass { };
int main()
{
    MyClass o1;
    MyClass o2 = std::move(o1);
    std::cout << "Move constructor invoked.";
    // or MyClass o2{std::move(o1)};
}

In this example, we define an object of type MyClass called o1. Then we initialize the second object o2 by moving everything from object o1 to o2. To do that, we need to cast the o2 to rvalue reference with std::move(o1). This, in turn, invokes the MyClass move constructor for o2.

If a user does not provide a move constructor, the compiler provides an implicitly generated default move constructor.

Let us specify our own, user-defined move constructor:
#include <iostream>
#include <string>
class MyClass
{
private:
    int x;
    std::string s;
public:
    MyClass(int xx, std::string ss) // user provided constructor
        : x{ xx }, s{ ss }
    {}
    MyClass(MyClass&& rhs) // move constructor
        :
        x{ std::move(rhs.x) }, s{ std::move(rhs.s) }
    {
        std::cout << "Move constructor invoked." << '\n';
    }
};
int main()
{
    MyClass o1{ 1, "Some string value" };
    MyClass o2 = std::move(o1);
}

This example defines a class with two data members and two constructors. The first constructor is some user-provided constructor used to initialize data members with provided arguments.

The second constructor is a user-defined move constructor accepting an rvalue reference parameter of type MyClass&& called rhs. This parameter will become our std::move(o1) argument/object. Then in the constructor initializer list, we also use the std::move function to move the data fields from o1 to o2.

23.4.6 Move Assignment

Move assignment operator is invoked when we declare an object and then try to assign an rvalue reference to it. This is done via the move assignment operator. The signature of the move assignment operator is: MyClass& operator=(MyClass&& otherobject).

To define a user-defined move assignment operator inside a class we use:
class MyClass
{
public:
    MyClass& operator=(MyClass&& otherobject)
    {
        // implement the copy logic here
        return *this;
    }
};
As with any assignment operator overloading, we must return a dereferenced this pointer at the end. To define a move assignment operator outside the class, we use:
class MyClass
{
public:
    MyClass& operator=(const MyClass& rhs);
};
MyClass& MyClass::operator=(const MyClass& rhs)
{
    // implement the copy logic here
    return *this;
}
Move assignment operator example adapted from a move constructor example would be:
#include <iostream>
#include <string>
class MyClass
{
private:
    int x;
    std::string s;
public:
    MyClass(int xx, std::string ss) // user provided constructor
        : x{ xx }, s{ ss }
    {}
    MyClass& operator=(MyClass&& otherobject) // move assignment operator
    {
        x = std::move(otherobject.x);
        s = std::move(otherobject.s);
        return *this;
    }
};
int main()
{
    MyClass o1{ 123, "This is currently in object 1." };
    MyClass o2{ 456, "This is currently in object 2." };
    o2 = std::move(o1); // move assignment operator invoked
    std::cout << "Move assignment operator used.";
}

Here we defined two objects called o1 and o2. Then we try to move the data from object o1 to o2 by assigning an rvalue reference (of object o1) using the std::move(o1) expression to object o2. This invokes the move assignment operator in our object o2. The move assignment operator implementation itself uses the std::move() function to cast each data member to an rvalue reference.

23.5 Operator Overloading

Objects of classes can be used in expression as operands. For example, we can do the following:
myobject = otherobject;
myobject + otherobject;
myobject / otherobject;
myobject++;
++myobject;

Here objects of a class are used as operands. To do that, we need to overload the operators for complex types such as classes. It is said that we need to overload them to provide a meaningful operation on objects of a class. Some operators can be overloaded for classes; some cannot. We can overload the following operators:

Arithmetic operators, binary operators, boolean operators, unary operators, comparison operators, compound operators, function and subscript operators:
+ - * / % ^ & | ~ ! = < > == != <= >= += -= *= /= %= ^= &= |= << >> >>= <<= && || ++ -- , ->* -> () []
Each operator carries its signature and set of rules when overloading for classes. Some operator overloads are implemented as member functions, some as none member functions. Let us overload a unary prefix ++ operator for classes. It is of signature MyClass& operator++():
#include <iostream>
class MyClass
{
private:
    int x;
    double d;
public:
    MyClass()
        : x{ 0 }, d{ 0.0 }
    {
    }
    // prefix operator ++
    MyClass& operator++()
    {
        ++x;
        ++d;
        std::cout << "Prefix operator ++ invoked." << '\n';
        return *this;
    }
};
int main()
{
    MyClass myobject;
    // prefix operator
    ++myobject;
    // the same as:
    myobject.operator++();
}

In this example, when invoked in our class, the overloaded prefix increment ++ operator increments each of the member fields by one. We can also invoke an operator by calling a .operatoractual_operator_name(parameters_if_any); such as .operator++();

Often operators depend on each other and can be implemented in terms of other operators. To implement a postfix operator ++, we will implement it in terms of a prefix operator:
#include <iostream>
class MyClass
{
private:
    int x;
    double d;
public:
    MyClass()
        : x{ 0 }, d{ 0.0 }
    {
    }
    // prefix operator ++
    MyClass& operator++()
    {
        ++x;
        ++d;
        std::cout << "Prefix operator ++ invoked." << '\n';
        return *this;
    }
    // postfix operator ++
    MyClass operator++(int)
    {
        MyClass tmp(*this); // create a copy
        operator++();       // invoke the prefix operator overload
        std::cout << "Postfix operator ++ invoked." << '\n';
        return tmp;         // return old value
    }
};
int main()
{
    MyClass myobject;
    // postfix operator
    myobject++;
    // is the same as if we had:
    myobject.operator++(0);
}

Please do not worry too much about the somewhat inconsistent rules for operator overloading. Remember, each (set of) operator has its own rules for overloading.

Let us overload a binary operator +=:
#include <iostream>
class MyClass
{
private:
    int x;
    double d;
public:
    MyClass(int xx, double dd)
        : x{ xx }, d{ dd }
    {
    }
    MyClass& operator+=(const MyClass& rhs)
    {
        this->x += rhs.x;
        this->d += rhs.d;
        return *this;
    }
};
int main()
{
    MyClass myobject{ 1, 1.0 };
    MyClass mysecondobject{ 2, 2.0 };
    myobject += mysecondobject;
    std::cout << "Used the overloaded += operator.";
}

Now, myobject member field x has a value of 3, and a member field d has a value of 3.0.

Let us implement arithmetic + operator in terms of += operator:
#include <iostream>
class MyClass
{
private:
    int x;
    double d;
public:
    MyClass(int xx, double dd)
        : x{ xx }, d{ dd }
    {
    }
    MyClass& operator+=(const MyClass& rhs)
    {
        this->x += rhs.x;
        this->d += rhs.d;
        return *this;
    }
    friend MyClass operator+(MyClass lhs, const MyClass& rhs)
    {
        lhs += rhs;
        return lhs;
    }
};
int main()
{
    MyClass myobject{ 1, 1.0 };
    MyClass mysecondobject{ 2, 2.0 };
    MyClass myresult = myobject + mysecondobject;
    std::cout << "Used the overloaded + operator.";
}

Summary:

When we need to perform arithmetic, logic, and other operations on our objects of a class, we need to overload the appropriate operators. There are rules and signatures for overloading each operator. Some operators can be implemented in terms of other operators. For a complete list of rules of operator overloading rules, please refer to C++ reference at https://en.cppreference.com/w/cpp/language/operators.

23.6 Destructors

As we saw earlier, a constructor is a member function that gets invoked when the object is initialized. Similarly, a destructor is a member function that gets invoked when an object is destroyed. The name of the destructor is tilde ~ followed by a class name:
class MyClass
{
public:
    MyClass() {}    // constructor
    ~MyClass() {}   // destructor
};
Destructor takes no parameters, and there is one destructor per class. Example:
#include <iostream>
class MyClass
{
public:
    MyClass() {}    // constructor
    ~MyClass()
    {
        std::cout << "Destructor invoked.";
    }    // destructor
};
int main()
{
    MyClass o;
}   // destructor invoked here, when o gets out of scope

Destructors are called when an object goes out of scope or when a pointer to an object is deleted. We should not call the destructor directly.

Destructors can be used to clean up the taken resources. Example:
#include <iostream>
class MyClass
{
private:
    int* p;
public:
    MyClass()
        : p{ new int{123} }
    {
        std::cout << "Created a pointer in the constructor." << '\n';
    }
    ~MyClass()
    {
        delete p;
        std::cout << "Deleted a pointer in the destructor." << '\n';
    }
};
int main()
{
    MyClass o; // constructor invoked here
} // destructor invoked here

Here we allocate memory for a pointer in the constructor and deallocate the memory in the destructor. This style of resource allocation/deallocation is called RAII or Resource Acquisition is Initialization. Destructors should not be called directly.

Important

The use of new and delete, as well as the use of raw pointers in Modern C++, is discouraged. We should use smart pointers instead. We will talk about them later in the book. Let us do some exercises for the class's introductory part.