Data structures as cutom datatypes
Objects in the real world are a collection of traits we group under a word. A car tire has a pressure, a circumference, a depth, and so on. These traits determine how the tire will handle. We could store these traits as variables.
float tire_pressure;
float tire_circumfrance;
float tire_depth;
This approach requires variables for each tire and quickly gets messy. tire1_pressure, tire2_pressure, etc.
We can create a custom data structure using the struct keyword to group these variables under one name.
struct Tire {
float pressure;
float circumference;
float depth;
};
Now, we can create an instance of a tire by using the struct name "Tire" to declare instances, just like a variable.
Tire tire;
Each instance of a struct is called an object.
An object contains all the variables from the struct it's from. You can access an instance's variables using the member access operator (.).
Tire tire;
tire.pressure = 35.f;
tire.circumference = 74.4f;
tire.depth = 9.8f;
std::cout << "The tire-pressure is: " << tire.pressure;
Variables declared inside a struct are called member variables.
The member variables pressure, circumference, and depth are unique to each object. Another Tire object would have unique pressure, circumference, and depth values.
Declaring Tire tire; will create a space in memory for every member variable of Tire. The object tire will take up as much space in memory as 3 float variables.
You can pass entire objects into functions by using the struct name as the parameter.
#include <iostream>
#include <string>
struct Animal {
std::string name;
std::string species;
float age;
};
void print_animal_info(Animal a)
{
std::cout << "Name: " << a.name;
std::cout << "\nSpecies: " << a.species;
std::cout << "\nAge: " << a.age << "\n";
}
int main()
{
Animal lion;
lion.name = "Simba";
lion.species = "Panthera leo";
lion.age = 12;
Animal clown_fish;
clown_fish.name = "Nemo";
clown_fish.species = "Amphiprioninae";
clown_fish.age = 5;
print_animal_info(lion);
print_animal_info(clown_fish);
}
Name: Simba
Species: Panthera leo
Age: 12
Name: Nemo
Species: Amphiprioninae
Age: 5
This program creates a struct for animals to share. It makes 2 instances of animals. After filling in the animal information, it uses a function that takes an Animal object as the only argument to print the information of both lion and clown_fish.
Member Functions
Declaring a function inside our struct will make it a member function. Member functions are called in relation to an object. Using member variables inside the function will use the member variables of the object the function is called for.
You have already used member functions like .size(), and .pop_back() when working with std::vector and std::string.
Here, the print_animal_info(Animal a) function from the last section has moved into the struct. Note that the parameter is no longer there, as we have direct access to the member variables.
#include <iostream>
#include <string>
struct Animal
{
std::string name;
std::string species;
float age;
void print_animal_info()
{
std::cout << "Name: " << name;
std::cout << "\nSpecies: " << species;
std::cout << "\nAge: " << age << "\n";
}
};
Now we can call print_animal_info from any Animal object
Animal lion;
lion.name = "Simba";
lion.species = "Panthera leo";
lion.age = 12;
lion.print_animal_info();
Name: Simba
Species: Panthera leo
Age: 12
Member function examples:
This member function changes the data in the struct. Calling animal_object.birthday() outputs a birthday message and increases the object's age by 1.
struct Animal
{
std::string name;
std::string species;
float age;
void birthday()
{
std::cout << "Happy birthday " << name << "!\n";
age+=1.f;
}
};
Try using this code to declare an Animal object and call its birthday.
This member function compares an animal with another and returns true if they are of the same species.
struct Animal
{
std::string name;
std::string species;
float age;
bool same_species(Animal a)
{
return a.species == species;
}
};
int main()
{
Animal lion;
lion.name = "Simba";
lion.species = "Panthera leo";
lion.age = 12;
Animal clown_fish;
clown_fish.name = "Nemo";
clown_fish.species = "Amphiprioninae";
clown_fish.age = 5;
if (lion.same_species(clown_fish))
std::cout << "Place in the same cage";
else
std::cout << "Species are different, place in different cages.";
}
Constructors
We often want to initialize the member variables when declaring an object. You can use an initializer list that matches the datatypes of the variables to define them.
struct Animal {
std::string name;
std::string species;
float age;
};
Animal lion = {"Simba", "Panthera leo", 12};
Constructors offer a better way to initialize our objects. Constructors are special member functions that initialize objects. They let us create custom initializers that include logic. We can automatically handle any calculations or data validation checks in the constructor.
You can declare a constructor by declaring a function with the same name as the struct without a return type. The parameters will be the data you use to create the object. You decide how to use the passed arguments to initialize the object inside the constructor.
struct Animal {
std::string name;
std::string species;
float age;
// constructor
animal(std::string p_name, std::string p_species, float p_age)
{
name = p_name;
species = p_species;
if (p_age < 0) // Now we don't have to check this every time we create an animal
std::cout << "WARNING: cannot create an animal with age less than 0!";
else
age = p_age;
}
};
Add round brackets () (with your arguments inside) after the object declaration to use the constructor. Make sure that the arguments match the parameters of a constructor.
Animal lion("Simba", "Panthera leo", 12.f);
You can declare many constructors as long as the parameters differ. You can also call a constructor from another constructor. You can use this to add more ways to declare your objects.
In this example, we have two constructors. The second constructor uses the first constructor to initialize the object.
Animal(std::string _name, std::string _species, int _age)
{
name =_name;
species =_species;
age = _age;
}
Animal(std::string _name)
{
Animal(_name, "unidentified", 0);
}
Auto-casting
If your struct type is needed for an assignment or an argument, you can pass a value that matches the data type of one of your constructors.
#include <iostream>
struct fruit {
std::string name;
fruit(const std::string& _name) {
name = _name;
}
}
void print_fruit(fruit f) {
std::cout << f.name;
}
int main() {
std::string fruit_name = "apple";
print_fruit(fruit_name);
}
In this example, a string is passed to a function that requires a fruit parameter. std::string is automatically used to create a temporary fruit object because fruit has a constructor using the std::string parameter.
You can use the explicit keyword to avoid auto-constructions/casts.
#include <iostream>
struct fruit {
std::string name;
explicit fruit(const std::string& _name) {
name = _name;
}
}
void print_fruit(fruit f) {
std::cout << f.name;
}
int main() {
std::string fruit_name = "apple";
print_fruit(fruit(fruit_name)) // Explicitly creating fruit object (will work)
print_fruit(fruit_name); // Not an explicit construction (won't work)
}
Member initializer list - constructors
You can assign initial values for your member variables by adding a colon after the parameter list. This is known as the member initializer list.
struct RGBColor {
int red, green, blue;
// Basic use of member initializer list
RGBColor (int r, int g, int b) : red(r), green(g), blue(b) {}
// Example using values instead of parameters
RGBColor () : red(0), green(0), blue(0) {}
};
You can also call other constructors from this list.
// Example using another constructor in the initialization
// The assumption is that the values for r, g, and b parameters are from 0-1 when we want them from 0-255 for our colors.
RGBColor(float r, float g, float b) : RGBColor(int(r*255.f), int(g*255.f), int(b*255.f))
{
if (r > 1 || g > 1 || b > 1 || r < 0 || g < 0 || b < 0)
std::cout << "Color construction was done out of range!\n";
}
Const and reference member variables
A member initializer list is the only way to initialize a const declared member variable. You can only bind a reference variable in the member initializer list.
struct MyStruct {
const int x;
int& y;
MyStruct (int val, int& ref) : x(val), y(ref) {
}
};
You cannot change a const member outside the member initializer list. Setting the value of a reference variable will try to set the value of the variable it references - Even if the variable isn't referencing a variable.
Operator Overloading
Operators are symbols with defined functions. Symbols like +, /, +=, &&, etc. are operators.
You can get a more natural syntax by using operator overloads to define custom operator behavior for struct/class objects. You can define a custom operator by defining a member function with the name operator followed by the operator you want to add.
Example of operator overloading the + operator:
struct Example
{
int internal;
Example(int i) : internal(i) {}
Example operator+(Example i) {
return Example(this->internal + i.internal);
}
};
Adding two Example objects together will return/evaluate as an Example instance.
int main()
{
Example var1(6);
Example var2(4);
Example sum_var = var1 + var2;
std::cout << sum_var.internal;
}
The parameter of the operator overload determines the type you can use the operator with.
Changing the parameter to int lets us add integers to Example objects.
// This is declared inside the `Example` struct
Example operator+(int i) {
return Example(this->internal + i);
}
int main()
{
Example object = Example(10) + 5;
std::cout << object.internal;
}
This program would output 15.
You can change what the expression evaluates to by changing the return value. Assignment (=, +=, etc.) and increment operators (++, --) generally return a reference to the object they act on. Doing the same when creating operator overloads helps you create consistent and intuitive code.
Example& operator+=(int i) {
this->internal += i;
return *this;
}
You can add multiple operators of the same type as long as the function signature varies. This way, you can add support for different datatypes and what the operation evaluates as.
OOP vs Procedural
Data and functions are separate in procedural programming. This leads to code that looks like this:
barkAt(dog, cat);
Structs let us organize data and related logic together in one place. This is known as Object Oriented Programming (OOP for short).
This results in more intuitive code like:
dog.barkAt(cat);
This approach separates the subject (dog) from the parameters (cat).
How to decide which to use
OOP adds a degree of complexity and can lead to slightly slower code (unnoticeable for the most part). However, it more than makes up for it in scaleability, reusability, and modularity. Think about how frustrating working with strings and arrays would be without std::string and std::vector. Or better yet, go ahead and try.
OOP is great for making systems, especially in large programs. Procedural programming is good for its simplicity and efficiency.
Data encapsulation and abstraction (Public & Private)
If you misuse a member variable or function, you risk breaking your object, rendering it useless.
In this Circle struct, we initialize radius, area, and perimeter based on the value of the _radius parameter. All the variables describe the same circle.
struct Circle {
double radius;
double area;
double perimeter;
// The constructor accepts _radius and computes area and perimeter from it
Circle(double _radius) {
radius = _radius;
area = 3.14 * _radius * _radius;
perimeter = 2 * 3.14 * _radius;
}
};
If we change one of the variables without updating the others, the variables no longer correspond to attributes of the same circle. This compromises the integrity of the object.
int main() {
Circle circle(10.0);
circle.radius = 5.0;
// The radius no longer matches the rest of the data of `circle`.
// This makes the circle object useless because of the invalidity of its data.
}
You can avoid this problem by restricting access to a variable to inside the struct. By using access specifiers, you can make it so that you cannot reference a member outside the struct. This lets us protect the integrity of our data.
Access specifiers act as tags inside the scope of the struct. Everything that comes after one follows the accessibility rules it sets until the end of the scope, or next access specifier.
private: Restricts access of members to within the struct. You cannot access them from outside.
public: Let you access members from anywhere in the program.
This is the default state of a struct.
Access specifiers help write maintainable and safe code.
We can prevent the circle-data-mismatch scenario by making the member variables private:.
struct Circle {
// Make variables private so that we don't accidentally modify a value.
private:
double radius;
double area;
double perimeter;
// Return to public so that the constructor can still be used
public:
Circle(double _radius) {
radius = _radius;
area = 3.14 * _radius * _radius;
perimeter = 2 * 3.14 * _radius;
}
}
The possibility of outside interference invalidating the object is gone now. circle.radius = 5.0; will give an error that tells the user; they are trying to access a private variable outside the struct.
But what if you want to get or update the information of the circle object? To fix this, we can declare member functions that get and set the member variables.
The set functions update all the variables. This way, no data mismatch occurs from setting a single member variable.
struct Circle {
// Make variables private so that we don't accidentally change a value
private:
double radius;
double area;
double perimeter;
// Return to public so that the constructor can still be accessed/used
public:
Circle(double _radius) {
radius = _radius;
area = 3.14 * _radius * _radius;
perimeter = 2 * 3.14 * _radius;
}
double get_radius() {
return radius;
}
double get_area() {
return area;
}
double get_perimeter() {
return perimeter;
}
void set_radius(double r) {
radius = r;
area = 3.14 * r* r;
perimeter = 2 * 3.14 * r;
}
void set_area(double a) {
area = a;
radius = sqrt(a / 3.14);
perimeter = 2 * 3.14 * radius;
}
void set_perimeter(double p) {
perimeter = p;
radius = p / (2 * 3.14);
area = 3.14 * radius * radius;
}
}
Invariants
An invariant is a condition that has to be true while a component executes. A "date" struct needs the day variable between 1 and 31 to function.
Try to create struct designs that don't let the user invalidate the object. You should inform the user if they invalidate the object.
Public Interface
The public interface consists of all members under the public access specifier. Everything accessible from an object outside the struct is part of the public interface.
We have already discussed why you should be careful when exposing member variables to public access. The same thing goes for member functions.
You will often create member functions to help inside a struct. It is usually best to restrict these to private access, especially if these functions change data.
You want to keep the public interface as minimal as possible.
You only want to make what is necessary public while keeping implementations private.
Writing comments for your public methods when making systems others rely on is often a good idea. These should explain the function's use, parameters, and return value.
I WOULD HIGHLY RECOMMEND GOING TO THE PRACTICE SECTION AND DOING SOME TASKS BEFORE CONTINUING!
Classes
The class keyword functions similarly to struct. A class is also a blueprint for creating objects with data and methods to manipulate that data.
class Circle {
double radius;
double area;
double perimeter;
// Constructor that accepts radius and computes area and perimeter
public:
Circle(double _radius) {
radius = _radius;
area = 3.14 * _radius * _radius;
perimeter = 2 * 3.14 * _radius;
}
};
What's the difference?
Classes and structures are the same, but we use them differently based on intent. The technical difference between a class and a struct is the default access level:
- In a
class, members are private by default.
- In a
struct, members are public by default.
The different default access level stems from the philosophy behind their usage. You use structs to structure related data without adding extra behavior. You use classes for an encapsulated, behavior-driven representation of some entity. Outside of this minor technicality, the only real difference is intent.
When discussing how a struct or class works, it applies to both.
Templates
You can use templates for structs as well!
Add the standard syntax for templating (template<typename T>) before the struct declaration, and use the defined type inside the struct.
template<typename T>
struct MyStruct {
T value;
MyStruct(T v) : value(v) {}
};
You have to specify the type of the struct in the object declaration.
MyStruct<int> object(4);
The same template rules apply here as with functions. You can have many types.
You might recognize this syntax from std::vector. It is what the container uses to let you decide the type for your dynamic array.
Const functions
As mentioned in the previous chapter, an object declared as const can only use const-supported functions. You can declare a function as const by adding const after the parameters.
struct Circle {
private:
double radius, area, perimeter;
public:
double get_radius() const;
double get_area() const{
return area;
}
}
double Circle::get_radius() const {
return radius;
}
Declaring a function as const prevents you from modifying the member variables inside the function. This lets you call the function from objects declared as const. It also helps with code clarity by telling the programmer that the function doesn't change the object.
Static members
Like static variables declared in a function, static members act like they are declared in the global scope, but are confined to the function's scope, or in this case, struct/class.
Functions and variables declared as static inside a struct are not unique for each object. They don't get included as part of an instance of a struct. The struct name still encapsulates them.
Here is the basic syntax for declaring and accessing a static variable.
The definition of a static variable has to occur outside the struct.
#include <iostream>
struct ExampleStruct {
// Static member variable
static int instance_count;
ExampleStruct()
{
instance_count++; // This is the same variable for every object
}
};
// Definition has to be outside struct
int ExampleStruct::instance_count = 0;
int main()
{
std::cout << "Static var value: " << ExampleStruct::instance_count; // Prints "Static var value: 0"
// Looping to create 10 instances of the struct
for (int i = 0; i < 10; i++)
ExampleStruct s();
std::cout << "\nStatic var value: " << ExampleStruct::instance_count; // Prints "Static var value: 10"
}
Static functions cannot access non-static members without an object because they aren't associated with an instance. They are generally used to provide some utility related to a struct. You can call a static function using a similar syntax:
struct_name::function(parameters);
You can also call them from an object, which can lead to misleading code since they aren't called in relation to the object.
Nested Types
Nesting structs or enums inside another struct can help you structure your data more logically. Confining "enum SubjectGrade" to the Student struct makes sense.
struct Student{
enum struct SubjectGrade{
NOGRADE = -1,
F = 0,
D = 1,
C = 2,
B = 3,
A = 4
};
struct StudentGrades{
SubjectGrade maths;
};
StudentGrades grades;
};
If you want to use these types outside the struct you have to use the same notation you use for static members.
Student::StudentGrades passingGrades ={Student::SubjectGrades::B};
Single Responsibility
The single responsibility principle for functions also applies to struct designs.
A class or struct should only have one responsibility. Just like functions, they get long, complex, hard to maintain, and not reusable when they have to do many things. The problem is accentuated because we use structs and classes for larger systems. If a class performs many roles, it is probably a good idea to separate it into multiple classes.
Having one responsibility per struct or class makes it easier to understand and maintain.
GOOD JOB!
You finished a chapter.
This would be a good time to practice what you've just learned.
Last ChapterPracticeNext Chapter