Введение

Одним из главных нововведений стандарта C++20 является добавление модулей. Модули призваны радикально изменить структуру кодовых баз C++ и, возможно, сигнализируют о грядущей кончине заголовков (но, скорее всего, не на моем веку). Они также потенциально открывают дорогу для внедрения унифицированной системы сборки и менеджера пакетов, наподобие Rust Cargo; хотя я не сомневаюсь, что стандартизация унифицированной системы сборки будет тем еще кровавым побоищем.

Сборки до C++20

Самый простой способ начать холивар на любом форуме/канале, посвященному C++, это заявить, что какая-нибудь конкретная система сборки (например, Meson, CMake, Bazal и т. д.) лучше других; или что именно ваш способ использования этой системы сборки является «единственным правильным способом». Если вы не знакомы с системами сборки, я бы рекомендовал вам прочитать для начала этот пост, чтобы понять глубину этой проблемы.

Начнем с «Зачем»

О модулях уже было написано несколько статей (в основном Microsoft). Но, когда я читал их, я заметил, что они фокусируются на том, «как» работают модули в C++20, и при этом упускают из виду «зачем» они вообще нужны. Может быть, авторы считают это очевидным, но я думаю, что это очень сильно зависит от уровня читателя. Кроме того, абсолютно все, что я читал, использует Microsoft MSVC, потому что из всего доступного инструментария, он может похвастаться наиболее полной поддержки модулей.

Прежде всего, когда речь заходит о модулях, безусловно, следует сказать пару слов о модульности. В C++ у нас уже есть одна форма модульности — модель объект/класс. Но это «локальная» модульность; модули же представляют «глобальную» модульность, то есть модульность всей программы.

Итак, какую же проблему мы пытаемся решить с помощью модулей?

Давайте будем откровенны: в неумелых руках заголовки очень легко превращаются в беспорядок. Их можно использовать рационально (что все мы вынуждены делать на протяжении многих десятилетий), но, тем не менее, я часто встречаю очень плохо продуманные (на мой взгляд) заголовки. Хорошо продуманное приложение, как правило, «имитирует» модули с помощью пар файлов, например, file.h и file.cpp. Но нет никакого механизма, который принуждал бы нас следовать этому подходу. Нам также необходимо понимать правила внешней и внутренней компоновки, чтобы безопасно строить модульную архитектуру.

Корень проблемы с заголовками кроется в том, что они пропадают после этапа препроцессинга:

На этапе компиляции никаких заголовков уже не существует.

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

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

Прежде чем мы продолжим, мне нужно подчеркнуть один важный аспект — модули и пространства имен полностью ортогональные модели и сосуществуют как независимые аспекты (подробнее об этом позже).

Другие языки

Многие современные языки выстраивают семантику модулей, основываясь на том, что весь код модуля будет заключен в одном файле, как, например, в Java и Python.

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

Интересно, что более старые языки, такие как Ada и Modula-2, разработанные в 1980-х годах, примерно в то же время, что и исходный C++, для определения модулей (или пакетов в случае Ada) используют двухфайловую структуру. В рамках этих архитектур интерфейс модуля отделен от его реализации.

Среди существенных преимуществ такой файловой структуры (интерфейс/реализация) можно выделить:

  • Лучшее время сборки

  • Упрощенная интеграция и тестирование

Хотя, конечно, это еще одна тема для холивара.

Файловая структура модулей С++20

Кто бы там что ни говорил, но C++ — это C++, а не просто способ структурирования моделей (аля Java), поэтому в качестве подхода к организации модулей мы снова получили швейцарский армейский нож. У нас есть множество способов сделать одно и то же, а также множество особых случаев. Поначалу это меня не очень радовало, поскольку моя ментальная модель (основанная на других языковых парадигмах) не налазила на то, что мне дали.

Какого-то одного единственно правильного способа использования модулей C++20 как всегда нет.

Я уверен, что со временем мы придем к новым идиомам относительно использования модулей, но пока я вижу три очевидных способа их использования (на ум приходит правило 80:20).

  1. Однофайловый модуль или полный модуль (complete module) – модель Java/Python 

  2. Разделение модуля на два отдельных файла для интерфейса и реализации — модель Ada.

  3. Несколько отдельных файлов (разделов/partitions), которые впоследствии формируют один модуль — модель C++20.

В C++20 любой файл, содержащий модульный синтаксис, называется модульная единица (трансляции) (Module Unit). Поэтому именованный модуль (Named Module) может состоять из одного или нескольких модульных единиц.

Однофайловый (полный) модуль

Код до C++20

Начнем с классического примера «hello, world!», для которого мы разделим код на два файла.

Как я уже говорил ранее:

На этапе компиляции заголовков не существует.

