Pull to refresh
222.19
Яндекс Практикум
Помогаем людям расти

Стандарт C++20: обзор новых возможностей C++. Часть 1 «Модули и краткая история C++»

Reading time10 min
Views65K


25 февраля автор курса «Разработчик C++» в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ — Стандарте C++20. В лекции сделан обзор всех основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.

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

  1. Модули и краткая история C++.
  2. Операция «космический корабль».
  3. Концепты.
  4. Ranges.
  5. Корутины.
  6. Другие фичи ядра и стандартной библиотеки. Заключение.

Это первая часть, рассказывающая о модулях в современном C++. Если вы предпочитаете снайдеркатам краткие изложения, то добро пожаловать в статью.

Update. К статье добавлены правки и комментарии Антона Полухина.

Краткая история C++


В самом начале я задал слушателям вебинара вопрос: сколько всего существует стандартов C++?

Результаты голосования:

  • правильных ответов — 58 (96.67%)
  • неправильных ответов — 2 (3.33%)



Давайте посчитаем. Бьёрн Страуструп занялся разработкой 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, а может быть, обгоняет его.



Мы рассмотрим несколько ключевых возможностей C++20. Их список есть в анонсе: это модули, концепты, ranges, корутины. Также будет дан краткий обзор всего, что не вошло в этот список: другие фичи ядра и стандартной библиотеки. Пойдём по порядку.

Модули




Мотивация


До C++20 вместо модулей использовали хедеры — отдельные текстовые файлы .h. При подключении хедера программой на C++ он просто копируется в место включения. В связи с этим возникает много проблем.

  • Дублирование. При добавлении определения функции в .cpp-файл, нужно добавить объявление в .h-файл. А дублирование порождает ошибки.
  • Неочевидный побочный эффект включения заголовочных файлов. В зависимости от порядка расположения два скопированных фрагмента могут влиять друг на друга.
  • Нарушение one definition rule
    Правило одного определения. В программе не должно быть конфликтующих определений одной и той же сущности. Наличие нескольких определений может влечь неопределённое поведение
    Функция или класс могут включаться в разные файлы .cpp, разные единицы трансляции. Если вдруг они включились по-разному — например, в этих единицах трансляции определены разные макросы, — нарушится one definition rule. Это серьёзная ошибка.
  • Неконсистентность включений. То, что включится из хедера, зависит от макросов, которые определены в момент включения хедера.
  • Медленная компиляция. Когда один и тот же хедер целиком включается в разные единицы трансляции, компилятор вынужден его компилировать каждый раз. Кстати, это же касается стандартных библиотек. Например, iostream — это огромный файл, и компилятор вынужден компилировать его со всеми зависимыми единицами трансляции.
  • Мы не можем контролировать, что нужно экспортировать, а что — нет. При включении хедера единица трансляции получит всё, что в нём написано, даже если это не предназначено для включения.

Для некоторых из этих проблем есть решения: предкомпилированные заголовки и идиома, согласно которой мы используем только одну единицу трансляции, а всё остальное — заголовочные файлы. Но часть проблем так просто не решить.

В итоге использование хедеров:

  • небезопасно;
  • повышает время компиляции;
  • некрасиво: компилятор никак не обрабатывает процедуру включения, а просто вставляет один текст в другой.

У хедеров есть плюсы. Перечислять их я, конечно же, не буду.

Что у других


Посмотрим на ситуацию в других языках — ведь модули есть везде. Для примера возьмём Python. Мне нравится, как модули реализованы в нём. Есть возможность импортировать модуль целиком или ограничиться определёнными именами. При импорте имена можно переназвать. На слайде вы видите небольшой пример.



Или рассмотрим Fortran. Выбор может показаться неожиданным, но почему бы не рассмотреть его, раз такой язык существует, и в нём есть модули. Сам Fortran появился в 1957 году, а модули ввели в 1991-м. Соответственно, схему придумали когда-то между этими двумя датами. Пример на слайде — просто иллюстрация, к модулям она не относится.



В Fortran единицу трансляции можно скомпилировать только в том случае, если все зависимости уже скомпилированы. Из-за этого появилось правило run make until it succeeds, то есть нужно продолжать запускать make, пока наконец не скомпилируется. В первый раз скомпилируются модули, у которых нет зависимостей, во второй раз — модули, которые зависели от первых. В какой-то момент вся программа соберётся. Если повезёт, даже раньше, чем вы ожидаете.

Как вы думаете, по какому пути пошёл C++?



Конечно же, по пути Фортрана! Хотя за три десятка лет в Фортране как-то научились обходить проблемы модулей, фортрановские решения для C++ не годятся — ситуация сложнее.
«C++ не был бы C++ если бы всё было так просто. Модули пошли по пути Фортрана и Питона. Синтаксис модулей специально затачивался на то, чтобы можно было создать сборочные системы, автоматически выводящие зависимости между модулями и автоматически их собирающие — то есть, это путь Питона. Однако, пока такие инструменты не появились, есть возможность указывать зависимости и правила сборки вручную».

Антон Полухин

Но не всё так плохо.

Пример


Рассмотрим пример из трёх файлов. Заметьте, что два из них имеют расширение .cppm — такое расширение для модулей принято в компиляторе Clang. Третий файл — обычный .cpp, который импортирует модули.



В модулях есть ключевое слово export. Те декларации, которые мы хотим экспортировать, нужно пометить этим словом. Тогда к ним получат доступ все единицы трансляции, импортирующие этот модуль, — cpp-файлы и другие модули.

При компиляции примера нужно вначале собрать модуль foo2.cppm, потому что он ни от чего не зависит. Затем нужно собрать foo.cppm и только потом bar.cpp.

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

Поэтому компилировать проект с модулями нужно два раза. Появляется новая операция — предкомпиляция. На слайде я привёл команды для сборки этой программы компилятором Clang.



Для начала нужно предкомпилировать оба файла .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++:

  1. Принцип независимости сборки. До этого программа на C++ состояла из разных единиц трансляции — файлов .cpp. Каждый из них можно было компилировать отдельно: сегодня один, завтра другой, через неделю третий, а потом уже слинковать всё вместе. Теперь порядок не произвольный. Файл нельзя собрать, пока не предкомпилированы модули, от которых он зависит. Поэтому собрать модуль не получится, если в каком-то зависимом модуле ошибка. Процесс сборки сильно усложняется.
  2. Принцип гомогенности кода. Хотя #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();
}

Во-вторых, модули поддерживают партиции, которые указываются через двоеточие после имени модуля. Партиции служат для структурирования внутри модуля и не видны вне него.

Статус




В 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 — с модулями можно совсем спрятать».

Антон Полухин

Главный минус: существенно усложняется процесс сборки. С их активным применением я бы пока подождал.

Опрос


Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.
Only registered users can participate in poll. Log in, please.
Оцените фичу «Модули»
35.5% Суперфича169
14.92% Так себе фича71
49.58% Пока неясно236
476 users voted. 55 users abstained.
Tags:
Hubs:
+38
Comments74

Articles

Information

Website
practicum.yandex.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
Ира Ко