Introduction to Classes¶
reading
Cyganek section 3.15
Classes are objects that hold both data and functions that know how to operate on the data.
Note
In C++, both struct
and class
create a class, with the difference being
whether the data is publicly accessible by default.
We’ll start by doing a struct
with a constructor – this will
behave like a class, except by default all of the data will be
publicly available.
Here’s the basic format of a struct / class:
struct ClassName {
// data members are declared here
ClassName(int param1, int param2) {
// do any initialization for when we create a ClassName object
}
// other member functions here
};
The most important function is the constructor. When we create an instance of our class, the constructor function is called and does any initialization / setup that the class requires.
Note
The constructor always has the same name as the class/struct.
Here’s a concrete example – we’ll build on our vector of planets, but making a class/struct that holds the data and member functions that know how to operate on that data.
First well define the struct
called SolarSystem
and write the constructor:
#ifndef SOLAR_SYSTEM_H
#define SOLAR_SYSTEM_H
#include <vector>
#include <cassert>
#include "planet.H"
struct SolarSystem {
double star_mass;
std::vector<Planet> planets;
SolarSystem(const double mass)
: star_mass{mass}
{
assert(mass > 0.0);
}
void add_planet(const std::string& name, const double a, const double e=0.0);
void print_planets();
};
#endif
Keeping with our strategy to reuse code, this uses our planet.H
header from before (and
will rely on the planet.cpp
source file that implemented some functions).
Some notes:
After the constructor name and arguments there is a
:
and an initialization:SolarSystem(const double mass) : star_mass{mass} { ...
this is called the initialization list. Any member data specified in that (comma-separated) list is initialized when an object is created as an instance of the class.
In the constructor body we have an
assert()
statement – this will abort the code if it is false. This is a way to add runtime checking to the code to ensure that it is being used properly / matching your assumptions.We end with some forward declarations of member functions of the class. These functions will have access to the class data directly.
To instantiate the class, i.e., create a SolarSystem
object, we would do:
SolarSystem ss(1.0);
where we pass the constructor arguments at the time we create our object.
Note
A class can have multiple constructors. A constructor that takes no arguments is called the default constructor.
We provide implementations of the member functions in a separate file:
#include <iostream>
#include <vector>
#include <cmath>
#include "planet.H"
#include "solar_system.H"
void SolarSystem::add_planet(const std::string& name, const double a, const double e) {
// make sure a planet with that name doesn't already exist
// if the pointer returned here is not null, then the planet exists
for (auto p : planets) {
if (name == p.name) {
std::cout << "planet already exists" << std::endl;
return;
}
}
Planet p;
p.name = name;
p.a = a;
p.e = e;
planets.push_back(p);
}
void SolarSystem::print_planets() {
for (auto p : planets) {
std::cout << p << std::endl;
}
}
Notes:
For each of the member functions, since we are defining them outside of the
struct SolarSystem {};
, we need to include the namespace and scope operator,::
, to reference the class that they belong to.Our
add_planet()
method checks to see if a planet already exists.
Finally, we can use our SolarSystem
class. Here’s a main()
function:
#include <iostream>
#include "solar_system.H"
int main() {
SolarSystem ss(2.0);
ss.add_planet("alpha", 1.0);
ss.add_planet("beta", 1.5, 0.1);
ss.add_planet("gamma", 3.0, 0.24);
// this generates an error
ss.add_planet("alpha", 2.0);
ss.print_planets();
}
We can compile this using the same general GNUmakefile
we developed previously. We’ll
add two features to it:
ALL: test_solar_system
# find all of the source files and header files
SOURCES := $(wildcard *.cpp)
HEADERS := $(wildcard *.H)
# create a list of object files by replacing .cpp with .o
OBJECTS := $(SOURCES:.cpp=.o)
CFLAGS := -Wall -Wextra -Wpedantic -Wshadow -g
# a recipe for making an object file from a .cpp file
# Note: this makes every header file a dependency of every object file,
# which is not ideal, but it is safe.
%.o : %.cpp ${HEADERS}
g++ ${CFLAGS} -c $<
# explicitly write the rule for linking together the executable
test_solar_system: ${OBJECTS}
g++ -o $@ ${OBJECTS}
clean:
rm -f *.o test_solar_system
The first is a new target: clean
. By doing
make clean
We will remove all of the object files (*.o
) and the executable.
The second new feature is the addition of some compilation flags:
CFLAGS := -Wall -Wextra -Wpedantic -Wshadow
These are specific to the GNU compilers, and turn on some warnings that help spot code mistakes. See GCC warning options for more details.
The main advantage to using a class here is that we don’t need to know
how the planet data is actually stored (in this case in a std::vector<Planet>
).
By creating member functions that are part of the class, we hide the implementation
details from the user. Instead they are given a simple set of functions
to interact with the data.
try it…
Let’s make our class more useful. Lets implement the following functions:
int get_planet(const std::string& name, Planet& p_return);
double get_period(const std::string& name);
The first will take a Planet
in the argument list (by
reference) and if name
exists, it will return its properties
through the reference. The return value here is int
and is
meant to return a status that we can check to see if the name
was a valid planet.
The second will take a planet name
and compute and return its
period.
solution
The updated header is:
#ifndef SOLAR_SYSTEM_H
#define SOLAR_SYSTEM_H
#include <vector>
#include <cassert>
#include "planet.H"
struct SolarSystem {
double star_mass;
std::vector<Planet> planets;
SolarSystem(const double mass)
: star_mass{mass}
{
assert(mass > 0.0);
}
void add_planet(const std::string& name, const double a, const double e=0.0);
int get_planet(const std::string& name, Planet& p_return);
double get_period(const std::string& name);
};
#endif
and corresponding functions:
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>
#include "planet.H"
#include "solar_system.H"
int SolarSystem::get_planet(const std::string& name, Planet& p_return) {
// istatus = 1 means the planet doesn't exist
int istatus = 1;
for (auto p: planets) {
if (p.name == name) {
p_return.name = p.name;
p_return.a = p.a;
p_return.e = p.e;
istatus = 0;
break;
}
}
return istatus;
}
void SolarSystem::add_planet(const std::string& name, const double a, const double e) {
// make sure a planet with that name doesn't already exist
// if the pointer returned here is not null, then the planet exists
Planet p_check;
int istatus = get_planet(name, p_check);
if (! istatus) {
std::cout << "Error: planet already exists" << std::endl;
return;
}
Planet p;
p.name = name;
p.a = a;
p.e = e;
planets.push_back(p);
}
double SolarSystem::get_period(const std::string& name) {
double period = -1;
Planet p;
int istatus = get_planet(name, p);
if (! istatus) {
period = std::sqrt(std::pow(p.a, 3.0) / star_mass);
}
return period;
}
and test driver:
#include <iostream>
#include "solar_system.H"
int main() {
SolarSystem ss(2.0);
ss.add_planet("alpha", 1.0);
ss.add_planet("beta", 1.5, 0.1);
ss.add_planet("gamma", 3.0, 0.24);
// this generates an error
//ss.add_planet("alpha", 2.0);
std::cout << "period of alpha = " << ss.get_period("alpha") << std::endl;
}
try it…
To understand the difference between a struct
, where everything is public, and
a class
where everything is private by default, let’s edit solar_system.H
and change struct
to class
.
What happens when we compile?
Now try adding a public:
statement to the code.
Tip
Instead of doing a get_planet()
to return a copy of a planet from our
solar system object, we can return an iterator using std::find_if()
as:
std::vector<Planet>::iterator SolarSystem::find_planet(const std::string& name) {
auto it = std::find_if(planets.begin(), planets.end(),
[&] (const Planet& p) {return p.name == name;});
return it;
}
In this simple example, we really did not need to use an initialization list, but there are situations when it is desired or even required:
if any of our member data is
const
or a reference.if we have other classes as our member data (including things like
std::vector
) and initializing them involves some overhead. It can be faster to do the initialization via the initialization list.
See the stack overflow Why should I prefer to use member initialization lists? discussion.