Department of Engineering

IT Services

   
More on Classes

Concrete Classes are the simple classes that behave just like int, char, etc once they're set up. ,/p>

To make best use of Classes it's sometimes useful to redefine the operators as well as define member functions. For instance, if you were going to invent a class to deal with strings, it would be nice for '+' to join strings together. Or if you wanted to add range-checking to arrays, you'd have to modify the [ ] behaviour. Below are some examples of overloading, but first more on the mechanism of inheritance.

Virtual members

As mentioned earlier, it's easy to redefine a base class's function in a derived class, but to fully exploit polymorphism (the ability to deal with many types transparently), a new concept needs to be introduced.

Suppose that we were designing a graphics editor. We might have a base class Shape and various derived classes Triangle, Rectangle, etc, each with their own print member function. We'd like to collect these objects together into groups sometimes. An array of pointers to the objects would be appropriate, but what sort of pointers could we use? Fortunately, it's possible to assign a derived class pointer to a base class pointer, so the following is possible, creating an array of pointers to the base class.

Triangle t1, t2;
Rectangle r1, r2;

typedef ShapePtr Shape*;
vector<ShapePtr> shapes(4);
shapes[0]=&t1; 
shapes[1]=&r1; 
shapes[2]=&t2; 
shapes[3]=&r2;

Then we could draw all the shapes in the group by calling the print function of each element. But there's a problem - because the elements are pointers to Shape, it's Shape's print function which is called, rather than the appropriate derived object's function.

The solution to this is to define print in the base class as a virtual function by having something like

   virtual void print();

which lets us deal with the shapes array without having to worry about the real type of each component; the derived object's print function will always be called.

Abstract Classes

Some classes represent abstract concepts for which objects cannot exist. The functions have to be defined in the derived classes. Such functions are called pure virtual functions and are denoted by the following notation

   virtual void print() =0;

in the base class. A class with one or more pure virtual functions is an Abstract Class.

Redefining operators

