Recall our definition of a Data Structure.
A data structure:
As a specific implementation of an ADT, there are two areas where we need to really stick to our ideal of separating interface from implementation in any Data Structure we create. The first is a mechanism for keeping the user from accessing data in an object directly, so that he's forced to interact with the object only through our interface. The private
access specifier in C++ allows us to protect the data, while we provide member functions through which the user must interact with our object. This meets our "get data in" and "get data out" requirements above.
What's left is for us as the definers of a class is to control creation (construction) and deletion (destruction) of objects of that class.
Objects are created in many different ways, but the most obvious way is by defining a variable. For example,
double x;
creates an object of type double
. This is the same regardless of what type of object you create. We can create an object of type ToD
(from the last lecture) with the statement:
ToD T;
However, when creating an object whose type is a user-defined class like ToD
, C++ provides a mechanism to control what happens when an object of type ToD
is created in a statement like the above. This mechanism is essentially a special kind of member function, called a constructor, that is called automatically when the object is created. Many constructors may be defined, all having no return type (not even void
), all having the same name as the class, all differing only in the arguments they take. The constructor that's called for a statement like the above is called the default constructor because it takes no arguments at all. Let's define a default constructor for ToD
that simply prints out a message stating that an object has been created.
class ToD {
private:
int h, m;
bool PM;
public:
ToD() {
cout << "ToD object created!" << endl;
}
};
A simple main function like:
int main() {
ToD x, y;
return 0;
}
which appears to do nothing will actually print out ToD object created!
twice - once for x
and once for y
. Now, we don't use constructors for printing out stupid messages, we use them to control the initialization of objects! So what we'd probably want to do for out ToD
class is to make sure that every object was initialized to midnight when it was created. Something like:
class ToD {
private:
int h, m;
bool PM;
public:
ToD() {
h = 12;
m = 0;
PM = false;
}
};
Combining this with the member functions we discussed last lecture provides us with a ToD
class that realizes our goal of separating interface and implementation: the user can only manipulate a ToD
object through its interface, i.e. the functions we give him. Moreover, nothing the user can do can put a ToD
object in an invalid state, meaning a state that does not represent a proper time of day.
Just as we can use constructors to control what happens when an object is created, C++ offers us a mechanism called the destructor for controlling what happens when an object is destroyed. For each class there is only one destructor, and it is essentially a special member function with no return type, whose name is the class's name with a "~" (tilde character) in front of it, and which takes no arguments. When an object of that class type is destroyed, whatever code appears in the definition of the destructor is executed. Here's a silly example of a default constructor and a destructor:
silly.h | silly.cpp |
|
|
Now, if we run the following main function:
int main(){
Silly first;
first.setName("George");
Silly *ptr = new Silly[2];
string sillyArray [] = {"John", "Thomas"};
moreSilly(ptr, sillyArray, 2);
Silly last;
last.setName("Barack");
delete [] ptr;
return 0;
}
here's what gets printed out:
A silly object is born! A silly object is born! A silly object is born! A silly object is born! Silly object Thomas dies! Silly object John dies! Silly object Barack dies! Silly object George dies! |
<-- first defined <-- 1st element of array defined <-- 2nd element of array defined <-- last defined <-- delete [] called <-- delete [] called <-- end of main reached, last dies <-- end of main reached, first dies |
You are probably curious as to why first
and last
"died" in the reverse order they were "born". It's because they were both allocated with memory from the stack. We we'll discuss stacks as an ADT in more detail later this semester.
Constructors are useful for initialization, that ought to be clear from our previous example, but when is a destructor useful? When do we have work to do when an object is destroyed? Well, destructors are only really interesting when the class object has data members that point to dynamically allocated memory from the heap (i.e. created with new). Whenever an object that lives on the heap is no longer needed, it's memory should be released to the operating system immediately in order to free up resources. If, for example, your object is made up of a bunch of other objects that were also created with new (think a linked list of nodes, like in Lab01), then all the member objects (nodes in the list) also need to be deleted explicitly destroyed with delete
commands, otherwise they take up space in memory but are inaccessible to the program. The destructor for such a class would ensure that this happens, similar to how deletelist
worked in Lab01
Here's an example of a simple list class with constructors and destructors: list.h, list.cpp, listex.cpp.
Even when you have a class like Point
that breaks all our ADT rules about data-hiding, initialization is pretty important. Consider the following:
in point.h | somewhere in main() |
|
|
Notice that it takes three lines to create and initialize each object, although logically there's only one thing to do: create a point with values and give it a name.
So in order to pull that off, we can declare constructors that take arguments, so all this can really happen in one step. For example:
in point.h | somewhere in main() |
|
|
Here we've properly protected our data members and defined a constructor that takes two arguments, doubles a and b, and initializes x and y accordingly. You can see how that simplifies our construction calls.
So you can have any argument list you want for a constructor, and a single class can have as many constructors as you want. If we wanted to give the user an option to create a Point with only a single argument, we'd declare something like:
class Point {
private:
double x, y;
public:
Point() {
x = y = 0;
}
Point(double a) {
x = a;
y = 0;
}
Point(double a, double b) {
x = a;
y = b;
}
double getX(){
return x;
}
double getY(){
return y;
}
};
You call a constructor explicitly in three circumstances. First in the way we've described, which is in the definition of a local variable. Second is with the new operator. Third is to call the constructor as a function that returns an object. The following example illustrates all three.
|
|
There is a way to declare a function inline
to gain speed for simple operations. Take these two member functions from out ToD
class, for example:
int get_h();
int get_m();
We can make these faster (and take up less space) by declaring and defining them as inline
within the header file. The compiler will take the entire function's body and insert it into any program which calls that function, making function calls resolve quicker.
inline int get_h() {
return h;
}
inline int get_m() {
return m;
}
Inlining functions has some additional benefits. Namely, you've already got the function implemented (so you don't have to do it later). Also, the function will be available to other source files without compiling the .cpp file as well. If only for the ease and clarity of use, I recommend using inline
in your single-line getter and setter functions. More complicated functions should be written in the normal manner to support information hiding, modularity, and simplicity of header files.