У нас есть два файла, func.cpp и main.cpp:

// func.cpp
#include <iostream>

void func() {  // определение
    std::cout << "hello, world!\n";
}
// main.cpp
void func();  // declaration

int main(){
    func();
}

Теперь давайте соберем и запустим приложение:

$ g++ -c func.cpp 
$ g++ -c main.cpp 
$ g++ -o App main.o func.o
$ ./App 
hello, world!

Оно, конечно, успешно собирается, так как функция func по умолчанию имеет внешнюю связь (часто называемую глобальной областью видимости). Так что если main имеет валидную декларацию, файл main.cpp может быть скомпилирован, и компоновщик разрешает экспортированные/импортированные символы.

С модулями С++20

Первые предложения поддержки модулей, P1103R3, оперируют термином полный модуль (complete module), где

Полный модуль может быть определен в одном исходном файле.

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

Во-первых, нам нужно создать наш именованный модуль. Файл полного модуля, как правило, состоит из двух, возможно, трех разделов (называемых фрагментами).

  • Глобальный фрагмент модуля — сюда мы прописываем инклюды (опционально).

  • Основной объем (purview) модуля — где мы можем экспортировать типы и поведение.

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

Приватный фрагмент модуля может появляться только в однофайловых модулях. Текущая версия GCC (gcc версии 11.1.0) не поддерживает приватные фрагменты, поэтому в этом посте я опущу их.

Стандарт C++ не определяет расширения файлов; он зависит от инструментария. В GCC суффикс имени файла определяет, как файл обрабатывается будет обрабатываться.

GCC интерпретирует следующие расширения файлов как исходный код C++, который должен быть пдвергнут препроцессингу:

  • file.cc

  • file.cp

  • file.cxx

  • file.cpp

  • file.c++

  • file.C

Для исходных файлов C++ в наших проектах и ​​при обучении C++ мы всегда предпочитали расширение .cpp. Поэтому в следующих примерах я буду использовать.cpp для обычных исходных файлов C++ и.cxx для файлов модулей. Но это не более чем личное предпочтение.

Примечательно, что Microsoft решила использовать для интерфейсов модулей расширение .ixx (читайте по ссылке). Мы могли бы использовать file.ixx, но для GCC нужно будет использовать директиву -x c++ file.ixx, указывающую, что файл следует рассматривать как файл C++. Вместо того, чтобы прибегать к этому усложнению, использование .cxx означает, что GCC будет рассматривать его как стандартный файл C++.

Чтобы сделать исходный файл func.cpp модулем (func.cxx), мы добавляем строку

export module MODULE-NAME;

Например:

// func.cxx
#include <iostream>

export module mod;

void func() {
    std::cout << "hello, world!\n";
}

Однако этот код еще не будет компилироваться. include должен быть в глобальном фрагменте. Глобальный фрагмент должен предшествовать основному объему и просто вводится с помощью ключевого слова module:

// func.cxx
module;

#include <iostream>

export module mod;

void func() {
    std::cout << "hello, world!\n";
}

Теперь мы можем импортировать модуль mod в main:

// main.cpp
import mod;

int main(){
    func();
}

И мы можем скомпилировать func.cxx:

$ g++ -c -std=c++20 -fmodules-ts func.cxx 

Следует отметить, что в GCC C++20 модули пока не включаются простым указанием С++20; вы также должны предоставить директивы -fmodules-ts.

Как и ожидалось, компиляция генерирует объектный файл func.o. Однако вы также можете заметить, что создается подкаталог gcm.cache с файлом mod.gcm. Это сгенерированный файл интерфейса модуля, используемый при компиляции.

Если мы продолжим и скомпилируем main.cpp:

$ g++ -c -std=c++20 -fmodules-ts  main.cpp 
main.cpp: In function 'int main()':
main.cpp:5:5: error: 'func' was not declared in this scope
    5 |     func();
      |     ^~~~

Мы получим ошибку, которая гласит, что func не была объявлена. Если бы мы попытались объявить ее в main.cpp (как делали это раньше), то программа бы стала собираться, но с ошибкой на этапе линковки.

Итак, мы подошли к первому существенному изменению:

В модулях все объявления и определения являются приватными, если только они не экспортированы.

Официально они имеют модульную связь (Module Linkage), которая отличается от внутренней связи. Это становится очевидным только при использовании многофайловых модулей с разделами (об этом мы поговорим подробнее в следующем посте).

Чтобы исправить это, мы экспортируем функцию:

// func.cxx
module;

#include <iostream>

export module mod;

export void func() {
    std::cout << "hello, world!\n";
}

Теперь проект успешно компилируется и линкуется:

$ g++ -c -std=c++20 -fmodules-ts func.cxx
$ g++ -c -std=c++20 -fmodules-ts main.cpp 
$ g++ main.o func.o -o App
$ ./App 
hello, world!

