Разработка C++20 идет полным ходом, и это принесет немало новых возможностей. У нас есть библиотека ranges, корутины, концепции и новая библиотека форматирования строк. Но, возможно, самой большой потенциальной возможностью повлиять на структуру и архитектуру нашего кода являются модули.

Модули C++ обещают уменьшить потребность в заголовочных файлах, и они являются частью постоянного движения к сокращению — и, в конечном счете, удалению — препроцессора. В этой статье блога мы рассмотрим, что такое модули C++, обсудим их потенциальные преимущества и недостатки, а также приведем пример их использования.

Немного истории

Язык C++ был создан на основе языка C, и поэтому он унаследовал от него многие особенности — прежде всего, препроцессор и концепцию разделения заголовочных и исходных файлов. Директивы препроцессора — это набор инструкций, которые выполняются компилятором в каждой единице трансляции перед началом компиляции. Вы можете определить их, потому что любая строка кода, начинающаяся с символа #, является таковой.

Модули C++ — это попытка уменьшить потребность в одной конкретной директиве препроцессора, #include. #include позволяет нам разделить исходный код на логические части — в частности, интерфейс (обычно расположенный в файле ".h" или "header") и реализацию (обычно расположенную в файле ".cpp" или "source"). Разделение на заголовочный и исходный файлы дает огромное количество преимуществ, включая:

  • Создание многократно используемых библиотек кода, которые являются краеугольным камнем C и C++

  • Разделение интерфейса и его реализации

  • Организация кода в логические и многократно используемые части.

Каковы же недостатки использования заголовочных файлов, и как сохранить вышеперечисленные преимущества с помощью модулей? Давайте рассмотрим подробнее.

Что не так с #include?

Использование препроцессора для включения заголовочного файла — это, по сути, аккуратный способ скопировать все из одного файла и вставить в другой; это, грубо говоря, хак для обработки текста. При этом возникают некоторые проблемы, о которых мы, программисты, должны знать, поскольку в конечном итоге у нас появляется больше работы.

Например, нам нужно следить за тем, чтобы не включать заголовочный файл дважды из-за риска переопределения кода. Мы обходим это, оборачивая наши заголовки довольно грубой защитой #ifndef FILE_ID (или нестандартной, но также менее подверженной ошибкам #pragma once). Мы также рискуем объявить макрос где-то в нашем коде до включения заголовка, что может непреднамеренно повлиять на импортируемый заголовочный файл. Или же мы можем продублировать что-то, что было определено ранее во включенном заголовке. Это часто может привести к очень неприятным ошибкам и непреднамеренному поведению. Смотрите следующий пример:

// A preprocessor macro defined above an include directive.
#define INNOCUOUS_DEFINE 1

// It’s difficult to know whether or not `INNOCUOUS_DEFINE` is changing behavior
// in the following header file or in another header nested inside.
// Sometimes this is done on purpose, but it highlights poor architecture and can be misleading.
#include "Mysterious.h"

#ifdef INNOCUOUS_DEFINE
// Do innocuous things.
#endif

Другой проблемой заголовочных файлов является риск создания длительного времени компиляции, особенно для сложного шаблонного кода, такого как контейнеры STL. Контейнеры STL часто используются в большом количестве, и компилятору приходится проделывать много работы по разбору заголовочных файлов при каждом их включении. В следующем разделе мы увидим, как модули могут помочь в этом процессе.

Что такое модули C++ и как они могут улучшить наш код?

Как говорилось в одной из предыдущих статей нашего блога, посвященной эффективному управлению заголовочными файлами, заголовочными файлами можно легко злоупотребить и переусердствовать с их использованием. Необходимо знать технологии, чтобы не использовать их неправильно, поскольку изолированная и блочная природа модулей помогает компилятору обеспечить лучшую архитектуру и обеспечивает семантику, позволяющую раскрывать только то, что необходимо.

