Example: Solar System Class

Example: Solar System Class#

Let’s build on our vector of planets (from Vector of Structs), but now make a class/struct that holds the data and member functions that know how to operate on that data.

Implementation#

First well define the struct called SolarSystem and write the constructor:

Listing 110 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) {

        // 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{.name=name, .a=a, .e=e};
        planets.push_back(p);
    }

    void print_planets() {

        for (const auto& p : planets) {
            std::cout << p << std::endl;
        }
    }
};
#endif

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.

    Note

    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.

  • 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 implement the member functions directly in the header. They are small, so there doesn’t seem to be a need to break them out into a separate .cpp file.

  • Our add_planet() member function checks to see if a planet already exists.

This uses planet.H header from before:

Listing 111 planet.H#
#ifndef PLANET_H
#define PLANET_H

#include <string>
#include <iostream>
#include <format>

struct Planet
{
    std::string name{};
    double a{};            // semi-major axis
    double e{};            // eccentricity
};

std::ostream& operator<< (std::ostream& os, const Planet& p) {
    os << std::format("{:12} : ({:6.3f}, {:6.3f})",
                      p.name, p.a, p.e);
    return os;
}

#endif

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.

Driver#

Finally, we can use our SolarSystem class. Here’s a main() function:

Listing 112 test_solar_system.cpp#
#include <iostream>
#include "solar_system.H"
#include "planet.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();

}

Building#

We can compile this using the same general GNUmakefile we developed previously. We’ll add two features to it:

Listing 113 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 -std=c++20

# 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 -std=c++20

We saw these in our Making the Compiler Do the Work discussion.

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.