И последняя деталь: мы все еще можем отделить объявление от определения:

// func.cxx
module;

#include <iostream>

export module mod;

export void func();

void func() {
    std::cout << "hello, world!\n";
}

Я не уверен, что от этого есть какая-то большая польза — я думаю, что по большей части вопрос стиля.

Отдельные файлы для интерфейса и реализации

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

Каждый файл, относящийся к модулю, называется модульной единицей (Module Unit). Мы собираемся создать два модуля:

  • Модульную единицу трансляции с интерфейсом (Primary Module Interface Unit — PMIU)

  • Модульную единицу трансляции с реализацией (Module Implementation Unit)

Модульная единица трансляции с интерфейсом

Каждый именованный модуль должен иметь одну и только одну модульную единицу с интерфейсом. Это дополненный файл func.cxx, который содержит выражение:

// func.cxx

export module MODULE-NAME;

И другие наши экспорты, т.е.:

export module mod;

export void func();

Это все, что нам нужно; мы создали именованный модуль mod, который экспортирует единственную функцию func. Наконец мы можем скомпилировать этот модуль:

$ g++ -c -std=c++20 -fmodules-ts func.cxx

Как и прежде, в результате будут сгенерированы func.o и gmc.cache\mod.gmc.

Модульная единица трансляции с реализацией 

В настоящее время еще нет соглашения на счет идиоматики имен, поэтому я использовал func_impl.cxx, но это может быть любое имя файла/расширение, которое вы предпочитаете. Вы не можете использовать func.ixx, так как он также будет генерировать объектный файл func.o, который перезапишет сгенерированный объектный файл func.cxx.

Единица с реализацией содержит строку:

module MODULE-NAME;

Обратите внимание, что ключевого слова export здесь нет. Это неявно делает все, что объявлено/определено в PMIU, доступным в единице трансляции с реализацией (противоположное неверно). Также стоит отметить, что единицы с реализацией не могут иметь операторов export.

// func_impl.cxx
module;

#include <iostream>

module mod;

void func() {
    std::cout << "hello, world!\n";
}

И вот и все. Теперь этот реализация модуля может быть скомпилирована:

$ g++ -c -std=c++20 -fmodules-ts func_impl.cxx 

Что сгенерирует func_impl.o — у нас есть два объектных файла модуля как часть нашей компоновки, но, конечно, это также означает, что изменения в реализации модуля не требуют перекомпиляции клиентов (имитируя поведение включенных заголовков).

$ g++ main.o func.o func_impl.o -o App
$ ./App 
hello, world!

В GCC единица трансляции с интерфейсом должна быть скомпилирован до единицы с реализацией.

export

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

Экспортирование каждой функции

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

// func.cxx

export module mod;

export void func();
export void func(int);

Экспорт блока 

В качестве альтернативы мы можем сгруппировать множество объявлений в экспорт блока, например:

// func.cxx

export module mod;

export {
    void func();
    void func(int);
}

namespace

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

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

// func.cxx

export module mod;

namespace X {
    export void func();
    export void func(int);
}
// func_impl.cxx
module;

#include <iostream>

module mod;

namespace X {
    void func() {
        std::cout << "hello, world!\n";
    }

    void func(int p) {
        std::cout << "hello, " << p << '\n';
    }
}
// main.cpp
import mod;

int main(){
    X::func();
    X::func(42);
}

Export namespace

В качестве альтернативы, если мы экспортируем пространство имен, все объявления в этом пространстве имен автоматически включаются в интерфейс модуля, например:

// func.cxx

export module mod;

export namespace X {
    void func();
    void func(int);
}

Экспортирование типов и тому подобного

Все, что нам требуется как часть интерфейса модуля, должно быть экспортировано. Например, если функция принимает ссылку на объект в качестве параметра, применяются обычные правила видимости определения типа, т.е.:

// func.cxx
export module mod;

export class S {
public:
    S() = default;
    explicit S(int p):val{p}{}
    int get_val() const;
 private:
    int val{};
};

export void func(const S&);

Или

// func.cxx
export module mod;

export  {
    class S {
    public:
        S() = default;
        explicit S(int p):val{p}{}
        int get_val() const;
    private:
        int val{};
    };

    void func(const S&);
}
// func_impl.cxx
module;

#include <iostream>

module mod;  // implicitly import everything in PMIU

void func(const S& ptr) {
    std::cout << "hello, " << ptr.get_val() << '\n';
}

int S::get_val() const {
    return val;
}
// main.cpp
import mod;

int main(){
    S s{10};
    func(s);
}

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

Инклюды

