Abstract data types, part 2: data hiding and class
es
So far, we have covered some of the basics of object-oriented programming: storing related data in member variables, performing computations with/on that data using member functions, and how to initialize objects using constructors. Here, we will explore methods to protect the state of an abstract data type, while still allowing clients (people using your code) to interact with your data type.
Introduction
Consider our Student
struct
from before:
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; Student (); Student (const string &firstIn, const string &lastIn); string className (); //Implementations omitted for brevity };
Suppose we had the following Student, initialized like so:
Student alice ("Alice", "Zircon");
After initialization, alice
will have an age of 14 be in the 9th grade.
However, we can still do the following:
alice.age = -10; alice.gradeLevel = 50;
After these two lines, alice
will be -10 years old and be in the 50th grade!
This is violating something called an invariant, or a property of our abstract data type that must never change.
In this case, we are violating two invariants: that age
must be positive, and that gradeLevel
must be between 0 and 12.
Our data type should not allow this to happen. Rather than hoping that no one will break our Student
data type,
we should program Student
so that this can never happen in the first place.
This is where data hiding, and, more specifically, access levels come into play.
The issue here is that external code is allowed to modify the data that belongs to Student
without any sort of error checking.
Using access levels, we can prevent external code from being able to see this data, and instead provide an interface
through which external code can still interact with the data in Student
, but also performs error checking before modifying its state.
The two basic access levels are public
and private
. As their names suggest, all internal and external code can see and
interact with member functions and variables with the public
access level, whereas only member functions can access data and functions
with the private
access level.
Let's use the following diagram to visualize how we're applying access levels:
Everything in the private
box has the private
access level and can only be accessed by member functions,
whereas everything in the public
box has the public
access level and can be accessed by both member functions and external code.
You'll notice is that we have placed all of our member variables in private
. Generally speaking, we don't want any code other than
Student
touching its member data, so we have moved it all into private
.
The next thing is we'll place our constructors in public
.
Constructors need to be public
in order to instantiate an object of our data type.
This will allow external code to create new Student
objects and set some initial values (such as the student's name).
Now, we need to develop the interface. We will create a number of member functions with a public
access level so external code can access them.
These functions will provide a way through which we can read and write to Student
's data; when external code calls the function to write state
information to a Student
, we will first check if the new state information violates any applicable invariants.
If it does, we simply ignore the input. Otherwise, we set the new state. Our Student
now looks like this:
As you can see, we have created a get
and a set
function for each member variable in Student
(and combined the functions for firstName
and lastName
). External code can use the get
function to get the value of the member variable,
and it can call the set
function with the new value to set the value of the member variable.
Now, when external code tries to set values in our Student
struct, the set
functions can check to make sure that
Student
's invariants are not violated before writing the input value to the corresponding member variable.
Writing get
and set
functions to allow external code to access the data in your abstract data type is a common practice;
these types of functions are called getters and setters.
This might be a little difficult to understand conceptually at first -- we're preventing external code from accessing the data that belongs to Student
,
while still allowing it to modify that same data using interface functions (getters and setters). This does not violate the
private
access level, as access levels only pertain to direct accesses (e.g. student.age
).
Because the interface functions are public
and have access to private
member variables, they can serve as a bridge to the data in Student
.
class
es
Now that we have discussed the idea behind access control and data hiding, let's look at how to do it in code.
The big issue with using a struct
is that everything is public
by default,
so we were able to modify everything that was not explicitly private
in our definition.
Rather than manually apply the private
keyword to all of the members that need to be private,
we can change our Student
to be a class
so that everything is private
by default.
The idea behind this is that we want to expliclty grant access to members, rather than explicitly deny access.
Our Student now looks like this:
class Student { string firstName; string lastName; int age; int gradeLevel; double score; Student (); Student (const string &firstIn, const string &lastIn); string className (); //Implementations omitted for brevity };
Note that we only changed struct
to class
-- everything else is exactly the same.
class
es are almost exactly identical to struct
s, the only
difference being that members are private
instead of public
by default.
However, as we mentioned before, our constructors should be public
, so external code can instantiate Student
objects.
We do this by placing the label public:
before all members that need to be public
:
class Student { string firstName; string lastName; int age; int gradeLevel; double score; public: Student (); Student (const string &firstIn, const string &lastIn); string className (); //Implementations omitted for brevity };
Above, all members that are declared/prototyped after line 8 will be public
instead of private
.
All of the members declared before that line will be private
.
Now, we need to implement the getters and setters for Student
. Getters are the simplest, so let's start there:
class Student { //... public: //... //return const ref for speed string getName () { return firstName + " " + lastName; } int getAge () { return age; } int getGradeLevel () { return gradeLevel; } double getScore () { return score; } };
Getters are quite straightforward: they simply return their corresponding variable.
getName
is an example of a more complex getter, it uses multiple member variables to produce
an output that is indicative of the requested state information.
Setters are void
functions that (typically) take a single input, the new value for the corresponding member variable.
When called, we check that the input value is a valid value for that particular variable, and we only overwrite the value when it is valid.
class Student { //... public: //... void setName (const string &first, const string &last) { //There are no invariants to check here, so just set the variables. firstName = first; lastName = last; } void setAge (int newAge) { if (newAge >= 0) age = newAge; } void setGradeLevel (int newGradeLevel) { if (newGradeLevel >= 0 && newGradeLevel <= 12) gradeLevel = newGradeLevel; } void setScore (double newScore) { if (newScore >= 0 && newScore <= 100) score = newScore; } };
Now, when external code calls student.setAge(-1)
, for example,
Student
will detect a violation of the invariant and return without changing any internal state.