Memory Management


Memory Management

oop



Learning objectives

  • Explain the difference between memory allocation on the stack and on the heap.
  • Make good use of the new and delete commands.
  • Recognise problems associated with memory management, such as memory leaks and avoid them.
  • Implement effective memory management.
  • Understand how new and delete can affect constructors/destructors of class instances.
  • Explain why a copy constructor and overloaded assignment operator can be necessary in C++ classes and what is meant by a deep copy

Scope and memory

Memory management refers to arranging the allocation and deallocation of memory for the purpose of storing data for the program’s use. When a variable (or instance) is declared, the compiler allocates memory for it. The space must be freed up (deallocated) once it goes out of scope. Recall that there are three types of scope

  • Global scope: Variable is declared before the main function (it can be accessed from anywhere within the program).
  • Function scope: Variable is declared inside and function (only accessible from within that function).
  • Block scope: Variable is declared inside a code block (ie. {,…}) (only accessible from within that block).

The following code illustrates a potential problem that can occur when the concepts of scope is combined with pointers:

int main()
{
  int *p;
  if (1 == 1) { // always true, just to make block scope
    int a = 10;
    p = &a; // p points to contents of a
  }
  
  // a has now gone out of scope so what does p point to
  // now? It may still work but strictly the value
  // output is undefined
  cout << *p << endl;
  
  return 0;
} 

In this code, the variable a has a block scope, so it will go out of scope as soon as the if loop finishes, at which the memory allocated for it will be deallocated (freed up). However the pointer variable p is still in scope as it has function scope, and p was assigned to point to the address of a inside the if loop. Because of this p ends up pointing to nothing, so the program’s behaviour will be undefined. The use of pointers and the rules of variable scope have cause an unexpected problem with memory management.

New and Delete Operators

The new operator allocates memory for a pointer to point to, without explicitly declaring a variable and taking its address. e.g.

int *x = new int;
*x = 5;

A useful property of new its that the memory will stay allocated until explicitly deallocated, even if the original pointer to it goes out of scope. This happens as the new operator does not allocate memory onto the program stack, instead it uses another block of memory called the heap.

Memory allocated onto the heap never goes out of scope except when the program terminates. The only way to deallocate memory using new is to use the delete operator. The delete operator deallocates the memory pointed to by an argument, e.g.

int *x = new int;
...
delete x;

Memory Leaks

However a new a problem arises when using the new operator (mind the pun). A memory leak occurs when the program does not deallocate memory that it has finished using. Usually a C++ compiler handles all allocation and deallocation of memory using the scoping rules. But by allocating memory ourselves, we run the risk of creating memory leaks if we don’t deallocate them. Memory leaks are considered to be bad because use up unnecessary memory, making programs less (space) efficient and causing them to run more slowly and in severe cases crash. TO illustrate this point consider the function below:

int *getPtr (int val)
{
  int *x = new int;
  *x = val;
  return x;
}
...
int main() {
  for (int i = 0; i < 5; i++) {
    int *ptr = getPtr(i);
    cout << *ptr << endl;
    // no delete!
  }
}

Every time around the for loop in the main function, the getPtr function is called. This allocates a block of memory of one int value on the heap, and returns a pointer to it. The value pointed to is displayed and then the next iteration begins. However, the next iteration assigns a new value to ptr but has deallocated the old block. This old block is left allocated with no pointer pointing to it, and therefore no way of freeing it up. This is a memory leak. The correct implementation is shown below:

int main() {
  for (int i = 0; i < 5; i++) {
    int *ptr = getPtr(i); // involves new operation
    cout << *ptr << endl;
    delete ptr; //  corresponding delete operation
  }
}

Ensure that for every new statement there should be exactly one delete statement, otherwise having more than one delete statement will result in a program error. On top of this, be careful to only use delete to deallocate memory if it has been allocated using new (not just a ‘normal’ pointer which has been created).

Allocating/Deallocating Variable Length Arrays

With ‘normal’ array declarations the memory for the array will be allocated on the program stack, and therefore the size of the array must be known at compile time. For example the following code is not allowed in standard C++ because the size of the array declared cannot be determined at compile time:

int n;
cout << "Enter length of array: ";
cin >> n;
int a[n];

However, arrays are actually implemented using pointers, so it is possible to handle memory allocation for arrays ourselves on the heap using a new statement. When allocating memory for arrays on the heap, the restriction of knowing the array size at compile-time does not apply. The following modified code illustrates this point:

int n;
cout << "Enter length of array: ";
cin >> n;
int *a = new int[n];
delete[] a;

Note that delete[] is used rather than the normal delete as this tells the compiler that an array of data should be deleted at the given address. This form should always be used with regard to arrays.

Here is another example, illustrating all of this, just for fun:

...
int n;
cout << "Enter length of array: ";
cin >> n;
int *a = new int[n];
for (int i = 0; i < n; i++) {
    cout << "Enter value " << i << ": ";
    cin >> a[i];
}
cout << "Array values:" << endl;
for (int i = 0; i < n; i++) {
    cout << a[i] << endl;
}
delete[] a;