В нашем примере мы использовали традиционную директиву препроцессора #include, чтобы добавить заголовок стандартной библиотеки iostream. Стандарт разрешает следующее:

import <iostream>;
import "header.h";

Это поддерживается GCC11, но есть некоторые выкрутасы, с которыми нам придется иметь дело, чтобы сделать пользовательский заголовок импортируемым (смотрите директиву -fmodule-header).

Если у вас есть заголовочный файл, как например:

// header.h
#ifndef _HEADER_
#define _HEADER_

constexpr int life = 42;

#endif

и вы хотите импортировать, то вам сначала нужно скомпилировать его:

$ g++ -c -std=c++20 -fmodule-header header.h 

Это создаст файл header.h.gcm. Заголовок теперь может быть импортирован с помощью директивы

import "header.h";

Обратите внимание на ;

Кроме того, Microsoft уже обернула стандартную библиотеку в модульную структуру, поэтому вы можете увидеть следующее:

import std.core

в примерах от Microsoft.

Заключение

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

В следующем посте я расскажу о более сложной возможности разделения интерфейса модуля на несколько файлов (называемых разделы/partitions). Использование разделов на первый взгляд выглядит просто, но, на самом деле это ящик Пандоры, поверьте мне.

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

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

Что касается embedded сферы, мы только недавно увидели выпуск GCC10 для Arm, поэтому я могу предположить, что пройдет еще какое-то время, прежде чем GCC11 можно будет использовать в нашем целевом проекте. А пока я продолжу экспериментировать с модулями и разделами на хосте.

Пример кода можно найти здесь


Сегодня вечером пройдет открытый урок, посвященный таким вспомогательным инструментам для C++ программистов, как Copilot и ChatGPT. Что будет на занятии:

  • copilot plugin к VSCode для языка C++;

  • создание функций по описанию;

  • генерация boilerplate кода;

  • refactoring и code review с помощью ChatGPT.

В результате научитесь эффективно использовать Copilot и ChatGPT в ежедневных задачах по разработке, узнаете, как можно начать писать на C++ даже с базовыми знаниями языка. Это будет полезно программистам на C++, которые хотят повысить свою продуктивность и программистам на других языках. Записаться на урок можно по ссылке.

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


  1. IsKaropki
    20.06.2023 10:22

    Примерно полгода назад потратил день, чтобы начать работать с модулями в MSVS 2022.

    Итог: "не шмогла".

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

    Позже, может быть.


    1. SIISII
      20.06.2023 10:22
      +3

      MSVS как IDE пока откровенно хреново с модулями работает, это да. Вот собственно компилятор мелкомягкий уже переваривает их более-менее нормально.

      Сами же модули, как они введены стандартом, лично мне не понравились: переусложнено, запутано, чревато ошибками... Как по мне, лучше было бы вводить модули в стиле Ады.


    1. CoolCmd
      20.06.2023 10:22

      IDE от них быстро плохеет: то одно криво, то другое.

      intellisense пока экспериментальный и кривой. когда его доделают — не понятно.
      а еще после перевода небольшого проекта на модули у меня скорость сборки упала.


      1. dyadyaSerezha
        20.06.2023 10:22

        Упала по сравнению с precompiled headers?


        1. CoolCmd
          20.06.2023 10:22

          ph отключены


      1. domix32
        20.06.2023 10:22

        Ну вообще несколько странно. По идее инкрементальная сборка должна была ускориться.


        1. CoolCmd
          20.06.2023 10:22

          я замерял rebuild. у модулей есть накладные расходы — создается файлов на сто и больше мегабайт (на nvme), т.е. в десятки раз больше. так что если процессор быстрый, то скорость может упасть.


  1. ababo
    20.06.2023 10:22

    Ну а теперь сравниваем этот зоопарк с Rust + rust-analyzer.


  1. Playa
    20.06.2023 10:22

    Перевод статьи двухлетней давности по модулям...
    Де́ржите планку.


    1. buldo
      20.06.2023 10:22

      IMHO в данном конкретном случае - ничего страшного. С поддержкой модулей в том же cmake всё на столько странно, что тему можно считать свежей как и старые статьи по ней :)


  1. SinsI
    20.06.2023 10:22

    В текущей версии GCC у модулей большая проблема с шаблонами - при попытке подключить модули и одновременно использовать заголовки QT появляется куча конфликтов с пространством имён.

    Похоже, стандарт сильно недоработан/недодуман в этом вопросе.


  1. MSerhiy
    20.06.2023 10:22
    +1

    На улице уж середина 2023 года, а я так и не видел модулей в продакшен.... да я их ввобще не видел. Думал может "import std" исправит ситуцию, но что то я сомневаюсь.


    1. domix32
      20.06.2023 10:22

      Тут рэнжи-то не везде видно, чего уж там про модули говорить.