![](https://habrastorage.org/webt/bs/ux/zh/bsuxzhf28lzu5gp_ju_iyetuin8.png)
25 февраля автор курса «Разработчик C++» в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ — Стандарте C++20. В лекции сделан обзор всех основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.
При подготовке вебинара стояла цель сделать обзор всех ключевых возможностей C++20. Поэтому вебинар получился насыщенным. Он растянулся на почти 2,5 часа. Для вашего удобства текст мы разбили на шесть частей:
- Модули и краткая история C++.
- Операция «космический корабль».
- Концепты.
- Ranges.
- Корутины.
- Другие фичи ядра и стандартной библиотеки. Заключение.
Это первая часть, рассказывающая о модулях в современном C++. Если вы предпочитаете снайдеркатам краткие изложения, то добро пожаловать в статью.
Update. К статье добавлены правки и комментарии Антона Полухина.
Краткая история C++
В самом начале я задал слушателям вебинара вопрос: сколько всего существует стандартов C++?
Результаты голосования:
- правильных ответов — 58 (96.67%)
- неправильных ответов — 2 (3.33%)
![](https://habrastorage.org/webt/qz/xt/tp/qzxttpbo7wqrtgwsn34gscmeuyu.png)
Давайте посчитаем. Бьёрн Страуструп занялся разработкой C++ в восьмидесятых годах. К нему пришли люди из ISO [международная комиссия по стандартизации] и предложили стандартизировать язык. Так и появился C++98 — первый Стандарт.
Прошло пять лет, и Стандарт исправили. Получился C++03. Это было не что-то революционное, а просто исправление ошибок. Кстати, иногда C++03 не считают отдельным Стандартом. Возможно, C++03 — самый популярный Стандарт с точки зрения примеров в интернете и ответов на Stack Overflow, но назвать его современным C++ сейчас невозможно.
Всё изменил следующий Стандарт, который планировалось выпустить до 2010 года. Он носил кодовое название C++0x, которое потом сменилось на C++1x. Решить все проблемы и издать Стандарт смогли только в 2011 году, он получил название C++11. Заметно расширились возможности языка: там появились auto, move-семантика, variadic templates. Когда я учил этот Стандарт, у меня возникло ощущение, что освоить C++11 равносильно изучению нового C++.
Прошло три года. Вышел C++14. Он не стал таким революционным и в основном содержал фиксы ошибок, неизбежных при принятии такого огромного набора документов, как C++11. Но и в 2014 году добавилось новое.
Ещё через три года C++17 добавил больше интересных вещей: дополнительные возможности стандартной библиотеки, распаковку при присваивании и прочее.
Логично ожидать, что за большим Стандартом последует Стандарт с исправлениями ошибок. Но что-то пошло не так. C++20 — это практически новый язык. По количеству нововведений он сравним с C++11, а может быть, обгоняет его.
![](https://habrastorage.org/webt/ts/r2/ap/tsr2apwocgihi9xz47ddidt2cdu.jpeg)
Мы рассмотрим несколько ключевых возможностей C++20. Их список есть в анонсе: это модули, концепты, ranges, корутины. Также будет дан краткий обзор всего, что не вошло в этот список: другие фичи ядра и стандартной библиотеки. Пойдём по порядку.
Модули
![](https://habrastorage.org/webt/3y/bl/sa/3yblsaequ_2kmdm-aky27_nwcao.png)
Мотивация
До C++20 вместо модулей использовали хедеры — отдельные текстовые файлы .h. При подключении хедера программой на C++ он просто копируется в место включения. В связи с этим возникает много проблем.
- Дублирование. При добавлении определения функции в .cpp-файл, нужно добавить объявление в .h-файл. А дублирование порождает ошибки.
- Неочевидный побочный эффект включения заголовочных файлов. В зависимости от порядка расположения два скопированных фрагмента могут влиять друг на друга.
- Нарушение one definition ruleФункция или класс могут включаться в разные файлы .cpp, разные единицы трансляции. Если вдруг они включились по-разному — например, в этих единицах трансляции определены разные макросы, — нарушится one definition rule. Это серьёзная ошибка.Правило одного определения. В программе не должно быть конфликтующих определений одной и той же сущности. Наличие нескольких определений может влечь неопределённое поведение
- Неконсистентность включений. То, что включится из хедера, зависит от макросов, которые определены в момент включения хедера.
- Медленная компиляция. Когда один и тот же хедер целиком включается в разные единицы трансляции, компилятор вынужден его компилировать каждый раз. Кстати, это же касается стандартных библиотек. Например, iostream — это огромный файл, и компилятор вынужден компилировать его со всеми зависимыми единицами трансляции.
- Мы не можем контролировать, что нужно экспортировать, а что — нет. При включении хедера единица трансляции получит всё, что в нём написано, даже если это не предназначено для включения.
Для некоторых из этих проблем есть решения: предкомпилированные заголовки и идиома, согласно которой мы используем только одну единицу трансляции, а всё остальное — заголовочные файлы. Но часть проблем так просто не решить.
В итоге использование хедеров:
- небезопасно;
- повышает время компиляции;
- некрасиво: компилятор никак не обрабатывает процедуру включения, а просто вставляет один текст в другой.
У хедеров есть плюсы. Перечислять их я, конечно же, не буду.
Что у других
Посмотрим на ситуацию в других языках — ведь модули есть везде. Для примера возьмём Python. Мне нравится, как модули реализованы в нём. Есть возможность импортировать модуль целиком или ограничиться определёнными именами. При импорте имена можно переназвать. На слайде вы видите небольшой пример.
![](https://habrastorage.org/webt/vp/zz/fp/vpzzfpx92roi7avx9tuibjw8jrs.png)
Или рассмотрим Fortran. Выбор может показаться неожиданным, но почему бы не рассмотреть его, раз такой язык существует, и в нём есть модули. Сам Fortran появился в 1957 году, а модули ввели в 1991-м. Соответственно, схему придумали когда-то между этими двумя датами. Пример на слайде — просто иллюстрация, к модулям она не относится.
![](https://habrastorage.org/webt/j1/hm/w9/j1hmw9v7zgggkf-ik1i_xni0iq0.png)
В Fortran единицу трансляции можно скомпилировать только в том случае, если все зависимости уже скомпилированы. Из-за этого появилось правило run make until it succeeds, то есть нужно продолжать запускать make, пока наконец не скомпилируется. В первый раз скомпилируются модули, у которых нет зависимостей, во второй раз — модули, которые зависели от первых. В какой-то момент вся программа соберётся. Если повезёт, даже раньше, чем вы ожидаете.
Как вы думаете, по какому пути пошёл C++?
![](https://habrastorage.org/webt/ps/lx/r7/pslxr7hnhvaw6-thijgyk-dvnac.jpeg)
Конечно же, по пути Фортрана! Хотя за три десятка лет в Фортране как-то научились обходить проблемы модулей, фортрановские решения для C++ не годятся — ситуация сложнее.
«C++ не был бы C++ если бы всё было так просто. Модули пошли по пути Фортрана и Питона. Синтаксис модулей специально затачивался на то, чтобы можно было создать сборочные системы, автоматически выводящие зависимости между модулями и автоматически их собирающие — то есть, это путь Питона. Однако, пока такие инструменты не появились, есть возможность указывать зависимости и правила сборки вручную».
Антон Полухин
Но не всё так плохо.
Пример
Рассмотрим пример из трёх файлов. Заметьте, что два из них имеют расширение .cppm — такое расширение для модулей принято в компиляторе Clang. Третий файл — обычный .cpp, который импортирует модули.
![](https://habrastorage.org/webt/ez/ev/0-/ezev0-nwz_zxwm9qnnubm2mwlng.png)
В модулях есть ключевое слово
export
. Те декларации, которые мы хотим экспортировать, нужно пометить этим словом. Тогда к ним получат доступ все единицы трансляции, импортирующие этот модуль, — cpp-файлы и другие модули.При компиляции примера нужно вначале собрать модуль foo2.cppm, потому что он ни от чего не зависит. Затем нужно собрать foo.cppm и только потом bar.cpp.
Почему сделали именно так? Комитет по стандартизации пошёл от решения проблемы наличия двух файлов. Хотелось иметь не два файла — хедер и .cpp, — а один. Из этого файла предлагается автоматически получать аналог заголовочного файла, который содержал бы всё необходимое для тех, кто его импортирует.
Поэтому компилировать проект с модулями нужно два раза. Появляется новая операция — предкомпиляция. На слайде я привёл команды для сборки этой программы компилятором Clang.
![](https://habrastorage.org/webt/sr/ye/k8/sryek88wxlvflb1osn_xoy_jo1u.png)
Для начала нужно предкомпилировать оба файла .cppm. Создастся файл с расширением .pcm — бинарный аналог файла .h. То есть h-файл теперь не нужно создавать вручную. Затем собирается вся программа. В данном случае это bar.cpp, который зависит от двух модулей.
В Visual Studio модули реализованы «из коробки». Вы добавляете в проект module unit с расширением .ixx, и VS всё соберёт за вас.
Эта концепция полностью ломает некоторые из существующих систем сборки C++ кода. Хотя всё налаживается. К примеру, в CMake добавили экспериментальную поддержку модулей. Такие системы, как Build2, b2, cxx_modules_builder, xmake, Meson, autotools, Tup, Scons, уже поддерживают модули.
Теория
Рассмотрим, какие проблемы модули решают, а какие не решают. Зададим вопросы.
- Можем ли мы импортировать выбранные имена?
- Получится ли переназвать имена при импорте, как в Python?
- Структурируют ли модули имена?
Ответ на эти три вопроса: нет. Импортируется всё, что экспортирует модуль, причём под теми же именами. Модули вообще не структурируют имена в C++. Для структурирования, как и раньше, используются пространства имён. Модули могут экспортировать их.
Следующий блок вопросов.
- Импортируются только нужные имена?
- Ускоряют ли модули процесс сборки?
- Модули не влияют друга на друга?
- Не пишем больше отдельно .cpp и .h?
- Не можем испортить код других модулей макросами при импорте?
Ответы на них — да. Это те проблемы, которые решает новый Стандарт.
Последний вопрос.
- В Python при импорте можно выполнять произвольный код. Есть ли в C++ такое?
В C++ импорт происходит во время compile-time, а не в runtime. Поэтому вопрос не имеет смысла.
Модули нарушают несколько устоявшихся принципов C++:
- Принцип независимости сборки. До этого программа на C++ состояла из разных единиц трансляции — файлов .cpp. Каждый из них можно было компилировать отдельно: сегодня один, завтра другой, через неделю третий, а потом уже слинковать всё вместе. Теперь порядок не произвольный. Файл нельзя собрать, пока не предкомпилированы модули, от которых он зависит. Поэтому собрать модуль не получится, если в каком-то зависимом модуле ошибка. Процесс сборки сильно усложняется.
- Принцип гомогенности кода. Хотя
#include
обычно пишут в начале, это договорённость, а не правило. Его можно писать в любом месте программы. И так — со всем, что есть в C++: никакой глобальной структуры у кода до C++20 не было. Синтаксические конструкции могли идти в любом порядке. Новым Стандартом вводится преамбула. И только в ней могут располагаться импорты модулей. Как только преамбула закончилась, писатьimport
стало нельзя. У файла кода появляется структура. Кроме того, перед преамбулой возможна предпреамбула — так называемый Global module fragment. В нём могут располагаться только директивы препроцессора. Но они допускают#include
, а значит, по факту — всё что угодно. Подробно разбирать Global module fragment не будем.
Я считаю появление структуры хорошим шагом, но это нарушение давно существовавших принципов C++.
Модули добавляют новые понятия. Например, новые типы единиц трансляции — они называются module unit и header unit. Появился тип компоновки module linkage.
Module unit бывают двух типов:
- Module interface unit. Начинается с
export module
. - Module implementation unit. Начинается с
module
.
Разница у них в том, что module interface unit — это интерфейс, предназначенный для тех, кто этот модуль будет импортировать. К нему может прилагаться любое количество module implementation units, в которые по желанию выносятся реализации функций и методов из этого модуля. Главное правило: для каждого модуля — ровно один module interface unit и сколько угодно module implementation unit.
В большинстве случаев module implementation unit вообще не понадобится. Он предназначен для больших модулей, код которых сам по себе требуется структурировать. Поэтому чаще всего один модуль — один module interface unit.
Посмотрим на допустимый формат импорта и экспорта из модулей.
import M;
import "my_header.h";
import <version>;
Модуль и любые cpp-файлы могут импортировать другие модули и, внезапно, заголовочные файлы. Последнее, к сожалению, мне пока не удалось протестировать — у компиляторов явно какие-то проблемы.
В теории, чтобы импортировать .h-файл, его тоже нужно предкомпилировать. При этом заголовок, который раньше был лишь придатком cpp-файла, рассматривается как самостоятельная единица трансляции, а вернее, header unit. Компилятор C++ вынет из него все имена и сделает подобие предкомпилированного модуля. Модуль в старом стиле, почему нет?
Интересно, что при этом импортируются макросы — то, от чего нас пытается избавить новый Стандарт. По легенде комитет по стандартизации рассматривал полное исключение импорта макросов, но представители крупных компаний попросили не делать этого.
В отличие от #include, при импорте нужна точка с запятой.
Я описал, что можно импортировать. Теперь обсудим, что модуль может экспортировать. Ответ прост: декларации, определения, псевдонимы. Всё, что создаёт новое имя. Достаточно написать перед соответствующей конструкцией слово
export
.Можно экспортировать шаблоны. А значит, экспорт — это не просто сохранение сигнатуры. Если мы экспортируем шаблон, то должен быть сохранён весь его код, потому что позднее при настройке шаблона он понадобится. Таким образом, предкомпиляция — это не компиляция, она сохраняет всю выразительность C++ кода.
Посмотрим на примерах. Из модулей экспортируются:
- декларации и определения, создающие имя (типы, using-декларации, функции, глобальные переменные, классы, enum). В том числе шаблонные.
export module M;
export template<class R>
struct Point {
R x, y;
};
export int f();
int f() { return 42; }
export int global_variable = 42;
- Целые namespace’ы или декларации внутри namespace'ов.
export namespace {
int prime_number = 13;
class CppCompiler {};
}
namespace A { // exported
export int f(); // exported
int g(); // not exported
}
Тут можно найти ещё одно применение безымянным namespace.
- Другие модули
export import MyModule;
Такая конструкция допустима в преамбуле. Текущий модуль будет экспортировать всё то, что экспортирует вызванный.
- Любые имена через using.
struct F {};
export using ::F;
Таким образом, имена тоже экспортируются: для этого пишите
::
перед именем, потому что using
требует указания пространства имён.- Имена под другим именем.
export using G = ::F;
Во многих языках модули поддерживают структурирование. Например, в Java есть пакет, названный так: com.sun.tools.javac.util. В C++ есть целых два типа структурирования. Во-первых, имя модуля может как и в Java состоять из нескольких идентификаторов, разделённых точкой:
// hw_printer.cppm
export module MyHelloWorld.Main.Printer;
#include <iostream>
#include <string_view>
export void PrintHelloWorld() {
using namespace std::literals;
std::cout << "Hello World"sv << std::endl;
}
// main.cpp
import MyHelloWorld.Main.Printer;
int main() {
PrintHelloWorld();
}
Во-вторых, модули поддерживают партиции, которые указываются через двоеточие после имени модуля. Партиции служат для структурирования внутри модуля и не видны вне него.
Статус
![](https://habrastorage.org/webt/bx/9x/z7/bx9xz7kii81ygy_ctctl0g9w5hs.png)
В Visual Studio у модулей частичная поддержка. Очень здорово, что в VS стандартная библиотека уже реализована на модулях, то есть вы можете написать
import std.core;
. Импорт h-файлов в VS пока не работает.«std.core это расширение Стандарта. Код, использующий его, скорее всего перестанет компилироваться через пяток лет».
Антон Полухин
В GCC поддержки модулей нет в trunk, но есть в ветке. Эту ветку планируют влить в GCC 11.
В Clang модули присутствуют давно. Вообще даже техническая спецификация модулей, принятая в C++20, далеко не первая. Их давно обсуждали и даже планировали включить в Стандарт C++17, но не успели. Clang поддерживает обе спецификации: новую и старую, но всё равно не полностью.
Насколько мне известно, ни один из компиляторов не поддерживает модули полностью. Я считаю, что время модулей пока не пришло. Модули — сырая фича, которая не везде реализована хорошо, хотя все основные компиляторы уже о ней отчитались. Будем надеяться, что вскоре мы сможем полноценно пользоваться модулями.
Заключение
Во время трансляции мы провели голосование, крутая это фича или нет. Результаты опроса:
- Суперфича — 16 (23.53%)
- Так себе фича — 6 (8.82%)
- Пока неясно — 46 (67.65%)
Расскажу о своём мнении по этому вопросу. Я считаю, что модули нужны обязательно, потому что так, как было 40 лет назад в C, никуда не годится. Во всех современных языках есть модули, почему в нашем современном языке их нет? Конечно, модули решают далеко не все проблемы: проблемы структурирования имён и распространения пакетов остаются нерешёнными. Но всё-таки они ускоряют сборку, структурируют зависимости, избавляют от дублирования и нарушения ODR. Поэтому вещь очень полезная.
«У модулей есть и другое, очень важное достоинство: они позволяют скрывать детали реализации. Всё, что выносили в заголовочных файлах в namespace impl или detail — с модулями можно совсем спрятать».
Антон Полухин
Главный минус: существенно усложняется процесс сборки. С их активным применением я бы пока подождал.
Опрос
Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Оцените фичу «Модули»
35.19% Суперфича171
15.23% Так себе фича74
49.59% Пока неясно241
Проголосовали 486 пользователей. Воздержались 55 пользователей.