IA Algorithms Enrichment 2017
Activities > Nov 18 > Structs

Abstract data types, part 1: structs

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, strings, and vectors 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.

structs (and, later, classes) 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, both alice and bob will have their own unique firstName, lastName, age, etc. member variables. Changing the member variables of bob will have no effect on the members of alice.

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 Students 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 single char such as M or F) and their street address (a string).
  • 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 a vector 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 structs -- 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 structs 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 structs 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.