Introduction to Classes
reading
C++ classes from Wikipedia
Cyganek section 3.15
Classes are a fundamental part of the object-oriented programming paradigm. A class is an objects that holds both data and functions that know how to operate on the data.
Tip
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.
Tip
There can be more than 1 constructor—each can take different arguments.
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()
member function 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 -g
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;
}
Then we can do a check like:
auto it = find_planet("mars");
if (it != planets.end()) {
// planet already exists
}
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.