Understanding C++ Class Default Member Functions: Copy Constructors and Operator Overloading
After covering destructors in a previous note, this antry continues with two essential default member functions: copy constructors and operator overloading.
Copy Constructor
A copy constructor is a special constructor whose first parameter is a reference to the same class type, and any additional parameters have default values. It is essentially a specific constructor overload.
Key characteristics of copy constructors:
- It is an overload of the constructor.
- C++ mandates that copying objects of class types must invoke the copy constructor. Hence, passing by value or returning by value for class types triggers the copy constructor.
- The first parameter must be a reference to the class type. Passing by value would cause infinite recursion and result in a compilation error.
- If not explicitly defined, the compiler generates a default copy constructor. This default performs a shallow copy (bitwise copy) for built-in type members and calls the copy constructor of each custom type member.
Consider a simple Date class. The first function below is the constructor, and the second is the copy constructor.
class Date {
public:
Date(int year = 2024, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {}
Date(const Date& d)
: _year(d._year), _month(d._month), _day(d._day) {}
void print() const {
std::cout << _year << "/" << _month << "/" << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1(2024, 10, 5); // Calls constructor
Date d2 = d1; // Calls copy constructor
// Date d2(d1); // Equivalent to the above line
d1.print();
d2.print();
return 0;
}
When instantiating d1, we avoid Date d1(); because the compiler might interpret it as a function declaration. The object d2 is created using the copy constructor, producing a copy with the same values as d1. Both Date d2 = d1 and Date d2(d1) invoke the copy constructor.
If we omit the copy constructor for a class with only built-in members (like Date), the default copy constructor suffices due to shalow copying. However, when a class manages dynamically allocated resources, a shallow copy can cause issues.
class Stack {
public:
Stack()
: _a((int*)malloc(sizeof(int) * 10)), _top(0), _capacity(10) {
if (_a == nullptr) {
perror("malloc fail");
return;
}
}
~Stack() {
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
If we execute Stack st1; Stack st2 = st1;, the program will crash. The default shallow copy makes st2._a point to the same memory as st1._a. When both objects are destroyed, their destructors attempt to free the same memory twice, leading to undefined behavior.
Therefore, when a class explicitly implements a destructor that releases resources, a copy constructor must be written to perform a deep copy:
class Stack {
public:
Stack()
: _a((int*)malloc(sizeof(int) * 10)), _top(0), _capacity(10) {
if (_a == nullptr) {
perror("malloc fail");
return;
}
}
Stack(const Stack& s)
: _a((int*)malloc(sizeof(int) * s._capacity)),
_top(s._top),
_capacity(s._capacity) {
for (int i = 0; i < _top; ++i) {
_a[i] = s._a[i];
}
}
~Stack() {
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
Operator Overloading
C++ allows operators to be overloaded for user-defined types, enabling intuitive operations similar to built-in types. For instance, we can define how < works between two Date objects.
bool Date::operator<(const Date& d) const {
if (_year < d._year)
return true;
else if (_year == d._year) {
if (_month < d._month)
return true;
else if (_month == d._month) {
if (_day < d._day)
return true;
}
}
return false;
}
This function is named operator< and follows the same rules as a regular function but uses the operator keyword.
Key rules for operator overloading:
- The number of parameters in an overloaded operator equals the number of operands. Unary operators take one parameter, binary operators take two. The left operand is past as the first argument, the right as the second.
- If the operator is a member function, the leftmost operand is implicitly passed via the
thispointer, reducing the explicit parameter count by one. - The precedence and associativity of an overloaded operator remain the same as for the built-in type.
- You cannot create new operator symbols by combining characters that do not exist in the syntax, such as
operator@. - To differentiate between prefix and postfix
++(or--), C++ specifies that the postfix version takes an extraintparameter, allowing both to coexist as overloaded functions.