Introduction to Classes

reading

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:

Listing 46 solar_system.H
#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:

Listing 47 solar_system.cpp
#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:

Listing 48 test_solar_system.cpp
#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:

Listing 49 GNUmakefile
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:

Listing 50 new solar_system.H
#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:

Listing 51 new solar_system.cpp
#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:

Listing 52 new test_solar_system.cpp
#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.