Working with Multiple Files

Working with Multiple Files#

As our projects grow in size, it becomes beneficial to spread our code over multiple files. This has several benefits:

  • It logically groups functions by task, making it easier to understand

  • It allows for easy code reuse.

  • Smaller files compile quicker than a larger file, and we only need to compile a file if the code changes.

  • In version control, there is less chance of conflict when different people edit at once.

Generally speaking, we’ll talk about two types of files:

  • Header files : these contain the forward declarations of the functions we are going to use, as well as any types (structs, etc.) that we define.

    As we’ll see shortly, we can also put entire (usually short) functions in headers so the compiler can inline the code, resulting in faster performance.

    A header file will be #include-ed into source files.

  • Source files : these implement the functions defined in the header (the function definitions corresponding to the forward declarations), and of course the main() function.

Splitting our planet sort example#

Let’s consider our example of sorting planets.

We’ll start with a header file defining our struct and the operator declaration:

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

#include <string>
#include <iostream>

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

std::ostream& operator<< (std::ostream& os, const Planet& p);


#endif

A few things to note:

  • At the very top is a header guard.

    The is used to ensure that we don’t include a header twice in any program unit. As with the #include directives we’ve been using, the #ifndef and #define directives are processed by the C preprocessor.

  • We only have the forward declaration for operator<< and not the definition of the function itself here.

Note

Header files usually have the extension .H or .hpp. I’ll use .H throughout this course.

Now we’ll create a source file that implements the << operator:

Listing 104 planet.cpp#
#include <iostream>
#include <format>

#include "planet.H"

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

We use " in the #include for planet.H:

#include "planet.H"

This gives us the forward declaration we need for this function.

Important

The compiler treats

#include <planet.H>

and

#include "planet.H"

differently.

When using quotes "...", the compiler will look in the current directory for the header first, and then in the system include paths.

When using < ..>, it will look in the system include paths, but not your current directory (unless you explicitly force it to).

Additional paths to search can be specified using the -I flag to the compiler (we won’t consider this).

Finally, we’ll put the main() in a third file:

Listing 105 planet_sort_split.cpp#
#include <iostream>
#include <vector>
#include <algorithm>

#include "planet.H"

int main() {

    std::vector<Planet> planets {{"Mercury",  0.3871, 0.2056},
                                 {"Venus",    0.7233, 0.0068},
                                 {"Earth",    1.0000, 0.0167},
                                 {"Mars",     1.5237, 0.0934},
                                 {"Jupiter",  5.2029, 0.0484},
                                 {"Saturn",   9.5370, 0.0539},
                                 {"Uranus",  19.189,  0.0473},
                                 {"Neptune", 30.070,  0.0086}};

    std::ranges::sort(planets,
                      [] (const Planet& a, const Planet& b) {return a.e < b.e;});

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

}

Compiling#

To produce an executable, we need to compile each of the .cpp files first and then link them all together. Here are the steps:

g++ -std=c++20 -c planet.cpp
g++ -std=c++20 -c planet_sort_split.cpp
g++ -o planet_sort_split planet.o planet_sort_split.o

Note

We see a new compiler flag, -c. This tells the compiler to compile the file, but not do the final linking step to make the executable. This results in an output file with the .o extension (for object).

The first two commands are the compilation step. The take the source file and produce an object file (e.g., planet.cppplanet.o).

The final command is the link step—notice that we use the same command, g++, as the compiler and linker. In this case, we tell it the name for our executable and then give it the list of all object files that it needs to link together.

Notice that we don’t explicitly do anything with the header file, planet.H—the preprocessor includes this as part of the compilation step.

try it…

What happens if you don’t pass planet.o to the link step?

One definition rule#

An important concept when working with multiple files is the One Definition Rule (ODR).

This means that we can only have a single definition of a function or type in our program.

A consequence of the ODR is that if you put a function entirely in a header, then you need to make it inline.

Here’s an example of putting our operator<< function directly into the planet.H header. Now we add inline before the function, and we no longer need planet.cpp

Listing 106 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
};

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


#endif

We can compile the source now simply as:

g++ -std=c++20 -o planet_source planet_sort_split.cpp