The last chapter introduced the idea of Abstract Data Types (ADTs) as an approach to building modular systems in programming teams. This chapter deals with the specifics of realising ADTs in C++ through the new concept of CLASSES.
The chapter introduces necessary terminology and covers the fundamental stages of defining, implementing and using classes. Some initial guidelines for good class design are also provided.
The chapter follows three examples: a simple Counter class, used for counting the occurrences of events, a Money class used to manipulate values of pounds and pence, and a more complex Set class which allows programmers to work with sets of characters and supports set operations such as union and intersection.
The Class is a new concept which can be used to introduce new C++ types for programmers to use. We will introduce classes through a simple example - a generalised Counter which counts the number of occurrences of some event in a program. Effectively, a Counter is an object which starts with an initial value of zero, and whose value can be increased one step at a time. At any point the value can be read by the program, tested whether it is zero or compared against another counter. The value can also be reset to zero at any time.
Our goal is to build a general Counter class that can be used in many different programs. Bear in mind that there are two kinds of people involved with the Counter. Implementors, who design and build it, and its user(s) who use it in their programs. We shall look at each role in turn. Overall, there are three stages to building the class.
Stages 2 and 3 can occur in parallel.
Definition of the Counter means exactly specifying its interface (what it does and how to use it) and writing this as a C++ class definition in a header file. The name of the header file is usually (but not necessarily) that of the class with the added suffix .h.
In our example, the following C++ code is written in the file Counter.h.
// definition file for a simple counter class // Steve Benford November 1991 #ifndef COUNTER_H #define COUNTER_H class Counter { private: int count; // stores the current count value public: // constructor Counter(); // initialise counter to 0 // access void reset(); // reset counter to 0 int read(); // return current count void inc(); // increment count // tests int iszero(); // test whether 0 int equals(Counter c); // test equivalence }; #endif
The class definition starts with the word class, followed by the name of the class and then the rest of the definition between braces. The definition must be terminated with a semi-colon. The definition is divided into two parts.
The public part defines the interface to the class in terms of a set of function declarations. These follow the word public:. Each declaration gives the name of a function along with its argument and result types (argument names are optional, but it is good style to include them). Another name for these declarations is function prototypes. Being in the public part means that users of the class can invoke these functions.
The interface functions define all the actions that users can take on variables of the class. Thus, the reset function simply sets the value to zero (hence no argument or result); the read function returns the current value as an integer; the inc function adds one to the current value; the iszero function tests whether the current value is zero and the equals function tests against another counter (hence one argument of type Counter). The last two functions return integer results (1 indicates TRUE, 0 indicates FALSE).
The Counter interface function is special. It is called the constructor function for the class and is used to initialise variables of the class. In this case it will set the initial count value to zero. Most classes will have a constructor function which must have the same name as the class. The constructor doesn't return a result (not even void) but can take arguments. It is automatically invoked whenever a variable of the class is declared in a program.
Note that good comments are an essential guide to how to use the class.
The private section defines the data that is needed to implement the class. In the case of the Counter we need only remember the current count value. This requires a single integer variable. Being in the private section means that users of the class are prohibited from directly accessing the variable "count". It can only be accessed through the interface functions.
Now for some terminology. Anything that appears in the class definition is a member of the class. In general we refer to member functions and data members. A member function is also often called a method of the class. Thus, when someone talks about "invoking a method in a class" they mean using a class interface function.
A variable of the class which is declared in a users program is called an object (or sometimes an instance of the class). Thus, the distinction between classes and objects is the same as for types and variables.
You should note that data members and methods can appear in both the public and private sections of a class. It is quite common to see private methods (which can only be used by other methods). It is very rare (and probably very bad practice) to see public data members.
It is a convention that the private section is written before the public section.
You may wonder about the lines:
#ifndef COUNTER_H #define COUNTER_H #endif
These are needed to stop multiple definitions of the same class occurring when a header file is accidently referred to more than once by a user program. We shall see later that this easily happens when classes use other classes (see chapter 3).
The statements are a kind of if-statement for the compiler. The first line says: "has the name COUNTER_H been defined yet?" If the answer is no, everything up to #endif is compiled. This includes defining the name COUNTER_H as the very next statement. If the answer is yes, the compiler ignores everything up to the #endif. The logic of this is that the code between the statements will only be looked at once by the compiler. If you don't understand this, you should at least be aware that YOU MUST ALWAYS PLACE SIMILAR STATEMENTS AROUND CLASS DEFINITIONS THAT YOU WRITE. You can chose the name to be defined yourself.
To summarise, the general structure of a class definition is:
class class-name { private: declaration of data members and private methods public: declaration of constructor methods declaration of interface methods };
Watchout for the semi-colon at the end and also remember the use of #ifndef, #define and #endif in the header file.
Now let's play the role of the implementor. Once it has been defined, the Counter class needs to be implemented. This means implementing all of the functions specified in its definition. It is a good idea to write this implementation in a separate file from the definition. For example, the following code could be placed in a file called Counter.C.
// simple counter class: implementation of methods // Steve Benford November 1991 #include "Counter.h" // construct and initialise to 0 Counter::Counter() { count = 0; } // reset data member count to 0 void Counter::reset() { count = 0; } // return current value of data member count int Counter::read() { return count; } // increment the value of the data member count void Counter::inc() { count++; } // test whether the value of the data member count is zero int Counter::iszero() { if (count == 0) return 1; else return 0; } // test whether the value of data member count is equal to that of c int Counter::equals(Counter c) { if(count == c.count) return 1; else return 0; }
The statement #include "Counter.h" tells the compiler to insert all of the code from the class definition file at this point of the implementation file. This must be done by any program which refers to the Counter class. You can think of it as merging the two files together. The UNIX pathname of the file appears between double quotes, so in this case it looks in the current directory.
The rest of the file consists of code implementing the interface functions. This is mostly straight forward as the functions are very simple. However, you should note the following points:
Now let's assume the users perspective. The user wants to employ the class to solve a specific problem. The following program reads in a sentence of text ending with a full-stop, a character at a time. It counts the number of occurrences of the letter e (lower or uppercase) and prints out the result.
// test the counter class // count all occurrences of the letter 'e' in a sentence #include <stream.h> // use the Counter class #include "Counter.h" main() { Counter count; char c; // read first character cin >> c; // loop until a fullstop while(c != '.' ) { // is the character upper or lowercase 'e'? if (( c == 'e') || (c == 'E')) count.inc(); cin >> c; } cout << "There were " << count.read() << " letter 'e' in the text\\n"; }
This program must also include the "Counter.h" header file. The statement Counter count; declares an object (variable) of Class (type) Counter, called count. This statement automatically calls the constructor function which initialises its internal data member to zero.
Inside the while loop, we see the statement count.inc(). This says invoke the inc() function on the object count. Its effect is to increase the internal value of count by one.
The statement count.read() invokes the read() function on count which returns its current value ready to be printed out.
Notice how easy it is to use the Counter class. Notice also how the user is oblivious of its internal implementation (and also how they cannot tamper with internal details!).
The program doesn't use the equals function. However, the following fragment of code shows it might be used.
Counter a,b; if ( a.equals(b) ) cout << "They are the same!\\n";
Notice how equals, a binary operation between two objects, is expressed - invoke the method ON one object and pass the other as an argument. This may be a little confusing at first and is important to take some time to understand.
A consequence of splitting our code between different files is that the compilation process becomes a little more complex.
The implementor must compile the implementation file (Counter.C). However, because this is incomplete (no main()) they need to tell the compiler to stop short of linking the code into a final executable program. This is done via the -c flag to the g++ command. Thus, they enter the command:
This places compiled versions of each method in a file called Counter.o.
The user must compile their own code and also link in the implementors complied methods. They do this with the command:
Where myprog.C is the name of their program file. Note that to use someone elses class you need to i) include the header file in your program ii) include the compiled functions when you compile your program. The following diagram summarises the files and commands involved in the process of implementing and using the Counter class.
-Counter.h- / \ #include Counter.h #include "Counter.h" / \ / \ myprog.C Counter.C | | | | | | g++ -c Counter.C | | g++ myprog.C Counter.o Counter.o | / | ----------------- |/ | a.out
If the class user has access to the source code for the class implementation they could also enter the command:
This bundles togther all of the compilation and linking steps in the above sequences of commands into a single command.
Our second example is rather more complex and will highlight some of the issues involved in designing good classes. Our goal is to define and implement a general purpose Money class which can be used to manipulate pounds and pence values. This class might be very useful for a variety of accounting applications (as we shall see in chapter 3).
Bearing in mind that we want a generally useful class, our biggest problem is how to design the right interface. Unfortunately there is no easy formula to be applied. Good class design is a matter of experience and trial-and-error. In spite of the impression that you might get in lectures, it is often an iterative process. This means that there might be many revisions before the definition is satisfactory. However, I will give you some guidelines that represent a good starting point. Remember they are guidelines - not a formula!
First, think what are the general properties of the Money class? Well, it stores pounds and pence values. You can add and subtract money. Subtraction implies that we can have negative values. You can compare money values (<, >, == etc). You might want to change an overall value or just inspect the pounds or pence components. You might want to print out money values in a special format.
My guidline is to think about methods which fall into five general categories: -
After several iterations, I arrived at the following methods for the Money class.
These translate into the following C++ class definition which I put in the file Money.h.
// C++ class definition for money objects (pounds and pence values) // Steve Benford - November 1991 #ifndef MONEY_H #define MONEY_H class Money { private: int pnce; // total number of pennies public: // constructor function Money(int pounds, int pence); // construct with supplied values // 0 <= pence <= 99 and 0 <= pounds // combination functions Money add(Money m); // add two money values Money subtract(Money m); // subtract two money values // access functions void negate(); // change sign of value (+ve <-> -ve) // this allows negative values int pounds(); // return number of pounds int pence(); // return number of pence void set(int pounds, int pence);// set to new value void increase(Money m); // increase current value by m void decrease(Money m); // decrease current value by m // test functions // compare values int equals(Money m); // equal to? int less(Money m); // less than? int greater(Money m); // greater than? int less_equals(Money m); // less than or equal to? int greater_equals(Money m); // greater than or equal to? int is_zero(); // has zero value? int is_positive(); // is the value +ve or -ve // I/O functions void print(); // display in the format // +/-$pounds.pence }; #endif
The most difficult design decision was how to build negative Money values. At first, I thought that the user could supply negative values of pounds in the constructor (e.g. Money a(-6,40)). However, initialising a sum such as -40 pence would be impossible this way because the computer interprets -0 as 0. In the end I decided that the constructor could only build positive values and the user would have to subsequently use the negate() method to make them negative. This is clumsy but consistent.
There are several possible implementations of the money class. My first thought was to use two internal integer data members called pounds and pence. Each method would then manipulate these as appropriate. For example, adding would involve adding the pounds and pence values from two objects. However, because the pence data member should have a maximum value of 99, this would involve some quite complex calculations in all combination and test operations (particularly for subtraction involving negative values). So, in the end I chose a second approach - use only one data member to represent the total number of pence. This simplifies most calculations. The only extra work is now in the print() pounds() and pence() methods. However, I think that this is easier. This kind of trade off is common to a variety of problems. Here is the implementation file for the Money class (in Money.C).
// Implementation of the Money class // Steve Benford - November 1991 #include <stream.h> #include <stdlib.h> #include "Money.h" // constructor - check and initialise values // NOTE: can only initialise to positive value // negative values must be obtained via the negate function Money::Money(int pounds, int pence) { // check for pounds out of range if (pounds < 0) { cerr << "Error initialising Money: pounds out of range\\n"; exit(-1); } // check for pence out of range if ( (pence < 0) || (pence > 99) ) { cerr << "Error initialising Money: pence out of range\\n"; exit(-2); } // set data value to total number of pennies pnce = (pounds * 100) + pence; } // add two Money objects Money Money::add(Money m) { // declare result object Money res(0,0); res.pnce = pnce + m.pnce; return res; } // subtract two money objects Money Money::subtract(Money m) { // declare result object Money res(0,0); res.pnce = pnce - m.pnce; return res; } // negate current value void Money::negate() { pnce = -pnce; } // return number of pounds (absolute value) int Money::pounds() { return abs(pnce / 100); } // return number of pence (absolute value) int Money::pence() { return abs(pnce % 100); } // is the value positive int Money::is_positive() { if(pnce >= 0) return 1; else return 0; } // set to a new value void Money::set(int pounds, int pence) { // check for pounds out of range if (pounds < 0) { cerr << "Error initialising Money: pounds out of range\\n"; exit(-1); } // check for pence out of range if ( (pence < 0) || (pence > 99) ) { cerr << "Error initialising Money: pence out of range\\n"; exit(-2); } // set data value to total number of pennies pnce = (pounds * 100) + pence; } // increase current value void Money::increase(Money m) { pnce = pnce + m.pnce; } //decrease current value void Money::decrease(Money m) { pnce = pnce - m.pnce; } // are two values equal? int Money::equals(Money m) { if(pnce = m.pnce) return 1; else return 0; } // is one value less than another? int Money::less(Money m) { if(pnce < m.pnce) return 1; else return 0; } // is one value greater than another? int Money::greater(Money m) { if(pnce > m.pnce) return 1; else return 0; } // is one value less than or equal to another? int Money::less_equals(Money m) { if(pnce <= m.pnce) return 1; else return 0; } // is one value greater than or equal to another? int Money::greater_equals(Money m) { if(pnce >= m.pnce) return 1; else return 0; } // is a money value zero int Money::is_zero() { if(pnce == 0) return 1; else return 0; } // print a money value void Money::print() { int p; // display sign and money symbol if (!is_positive()) cout << "-"; cout << "$"; // calculate and print pounds and pence values cout << abs(pnce / 100) << "."; p = abs(pnce % 100); if(p < 10) cout << "0" << p << "\\n"; else cout << p << "\\n"; }
Note the use of the abs function to get absolute (i.e. positive) values and the remainder (%) operation in the print() and pence() functions.
The following user program shows how Money objects might be built, added and subtracted. The program does nothing useful other than show the syntax of manipulating Money objects.
#include <stream.h> #include <stdlib.h> #include "Money.h" main() { int Pounds, Pence; char ch; // read in first money value cout << "enter a - pounds: "; cin >> Pounds; cout << "pence: "; cin >> Pence; Money a(Pounds, Pence); cout << "negate? "; cin >> ch; if(ch == 'y') a.negate(); // read in second money value cout << "enter b - pounds: "; cin >> Pounds; cout << "pence: "; cin >> Pence; Money b(Pounds, Pence); cout << "negate? "; cin >> ch; if(ch == 'y') b.negate(); Money c(0,0); // test add function cout << "a + b is "; c = a.add(b); c.print(); cout << "\\n"; // test subtract function cout << "a - b is "; c = a.subtract(b); c.print(); cout << "\\n"; // test a comparison operator if(a.less_equals(b)) cout << "a <= b\\n"; else cout << "a > b\\n"; }
The final example in the chapter is a Set class used for manipulating sets of characters. Even though it is a complex example, these notes only present the source code and a few comments about its design. It is left as an "exercise for the student" to work through the example in detail.
A set is a collection of items of some type (in this case single characters). Each item can only appear once in the set and the items are in no particular order. Thus, unlike the arrays and structured from last term, there is no way of saying "give me the 5th item" or "give me the item called Fred". Instead, the Set provides the following basic operations:
The following header file contains the class definition for the Charset (set of characters) class. Notice how the operations are divided into the five categorise mentioned above.
// Definition of a class for a set of characters // Steve Benford - November 1991 #ifndef CHARSET_H #define CHARSET_H // upperbound on the possible number of elements in the set const int MAXSIZE = 500; class Charset { private: char set[MAXSIZE]; // array storing elements in the set int n_elems; // number of elements at any one time public: // constructor function Charset(); // construct an initially empty set // access functions int include(char c); // add a character to the set // return status (success or // failure due to array overflow int remove(char c); // remove a character from the set // indicate success or failure due // to element not being in set int size(); // how many elements are in the set? // combination functions Charset unionof(const Charset &s); // set union Charset intersection(const Charset &s); // set intersection Charset difference(const Charset &s); // set difference // test functions int equals(const Charset &s); // are two sets equal? int subset(const Charset &s); // subset? int superset(const Charset &s); // superset? int in(char c) const; // is an element in the set? int isempty(); // is the set empty? int disjoint(const Charset &s); // are the two sets disjoint? // I/O functions void print(); // display contents in the form { ... } }; #endif CHARSET_H
Notice how we use the & symbol in front of the variable name whenever we pass a Charset as a parameter. For example, we see,
int equals(const Charset &s); // are two sets equal?
We call this passing paramters by reference (whereas the usual way is called passing by value). This is discussed in detail in chapter 4. For now, you need to know that this reduces the amount of copying of data that the computer does whenever the function is called. Pass by value means that the function uses a copy of the parameter not the original whereas pass by reference means that the code inside the function actually accesses the original value.
When passing large paramters such as a Charset, pass by reference makes the program run faster and use less of the computer's memory. HOWEVER, this does mean that the function can acually change the original value. We call this a side-effect and sometimes it can be useful and sometimes it can be positively dangerous. Putting the word const in front of the parameter as we see in the Charset example prevents side-effects. In other words, the definition
int equals(const Charset &s); // are two sets equal?
Can be read as "there is a function called equals with one parameter of type Charset called s. For reasons of efficiency, use pass by reference for s by for reasons of safety disallow any side effects from happening."
There is one further wrinkle to be considered. We shall see below in the implementation file that some of these functions call the member function in. In order for const to work as required, the programmer has to inform the compiler that this function (in) will never try to alter the value of its object. This is why the word const appears after its decalration:
int in(char c) const; // is an element in the set?
The implementation of the Charset class uses an internal array to store the elements of the set. The integer data member n_elems indicates how many elements are in the set and, consequently, how much of the array is currently being used.
It is important to note that using an array introduces a practical restriction on how many elements can be in the set. This is determined by the constant MAXSIZE.
The implementation of the Charset methods involves the kind of array processing that you saw last term (e.g. finding the elements in common). The implementation file (Charset.C) is listed below.
// Implementation of the Charset class functions // Steve Benford - November 1991 #include <stream.h> #include "Charset.h" // construct an empty set Charset::Charset() { // set number of elements to 0 n_elems = 0; } // test whether the element c is in this set int Charset::in(char c) const { // search through array containing elements for (int i = 0; i < n_elems; i++) if ( set[i] == c ) return 1; return 0; } // include an element in a set int Charset::include(char c) { // first make sure that the element is not already in the set if (in(c) == 0) { // check the array bounds to make sure there is space if (n_elems == MAXSIZE) { return 0; } // add the element to the array else { set[n_elems] = c; n_elems++; return 1; } } else // element already in set, do nothing return 1; } // remove a single character from this set int Charset::remove(char c) { // first look for the element in the array for (int i = 0; i < n_elems; i++) // if we find it, then we remove it if (set[i] == c) { // shuffle the remaining elements down the array for(int j = i+1; j < n_elems; j++) set[j-1] = set [j]; // decrease number of elements n_elems--; return 1; } // if we get to this statement, we didn't find the element return 0; } // return the union of this set and the given set, s Charset Charset::unionof(const Charset &s) { // build a new set called res Charset res; int i; // include all the elements of this set in res for(i=0;i< n_elems; i++) res.include(set[i]); // include all three elements of the given set, s, in res for(i=0;i<s.n_elems; i++) res.include(s.set[i]); return(res); } // return the intersection of this set and the given set, s Charset Charset::intersection(const Charset &s) { // build a new set called res Charset res; int i; // for each of the elements in this set ... for(i=0; i<n_elems; i++) // ... see if it is in set s ... if (s.in(set[i])) // ... if so, include it in res. res.include(set[i]); return(res); } // return the difference between two sets Charset Charset::difference(const Charset &s) { // build a new result set called res Charset res; // for each of the elements in this set ... for(int i = 0; i< n_elems; i++) // ... if it isn't in the given set,s ... if( s.in(set[i]) == 0) // ... then include it in the result set res.include(set[i]); return(res); } // test whether this set is a subset of the given set int Charset::subset(const Charset &s) { // see whether each element of this set is in s for(int i=0;i<s.n_elems;i++) if(in(s.set[i]) == 0) return 0; return 1; } // test whether this set is a superset of the given set int Charset::superset(const Charset &s) { for(int i=0;i<n_elems;i++) if(!s.in(set[i])) return 0; return 1; } // test whether two sets are equivalent (contain the same characters) int Charset::equals(const Charset &s) { // first, are the number of elements the same (quick test) if (n_elems != s.n_elems) return 0; // now, is one a "subset" of the other if ( subset(s) ) // same number of elements and a subset => return true return 1; else // same number of elements but not the same elements return 0; } // test whether this set is empty int Charset::isempty() { if (n_elems == 0) return 1; else return 0; } // test whether this set and the given set have no members in common int Charset::disjoint(const Charset &s) { // our method is to build the intersection and test whether it is empty // take the intersection of this set and the given set Charset temp = intersection(s); // is it empty? if (temp.isempty() == 1) return 1; else return 0; } // return the number of elements in the set int Charset::size() { return n_elems; } // display the set in the format "{ .... } (n_elems)" void Charset::print() { cout << "{ "; // print each element in separated by spaces for(int i=0; i< n_elems; i++) cout << set[i] << " "; // print closing bracket and number of elements cout << "}" << " (" << n_elems << ")\\n"; }
Several points are worth noting about this implementation.
The following simple test program reads text from the keyboard until end of file (control-D if from the keyboard) and prints out all of the characters used in the text. This is gracefully achieved by building up a set of characters.
// program to record and then display the set of characters // used in some input text #include <stream.h> #include <stdlib.h> #include "Charset.h" main () { char c; Charset s; // read until the end of file (^D for keyboard input) while (cin.eof() == 0) { // read a character (ignore whitespace) cin >> c; // include this in the set if(s.include(c) == 0) { cerr << "Array overflow in set s\\n"; exit(-3); } } // print the set s.print(); }
Note how the program tests the results of the include method in case of array overflow.
Notes converted from troff to HTML by an Eric Foxley shell script, email errors to me!