Модули — это попытка сгруппировать код в скомпилированные двоичные файлы, или "modules", которые раскрывают имена типов, функций и т.д. для использования в коде, который их импортирует. Когда код компилируется, в качестве одного из этапов процесса создается нечто, называемое абстрактным синтаксическим деревом (AST). AST - это сериализованное представление без потерь всех имен функций, классов, шаблонов и т.д. кода. Компилятор использует эту информацию для создания конечного двоичного модуля.

Это ничем не отличается от того, что происходит с заголовочными файлами, но генерация AST — довольно медленный процесс, и эта медлительность увеличивается при каждом включении заголовочного файла. С модулями компилятор должен выполнить этот шаг только один раз, независимо от того, сколько раз импортируется модуль. Предварительно откомпилированные заголовки (PCH) обеспечивают то же преимущество, но модули упаковывают все в удобную и стандартизированную языковую функцию. Более подробную информацию о реализации PCH в Clang можно прочитать в документации компании.

Все, что находится внутри модуля и не раскрыто явно при export, будет недоступно при import. Модули компилируются в состоянии "песочницы" (sandboxed) и не будут изменены, независимо от того, что определяет импортирующая единица перевода. Кроме того, импортирующая единица перевода не будет иметь побочных эффектов от того, что определено внутри модуля при его импорте.

"Но как быть со всей моей существующей структурой кода и управлением файлами?" — спросите вы. Не бойтесь! Модули можно использовать наряду с заголовочными файлами, поэтому нет риска отбросить текущую методологию или структуру кода в пользу модулей; их можно постепенно внедрять один за другим.

Модули — это, по сути, новый способ разделить код, заменив #include на import, а также позволяя нам убрать разделение между интерфейсами и реализациями, тем самым потенциально вдвое сократив количество файлов. Теперь мы можем поместить все в один файл и открыть внешнему миру только то, что мы явно помечаем как экспортируемое. Это может быть полезно в некоторых случаях, когда разделение файлов необходимо только для ускорения компиляции и не способствует пониманию кода. Стоит отметить, что разделение файла интерфейса / реализации по-прежнему возможно с помощью модулей.

Используя ключевые слова import и export, наш код становится гораздо более выразительным, предоставляя читателю четкую семантику того, что является общедоступным, а что нет. Несколько модулей также могут быть упакованы вместе в один пакет, что в конечном итоге облегчает жизнь конечному пользователю, поскольку ему приходится иметь дело только с одним, содержащимся по логике пакетом.

Как мы используем модули?

В этом базовом примере мы определяем именованный модуль с помощью export module, а также функцию, которую мы хотим раскрыть для пользователя с помощью export:

// NumberCruncher.cppm -> We use a new file extension, `cppm`, to distinguish modules from other source code.

// This line defines the module name and allows it to be imported.
export module NumberCruncher;

// Other modules can be imported from within a module.
// Here we import an imaginary logging module written elsewhere that exports `logger::info`.
import logger;

// Any macros defined inside a module are not exposed to the importer.
#define CRUNCH_FACTOR 3.14

// Namespaces work as normal and will need to be used by the caller.
namespace numbers {

// Anything not explicitly exported is internal to the module and cannot be used by the code importing it.
float applyCrunchFactor(float number) {
    return number * CRUNCH_FACTOR;
}

// We can choose which functions to export using the `export` keyword.
export float crunch(float number) {
    // Calls an internal function.
    auto crunched = applyCrunchFactor(number);
    // Use our imaginary logger.
    logger::info("Crunched {} with result of {}", number, crunched);
    return crunched;
}

} // namespace numbers

И мы можем использовать его в нашем приложении:

// main.cpp
import NumberCruncher;  // Imports our custom module.

int main() {
    // Use it!
    auto value = numbers::crunch(42);
}

Если мы хотим сохранить разделение интерфейс / реализация, то можно также разделить наш блок кода реализации на другой файл и получить гораздо более простой файл интерфейса cppm:

// `NumberCruncher.cppm`.
export module NumberCruncher;

namespace numbers {

// Crunch `number` using our magic crunch factor.
export float crunch(float number);

} // Namespace numbers.

Реализация:

// `NumberCruncher.cpp`

