CPSC170
Fundamentals of Computer Science II

Lab 13

Consider the following simple class called Array. It's meant to model an array of n > 0 integers. Since n > 0 isn't known we use dynamic memory. This class only has two members, array and size. m_array is a pointer to the first element of the array and m_size is the size of the array. The Array class is meant for illustration purposes, so the comments will be to illustrate a concept rather the correctness of the class.


#include < iostream >
#define DEFAULT_SIZE 8

using namespace std;

class Array {
private:
  int* m_array;
  unsigned m_size;

public:
  void set(int value, unsigned index){ // sets m_array[index] to value.
    m_array[index] = value;
  }
  
  int get(unsigned index){ // returns m_array[index].
    return m_array[index];
  }
  
  void print(){  // prints the array.
    if(m_size == 0){
    cout << "[]" << endl;
    }
    else{
      cout << "[" << m_array[0];
      for(int i = 1; i < m_size; i++){
        cout << " " << m_array[i];
      }
      cout << "]";
    }
  }   
};

Default Constructors

The compiler creates a default constructor since we haven't provided any constructor. Unlike some of the classes we've seen so far, the compiler's default constructor isn't helpful. It doesn't allocate any space for storing our array. What do you think will happen if you were to run the following program? A careful hand trace will show that since no space is allocated for the array m_array, m_array[index] refers to an unallocated location. Thus, the statement m_array[index] = value; in the function set(), results in a segmentation fault.


int main(){
  Array A;
  A.set(3,0);
  return 0;
}

We can fix this by providing a default constructor that allocates heap space for the array. Add the following default constructor to the Array class.


Array(){
  cout << "default constructed" << endl;
  m_array = new int[DEFAULT_SIZE];
  m_size = DEFAULT_SIZE;
}

The default constructor is called when an Array object is declared. For example, the following lines of code will call the default constructor twice. Once for A and once for B.


int main(){
  Array A;
  Array B;
  return 0;
}


You should see the message "default constructed" displayed twice. The default constructor is also called when we make an array of objects. Try making an array of 5 Array objects. How many times is the default constructor called?

Copy Constructors

A copy constructor is a constructor that creates an object A from an existing object B. It is called when an object A is created in the following lines of code.


Array B;     // B is created with the default constructor.
Array A = B; // A is created with the copy constructor.

Try running both lines in main. Notice that "default constructed" is printed only once. When a class doesn't have a copy constructor, the compiler provides one. This compiler's constructor isn't suitable for some classes, especially classes with pointer members.

What do you think will happen when the following lines of code are executed? Will the copy constructor be called a third time?


Array A; // A is created with the default constructor.
Array B; // B is created with the defualt constructor.
A = B;   // Is the copy consructor called?

Write code to test whether the array stored in A is an alias of the Array stored in B. The last line of the previous code uses the assignment operator. In a future lab, we will learn how to customize its behavior.

Now, let's get back to the copy constructor. The copy constructor is called when an object is passed by value. Add the following non-member function to your program.


void mutate(Array array, unsigned index){
   array.set(-1, index);
}

Now consider the following lines of code.


Array A;     // A is created with the default constructor.

Array B = A; // B is created with the copy constructor.

mutate(B, 0);  // Copy constructor is called when B is passed by value.
               // The compiler's copy constructor makes A and B point to
               // the same array. Changes in B will cause changes in A.
A.print();
B.print();

mutate(A, 1);  // Copy constructor is called when A is passed by value.
               // Changes in A will cause changes in B.
A.print();
B.print()

The goal of a copy constructor is to create an independent copy of an object. We don't want changes in one copy to affect the other. Run the previous lines of code. You will notice that changes in A affect B and vice versa. This is because the default copy constructor just makes copies of the member variables. Since the data member m_array is a pointer, both A and B end up pointing to the same array. We can fix this by making our own copy constructor. Add the following constructor to the array class.


Array(const Array& array){
  cout << "copy constructed" << endl;
  
  m_size = array.m_size;           // Copies the size of array.
  
  m_array = new int[m_size];       // Creates an array of size m_size.

  for(int i = 0; i < m_size; i++){ // Copies the contents of array.m_array into m_array.
    m_array[i] = array.m_array[i];
  }
}

The copy constructor takes a constant reference to the object to be copied. This prevents us from changing the object we are trying to copy. Next, for illustration purpose, a message is printed to show when the copy constructor is called. Since m_size is a fundamental type we can just store its value directly. The main job of the constructor is to make a new array to store the values in the array of the object we are copying. We create a new array of size m_size and then copy the values of array.m_array. Now try running the following lines of code.


Array A;     // A is created with the default constructor.
Array B = A; // B is created with our copy constructor.

When you run the previous lines of code, "Default constructed" and "Copy constructed" should be printed. Make a change to A then print both A and B. Only A should be changed. Do the same to B to convince yourself that B is an independent copy of A.

