IA Algorithms Enrichment 2017
Activities > Dec 2 > Structs

Abstract data types, part 2: data hiding and classes

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:

Student with 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).

Student constructors with public access level

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:

Student with getters/setters

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.

classes

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. classes are almost exactly identical to structs, 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.