// Declare the module without exporting it so the compiler can get the information it needs.
module NumberCruncher;

// Logger isn’t used in the interface file, so no need to import it there.
import logger;

#define CRUNCH_FACTOR 3.14

float numbers::applyCrunchFactor(float number) {
    return number * CRUNCH_FACTOR;
}

// No need to use the `export` keyword in the implementation; that is only for the module interface file.
float numbers::crunch(float number) {
    auto crunched = applyCrunchFactor(number);
    logger::info("Crunched {} with result of {}", number, crunched);
    return crunched;
}

Чтобы скомпилировать приведенный выше код компилятором Clang, нам нужно включить функцию модулей с помощью флага -fmodules и указать, какую версию C++ мы используем с помощью -std=c++2a. Модуль должен быть предварительно скомпилирован с помощью команды --precompile. В результате будет создан файл .pcm, который используется для сборки конечного исполняемого файла. Подробности об интеграции модулей Clang можно найти здесь.

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

В больших существующих проектах, таких как наша библиотека Core C++ в PSPDFKit, модули не так просто внедрить повсеместно. По этой причине их рекомендуется внедрять по частям; найдите в своем проекте несколько наиболее часто используемых заголовочных файлов, попробуйте преобразовать их в модули и посмотрите, уменьшится ли время компиляции или повысится сопровождаемость кода, что позволит оправдать изменения.

Есть ли недостатки?

У модулей есть несколько существенных недостатков, которые подробно обсуждаются в статье "Опасения по поводу инструментальных возможностей модуля".

В статье говорится о потенциальном влиянии модулей на системы сборки и инструменты разработчика. В настоящее время, при наличии заголовочных файлов, исходный файл может быть собран в изоляции от других частей системы до тех пор, пока доступны все заголовочные файлы. Но поскольку модуль должен быть собран до того, как будет собран код, который его использует, это может привести к проблемам с возможностью распараллеливания сборок, которые полагаются на многие модули. Это может оказаться сложной проблемой для систем сборки, которые используют несколько сетевых машин для компиляции одного проекта.

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

В Стандартной библиотеке также нет официальной поддержки модулей, хотя Microsoft добавила std.core в Visual Studio.

Будущее

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

Как производители SDK и инструментов для разработчиков в PSPDFKit, мы уже видим огромную пользу от внедрения модулей в нашу кодовую базу. Существует потенциал для упрощения нашей архитектуры и ускорения процесса разработки, а возможность упаковывать и выпускать собственные самодостаточные модули для клиентов будет выгодна, в конечном итоге помогая им безопасно и эффективно разрабатывать свой код.

Заключение

В мире C++ наступили захватывающие времена, на горизонте маячат большие изменения в C++20. В этой статье блога мы увидели, как модули C++ потенциально могут уменьшить использование препроцессора, что в конечном итоге может привести к улучшению структуры кода, ускорению времени сборки и в целом к созданию более выразительного и менее подверженного ошибкам кода.

Мы также увидели, что существуют некоторые потенциальные недостатки инструментария для разработчиков, если использовать эту возможность поспешно и неправильно, и как такие компиляторы, как Clang и MSVC, работают над тем, чтобы сделать использование этой функции удобным.


Материал подготовлен в рамках курса «C++ Developer. Professional». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.

Комментарии (4)


  1. ncr
    02.09.2021 17:22
    +3

    они являются частью постоянного движения к сокращению — и, в конечном счете, удалению — препроцессора

    Удаление препроцессора подобно построению коммунизма.
    Движение есть, прогресса нет.


  1. neverlight
    03.09.2021 13:18
    +1

    Разработка C++20 идет полным ходом

    Сам по себе стандарт уже готов и опубликован в декабре 2020-го, сейчас активно реализуется поддержка новых возможностей в компиляторах.


  1. DeepFakescovery
    06.09.2021 18:21
    -1

    похороните уже эту бабушку


  1. valeramikhaylovsky
    10.09.2021 08:48

    Пробовал начать использовать модули, но компиляторы ещё не поддерживают их, так что ещё год ждать как минимум.