As mentioned earlier, a copy constructor is called when an object is passed by value. Run the following lines of code.


Array A;      // A is created with the default constructor.
mutate(A, 0); // Copy constructor is called when A is passed by value.
A.print();    // A is unaffected by the change to the copy of A passed to mutate.

Like before, the messages displayed are "Default constructed" and "Copy constructed". Always consider a copy constructor when you have a class with pointer members. Try changing the mutate so it takes the Array object by reference. "Copy constructed" will no longer be printed. This should be expected since we pass a reference and don't create a new object. This also shows that passing an object by value can be expensive. Imagine if we passed an Array with a million integers by value. That means we will have to copy a million integers every time the Array is passed by value. In general, don't pass objects by value unless you really have to, especially if they are large.

Destructors

For reference, here's the default constructor for the array class.


Array(){
  cout << "default constructed" << endl;
  m_array = new int[DEFAULT_SIZE];
  m_size = DEFAULT_SIZE;
}

We used the new operator to allocate space on the heap. As we learned in the previous lab, we need to free up this space when we're done with it. When an object goes out of scope, we can no longer reference the object, so we need to free the memory allocated to it. If we don't we will have a memory leak. Any class that has any dynamically allocated member data objects must have a user-defined destructor so that the dynamically allocated memory for an object of that class type can be deallocated. This is one of the main purposes of a destructor. It's meant to free up resources used by an object once it goes out of scope. Typical resources are space and file access.

A destructor is declared almost identically to a default constructor. The name of the destructor is the name of the class preceded by the character ~. For our Array class, its destructor name will be ~Array. A destructor takes no arguments. Add the following destructor to the array class.


~Array() {
  cout << "Deconstructed" << endl;
  delete[] array;
}

Our destructor has only one job, to free up the space allocated to object. We also display the string "Deconstructed" for illustration purposes. Try running the following line of code.


  Array A;

Two messages will be printed, "Default constructed" and "Deconstructed". Once main() is done executing, A is deconstructed because it goes out of scope. Try passing an Array object by value to a function. You will get messages for the creation and destruction of the copy. This is illustrated by the following example. Make sure mutate takes an Array by value.


Array A;
mutate(A, 2);

Grades Class

Here's another example of a class that uses dynamic memory. A class to hold student grades. The member data object declared in the class is a pointer, and then, when the number of data items needed is known, space can be allocated for this member data object using a member function. For example, the class Grades may have member data objects declared as: (the constructor is included in the class declaration for convenience)

    class Grades {
      private:
        int numGrades;  // Holds the number of grades
        int * grades;   // Array of grades

         ...
      public:
         Grades() {
           numGrades = 0;
           grades = nullptr; // nullptr is a special value for pointer
                             // variables.
         };
         ...

         // PRE: pNum is defined = n > 0
         // POST: numGrades = n,
         //       grades points to n integers allocated dynamically.
         void setSize (int pNum);
    };
  

The files Grades.h and Grades.cc contain an incomplete declaration and implementation for the Grades class.

Now, consider the function main to see how the above class may be used.

    int main () {
      int numGrades;
      Grades studentGrades;
      // studentGrades is declared. Two words of stack memory for the object
      // studentGrades.
      cin >> numGrades;
      // numGrades = n (say, n = 5)
      studentGrades.setSize(numGrades);
      // studentGrades is capable of holding n = 5 grades.
      // For the object studentGrades: Two words on stack (from
      //        before) + 5 words on heap. 
      ...
      return (0);
    }

The file useGrades.cc contains a main function similar to the one above. It contains some other member function calls. Complete the Grades declaration and implementation so that useGrades.cc will compile and run. Write a Makefile to compile useGrades.cc.

As seen earlier, when a class has dynamic storage for its member data objects, it is necessary to deallocate all the dynamic storage allocated for an object, when the program is done using that object. When a class is declared, a destructor for objects of that class is automatically provided. But, this default destructor only deallocates any memory that was allocated from stack space. The author of the class can also provide a user-defined destructor for the class. This user-defined destructor must be a public member function of the class. The name of the destructor is a tilde followed by the name of the class. Note that, just as the constructor, there is no return type for a destructor. There can be only one destructor for a class. The destructor for a class is called whenever an object of that class type goes out of scope.

For example, the destructor for the above class in the class declaration is

    ...
    ~Grades();
    ...
  

and in the implementation of the class:

    Grades::~Grades() {
      delete [] grades;
      grades = nullptr;
      numGrades = 0;
    }
  

Add a destructor to the Grades class. Include code at the beginning of the destructor to print out a message indicating that the destructor function was called. Compile and run the above useGrades.cc program. Is the destructor called? How many times was it called?

Add to your Makefile to compile the program useGrades1.cc. hand-trace the program. How many times should the destructor be called? Run the program. Was the number of times the destructor was called the same as what you expected? How could you figure out which object was destroyed each time the destructor was called?

Add a copy constructor to the class. Make sure it isn't a shallow copy.