Перейти к содержимому
Topos Uranos

Краткое содержание

До сих пор каждая программа курса помещалась в одном файле. Программу в тысячу строк уже не прочесть за один раз, а её функции заслуживают того, чтобы служить и другим программам. Этот урок учит её разделять: заголовочный файл, который объявляет, что предлагает модуль, файл реализации, который это определяет, и main.cpp, который этим пользуется. Урок знакомит с защитой от повторного включения; с правилом одного определения, которое объясняет две классические ошибки компоновщика и современный смысл inline; с раздельной компиляцией, которая перекомпилирует только то, что изменилось; и завершается собственной библиотекой, составленной из функций предыдущих уроков.

Цели обучения

  1. Разделять программу на заголовочный файл с объявлениями, файл реализации и main.cpp и компилировать её.
  2. Писать защиту от повторного включения и объяснять, какую задачу она решает; узнавать в #pragma once расширение, которое принимают все компиляторы, но которого нет в стандарте.
  3. Объяснять правило одного определения и диагностировать его нарушения по сообщению компоновщика («multiple definition», «undefined reference»).
  4. Компилировать раздельно с -c и компоновать объектные файлы, объясняя, что перекомпилируется при изменении каждого файла.

Интерфейс и реализация

Модульность

Функция с контрактом, как функции урока Проектирование с помощью функций, уже отделяет то, что она обещает (это записано в её объявлении и контракте), от того, как она это выполняет (это записано в её теле); вызывающему нужно только первое. Если это так, ничто не мешает хранить обе части в разных файлах. Модуль есть группа функций (и типов, которыми они пользуются) с общим назначением; его интерфейс составляет то, что он предлагает пользователю, а его реализация есть способ, которым он это делает. В C++ интерфейс записывается в заголовочном файле (расширение .hpp или .h, унаследованное от C), а реализация в файле .cpp с тем же именем.

Отсюда следуют три преимущества. Код понимается по частям: чтобы пользоваться модулем, достаточно прочесть его заголовочный файл. Он компилируется по частям: изменение реализации не заставляет перекомпилировать тех, кто пользуется только интерфейсом. И он тестируется по частям, на соответствие каждому контракту, как покажет урок Тестирование программ. Курс «LaTeX для академического письма» так же делит длинный документ в своём уроке «Длинные документы».

Три файла

Модуль плоской геометрии из этого примера предлагает три функции; две из них взяты из урока 5.3. Заголовочный файл только объявляет их, каждую с её контрактом. Три строки, начинающиеся с #, будут объяснены в следующем параграфе; пока достаточно знать, что они есть в каждом заголовочном файле.

geometry.hpp
// geometry.hpp: интерфейс модуля плоской геометрии.#ifndef GEOMETRY_HPP#define GEOMETRY_HPP​// Площадь круга радиуса radius.// Предусловие: radius >= 0.// Постусловие: возвращает площадь круга радиуса radius.double circleArea(double radius);​// Являются ли три длины сторонами треугольника.// Предусловие: нет.// Постусловие: возвращает true тогда и только тогда, когда существует треугольник со сторонами a, b и c.bool isTriangle(double a, double b, double c);​// Площадь треугольника со сторонами a, b и c.// Предусловие: isTriangle(a, b, c).// Постусловие: возвращает площадь треугольника со сторонами a, b и c (формула Герона).double triangleArea(double a, double b, double c);​#endif

Файл реализации включает собственный заголовочный файл и определяет функции. Заголовочные файлы стандартной библиотеки, которые ему нужны (<cmath> для квадратного корня, <numbers> для числа π\pi), включаются здесь, а не в заголовочном файле, потому что пользователю модуля они не нужны.

geometry.cpp
// geometry.cpp: реализация модуля плоской геометрии.#include "geometry.hpp"​#include <cmath>#include <numbers>​double circleArea(double radius){    return std::numbers::pi * radius * radius;}​bool isTriangle(double a, double b, double c){    return a > 0 && b > 0 && c > 0 && a < b + c && b < a + c && c < a + b;}​double triangleArea(double a, double b, double c){    const double s{(a + b + c) / 2};    return std::sqrt(s * (s - a) * (s - b) * (s - c));}

Наконец, main.cpp включает заголовочный файл и пользуется модулем, не видя ни одной строки его реализации:

Программа на C++
#include <print>​#include "geometry.hpp"​int main(){    std::println("Круг радиуса 2: {:.4f}", circleArea(2.0));    std::println("Есть ли треугольник со сторонами 3, 4, 5? {}", isTriangle(3.0, 4.0, 5.0));    std::println("Его площадь: {}", triangleArea(3.0, 4.0, 5.0));    std::println("А со сторонами 1, 2, 3? {}", isTriangle(1.0, 2.0, 3.0));}
Вывод
Круг радиуса 2: 12.5664Есть ли треугольник со сторонами 3, 4, 5? trueЕго площадь: 6А со сторонами 1, 2, 3? false

Кавычки в #include "geometry.hpp" ищут файл прежде всего в папке того файла, который его включает, а угловые скобки в #include <print> ищут в системных папках: кавычки предназначены для собственных файлов. Файл geometry.cpp включает свой заголовочный файл первым, так что, если бы заголовочному файлу не хватало включения, которое нужно ему самому, ошибка появилась бы при компиляции модуля, а не в файле другого человека. А треугольника со сторонами 1, 2 и 3 не существует (3=1+23 = 1 + 2), так что предусловие triangleArea обязывает спросить об этом заранее.

Для компиляции команда получает оба файла .cpp; заголовочный файл не называется, потому что попадает в каждый из них через #include. Опции курса сохраняются в переменной терминала FLAGS, чтобы не повторять их в каждой команде; $FLAGS их подставляет. Так пишут в Linux и macOS; в PowerShell переменные записываются иначе, и там лучше писать опции полностью или компилировать из редактора (параграф «Раздельная компиляция»).

Терминал
$ FLAGS="-std=c++23 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -g -fsanitize=address,undefined -fno-sanitize-recover=undefined"$ g++ $FLAGS main.cpp geometry.cpp -o main$ ./mainКруг радиуса 2: 12.5664Есть ли треугольник со сторонами 3, 4, 5? trueЕго площадь: 6А со сторонами 1, 2, 3? false
ПримерПуть трёх файлов

Рисунок повторяет рисунок урока Программы, компиляторы и C++ для этого проекта.

Доказательство

  1. Программист пишет три файла: заголовочный файл и два файла .cpp. Компилятору передаются только .cpp.

  2. Препроцессор копирует geometry.hpp в каждый файл, который его включает. Следовательно, есть две копии объявлений, по одной в каждой единице трансляции, и это правильно: функцию можно объявлять сколько угодно раз.

  3. Каждая единица компилируется отдельно в свой объектный файл. Компилируя main.cpp, компилятор не смотрит в geometry.cpp: объявления ему достаточно, чтобы проверить типы в каждом вызове.

  4. Компоновщик соединяет оба объектных файла и стандартную библиотеку и связывает каждый вызов circleArea из main.o с определением, которое находится в geometry.o.

Частая ошибка состоит в том, чтобы включить файл реализации: #include "geometry.cpp". Если к тому же он назван в команде, его функции оказываются определены дважды и компоновка не удаётся по правилу, которое объяснит параграф «Правило одного определения»; если не назван, main.cpp снова становится единым текстом, который при каждом изменении перекомпилируется целиком. Файлы .cpp компилируются; заголовочные файлы включаются.