To understand classes you need to understand encapsulation. The whole idea is to design a singular "thing" (a class) that can be easily used, but not easily misused. The class maintains its own internal state that cannot be messed with by outside code. Ensuring that it's always stable.
To illustrate this, I'm going to go make a poorly-encapsulated class which wraps around stdio's FILE* structure. Each step I will illustrate why it has flaws, and how it can be improved by strengthening the encapsulation. In the end we'll have a fully encapsulated class which will be as solid as possible.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
|
class File
{
public:
FILE* theFile; // pointer to the FILE structure if a file is opened, null otherwise
bool Open(const char* filename)
{
if(IsOpen()) // if we already have a file open...
Close(); // ... close it first
theFile = fopen(filename,"rb"); // then open the new file
return (theFile != 0); // returns true if successful
}
void Close()
{
if(IsOpen()) // if the file is open
{
fclose(theFile); // close it
theFile = 0; // reset our pointer to null so we know it is now closed
}
}
bool IsOpen()
{
return (theFile != 0);
}
int Read(void* buffer, int size)
{
if(IsOpen())
return fread(buffer,1,size,theFile);
else
return 0;
}
};
|
An example of how this class might be used is like this:
1 2 3 4 5 6 7
|
File myfile;
myfile.Open("foo.bin");
char somebytes[10];
myfile.Read(somebytes,10); // read 10 bytes from the file
myfile.Close();
|
Seems simple enough, right?
However, it has several problems.
Problem #1: its internal state is exposed
The class relies on its 'theFile' member to keep track of its internal state (whether or not the file is opened, which file it's dealing with). Since this member is public, it means anybody can mess with it, which is BAD because it allows for people to screw up and break the class. Example:
1 2 3 4 5
|
File myfile;
myfile.Open("foo.bin");
myfile.theFile++; // mess with the pointer... now theFile is a bad pointer
myfile.Close(); // !!!!!EXPLODE!!!!!
|
To prevent people from doing this (intentionally or accidentally), this can be solved by making 'theFile' private. This is the core concept of encapsulation: restrict access to critical information.
1 2 3 4 5 6 7 8
|
class File
{
private:
FILE* theFile;
public:
bool Open(const char* filename)
//...
|
With that, we've fixed that problem. But there's more problems:
Problem #2: its initial state is undefined
Say the user does something like this:
1 2 3 4 5
|
File myfile;
if( myfile.IsOpen() )
{
// ...
}
|
IsOpen looks at theFile to determine whether or not a file has been opened. But since the only function that modifies theFile is
Open, and Open was never called... that means theFile has never been set, and therefore it is unitialized! As a result, IsOpen might return true even though no file is opened!
To solve this, we use a constructor. Constructors are just functions that are
automatically called when the object is created. Here's a basic constructor that will solve this problem:
1 2 3 4 5 6 7 8 9 10
|
class File
{
//...
public:
File()
{
theFile = 0;
}
//...
};
|
Now... if we try that same problem code again...
1 2
|
File myfile; // this automatically calls the constructor, initializing 'theFile' to 0
if( myfile.IsOpen() ) // so we are now certain that IsOpen will return false, as it should
|
Problem #3: it doesn't clean up after itself
What if the user forgets to close the file? Example:
1 2 3 4 5 6 7 8 9
|
void SomeFunc()
{
File myfile;
myfile.Open("foo.bin");
myfile.Read(blah,10);
// whoops, forgot to Close() the file
}
|
If this happens, the file remains open forever (or really, for the lifetime of the program). This puts an unnecessary strain on file I/O for the entire computer, and consumes extra memory. If 'SomeFunc' is called a few thousand times during the lifetime of the program, this might result is substantial memory leaks, and possibly even program crashes!
To solve this problem, we use a Destructor. Destructors are just functions that are
automatically called when an object dies (goes out of scope). A basic destructor:
1 2 3 4
|
~File()
{
Close(); // automatically close the file when this object dies
}
|
Now we're OK. That same problem code:
1 2 3 4 5 6
|
void SomeFunc()
{
File myfile;
//...
// forgot to Close()
} // <- destructor called here. So file is automatically closed
|
Problem #4: it doesn't copy correctly
What if the user tries to do this:
1 2 3 4 5 6 7 8 9 10
|
{
File a;
a.Open("foo.bin");
{
File b = a; // copy the file object
// now 'b.theFile' points to the same FILE that 'a.theFile' points to
}// <- b's destructor called, closing the file
a.Read(foo,10); // FAIL OR EXPLODE, because the file has been closed!
}
|
There are a number of solutions to this problem. One solution would be to record the filename and have 'b' reopen the file on its own so that each object has a unique FILE. But the simplest would be to just forbid copying altogether... so let's do that.
When you copy an object, C++ calls a special "copy constructor". If you make that copy constructor private, then nobody outside the class can access it. If they try to copy it (intentionally or on accident), it will give a compiler error, rather than crash the program at runtime.
The same thing also happens with the assignment (=) operator. So we need to make that private as well. The general rule is known as the "rule of 3". If you need to write your own destructor, copy constructor, or assignment operator... then it's almost certain that you need to write ALL THREE of them. So with them it's usually all or nothing.
However, since we are just forbidding copying, we don't need to actually write code for the copy constructor / assignment operator. We just have to make them private:
1 2 3 4 5 6 7
|
class File
{
//...
private:
File(const File&); // private copy constructor
void operator = (const File&); // private assignment operator
};
|
Now that problem is solved! The same problem code:
1 2 3 4
|
File a;
a.Open("foo.bin");
{
File b = a; // COMPILER ERROR, 'b's copy constructor is private
|
So what we're left with after all that is this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
|
class File
{
private:
FILE* theFile; // pointer to the FILE structure if a file is opened, null otherwise
public:
File()
{
theFile = 0;
}
~File()
{
Close();
}
bool Open(const char* filename)
{
if(IsOpen()) // if we already have a file open...
Close(); // ... close it first
theFile = fopen(filename,"rb"); // then open the new file
return (theFile != 0); // returns true if successful
}
void Close()
{
if(IsOpen()) // if the file is open
{
fclose(theFile); // close it
theFile = 0; // reset our pointer to null so we know it is now closed
}
}
bool IsOpen()
{
return (theFile != 0);
}
int Read(void* buffer, int size)
{
if(IsOpen())
return fread(buffer,1,size,theFile);
else
return 0;
}
private:
File(const File&); // private copy constructor
void operator = (const File&); // private assignment operator
};
|
This is a fully encapsulated class that is simple to use... and next to impossible to break.