Lab 4
Lab 4
LABORATORY 4
OBJECTIVES
In this laboratory session, you will get familiarized with the rule of three from C++, so you will
learn when and how to implement the destructor, copy constructor and assignment operator in C++.
THEORETICAL NOTIONS
Rule of three
The rule of three formalizes the rules on correct resource management and writing exception-safe
code. It is related to three special member functions in a class: the copy constructor and the
assignment operator and the destructor.
Both the copy constructor and the assignment operator are used when copying objects. The
difference is that the copy constructor is used to initialize new objects as a copy of another object (as
we are dealing with a constructor, the destination object is just being created), while the assignment
operator works on existing objects (the destination object already exists). The destructor of an object
is automatically called when the objects get out of scope (in the case of stack-allocated variables), or
when delete is being called (in the case of heap-allocated variables).
“If a class defines any of the following special member functions (destructor, copy constructor or
assignment operator) it should probably explicitly define all three.”
If these special member functions are not explicitly implemented by the user, the compiler will
automatically define them, as they are required in various situations (when passing function
parameters by value, when returning objects from a function, when working with containers, just to
name a few). The compiler-defined versions of these functions have the following semantics:
- Destructor – it just calls the destructors of all the class-type attributes of the current object;
- Copy constructor – builds all the attributes by calling the copy constructors of the object's
class-type members. For non-class types (e.g., char, int, or pointer) data members it just uses
assignment.
- Assignment operator – uses the assignment operator for all of the object's class-type
members. For non-class types (e.g., char, int, or pointer) it just uses plain assignment.
Let’s create an Array class that stores a heap-allocated array of characters and see these three
functions in action:
class Array
{
public:
// precondition capacity > 0
Array(unsigned int capacity = 10):m_data{new
int[capacity]},m_length{0},m_capacity{capacity}{}
private:
int* m_data;
unsigned int m_length;
unsigned int m_capacity;
};
int main()
{
Array a1{10};
cout<<"Array 1: "<<a1;
Array a2{a1};
cout<<"Array 2: "<<a2;
Array a3{};
a3 = a1;
cout<<"Array 3: "<<a3;
a2.at(0) = 10;
return 0;
}
Array 1: 1 2 3 4 5
Array 2: 1 2 3 4 5
Array 3: 1 2 3 4 5
a2[0] = 10
Now the arrays are:
Array 1: 10 2 3 4 5
Array 2: 10 2 3 4 5
Array 3: 10 2 3 4 5
which is not the expected result. When modifying array a2, we want to modify only the values stored
in that array, and not also the values stored in a1 and a3.
- memory management: in the constructor of the array class we allocate memory on the
heap, but we never release it. To fix this issue, we can implement the destructor of the class
and release that memory:
- initializing an Array as a copy of another Array: this is the responsibility of the copy
constructor (in the code example: Array a2{a1}; ). The default copy constructor
implemented by the compiler does a simple assignment between the m_data pointers of the
newly constructed array and the array passed as a parameter. This is not correct, as in this
case, the m_data field for both objects will point to the same memory location. To fix this
issue, we need to explicitly implement the copy constructor of the class:
- copying an Array into another (existing) Array: this is the responsibility of the
assignment operator (in the code example: a3 = a1; ). The default assignment operator
implemented by the compiler does a simple assignment between the m_data pointers of the
two Array instances. This is not correct, as in this case, the m_data field for both objects will
point to the same memory location. To fix this issue, we need to explicitly implement the
assignment operator of the class. In the case of the assignment operator, because the
destination object already existed, we need to explicitly free the memory to avoid memory
leaks.
// deep copy
m_capacity = other.m_capacity;
m_length = other.m_length;
m_data = new int[m_capacity]();
for(int i{0}; i < m_length; i++)
m_data[i] = other.m_data[i];
}
// returning the current object
// this is a pointer, so we need to dereference it
The difference between the copy constructor and the assignment operator can be confusing at first,
but is really not that difficult. You just need to keep in mind that the constructor is used to create
new objects. Therefore:
- if a new object needs to be created before performing the copy, the copy constructor will be
used. The copy constructor is also called when we pass a parameter by value, or when we
return by value from a function.
- otherwise, if the “destination” object already exists and therefore the object doesn't need to
be created, the assignment operator is used.
Below you have some examples of when the copy constructor and the assignment operators are
called.
Array f2(){
// your complex implementation here
Array a;
return a; // COPY CONSTRUCTOR CALLED: return by value
}
int main(){
Array a;
return 0;
}
We already learned that in C++, operators can be overloaded to work with user-defined types
(classes). The increment operator (++) can be overloaded in two different ways:
#include <iostream>
using namespace std;
class Counter {
private:
int value;
public:
Counter(int v = 0) : value(v) {}
// Pre-increment (++obj)
Counter& operator++() {
++value; // Increment value first
return *this;
}
// Post-increment (obj++)
Counter operator++(int) {
Counter temp = *this; // Store current state
value++; // Increment value
return temp; // Return old state
}
int main() {
Counter c1{5};
Counter c2{5};
1. Write a C++ class to implement a double-ended queue that stores integers. A double-ended
queue, often abbreviated as deque, is a data structure that allows the insertion and deletion
of elements from both the front and the back. The data structure that you implement
should be dynamic in size, meaning that it will dynamically adjust its size during runtime to
accommodate a varying number of elements. The deque should be able to grow based on the
number of elements it currently holds.
Additionally, you need to implement the Rule of Three for proper memory management:
● Copy Constructor (Deque(const Deque& other)): creates a deep copy of the
contents of another deque.
● Copy Assignment Operator (Deque& operator=(const Deque& other)): assigns the
contents of another deque to the current deque.
● Destructor (~Deque()). Release any dynamically allocated memory.
Your implementation should ensure proper memory management! Write comprehensive test
cases to catch all possible errors and ensure the correctness of your implementation. Test for
scenarios such as: empty deque operations, adding and removing elements from both ends,
resizing, deep copies, and proper memory cleanup.
Alternatively, if you find the deque data structure is too difficult, you can implement
a dynamic queue data structure.
2. It is well known that the primitive data types have a limited range of values. For example, the
range of values for a long long int is typically from -263 to 263-1. If we need to work with really
really big numbers, we cannot use primitive data types.
a. Write a class called BigInteger to represent a large integer. You have two options: you
either use a heap allocated array of digits and a variable to indicate the sign of the
integer to store the number, or you use the Deque class from the previous exercise.
(Don’t change the name of the class, because the tests won’t work! The class should be
stored in )
b. Implement a default constructor and a parameterized constructor for your class. The
default constructor initializes the number to 0. The parameterized constructor takes as
d. Use the compare function to overload the comparison operators ==, <, <=, >, and >=.
Don’t duplicate the code.
e. Ensure that the rule of three(copy constructor, assignment operator, and destructor for
the BigInteger class) is properly implemented. If you use the Deque class do you still need
to implement the rule of three for the BigInteger class?
and use them to overload the arithmetic operators: +, +=, -, -= (reuse code, don’t
copy-paste the code).
This design is intended to separate the task of performing the operation (like addition or
comparison) from the task of overloading the operator. Each task presents its own
challenges, and it is better to face them separately. Implement the functions first, then
overload the operators, not the other way around.
When implementing these operations you might need several auxiliary functions to make
your implementation cleaner:
You can use this function to align two numbers before processing them. For
example, if you want to add the numbers 123 and 46572 before adding them, you
should prepend to zeros to 123:
0 0 1 2 3+
4 6 5 7 2
4 6 6 9 5
This function will multiply (element-wise) with -1 each element from the array and
return the result. The original array is left unchanged! You could use this function
when processing negative numbers or for the subtraction operation.
This function takes as parameters two arrays of the same length (so you need the
function at step 1), adds them (elementwise), and returns the result. You don’t need
to do anything if the result is greater or equal to 10 (if the result is not a digit), or if
the result is smaller than 0. You will use the next function in such cases.
0 0 1 2 9+
4 6 5 8 2
4 6 6 10 11
For example, if you need to add a positive number with a negative number 46582 +
(-729). You could use the negate function (the function written at step 2), and call
the add method.
4 6 5 8 2+
0 0 -7 -2 -9
4 6 -2 6 -7
When you have numbers that are greater than 9 in your array, you will need to use a
carry. Initially, the carry will be 0.
You will start traversing the array from the last element, and to each element, you
will add the previous carry value. If the result is larger than 10, you will subtract 10
from it and you will have a carry of 1.
Original 4 6 6 10 11
When you have numbers that are negative in your array, you will need to use a borrow.
Initially, the borrow will be 0.
You will start traversing the array from the last element, and to each element you will
subtract the previous borrow value. If the result is larger than 10, you will add 10 from it and
you will have a borrow of 1.
With the help of these functions the BigInteger operations are trivial to implement. Of
course, you can come up (and you are encouraged to do so!) with your own idea of
implementation.
h. Overload the post-increment (x++) and pre-increment operators (++x) for this class.
Both these operators should increment the big integer by 1 (take into consideration
the sign of the big integer when implementing this!).
The pre-increment operator should return a reference to the current object after it is
incremented.
The post-increment operator should return the value of the object before it was
incremented. You can reuse the additional functions.
In the main.cpp file write the code that generates the first N Fibonacci numbers. You will see
that you soon have an overflow even when using long long data type for storing the
Fibonacci numbers. Modify the provided code in main.cpp such that it uses the BigInteger class.
Write your own tests (using assertions) to test the Fibonacci sequence.