Allocating/Deallocating Class Instances

It is also possible to use new to allocate class instances, for example consider the following code:

...
class Point {
  private:
    float _x, _y;
  public:
    Point() {_x = _y = 0;} // default constructor
    Point(float x, float y) // constructor
    {_x = x; _y = y;}
    ...
};

...
int main() {
  Point *p_ptr;
  if (true) {
    p_ptr = new Point(); // calls default constructor
    Point p1;            // calls default constructor
    Point p2(1.0, 2.4);  // calls second constructor 
  } // p1, p2 out of scop - desctructor called for each
  delete p_ptr; // calls destructor for *p_ptr

  return 0;
}

This code creates an instance of the Point class using the default constructor (i.e. the one with no arguments), and makes p_ptr point to it. The class destructor is called whenever the instance gets deallocated using the delete statement.

Notice that we did not define our own destructor for Point as the default destructor is created automatically by the compiler. We only need to define our own destructor, if we have used new, to perform memory management. To show this as an example, consider the following which dynamically allocates the array on the heap when the sample size is specified:

class Sample {
  public:
    Sample() { _n = 0; }
    ~Sample() { delete[] _x; }
    int Size() { return _n; }
    float Data(int i) { return _x[i]; }
    void SetSample(float val[], int n) {
      _n = n;
      _x = new float[_n];
      for (int i = 0; i < _n; i++)
          _x[i] = val[i];
    }
    float Mean() const;
    float StdDev() const;
    void Display() const;
    void Sort();
  private:
    float *_x;
    int _n;
};

In this implementation, instead of a float array of fixed length, we now have a float pointer as the data member _x. This is allocated in the SetSample member function. Therefore, the destructor for Sample must delete this allocated memory. Failure to perform this memory management would result in a memory leak in the program.

Copy Constructors

Consider the following main function which makes use of the new Sample class.

int main() 
{
  float a[8] = {18.44,14.18,19.79,15.73,15.36,
                16.17,13.91,15.35};
  Sample samp1;
  samp1.SetSample(a, 8);
  samp1.Display();
  
  if (true) {
    // copies data member by data member
    Sampler samp2 = samp1;
  } // samp2 out of scope so destructor is called
  
  // undefined output because samp2 destructor will
  // have deleted the data pointed to by samp1 ...
  samp1.Display();
  
  return 0;
}

Although at first sight this looks fine, when the Sample instance samp1 is displayed the second time it will produce an undefined output. This occurs as the pointer _x in samp2 points to the same block of memory as the pointer _x in samp1.

When samp2 goes out of its block scope, the default destructor for it is called, deallocating the memory pointed to by both samp1 and samp2. So when samp1 tries to display afterwards the block of memory containing its sample data no longer exists. An additional problem is that when samp1 goes out of scope at the end of the main function, it will try to delete memory that has already been deallocated.

We can get around this problem by overloading the copy constructor to perform a ‘deep copy’ rather than a member-by-member copy. This is illustrated in the modified implementation of Sample shown below:

class Sample {
  public:
    Sample() { _n = 0; }
    Sample(Sample& s) // copy constructor
    {
      _n = n.Size();
      _x = new float[_n];
      for (int i = 0; i < _n; i++)
          _x[i] = s.Data(i);
    }  
    ~Sample() { delete[] _x; }
    int Size() { return _n; }
    float Data(int i) { return _x[i]; }
    void SetSample(float val[], int n) 
    ...
  private:
    float *_x;
    int _n;
};

The new overloaded copy constructor allocates a block of memory using new and copies each element of the array pointed to by _x individually.

Overloaded Assignment Operator

A similar issue to that highlighted in the previous example with copy constructors, comes up when performing assignments of classes that allocate memory on the heap. First, recall when the copy constructor is used and when the assignment operation is used:

  • Copy constructor is called when either an instance is declared using another instance as an argument; or an instance is declared and assigned to in a single program statement.
  • The assignment operation is used when an existing instance is assigned to.

Just as with copy constructors, if a ‘deep’ copy is required then the assignment operator must be overloaded. For example, a modified implementation of the assignment operator for Sample is shown below:

class Sample
{
  public:
    Sample() { _n = 0; }
    Sample(const Sample&); // copy constructor
    ...  
    ~Sample() { delete[] _x; }
    int Size() const { return _n; }
    float Data(int i) const { return _x[i]; }
    ...
    Sample& operator=(const Sample& s)
    {
      // delete if already data in this instance
      if (_n > 0)
        delete[] _x;
      
      // copy
      _n = s.Size();
      _x = new float[_n];
      for (int i = 0; i < _n; i++)
          _x[i] = s.Data(i);
      return *this;
    }
  private:
    float *_x;
    int _n;
};

In the implementation for the overloaded assignment operator Sample& operator=(const Sample& s), the main difference is that it first checks to see if there is already data stored in the current instance of Sample. If there is, it must delete it. The copy constructor did not need to do this as, by definition, the instance was only just been created. The overloaded assignment operator must always return the current instance using the this pointer.


return  link
Written by Tobias Whetton