Главная / Курсы / C++ по спирали / Структура программы
# Глава 10. Структура программы Проект на C++ может быть сколь угодно сложным и содержащим произвольную иерархию директорий. Он почти всегда включает специфичные для системы сборки скрипты, конфиги и другие вспомогательные файлы. Но в этой главе нас интересуют именно файлы с кодом на C++. Мы разберем, по какому принципу они составляются. А в следующей главе мы научимся собирать проект. ## Объявления и определения Функции, классы, переменные и другие сущности в программе становятся видны компилятору после точки своего объявления. Если использовать их до места в коде, где они объявлены, то компилятор выдаст ошибку. Убедимся в этом на примере, в котором есть [взаимная рекурсия.](https://ru.wikipedia.org/wiki/%D0%92%D0%B7%D0%B0%D0%B8%D0%BC%D0%BD%D0%B0%D1%8F_%D1%80%D0%B5%D0%BA%D1%83%D1%80%D1%81%D0%B8%D1%8F) Пример реализует [гипотезу Коллатца:](https://ru.wikipedia.org/wiki/%D0%93%D0%B8%D0%BF%D0%BE%D1%82%D0%B5%D0%B7%D0%B0_%D0%9A%D0%BE%D0%BB%D0%BB%D0%B0%D1%82%D1%86%D0%B0) какое бы натуральное число `n` мы ни взяли, рано или поздно мы получим единицу, если будем совершать действия: - Если `n` четное, делить его на 2. - Если `n` нечетное, умножать на 3 и прибавлять 1. ```c++ {.example_for_playground} import std; int collatz_multiply(int x) { return (x % 2 > 0) ? 3 * x + 1 : collatz_divide(x); } int collatz_divide(int x) { return (x % 2 == 0) ? x / 2 : collatz_multiply(x); } int main() { int n = 17; std::println("Checking Collatz conjecture for {}", n); while (n > 1) { n = collatz_multiply(n); std::print("{} ", n); } } ``` ``` main.cpp:5:36: error: use of undeclared identifier 'collatz_divide' 5 | return x % 2 > 0 ? 3 * x + 1 : collatz_divide(x); | ^ ``` Компилятор не нашел функцию, вызванную до своего объявления. Поправить это простым переносом функции выше места ее вызова не получится. Ведь `collatz_multiply()` вызывает `collatz_divide()` и наоборот! Кроме того, код может быть достаточно сложным, чтобы подходящего места для размещения всех функций до их вызовов просто бы не нашлось. На помощь приходят [объявления](https://en.cppreference.com/w/cpp/language/declarations.html) (declarations). Объявление делает сущность видимой для компилятора. Объявление функции состоит из возвращаемого типа, имени функции и параметров. После объявления ставится оператор `;`. ```c++ int collatz_multiply(int x); ``` Все функции, классы, структуры и перечисления, с которыми вы успели поработать в предыдущих главах, являются _определениями._ Единственное исключение — объявление функции `read_file()` в файле `main.cpp` [предыдущей практики](/courses/cpp/practice/cpp_brainfuck_interpreter/) «Интерпретатор Brainfuck». [Определение](https://en.cppreference.com/w/cpp/language/definition.html) (definition) — это объявление вместе с информацией, которой достаточно для использования сущности в коде. Определение функции включает и ее тело, то есть реализацию. Любое определение также является и объявлением. Чтобы исправить пример кода с гипотезой Коллатца, разместим объявления функций до их использования: ```c++ {.example_for_playground} import std; int collatz_multiply(int x); int collatz_divide(int x); int main() { int n = 17; std::println("Checking Collatz conjecture for {}", n); while (n > 1) { n = collatz_multiply(n); std::print("{} ", n); } } int collatz_multiply(int x) { return (x % 2 > 0) ? 3 * x + 1 : collatz_divide(x); } int collatz_divide(int x) { return (x % 2 == 0) ? x / 2 : collatz_multiply(x); } ``` ``` Checking Collatz conjecture for 17 52 26 13 40 20 10 5 16 8 4 2 1 ``` Объявлений одной и той же сущности в программе может быть сколь угодно много. Но определение должно быть единственным. Так гласит важный пункт из [правила одного определения](https://en.cppreference.com/w/cpp/language/definition) (ODR, one definition rule). Нарушение ODR приведет к ошибке компиляции. Что выведет этот код? {.task_text} В случае ошибки напишите `err`. {.task_text} ```c++ {.example_for_playground} import std; int main() { std::println("{}", to_miles(0.0)); } double to_miles(double km) { return km * 0.62; } ``` ```consoleoutput {.task_source #cpp_chapter_0100_task_0010} ``` Функция `to_miles()` объявлена после ее вызова. {.task_hint} ```cpp {.task_answer} err ``` Объявление класса не включает реализацию его методов: ```c++ {.example_for_playground .example_for_playground_001} class Message { public: Message(std::string raw_text); std::string get_message(); std::time_t get_timestamp(); private: std::string msg; std::time_t ts; }; ``` Объявление класса в связке с реализацией методов считается определением класса. При реализации метода _вне_ тела класса перед методом указывается имя класса, отделенное от имени метода оператором разрешения области видимости `::`. ```c++ {.example_for_playground .example_for_playground_002} Message::Message(std::string raw_text) { msg = parse_message(raw_text); ts = parse_time(raw_text); } std::string Message::get_message() { return msg; } std::time_t Message::get_timestamp() { return ts; } ``` Приемная комиссия колледжа принимает заявления на поступление вместе с результатами теста. Члены комиссии должны отслеживать `n`-ый наивысший балл по тестам. Это нужно делать в потоковом режиме, по мере того, как поступают новые заявления. Отслеживание `n`-ного наивысшего балла помогает определить пороговое значение, необходимое для зачисления `n` учеников. {.task_text} Перед вами объявление класса `NthLargest`. Реализуйте определения методов. Они должны идти после объявления класса. {.task_text} Конструктор принимает число `n` и значение по умолчанию, которое нужно возвращать, пока не накопится `n` результатов теста. {.task_text} Метод `add()` добавляет новый результат теста и возвращает обновленный `n`-ый наивысший балл. Пока `n` результатов не накопилось, метод возвращает `default_val`. {.task_text} В реализации используется [очередь с приоритетами.](/courses/cpp/chapters/cpp_chapter_0070/#block-priority-queue) {.task_text} ```c++ {.task_source #cpp_chapter_0100_task_0020} class NthLargest { public: NthLargest(std::size_t n, std::size_t default_val); std::size_t add(std::size_t val); private: std::size_t m_n; std::size_t m_default; // https://en.cppreference.com/w/cpp/container/priority_queue std::priority_queue<std::size_t, // тип элемента std::vector<std::size_t>, // контейнер для адаптера std::greater<std::size_t> // компаратор > m_pq; }; ``` Очередь с приоритетами реализует структуру данных [куча](https://ru.wikipedia.org/wiki/%D0%9A%D1%83%D1%87%D0%B0_(%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%82%D1%83%D1%80%D0%B0_%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85)) (heap). По умолчанию элемент с наибольшим значением ключа находится на вершине кучи. Чтобы на вершине оказался наименьший элемент, шаблонный класс `std::priority_queue` был инстанцирован компаратором `std::greater` вместо `std::less`. Вам осталось контроллировать количество элементов кучи. Оно не должно превосходить `n`. {.task_hint} ```c++ {.task_answer} class NthLargest { public: NthLargest(std::size_t n, std::size_t default_val); std::size_t add(std::size_t val); private: std::size_t m_n; std::size_t m_default; // https://en.cppreference.com/w/cpp/container/priority_queue std::priority_queue<std::size_t, // тип элемента std::vector<std::size_t>, // контейнер для адаптера std::greater<std::size_t> // компаратор > m_pq; }; NthLargest::NthLargest(std::size_t n, std::size_t default_val) { m_n = n; m_default = default_val; } std::size_t NthLargest::add(std::size_t val) { if (m_pq.size() < m_n) { m_pq.push(val); return (m_pq.size() < m_n) ? m_default : m_pq.top(); } if (std::size_t cur_nth_largest = m_pq.top(); cur_nth_largest < val) { m_pq.push(val); if (m_pq.size() > m_n) m_pq.pop(); } return m_pq.top(); } ``` Объявления и определения могут находиться в разных файлах проекта. Принципы их размещения зависят от того, как в проекте импортируются в код сущности: с помощью хедеров или модулей. ## Проекты до появления модулей До C++20 проекты содержали файлы двух видов: - Хедеры (headers) или заголовочные файлы. Им принято давать расширение `.h`, `.hpp` или `.hxx`. - Файлы реализации. Но на практике вы вряд ли услышите это название. Обычно говорят «файлы исходников» или просто «`cpp`-файлы». Варианты расширений: `.cpp`, `.cxx`, `.cc`. Хедеры содержат объявления, а `cpp`-файлы — определения. Но в некоторых случаях определения можно или даже нужно помещать в хедеры. Каждый из них мы рассмотрим отдельно. А пока помните: если допустимы оба варианта, всегда выбирайте `cpp`-файл. Кроме того, расширение файла — всего лишь договоренность. Оно может быть любым. И все же давайте файлам одно из общепринятых расширений. Например, `.h` для хедеров и `.cpp` для файлов реализации. В простейшем случае проект — это единственный `cpp`-файл, из которого компилятор создает исполняемый файл. Или единственный хедер, если проект — библиотека. Для демонстрации взаимосвязи хедеров и `cpp`-файлов заведем проект `hello_compiler` их трех файлов: ``` hello_compiler/ ├── hello_compiler.h ├── hello_compiler.cpp └── main.cpp ``` В файле `hello_compiler.h` разместим объявление функции, которая выводит информацию о компиляторе. Она принимает строку. Класс `std::string` объявлен в хедере `string`, и нам надо подключить его через директиву препроцессора `#include`. ```c++ #include <string> namespace sys { void show_compiler_info(std::string compiler); } ``` Функция находится в пространстве имен `system`. Со временем в проект планируется добавить больше вспомогательных функций, и пространство имен подходит для их логической группировки. Файл `hello_compiler.cpp` содержит определение `show_compiler_info()` и вспомогательную функцию `binary_name()`. Обратите внимание, что `show_compiler_info()` находится в пространстве имен `system`, как и в хедере. ```c++ #include <cstdlib> #include <map> #include <print> #include "hello_compiler.h" std::string binary_name(std::string compiler) { const static std::map<std::string, std::string> binaries = { {"clang", "clang++"}, {"gcc", "g++"}, {"msvc", "cl.exe"} }; return binaries.at(compiler); } namespace sys { void show_compiler_info(std::string compiler) { const std::string command = std::format("{} -v", binary_name(compiler)); const int code = std::system(command.c_str()); if (code != 0) std::println("Couldn't get clang compiler info. Error code: {}", code); } } ``` В `hello_compiler.cpp` подключен наш хедер и два хедера стандартной библиотеки: - `cstdlib`. Здесь объявлена функция [std::system()](https://en.cppreference.com/w/cpp/utility/program/system) для запуска консольной команды. - `map`. Здесь объявлен шаблонный класс `std::map`. - `print`. Здесь объявлена функция для вывода в консоль. - `hello_compiler.h`. Так компилятор поймет, что мы определяем функцию, уже объявленную в другом месте. Теперь вызовем `show_compiler_info()` из `main.cpp`. Чтобы функция стала доступна в этом файле, нужно подключить хедер с ее объявлением. ```c++ #include "hello_compiler.h" int main() { sys::show_compiler_info("clang"); } ``` ``` Debian clang version 20.1.7 Target: x86_64-pc-linux-gnu Thread model: posix ... ``` ### Структура проекта с хедерами и cpp-файлами Состоящий из хедеров и `cpp`-файлов проект составляется по простым принципам: - Объявления сущностей размещаются в хедерах, а их определения — в `cpp`-файлах. - Если объявление вложено в [пространство имен,](/courses/cpp/chapters/cpp_chapter_0050/#block-namespaces) определение должно повторять эту вложенность. - Чтобы использовать объявление из хедера, нужно его подключить. - Хедеры подключаются директивой препроцессора `#include`. - Хедеры можно подключать в `cpp`-файлы и другие хедеры. - При подключении хедера становятся доступны все его объявления. - Если в хедер А подключен хедер Б, и хедер А подключен в `main.cpp`, это делает содержимое хедера Б доступным в `main.cpp`. То есть подключение хедеров [транзитивно.](https://ru.wikipedia.org/wiki/%D0%A2%D1%80%D0%B0%D0%BD%D0%B7%D0%B8%D1%82%D0%B8%D0%B2%D0%BD%D0%BE%D1%81%D1%82%D1%8C) Чтобы использовать классы и функции из стандартной библиотеки, нужно подключать соответствующие хедеры. Например, `std::vector` объявлен в хедере `vector`, функция `std::sort()` — в `algorithm`, а тип `std::size_t` — в `cstddef`. На [cppreference](https://en.cppreference.com/w/cpp/types/numeric_limits/min.html) можно узнать, что в каком хедере находится. Хедеры подключаются с помощью директивы препроцессора `#include`. Разберемся, как это устроено. ### Директивы препроцессора Препроцессор — это часть компилятора, отвечающая за первичную обработку кода. Он обнаруживает и обрабатывает строки в коде, начинающиеся с символа решетки `#`. За ним следует ключевое слово и опционально параметры: ```c++ #keyword params ``` Такие строки называют _директивами препроцессора._ Их обработка сводится к примитивной замене фрагментов кода. Покажем это на примере двух директив: [#include](https://en.cppreference.com/w/cpp/preprocessor/include.html) и [#define](https://en.cppreference.com/w/cpp/preprocessor/replace). Ключевое слово `include` заменяет строку с директивой содержимым файла. Имя файла — обязательный параметр для ключевого слова `include`: ```c++ #include "common/logging.hpp" ``` Правило хорошего тона гласит: используйте директиву `#include` _только_ для подключения хедеров. Хоть технически она сработает и для произвольного файла, будь то `.cpp`, `.txt` или `.json`. А ключевое слово `define` объявляет _макрос_ — фрагмент кода, которому дано имя. Заведем макрос `PI` и используем его при выводе в консоль: ```c++ {.example_for_playground} #include <print> #define PI 3.1415926 int main() { std::println("{}", PI); } ``` ``` 3.1415926 ``` В процессе сборки проекта директива `#define` исчезнет, а по месту ее использования произойдет _макроподстановка_ — текстовая замена имени макроса на его тело: ```c++ std::println("{}", 3.1415926); ``` Макросам принято давать имена заглавными буквами, чтобы не путать их с переменными: `PI`, `MAX_JOBS` и т.д. При макроподстановках препроцессор не проводит синтаксических или семантических (смысловых) проверок. После макроподстановок вы можете получить некорректный код и ошибку компилятора, которую трудно диагностировать и еще труднее отладить. На заре появления C++ макросы позволяли писать обобщенный код. Они стали не нужны с появлением развитых средств для создания [шаблонов](/courses/cpp/chapters/cpp_chapter_0050/#block-templates) и выполнения кода на этапе компиляции. Неудивительно, что хорошие практики современного C++ [настаивают](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Res-macros2) на отказе от макросов. А подключение хедеров через директивы препроцессоров начиная с C++20 заменяется на импорт модулей. Директивы препроцессора — это архаичный инструмент. Но важно знать, как работать с хедерами и макросами: вы будете встречать их во всех проектах, которые не мигрировали на модули. А таких подавляющее большинство. ### Подключение хедеров На первых строках файла принято подключать хедеры, сущности из которых используются дальше по коду. В файле `hello_compiler.cpp` проекта `hello_compiler` мы подключили хедеры: - `hello_compiler.h`, чтобы реализовать объявленный в нем интерфейс. - `cstdlib`, `map` и `print` из стандартной библиотеки, чтобы воспользоваться предоставляемыми ими объявленными. ```c++ #include <cstdlib> #include <map> #include <print> #include "hello_compiler.h" ``` Хедеры стандартной библиотеки не имеют расширений. Также обратите внимание, что хедеры обрамляются треугольными скобками `<>` либо кавычками `""`. От этого зависит порядок, в котором препроцессор перебирает пути для поиска файла. Стратегия перебора зависит от реализации компилятора. Она [не определяется](https://eel.is/c++draft/cpp.include) стандартом C++. Однако у распространенных компиляторов стратегии схожи. Порядок перебора путей `#include "file"`: 1. Директория, содержащая обрабатываемый файл с директивой `#include`. 2. Другие директории проекта. 3. Набор путей по умолчанию. Их список отличается в зависимости от компилятора и дистрибутива ОС. Это так называемые [стандартные системные директории.](https://gcc.gnu.org/onlinedocs/gcc-4.5.4/cpp/Search-Path.html) Например, `/usr/local/include` и `/usr/include/`. Перебор директорий в случае `#include <file>` ограничивается стандартными системными директориями. Если препроцессор не находит в них нужный хедер, сборка проекта [завершается с ошибкой.](https://gcc.gnu.org/onlinedocs/cpp/Search-Path.html) Пути для поиска хедеров можно дополнить или полностью переопределить с помощью [опций компилятора и переменных окружения.](https://gcc.gnu.org/onlinedocs/gcc/Directory-Options.html#Directory-Options) При выборе способа подключения хедера руководствуйтесь правилами: - В треугольных скобках `<>` и без расширения указывайте хедеры стандартной библиотеки. - В двойных кавычках `""` указывайте хедеры внутри проекта. Перед именем хедера зачастую задается относительный путь. Усложним структуру проекта `hello_compiler`: вынесем `hello_compiler.h` и `hello_compiler.cpp` в директорию `utils`. ``` hello_compiler/ ├── main.cpp └── utils ├── hello_compiler.cpp └── hello_compiler.h ``` Тогда подключение `hello_compiler.h` в `main.cpp` будет выглядеть так: ```c++ #include "utils/hello_compiler.h" ``` При подключении хедера технически допустимо указывать абсолютный путь, например `#include "/usr/include/boost/fusion/include/array.hpp"`. Но делать так _нельзя,_ потому что при изменении расположения хедера или при переносе проекта на другую систему сломается компиляция. В проекте есть файл без расширения `vector`. Его имя совпадает с хедером стандартной библиотеки. Содержимое какого хедера препроцессор вставит по месту директивы `#include "vector"`? Введите букву: {.task_text} - `s`, если хедер из стандартной библиотеки. - `l`, если хедер внутри проекта. ```consoleoutput {.task_source #cpp_chapter_0100_task_0030} ``` Выше перечислены правила, позволяющие однозначно определить, к какому виду относится хедер. {.task_hint} ```cpp {.task_answer} l ``` Реализуйте функцию `score_sum()`. Она принимает словарь, в котором ключ — это id абитуриента, а значение — его оценка за экзамен. Второй параметр функции — id интересующего абитуриента. Функция должна вернуть сумму баллов, которые он получил за все экзамены. Если студент не найден, функция должна вернуть 0. {.task_text} Для суммирования используйте алгоритм [std::accumulate()](/courses/cpp/chapters/cpp_chapter_0080/#block-accumulate-overload). {.task_text} Над функцией разместите подключение всех необходимых хедеров. Для справки используйте [cppreference](https://cppreference.com/). {.task_text} ```c++ {.task_source #cpp_chapter_0100_task_0040} std::size_t score_sum(std::flat_multimap<std::string, std::size_t> applicants, std::string id) { } ``` Вам пригодится метод [equal_range()](/courses/cpp/chapters/cpp_chapter_0070/#block-equal-range), который есть у `multi`-версий контейнеров, в том числе у класса [std::flat_multimap](/courses/cpp/chapters/cpp_chapter_0080/#block-flat). {.task_hint} ```c++ {.task_answer} #include <flat_map> #include <numeric> #include <string> #include <utility> std::size_t fold(std::size_t left, std::pair<std::string, std::size_t> right) { return left + right.second; } std::size_t score_sum(std::flat_multimap<std::string, std::size_t> applicants, std::string id) { auto[it, it_end] = applicants.equal_range(id); return std::accumulate(it, it_end, 0, fold); } ``` ### Недостатки использования хедеров Работа препроцессора сводится к примитивным текстовым заменам. Синтаксический и семантический анализ при этом не проводится. Вместо _каждой_ директивы `#include file` препроцессор рекурсивно подставляет содержимое файла. Рекурсивно — потому что один хедер в свою очередь может включать другие хедеры. Это приводит к **долгой сборке проекта.** В проекте есть 3 `cpp`-файла и 2 хедера. Каждый из хедеров подключен во все `cpp`-файлы. Сколько раз в итоге препроцессор будет подставлять содержимое файла по месту директивы `#include`? {.task_text} ```consoleoutput {.task_source #cpp_chapter_0100_task_0050} ``` Каждый раз по месту директивы `#include` подставляется содержимое соответствующего заголовочного файла. Поэтому каждый из 2-х хедеров будет подставлен трижды. {.task_hint} ```cpp {.task_answer} 6 ``` Содержимое одного и того же хедера может попасть в `cpp`-файл несколько раз. И не обязательно из-за ошибки. Например, если помимо хедера А подключен хедер Б, который тоже в свою очередь подключает А, прямо или косвенно. А повторное включение одного и того же кода чревато ошибками компиляции. Оно порождает **конфликтующие объявления и определения.** Чтобы этого избежать, содержимое хедера оборачивается специальной директивой препроцессора для защиты от повторного подключения (include guard). О ней мы поговорим в главе про директивы препроцессора. Если макросы с одинаковым именем определяются в нескольких хедерах, то от _порядка включения_ хедеров зависит, тело какого макроса будет подставлено в код. Иными словами, результат подключения хедера зависит от контекста, в который он подключается. Это приводит к **нетривиальным ошибкам.** При подключении хедера становятся доступны абсолютно все его объявления. **Отсутствуют механизмы инкапсуляции** для выбора, что скрывать, а что экспортировать. Итак, у использования хедеров есть весомые недостатки. Поэтому в C++ был реализован принципиально другой механизм для подключения переиспользуемого кода — модули. ## Проекты с модулями В C++20 появилась возможность создавать и подключать собственные [модули.](https://en.cppreference.com/w/cpp/language/modules.html) А в C++23 был добавлен модуль стандартной библиотеки `std`. ### Преимущества модулей Какие задачи решают модули? - _Инкапсуляция:_ контроль того, что экспортировать, а что скрыть как внутреннюю реализацию. При подключении хедера становятся доступны абсолютно все его объявления. А модули позволяют явно определить интерфейс, доступный при подключении модуля. - _Предсказуемость и безопасность._ Порядок подключения хедеров имеет значение. И это приводит к нетривиальным ошибкам. Порядок подключения модулей не важен, потому что модули не экспортируют макросы. - _Ускорение сборки._ Содержимое хедера вставляется по месту директивы `#include` всякий раз, когда компилируется файл с этой директивой. Это приводит к долгой сборке. Модуль же компилируется единожды, в результате чего создаются два файла: бинарный файл с реализацией и текстовый файл с интерфейсом. - _Более прозрачная иерархия проекта._ Модули могут содержать подмодули. Для отражения этой вложенности в именах модулей используется точка. Например, `codecs.audio`, `codecs.video`. - _Сокращение количества файлов._ Теперь нет необходимости держать объявления отдельно, как это было в случае с хедерами. Разумеется, хедеры и модули прекрасно уживаются вместе. Модули можно подключать в хедеры, а хедеры — в модули. Хедеры и модули можно подключать в один и тот же файл. Но модули — это более удобная и безопасная альтернатива хедерам. Поэтому в проектах на C++20 и выше по возможности отказываются от хедеров в пользу модулей. Если вы начинаете новый проект, используйте модули и избегайте хедеры. ### Импорт модулей Вы уже неоднократно импортировали модуль стандартной библиотеки: ```c++ import std; ``` Выражение `import std;` делает доступными экспортируемые из модуля `std` объявления. Импортирование собственного модуля выглядит точно так же: ```c++ import codecs; ``` ### Структура простого модуля Модулям принято давать расширение `.cpp`, а интерфейсам модулей — `.cppm`. Но это всего лишь соглашение. А в некоторых проектах и интерфейс, и реализация хранятся в файлах с расширением `.cpp`. Интерфейс модуля — это файл, который экспортирует объявления наружу. Остальные файлы модуля содержат его реализацию. Если модуль небольшой, разбивать его на несколько файлов не имеет смысла. В этой главе мы рассматриваем простой вариант организации модуля из одного файла. В [первой практике](/courses/cpp/practice/cpp_div_without_div/) «Деление без деления» имя модуля `div` соответствует имени его файла `div.cppm`. Но имена не обязаны совпадать: мы могли бы назвать файл и `custom_math.cppm`. Так выглядит содержимое `div.cppm`: ```c++ export module div; // module declaration import std; // import declaration export std::size_t divide(std::size_t a, std::size_t b) // export declaration { // ... } ``` Выражение `export module div;` _объявляет модуль._ Оно делает возможным импорт содержимого модуля в другие файлы. Именно эта инструкция отличает файл с интерфейсом модуля от файла с реализацией. После объявления идет импорт модулей, сущности из которых потребуются дальше по коду. Мы подключили `std`, чтобы воспользоваться типом `std::size_t`. Затем следует содержимое модуля. Объявления, которые должны быть доступны снаружи, помечаются ключевым словом `export`. Экспортировать можно константы, функции, классы, пространства имен и т.д. Объявления экспортируются из модуля тремя способами. **Экспорт каждого интересующего объявления по отдельности.** Если объявление находится внутри пространства имен, то оно тоже автоматически экспортируется наружу. Этот способ применяется, когда экспортируемых объявлений не очень много: ```c++ export void f(); namespace A { export void g(); } ``` **Экспорт блока `{}`,** объединяющего объявления. Этот способ удобен, если нужно экспортировать много объявлений: ```c++ export { void f(); void g(); } ``` **Экспорт пространства имен:** ```c++ export namespace A { void f(); void g(); } ``` ### Подключение хедеров внутри модуля Структура модуля немного усложняется, если внутри него требуется подключить хедер. Рассмотрим это на примере модуля из [второй практики](/courses/cpp/practice/cpp_moving_average/) «Скользящее среднее»: ```c++ module; // global module fragment #include <cmath> export module moving_average; // module declaration import std; // import declaration export class MovingAverage // export declaration { public: // ... }; ``` Хедер `<cmath>` был подключен, потому что в нем объявлена константа `NAN`. В модуле `std` ее нет. Для подключения хедеров и определения макросов нужно создавать специальную секцию, которая начинается с инструкции `module;` и заканчивается инструкцией экспорта модуля. Называется эта секция [глобальным фрагментом модуля](https://en.cppreference.com/w/cpp/language/modules.html#Global_module_fragment) (global module fragment). ### Сквозной импорт модулей Если в модуль А подключен модуль Б, и модуль А подключен в `main.cpp`, это не делает содержимое модуля Б доступным в `main.cpp`. Иными словами, импорт модулей не транзитивен. Вернемся к модулю `div` из [первой практики.](/courses/cpp/practice/cpp_div_without_div/) В файле модуля есть импорт `std`: ```c++ export module div; import std; export std::size_t divide(std::size_t a, std::size_t b) { // ... } ``` Модуль `div` подключается в `main.cpp`. Обратите внимание, что рядом есть импорт `std`: ```c++ import std; import div; int main() { std::size_t res = divide(11, 5); } ``` Без этого импорта интерпретатор бы не обнаружил тип `std::size_t`: ```c++ import div; int main() { std::size_t res = divide(11, 5); } ``` ``` main.cpp:5:10: error: declaration of 'size_t' must be imported from module 'std' before it is required 5 | std::size_t res = divide(11, 5); | ^ ``` То есть пользователю модуля приходится самостоятельно подключать другой модуль, в котором объявлены типы, участвующие в экспортируемых интересующим модулем объявлениях. Можно сделать удобнее: при импорте `std` внутри `div` прокинуть этот импорт наружу. Организуется это с помощью ключевого слова `export`: ```c++ export import std; ``` ### Структура проекта с модулями Состоящий из модулей и обычных `cpp`-файлов проект строится по правилам: - В модулях размещаются объявления и определения, которые будут переиспользованы. - У модуля должен быть интерфейс — файл, который экспортирует модуль и его объявления. - Модули подключаются куда угодно: в другие модули, `cpp`-файлы, хедеры. - Если в модуль требуется подключить хедер, это делается в специальной секции, называемой глобальным фрагментом модуля. - При подключении модуля становятся доступны только экспортируемые им объявления. Подытожим это на примере проекта `hello_compiler`. Если переписать его с хедеров на модули, то он будет выглядеть так: ``` hello_compiler/ ├── hello_compiler.cppm └── main.cpp ``` Модуль `hello_compiler.cppm` содержит одну внутреннюю и одну экспортируемую функцию: ```c++ export module hello_compiler; export import std; std::string binary_name(std::string compiler) { const static std::map<std::string, std::string> binaries = { {"clang", "clang++"}, {"gcc", "g++"}, {"msvc", "cl.exe"} }; return binaries.at(compiler); } namespace sys { export void show_compiler_info(std::string compiler) { const std::string command = std::format("{} -v", binary_name(compiler)); const int code = std::system(command.c_str()); if (code != 0) std::println("Couldn't get clang compiler info. Error code: {}", code); } } ``` Модуль подключается в `main.cpp`: ```c++ import hello_compiler; int main() { sys::show_compiler_info("clang"); } ``` ### Как соотносятся модули и пространства имен У модулей и у [пространств имен](/courses/cpp/chapters/cpp_chapter_0050/#block-namespaces) (namespace) разное предназначение. Это может показаться не очевидным, если вы имели дело с модулями на Python. Усложняет ситуацию тот факт, что модуль `std` реализует одноименное пространство имен. Так выглядит использование модуля `std`: ```c++ // Подключение экспортируемых модулем объявлений import std; int main() { // Использование класса из пространства имен std std::vector<int> v; } ``` А так выглядит его реализация: ```c++ export moudule std; export namespace std { class vector { // ... }; // ... } ``` Пространства имен создают именованную область видимости. Они нужны, чтобы избежать конфликта имен. Одно и то же пространство имен может существовать в разных модулях. **Модули не создают новую область видимости.** Они нужны для упорядочения проекта на компоненты. ---------- ## Резюме {#block-summary} - Объявление (declaration) делает сущность видимой для компилятора, а определение (definition) реализует ее. - Любое определение является объявлением. - Объявлений одной и той же сущности в проекте может быть несколько. Но определение должно быть единственным. Это важный пункт ODR (one definiton rule). - Препроцессор — компонент компилятора, который выполняет текстовые замены. Он находит и обрабатывает директивы препроцессора. - Директива препроцессора `#inlude` подключает хедер. - Директива препроцессора `#define` определяет макрос. - Модули импортируются с помощью ключевого слова `import`. - Ключевое слово `export` объявляет модуль в файле с интерфейсом модуля. Также оно необходимо для экспорта из модуля объявлений. - Где возможно, хедерам предпочитайте модули. Это более современный способ организации кода.

Следующие главы находятся в разработке

Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!