If you need to redefine operators remember to redefine then consistently (if you redefine '+' don't forget '+=') and remember that you can't change precedence order. The default assignment operator does a simple copy of members, which might not be what you want.

Examples of redefining operators are in most books. Some things to remember when redefining assignment operators are

  • that reference parameters help to overload operators efficiently (objects aren't copied) while keeping their use intuitive (pointers would require the user to supply addresses of objects).
  • to deal with situations where a variable is assigned to itself. A pointer/reference to the object on the right of the = will be given to the function explicitly. A pointer to the object that the function is a member of is supplied implicitly to all non-static member functions. It's called this, so if this equals the supplied pointer, you'd usually want to do nothing.

  • to return a const. This catches errors like (i+j)=k

  • to return *this so that assignments can be chained (i.e. i=j=k becomes possible)

A class definition example

The following example highlights some of the more subtle issues associated with Constructors and Destructors.

It's the job of Constructors to allocate the resources required when new objects are created. If these are created using new then the Destructor should free the resources to stop memory being wasted. For example, if we want to store information about criminals we might define an object as follows

class criminal {
 string name;
 char* fingerprint; 
 int fingerprint_size;
}

where fingerprint points to an image. The memory (if any) allocated for the image would be freed by the destructor.

assignment operator

That works fine until the following kind of situation arises

void routine (criminal a)
{
  criminal b;
  b=a;
  ...
}

The assignment b=a copies the bytes of a to b, so the fingerprint field of both objects will be the same, pointing to the same memory. At the end of this routine criminal's destructor will be called for b, which will delete the memory that b.fingerprint points to, which unfortunately is the same memory as a.fingerprint points to.

To cure this we need to redefine the assignment operator so that the new object has its own copy of the resources.

void criminal::operator=(criminal const &b)
{
 name=b.name;
 fingerprint_size=b.fingerprint_size;
 delete fingerprint;
 fingerprint = new char[fingerprint_size];
 for (int i=0;i<fingerprint_size;i++)
    fingerprint[i]=b.fingerprint[i];
}

Note that space used by an existing fingerprint is freed first before a new fingerprint image is created - memory would be wasted otherwise.

this

But problems remain. If the programmer writes a=a, the delete command above frees a.fingerprint, which we don't want to free. We can get round this by making use of the this variable, which is automatically set to be the address of the current object. If the code above is bracketted by if (this != &b) { ... } then it's safe.

There's another improvement that we can make. So that assignments like a=b=c can work, it's better not to return void.

criminal const& criminal::operator=(criminal const &b)
{
 if (this != &b) {
   name=b.name;
   fingerprint_size=b.fingerprint_size;
   delete fingerprint;
   fingerprint = new char[fingerprint_size];
   for (int i=0;i<fingerprint_size;i++)
      fingerprint[i]=b.fingerprint[i];
 }
 return *this;
}

copy constructor

But there's still a problem! Suppose the programmer writes criminal a=b;. a is being created, so a constructor is called - not an assignment. Thus we need to write another constructor routine using similar ideas to those used in assignment. Note that in this case resources don't need deleting, and that this needn't be checked (because criminal a=a; is illegal).

Putting all these ideas together, and sharing code where possible we get

void criminal::copy (criminal const &b)
{
 name=b.name;
 fingerprint_size=b.fingerprint_size;
 fingerprint = new char[fingerprint_size];
 for (int i=0;i<fingerprint_size;i++)
    fingerprint[i]=b.fingerprint[i];

}

void criminal::destroy()
{
 delete fingerprint;

}

// Assignment Operator
criminal const& criminal::operator=(criminal const &b)
{
 if (this != &b) {
  destroy();
  copy(b);
 }
 return *this;
}

// Default Constructor
criminal::criminal()
{
  fingerprint=0;
  fingerprint_size=0;
}


// Copy Constructor
criminal::criminal(criminal const &b)
{
  copy(b);
}

// Destructor
void criminal::~criminal()
{
  destroy();
}

This seems like a lot of work, but it's necessary if objects use pointers to resources. You might think that by avoiding the use of commands like a=a in your own code, you can save yourself the trouble of writing these extra routines, but sometimes the problem situations aren't obvious. For instance, the copy constructor is called when an object is returned or used as an argument to a routine. Better safe than sorry.

Redefining [ ]

vector doesn't have range-checking by default, but if you use the member function vector::at(), out_of_range exceptions can be trapped, so you can add out_of_range checking by redefining [ ] in a class derived from vector, and using vector's constructors -

template<class T> class Vec: public vector<T> {
public:
Vec() : vector<T>() {}
Vec(int s) : vector<T>(s) {}

T& operator[] (int i) {return at(i);}
const T& operator[] (int i) const {return at(i);}
}

The following more complicated example creates a mapping (relating a string to a number) without using the Standard Library's map facility. It does a word-frequency count of the text given to it.

[fontsize=\small,frame=single,formatcom=\color{progcolor}]
#include <iostream>
#include <string>
#include <vector>
using namespace std;

// The Assoc class contains a vector of string-int Pairs.
class Assoc {
  struct Pair {
    string name;
    int val;
    // The following line is a constructor for Pair
    Pair (string n="", int v=0): name(n), val(v) {}
  };

  // create a vector of the Pairs we've just created
  vector <Pair> vec;

public:
  // redefine []
  int& operator[] (const string&);
  // a member function to print the vector out
  void print_all() const;
};


// This redefines [] so that a ref to the value corresponding to the 
// string is returned if it exists. If it doesn't exist, an entry 
// is created and the value returned.
int& Assoc::operator[] (const string& s)
{
// The next line's creating an appropriate iterator
// for the vector of Pairs
  for (vector<Pair>::iterator p=vec.begin(); p!=vec.end(); ++p)
     if (s == p->name) 
        return p->val;
    
  vec.push_back(Pair(s,0));
  return vec.back().val;
}

void Assoc::print_all() const
{
for(vector<Pair>::const_iterator p=vec.begin(); p!=vec.end(); ++p)
   cout << p->name<<": " << p->val << endl;
}

int main()
{
string buf;
Assoc vec;
  cout << "Type in some strings then press CTRL-D to end input\n";
  while(cin>>buf) vec[buf]++;
  vec.print_all();
}

Redefining ()

This can be used to provide the usual function call syntax for objects that in some way behave like functions (``function objects" or ``functors''). These are used by the Standard Library.

Redefining ->

Smart pointers are pointers that do something extra (e.g. increment a counter) when they are used.