Abstract data types, part 1: struct
s
Object-oriented programming is, in essence, the one feature that C++ is known for, and the facilities
that C++ provides to that end are very powerful. Essentially, object-oriented programming allows you to group both data and code
into one unit in your code (an "object") at a high level. In fact, you have already encountered pre-built objects in your code --
cin
, cout
, string
s, and vector
s are all examples of objects built
using C++'s object-oriented programming paradigm. In this activity, we will explore some of the basic features that object-oriented
programming has to offer us, using a new language feature: a struct
.
struct
s (and, later, class
es) allow us to create our own data types.
First, we use them to define both the behavior
and the state
of our data types.
From there, we can instantiate these data types as variables ("objects") in our program, taking advantage of the fact that
these variables/objects can take care of their own state requirements by themselves, which allows us to write our code
in a much more conceptual manner.
In this lesson:
Member data
As we proceed through this activity, we will be working to design a struct
called Student
,
which handles all of the records for one particular student in a class. This struct
will have a lot of requirements, so
we're going to build Student
incrementally.
Let's start by just looking at how to create a struct
:
struct Student { //... }; int main () { //create two Student variables called 'alice' and 'bob' Student alice, bob; //... return 0; }
So far, we see that there are two places where we are dealing with Student
: on lines 1 through 3 where we define the struct
,
and then on line 7 where we declare two Student
variables named alice
and bob
.
After that, we would presumably interact with alice
and bob
to handle student records, but let's focus on the struct
definition first.
First, note that the struct
definition is outside the definition for main
. While this isn't strictly necessary, doing so makes your
struct
visible to all of the functions in your file, which is typically what you want.
The first thing we are going to want to do is add the data for our Student
: likely their first and last name, their age, their grade level,
and their current score in the class (which we will measure as a decimal ranging from 0 to 100):
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; };
This is fairly straightforward: for all of the data we want Student
to retain, we simply declare a variable for each piece of data,
using the appropriate data type. These variables are called member variables, in that they are all members of the
Student
struct
.
Member variables are a little different from the variables you have interacted with so far, for a few reasons:
- The declarations in the
struct
definition don't actually set aside any memory for those variables. The declarations are really just notes to the compiler that say "I will need these variables when the programmer wants to create an object of this type". - Member variables are unique to each instance of
Student
. In the example above, bothalice
andbob
will have their own uniquefirstName
,lastName
,age
, etc. member variables. Changing the member variables ofbob
will have no effect on the members ofalice
.
It can easily be seen that the first point is a side effect of the second: because each member variable must be unique to each instantiation of the struct
,
we cannot set aside any memory for them until a Student
is declared.
So, we know how to tell the compiler what the member variables of a struct
are, but how do we use them in our code? Well, we know that each member variable
is attached to a specific instance of Student
, so let's try to do something with our Student
s alice
and bob
:
int main () { Student alice, bob; alice.firstName = "Alice"; alice.lastName = "Zircon"; alice.age = 16; alice.gradeLevel = 11; alice.score = 98.4; bob.firstName = "Bob"; bob.lastName = "Ytterby"; bob.age = 14; bob.gradeLevel = 9; bob.score = 93.5; if (alice.score > 96.0) { cout << "Alice has an A+" << endl; } else { cout << "Alice does not have an A+" << endl; } cout << "Bob is in " << bob.gradeLevel << "th grade" << endl; return 0; }
As we see above, we can access the member variables of alice
and bob
using the
.
or member-of operator. We use the variable name for the object,
and then following the member-of operator, we type the name of the member variable we want to access.
From there, we can use the member variable just like we would use any other variable.
Note: You've seen the member-of operator before, with vector
: vec.size()
,
vec.push_back()
, vec.resize()
, etc.
Activity
- Extend the
Student
struct
to also store gender (a singlechar
such asM
orF
) and their street address (astring
). - Write a program that gets all of the information in
Student
on 5 students, and then outputs each student's first and last name, their grade level, and their letter grade. Use an array or avector
to store the students. Use the following scale to compute the letter grade:- 93+: A
- [90, 93): A-
- [87, 90): B+
- [83, 87): B
- [80, 83): B-
- [77, 80): C++
- [73, 77): C
- [70, 73): C-
- [67, 70): D+
- [63, 67): D
- [60, 63): D-
- [0, 60): E
Member functions
So, we've seen that we can use a struct
to store abitrary, related data in one "varaible".
Our Student
struct
can tell us information about the student's name, gender, address,
age, grade level, and current score in the class. However, as you saw in the activity, it still comes short;
it takes a lot of work to convert some of this information into other usable formats, which may be more suited
to certain tasks.
In the case of the activity, we could just add another member variable that stores the letter grade, but that adds a bunch more problems. We have to update the letter grade every time we update the score; and if we update the letter grade first, what score do we choose? Also, aren't we storing the data in multiple locations? Isn't that something we want to avoid?
The answer to all of this is that we can have member functions in addition to member variables.
Just as with member variables, these functions are associated with a specific instance of the Student
struct,
and we can use them to do all sorts of useful things. You will be writing the member function implementation of the letter grade
determination algorithm in the activity at the end of this section, so let's focus on a similar, but slightly different task:
how do we get the colloquial name of their grade level? Let's implement this as a member function:
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; string className () { if (gradeLevel == 9) return "freshman"; else if (gradeLevel == 10) return "sophomore"; else if (gradeLevel == 11) return "junior"; else if (gradeLevel == 12) return "senior"; //For grades < 9, just return an empty string return ""; } };
First, we notice that the function definition is inside the definition for Student
.
This is to be expected, as it is a member of Student
, just like the member variables.
But the more interesting thing: we can just start using the gradeLevel
variable without declaring it or taking it as a parameter!
That is because the compiler automatically makes all member variables available to member functions, so we don't need to declare them twice.
However, if our member function needed to use more variables, you would still have to declare those variables in the function.
Any variables declared as such are not member variables, as they will disappear after the function returns.
Like regular functions, member functions can take parameters to get data from external sources.
This is useful if you want to update the state of the struct
without breaking some restriction. For example:
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; //... //Create a function to set the score so that student's cannot have scores //less than 0 or greater than 100 void setScore (double newScore) { if (newScore >= 0 && newScore <= 100) score = newScore; //otherwise, do nothing } };
Once we have member functions written, we can access them just like we would member variables:
cout << alice.className() << endl; //"junior" alice.setScore(68.9); cout << alice.score << endl; //68.9 bob.setScore(250); cout << bob.score << endl; //93.5
Prototyping member functions
Wait, if the function has to be defined inside the struct
definition, then doesn't that put a function definition before main
?
In order for main
and any other function to know about Student
, it must be defined before each of those functions.
Like before, we can use function prototyping to move all of the function implementation after main
, so main
is the first
function implementation that we see. There is the added benefit of prototyping functions in a struct
as moving the code elsewhere allows
us to quickly glance at what members the struct
has.
Prototyping member functions is very similar to prototyping regular functions, except there is one big difference in the syntax:
#include <iostream> using namespace std; struct Student { string firstName; string lastName; int age; int gradeLevel; double score; string className (); }; int main () { //... } //Note the "Student::" before the function name string Student::className () { if (gradeLevel == 9) return "freshman"; else if (gradeLevel == 10) return "sophomore"; else if (gradeLevel == 11) return "junior"; else if (gradeLevel == 12) return "senior"; return ""; }
We can see that the prototype itself is the same: just the function signature, followed by a semicolon; but the prototype
must be in the struct
defintion. The bigger difference is when we implement the function:
our function name has Student::
prefixed to it. Because we are no longer in the initial struct
definition,
we have to tell the compiler that the className
is a member of Student
.
Just like before, we can use the members of Student
without declaring them or taking them as parameters; the compiler
takes care of that for us.
Activity
Rewrite your code for the last activity so the letter grade is returned from a member function.
Also, write a member function to set a Student
's gradeLevel
such that the gradeLevel
is never negative nor greater than 12 (we will assume that a gradeLevel
of 0 is for kindergarten).
Constructors
We have seen the most basic uses of struct
s -- we can use them to store related data on a particular item,
and we can write functions to perform operations on that data. But so far, we've had to manually initialize all of the
data by hand. This can be very tedious, especially if we have several struct
s that we need to initialize.
Constructors allow us to create default initialization processes that run automatically. As we will see, we can also
customize constructors so we can modify the initialization process when necessary.
A constructor is, at its core, a function; however it is a little different from all of the other functions we have seen so far. Let's look at an example:
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; Student () { //Initialization code goes here } };
The two major things to point out here:
- The constructor has no return type, not even
void
. - The constructor has the same exact name as the
struct
's type. This is name case-sensitive.
We can now put our initialization code in the constructor:
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; Student () { age = 14; //reasonable defaults? gradeLevel = 9; score = 0; } };
Now, whenever we declare a variable of type Student
,
the initialization code in the constructor will run on our newly declared variable,
and all of the member variables will be initialized to the value specified in the constructor:
int main () { Student student; //constructor runs here cout << student.age << endl; cout << student.gradeLevel << endl; cout << student.score << endl; return 0; }
14
9
0
As with regular member functions, we can also prototype constructors. The format is the same, just remember that constructor has no return type.
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; Student (); }; int main () { //... } Student::Student () { //initialization code here }
But what if we wanted to pass in different initial values? We can accomplish this using a constructor, by passing in parameters:
struct Student { string firstName; string lastName; int age; int gradeLevel; double score; Student (const string &firstIn, const string &lastIn) { firstName = firstIn; lastName = lastIn; age = 14; gradeLevel = 9; score = 0; } }; int main () { //We pass the parameters at declare time, like a function call. Student student ("John", "Doe"); cout << student.firstName << ' ' << student.lastName << endl; return 0; }
John Doe
This way, whenever we create a Student
object, we can give it an initial name off the bat.
We could change the parameters the constructor takes in to include the student's age, grade, and so on.
These types of constructors are called custom constructors, and constructors
that take no parameters are called default constructors.
However, there are a few caveats you need to consider:
- Constructor parameters should never have the same name as any member variables. While this is technically valid, it causes scoping issues and is very likely to cause headaches.
- If you write a default constructor, do not place empty parentheses after variable declarations for that type
(i.e. do not write
Student student ();
). It is syntactically incorrect C++. - If you write a custom constructor, all declarations of that type must specify those parameters at declaration time (except as noted below).
Let's look at the last caveat listed above: if you create a custom constructor, all declarations of that struct
must pass values for that constructor;
in other words, the default constructor is not defined for struct
s that have a custom constructor.
But then why were we able to declare struct
variables without having defined any constructors at all?
The reason is because the compiler creates a default constructor for you if you don't define any constructor(s) for a struct
.
However, as soon as you define a constructor, the compiler no longer creates the default constructor;
at that point it is up to you to define a default constructor. As we are about to see, there are ways to give a struct
both a custom constructor
and a default constructor.
Default parameters
The first method is to define default parameters. Default parameters are default values that are assigned to function parameters if the caller does not pass any value for that parameter. You can define default parameters like so:
struct Student { //... //Make the name blank by default Student (const string &firstIn="", const string &lastIn="") { firstName = firstIn; lastName = lastIn; age = 14; gradeLevel = 9; score = 0; } }; int main () { //Now, you can treat the custom constructor like a default constructor Student student1; Student student2 ("John", "Doe"); //Outputs a single space cout << student1.firstName << ' ' << student1.lastName << endl; cout << student2.firstName << ' ' << student2.lastName << endl; return 0; }
John Doe
You can use default parameters with prototypes, as well. Define the default parameters in the prototype, but not the definition:
struct Student { //... Student (const string &firstIn="", const string &lastIn=""); }; int main () { //Treat the custom constructor like a default constructor Student student; //Outputs a single space cout << student.firstName << ' ' << student.lastName << endl; return 0; } //No default parameters here! Student::Student (const string &firstIn, const string &lastIn) { firstName = firstIn; lastName = lastIn; age = 14; gradeLevel = 9; score = 0; }
However, there is one catch to using default parameters: all parameters following a parameter with a default value must also have default values. In other words, a constructor like this is not allowed:
Student (const string &firstIn="", const string &lastIn); //WRONG: no default for lastIn!
Constructor overloading
Default parameters are the easy method to make a struct
have both a default constructor and a custom constructor, however,
it is somewhat restrictive in what you can do with it. The other method, which is what is normally used to solve this problem,
is to use a C++ feature called function overloading to define a separate custom and default constructors.
The advantage to this method is it allows you to have multiple different custom constructors for the same object.
Before we look and see how to overload a constructor, let's step back to functions for a moment. Recall that a function begins with a line that looks like this:
returnType functionName (paramType param1, ...);
This is called the function's signature. Using the information provided in this line, a function can be uniquely identified by the compiler.
Now, suppose we have the following function signature:
int myFunc (int a);
We can identify it as a function called myFunc
that takes a single int
as a parameter and returns an int
.
But what happens if we were to define another function with the following signature in the same file?
int myFunc (int a, double b);
It has the same name as our first function, but it has a different number of parameters, so it has a different signature than the first function. Because of this, the compiler has a way to distinguish the following two function calls:
myFunc(3); myFunc(2, 4.5);
The program will call our original function on the first call, and the new function on the second, even though they have the same name!
Because the compiler was able to tell that the first function call had only 1 parameter passed to it,
and that the second function call had 2 parameters passed to it (the second of which is a double
), it was able to correctly
identify the right function to call, because we overloaded the function name myFunc
with distinct function signatures. Functions that share the same name but have different signatures are called overloaded functions,
or overloads for short.
Function overloads don't even need to have a different number of arguments, even just one parameter having a different type is enough for the compiler to create a valid overload:
int myFunc (int a); int myFunc (string input);
Based on the type of the variable you pass to the function, the compiler can determine which overload to call.
Overloaded functions can even have different return types:
int myFunc (int a); string myFunc (string input);
The only catch regarding return types is that at least one parameter must be different from all of the other overloads of that name. It is not enough to simply change the return type of the function (even though it is technically a different function signature), because the return value can be ignored, so the compiler would not always have all the information it needs to properly identify the right overload to call.
Since a constructor is a function, we can overload it too!
struct Student { //... //Prototype a default constructor and a custom constructor Student (); Student (const string &firstIn, const string &lastIn); }; int main () { //Treat the custom constructor like a default constructor Student student1; Student student2 ("John", "Doe"); //Outputs a single space cout << student.firstName << ' ' << student.lastName << endl; cout << student.firstName << ' ' << student.lastName << endl; return 0; } //Define the overloaded constructors Student::Student () { age = 14; gradeLevel = 9; score = 0; //Use default string values for firstName and lastName } Student::Student (const string &firstIn, const string &lastIn) { firstName = firstIn; lastName = lastIn; age = 14; gradeLevel = 9; score = 0; }
John Doe
The big advantage here is that we don't have to stop here. We could define further overloads to create multiple custom constructors:
struct Student { //... Student (); Student (const string &firstIn, const string &lastIn); Student (const stirng &firstIn, const string &lastIn, int age, int gradeLevel); } //...
While overloading constructors is incredibly useful, keep in mind that
you can only call one constructor for any given struct
when you declare the object.
You cannot call other constructor overloads from within one constructor.
Activity
Using your code from the last activity, write in a default constructor and at least one custom constructor that allows you to set the name of the student, their age, and their grade level.