Все статьи / Программы из нескольких файлов на C++

В статье вы научитесь создавать программу из нескольких файлов


Объявления и определения

Рассмотрим задачу: надо вывести числа от N до 1, при этом для нечётных чисел надо писать odd ${N}, а для чётных even ${N}. Вывод должен выглядеть так:

odd 11
even 10
odd 9
even 8
odd 7
even 6
odd 5
even 4
odd 3
even 2
odd 1

Ради интереса решим задачу с помощью рекурсии:

#include <iostream>

void printEvenRecursive(unsigned value)
{
    std::cout << "even " << value << std::endl;
    if (value > 1)
    {
        printOddRecursive(value - 1);
    }
}

void printOddRecursive(unsigned value)
{
    std::cout << "odd " << value << std::endl;
    if (value > 1)
    {
        printEvenRecursive(value - 1);
    }
}

int main()
{
    printOddRecursive(11);
}

Увы программа не компилируется. В C++ каждая функция должна быть объявлена или определена до первого использования, но в нашем случае printEvenRecursive использует printOddRecursive, а printOddRecursive использует printEvenRecursive! Мы не можем поместить определение одной функции выше другой так, чтобы каждая функция была объявлена перед использованием.

Но кроме определений функций в C++ есть объявления функций

  • Объявление (declaration) - это конструкция, которая зарезервирует идентификатор и опишет для компилятора его тип, но не раскроет деталей работы объявленной сущности.
  • Определение (definition) - это конструкция, которая не только зарезервирует идентификатор, но и раскроет реализацию связанной с ним сущности

Объявление функции похоже на определение функции, только вместо тела стоит точка с запятой:

// Объявление функции sum
int sum();

// Определение функции sum
int sum(/* объявление параметра a*/ int a, /* объявление параметра b*/ int b)
{
    // Объявление переменной sum,
    //  эта переменная инициализируется при объявлении
    const int sum = a + b;
    return sum;
}

Теперь мы можем переписать рекурсивный пример, просто разместив определения обеих функций в начале файла.

Хорошим стилем считается заранее и в одном месте писать объявления всех функций, кроме main. Вы можете писать объявления в начале cpp-файла или в заголовке. О заголовках читайте ниже.

#include <iostream>

void printEvenRecursive(unsigned value);
void printOddRecursive(unsigned value);

void printEvenRecursive(unsigned value)
{
    std::cout << "even " << value << std::endl;
    if (value > 1)
    {
        printOddRecursive(value - 1);
    }
}

void printOddRecursive(unsigned value)
{
    std::cout << "odd " << value << std::endl;
    if (value > 1)
    {
        printEvenRecursive(value - 1);
    }
}

int main()
{
    printOddRecursive(11);
}

Пишем свой заголовок

В C/C++ заголовок - это файл с расширением *.h или *.hpp, который включается в другие файлы директивой #include.

  • Заголовок может иметь и другое расширение файла, но не стоит нарушать джентльменских соглашений: используйте h или hpp
  • В заголовке пишут только объявления функций, а все определения можно и нужно помещать в cpp-файл

Создайте каталог, и разместите в нём файл print.h, в котором будут объявления функций. Скопируйте туда код, приведённый ниже.

Мы могли бы убрать из заголовка printEvenRecursive и printOddRecursive, сделав его чище, но сейчас для примера оставим.

#pragma once

// Печатает числа от value до 1, добавляет слово
//  odd к нечётным числам и even к чётным
void printDownTo1(unsigned value);

// Для внутреннего использования
void printEvenRecursive(unsigned value);

// Для внутреннего использования
void printOddRecursive(unsigned value);

Запомните правила хорошего тона:

  • в начале заголовка пишите #pragma once, чтобы не получить ошибки компиляции при ромбовидном include, когда одни и те же сущности объявляются несколько раз (подробнее о pragma once и define guards…)
  • заголовок должен быть чистым, ведь он повторно используется многими файлами проекта
  • поэтому не пишите в заголовке using namespace ...;, иначе вы замусорите глобальное пространство имён целого проекта

Теперь создайте файл print.cpp, в котором будут реализованы функции из заголовка print.h. Скопируйте туда код, расположенный ниже.

Обратите внимание: #include <iostream> находится в cpp-файле, а не в заголовке, потому что экспортированные из iostream сущности нужны только в реализации функций, но не нужны в объявлении.

#include "print.h"
#include <iostream>

void printDownTo1(unsigned value)
{
    if (value % 2 == 0)
    {
        printEvenRecursive(value);
    }
    else
    {
        printOddRecursive(value);
    }
}

void printEvenRecursive(unsigned value)
{
    std::cout << "even " << value << std::endl;
    if (value > 1)
    {
        printOddRecursive(value - 1);
    }
}

void printOddRecursive(unsigned value)
{
    std::cout << "odd " << value << std::endl;
    if (value > 1)
    {
        printEvenRecursive(value - 1);
    }
}

В конце создайте файл main.cpp и скопируйте в него код:

#include "print.h"

int main()
{
	printDownTo1(11);
}

Компоновка программы из нескольких файлов

Собирая программу из одного файла с помощью g++, вы на деле выполняли одним махом два действия:

  • компиляцию, в ходе которой исходный текст файла превращается в логическую модель (AST) и затем превращается в объектный код, в котором машинные коды смешаны со ссылками на внешние функции
  • компоновку, в ходе все внешние ссылки на функции заменяются на машинный код либо превращаются в ссылки на динамические библиотеки (dll/so, также известны как shared libraries)

Сейчас эти же действия мы выполним раздельно. Отройте терминал и введите последовательно две команды:

g++ -c main.cpp
g++ -c print.cpp

Если код в обоих cpp-файлах синтаксически правилен, то компилятор создаст два файла: main.o и print.o. Эти файлы называют объектными файлами (object files). Именно они содержат машинный код, смешанный со ссылками на внешние функции.

Вы можете дизассемблировать эти файлы, чтобы посмотреть, во что компилятор превратил ваш код. Для этого выполните команду objdump -d main.o.

Теперь мы вызовем g++ для компоновки объектных файлов. На выходе мы получим исполняемый файл print_executable.exe

g++ main.o print.o -o print_recursive.exe

На деле компилятор не будет компоновать: он передаст эту задачу утилите ld. Вызывать утилиту ld вручную мы не станем, потому что потребуются дополнительные флаги, которые включают компоновку со стандартной библиотекой C++.

Компоновка программы в CMake

CMake прячет факт раздельной компиляции файлов. Чтобы в CMake скомпоновать программу, состоящую из нескольких частей, просто перечислите эти файлы в add_executable.

Удалите файл print_recursive.exe:

del print_recursive.exe

Создайте файл CMakeFiles.txt с одной строкой:

add_executable(print_recursive main.cpp print.h print.cpp)

Теперь выполните конфигурирование и сборку программы:

cmake -G "MinGW Makefiles" .
cmake --build .

Мы почти закончили! Остался только один вопрос: почему в add_executable мы указали заголовок print.h, если он всё равно не компилируется сам по себе? Дело в том, что при любых изменениях в коде заголовка print.h вся программа должна быть пересобрана, но файл print.h сам по себе не компилируется. Добавление print.h в список исходников в CMake позволяет CMake следить за датой и временем модификации заголовка, чтобы решить, надо ли повторно собирать проект из-за изменений в заголовках.