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

Kate

Administrator
Команда форума

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


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

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

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

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, а может быть, обгоняет его.

tsr2apwocgihi9xz47ddidt2cdu.jpeg


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

Модули​


3yblsaequ_2kmdm-aky27_nwcao.png


Мотивация​


Код на C++ хранится в .cpp, .cxx, .cc файлах. На самом деле этот код — программа не на C++, а на языке препроцессора C++. Это другой язык, который не понимает синтаксис C++. И наоборот, C++ не понимает синтаксис препроцессора. Формально он входит в Стандарт, поэтому препроцессор можно относить к C++. Но фактически это два разных языка.

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

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

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

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

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

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

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

Что у других​


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

vpzzfpx92roi7avx9tuibjw8jrs.png


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

j1hmw9v7zgggkf-ik1i_xni0iq0.png


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

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

pslxr7hnhvaw6-thijgyk-dvnac.jpeg


Конечно же, по пути Фортрана! Хотя за три десятка лет в Фортране как-то научились обходить проблемы модулей, фортрановские решения для C++ не годятся — ситуация сложнее.

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

Пример​


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

ezev0-nwz_zxwm9qnnubm2mwlng.png


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

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

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

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

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++:

  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++ вынет из него все имена и сделает подобие предкомпилированного модуля. Модуль в старом стиле, почему нет?

Интересно, что при этом импортируются макросы — то, от чего нас пытается избавить новый Стандарт. По легенде, когда комитет по стандартизации хотел полностью вычеркнуть импорт макросов, к нему обратились представители Microsoft. Они заявили, что никак не могут обойтись в Windows.h без макросов min, max и некоторых других. Меня сейчас поняли те, кто программирует на C++ под Windows.

В отличие от #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;

Модули поддерживают структурирование своих имён, но на этом останавливаться не будем. Там всё непросто и запутанно. Структурирование — это примерно как подпапки в файловой системе. Пакеты отделяются символом :. Ниже — пример со структурированием имён модулей. Это слегка отредактированный пример из Стандарта.

// TU 1
export module A;
export import :Foo;
export int baz();

// TU 2
export module A:Foo;
import :Internals;
export int foo() { return 2 * (bar() + 1); }

// TU 3
export module A:Internals;
int bar();

// TU 4
module A;
int baz() { return 30; }
int bar() { return baz() - 10; }


Статус​


bx9xz7kii81ygy_ctctl0g9w5hs.png


В Visual Studio у модулей частичная поддержка. Очень здорово, что в VS стандартная библиотека уже реализована на модулях, то есть вы можете написать import std.core;. Импорт h-файлов в VS пока не работает.

В GCC поддержки модулей нет в trunk, но есть в ветке. Эту ветку планируют влить в GCC 11.

В Clang модули присутствуют давно. Вообще даже техническая спецификация модулей, принятая в C++20, далеко не первая. Их давно обсуждали и даже планировали включить в Стандарт C++17, но не успели. Clang поддерживает обе спецификации: новую и старую, но всё равно не полностью.

Насколько мне известно, ни один из компиляторов не поддерживает модули полностью. Я считаю, что время модулей пока не пришло. Модули — сырая фича, которая не везде реализована хорошо, хотя все основные компиляторы уже о ней отчитались. Будем надеяться, что вскоре мы сможем полноценно пользоваться модулями.


Источник статьи: https://habr.com/ru/company/yandex_praktikum/blog/554874/
 
Сверху