В своём выступлении на CppCon 2018 Herb Sutter представил общественности свои наработки по двум направлениям. Во-первых, это контроль времени жизни переменных (Lifetime), который позволит обнаруживать целые классы багов на этапе компиляции. Во-вторых, это обновлённый proposal по метаклассам, которые позволят избежать дублирования кода, один раз описывая поведение категории классов и потом подключая его к конкретным классам одной строчкой.
Предисловие: больше = проще?!
Слышны обвинения C++ в том, что стандарт бессмысленно и беспощадно разрастается. Но даже самые ярые консерваторы не поспорят с тем, что такие новые конструкции, как range-for (цикл по коллекции) и auto (хотя бы для итераторов) делают код проще. Можно выработать примерные критерии, которым (хотя бы одному, в идеале всем) новые расширения языка должны удовлетворять, чтобы упрощать код на практике:
- Сокращать, упрощать код, убирать дублирующийся код (range-for, auto, lambda, Metaclasses)
- Делать безопасный код проще для написания, предотвращать ошибки и особые случаи (умные указатели, Lifetimes)
- Полностью заменять старые, менее функциональные фичи (typedef > using)
Herb Sutter выделяет "современный C++" — подмножество фич, которые соответствуют современным стандартам кодирования (вроде C++ Core Guidelines), а полный стандарт рассматривает как "режим совместимости", который каждому знать не обязательно. Соответственно, если "современный C++" не растёт, то всё хорошо.
Проверки времени жизни переменных (Lifetime)
Новая группа проверок Lifetime уже сейчас доступна в составе Core Guidelines Checker для Clang и Visual C++. Цель — не добиться абсолютной строгости и точности, как в Rust, а выполнять простые и быстрые проверки в рамках отдельных функций.
Основные принципы проверки
С точки зрения анализа времени жизни, типы разбиваются на 3 категории:
- Значение (value) — то, на что может указывать какой-нибудь Указатель
- Указатель (pointer) — обращается к Значению, но не управляет его временем жизни. Может быть висячим (dangling pointer). Примеры:
T*
,T&
, итераторы,std::observer_ptr<T>
,std::string_view
,gsl::span<T>
- Владелец (owner) — управляет временем жизни Значения. Обычно может удалить своё Значение досрочно. Примеры:
std::unique_ptr<T>
,std::shared_ptr<T>
,std::vector<T>
,std::string
,gsl::owner<T*>
Указатель может быть в одном из следующих состояний:
- Указывать на Значение, хранящееся на стеке
- Указывать на Значение, содержащееся "внутри" некоторого Владельца
- Быть пустым (null)
- Быть висячим (invalid)
Указатели и Значения
Для каждого Указателя отслеживается — множество значений, на которые он может указывать. При удалении Значения, его вхождения во все заменяются на . При обращении к Значению Указателя , такого что , выдаём ошибку.
string_view s; // pset(s) = {null}
{
char a[100];
s = a; // pset(s) = {a}
cout << s[0]; // OK
} // pset(s) = {invalid}
cout << s[0]; // ERROR: invalid ? pset(s)
С помощью аннотаций можно настроить, какие операции будут считаться операциями обращения к Значению. По умолчанию: *
, ->
, []
, begin()
, end()
.
Обращаю внимание, что варнинг выдаётся только в момент доступа к невалидному Указателю. Если Значение удалено, но к этому Указателю больше никто никогда не обратится, то всё в порядке.
Указатели и Владельцы
Если Указатель указывает на Значение, содержащееся внутри Владельца , то это обозначают .
Методы и функции, принимающие Владельцев, подразделяются на:
- Операции доступа к Значению Владельца. По умолчанию:
*
,->
,[]
,begin()
,end()
- Операции доступа к самому Владельцу, инвалидирующие указатели, вроде
v.clear()
. По умолчанию, это все остальные не-const операции - Операции доступа к самому Владельцу, не инвалидирующие указатели, вроде
v.empty()
. По умолчанию, это все const-операции
Старое содержимое Владельца объявляется при удалении Владельца или при применении инвалидирующих операций.
Этих правил достаточно, чтобы обнаружить многие типичные баги в коде C++:
string_view s; // pset(s) = {null}
string name = "foo";
s = name; // pset(s) = {name'}
cout << s[0]; // OK
name = "bar"; // pset(s) = {invalid}
cout << s[0]; // ERROR
vector<int> v = get_ints();
int* p = &v[5]; // pset(p) = {v'}
v.push_back(42); // pset(p) = {invalid}
cout << *p; // ERROR
std::string_view s = "foo"s;
cout << s[0]; // ERROR
// Расшифровка: сохраняем указатель на содержимое временного объекта
std::string_view s = "foo"s // pset(s) = {"foo"s '}
; // pset(s) = {invalid}
vector<int> v = get_ints();
for (auto i = v.begin(); i != v.end(); ++i) { // pset(i) = {v'}
if (*i == 2) {
v.erase(i); // pset(i) = {invalid}
} // pset(i) = {v', invalid}
} // ERROR: ++i
for (auto i = v.begin(); i != v.end(); ) {
if (*i == 2) i = v.erase(i); // OK
else ++i;
}
std::optional<std::vector<int>> get_data();
// Пусть мы уверены, что get_data() != nullopt
for (int value : *get_data()) // ERROR
cout << value;
// *get_data() — ссылка на временный объект
for (int value : std::vector<int>(*get_data())) // OK
cout << value;
Отслеживание времени жизни параметров функций
Когда мы начинаем иметь дело с функциями в C++, возвращающими Указатели, остаётся лишь догадываться о зависимости между временем жизни параметров и возвращаемого значения. Если функция принимает и возвращает Указатели на одинаковый тип, то делается предположение, что функция "достаёт" возвращаемое значение из одного из входных параметров:
auto f(int* p, int* q) -> int*; // pset(ret) = {p', q'}
auto g(std::string& s) -> char*; // pset(ret) = {s'}
Запросто обнаруживаются подозрительные функции, которые берут результат неоткуда:
std::reference_wrapper<int> get_data() { // странный тип функции
int i = 3;
return {i}; // pset(ret) = {i'}
} // pset(ret) = {invalid}
Так как в параметры const T&
можно передать временное значение, то они не учитываются, кроме случаев, когда результат больше неоткуда взять:
template <typename T>
const T& min(const T& x, const T& y); // pset(ret) = {x', y'}
// Возвращается указатель на const T&-параметр
// С этой функцией надо быть предельно аккуратным
auto x = 10, y = 2;
auto& bad = min(x, y + 1); // pset(bad) = {x, temp}
// pset(bad) = {x, invalid}
cout << bad; // ERROR
using K = std::string;
using V = std::string;
const V& find_or_default(const std::map<K, V>& m, const K& key, const V& def);
// pset(ret) = {m', key', def'}
std::map<K, V> map;
K key = "foo";
const V& s = find_or_default(map, key, "none");
// pset(s) = {map', key', temp} ? pset(s) = {map', key', invalid}
cout << s; // ERROR
Ещё считается, что если функция принимает указатель (вместо ссылки), то он может быть nullptr, и этот указатель до сравнения с nullptr нельзя использовать.
Заключение по контролю времени жизни
Повторю, что Lifetime — это пока не предложение для стандарта C++, а смелая попытка внедрения проверок времени жизни в C++, где, в отличие от Rust, например, никогда не было соответствующих аннотаций. На первых порах будет много ложных срабатываний, но со временем эвристики будут совершенствоваться.
Вопросы из зала
Дают ли проверки группы Lifetime математически точную гарантию отсутствия висячих указателей?
Теоретически можно было бы (в новом коде) навешивать кучу аннотаций на классы и функции, а взамен компилятор бы давал такие гарантии. Но эти проверки разрабатывались, следуя принципу 80:20, то есть можно поймать бОльшую часть ошибок, используя небольшое число правил и применяя минимум аннотаций.
Метаклассы
Метакласс некоторым образом дополняет код класса, к которому он применяется, а также служит названием для группы классов, удовлетворяющих определённым условиям. Например, как показано ниже, метакласс interface
сделает за вас все функции публичными и чисто виртуальными.
В прошлом году Herb Sutter впервые выступил со своим проектом метаклассов (смотреть тут). С тех пор текущий предлагаемый синтаксис поменялся.
Для начала, поменялся синтаксис использования метаклассов:
// Было
interface Shape {
int area() const;
void scale_by(double factor);
};
// Стало
class(interface) Shape { … }
Стало длиннее, зато теперь есть естественный синтаксис применения нескольких метаклассов сразу: class(meta1, meta2)
.
Описание метакласса
Раньше метакласс представлял из себя набор правил для модификации класса. Сейчас метакласс — это constexpr-функция, которая принимает на вход старый класс (объявленный в коде) и создаёт новый.
А именно, функция принимает один параметр — метаинформацию о старом классе (тип параметра зависит от реализации), создаёт элементы класса (фрагменты), после чего добавляет их внутрь тела нового класса с помощью инструкции __generate
.
Фрагменты можно генерировать с помощью конструкций __fragment
, __inject
, idexpr(…)
. Докладчик предпочёл не фокусироваться на их предназначении, так как эта часть ещё поменяется до того, как будет представлена комитету по стандартизации. Сами имена гарантированно будут изменены, двойное подчёркивание добавили специально, чтобы это прояснить. Акцент в докладе делался на примерах, которые и идут дальше.
interface
template <typename T>
constexpr void interface(T source) { // source описывает исходный класс
// Вначале тело целевого класса пусто. Здесь мы добавляем туда
// деструктор ~X, где X — имя целевого класса.
__generate __fragment struct X {
virtual ~X noexcept {}
};
// В отличие от static_assert, compiler.require может использовать
// значение параметра constexpr-функции.
// Запрещаем объявлять переменные в исходном классе.
compiler.require(source.variables().empty(),
"interfaces may not contain data members");
// member_functions(), вероятно, возвращает tuple<…>, поэтому нужен for...
for... (auto f : source.member_functions()) {
// Проверяем, что функция — не конструктор копирования/присваивания
compiler.require(!f.is_copy() && !f.is_move(),
"interfaces may not copy or move; consider a virtual clone()");
// Делаем функцию public по умолчанию
if (!f.has_default_access())
f.make_public(); // (1)
// Проверяем, что функция не была объявлена как protected/private
compiler.require(f.is_public(), "interface functions must be public");
// Делаем функцию чисто виртуальной
f.make_pure_virtual(); // (2)
// Добавляем функцию f в тело нового класса
__generate f;
}
}
Можно подумать, что на строках (1) и (2) мы модифицируем исходный класс, но нет. Обратите внимание, что мы итерируемся по функциям исходного класса с копированием, модифицируем эти функции, после чего вставляем их в новый класс.
Применение метакласса:
class(interface) Shape {
int area() const;
void scale_by(double factor);
};
// Преобразуется в:
class Shape {
public: virtual ~Shape noexcept {}
public: virtual int area() const = 0;
public: virtual void scale_by(double factor) = 0;
};
Отладка мьютекса
Пусть у нас есть не-потокобезопасные данные, защищённые мьютексом. Можно облегчить отладку, если в debug-сборке при каждом обращении проверять, залочил ли текущий процесс этот мьютекс. Для этого был написан простенький класс TestableMutex:
class TestableMutex {
public:
void lock() { m.lock(); id = std::this_thread::get_id(); }
void unlock() { id = std::thread::id{}; m.unlock(); }
bool is_held() { return id == std::this_thread::get_id(); }
private:
std::mutex m;
std::atomic<std::thread::id> id;
};
Далее, в нашем классе MyData хотелось бы каждое публичное поле вроде
vector<int> v;
Заменить на поле + getter:
private:
vector<int> v_;
public:
vector<int>& v() { assert(m_.is_held()); return v_; }
Для функций тоже можно провести аналогичные преобразования.
Такие задачи решаются с помощью макросов и кодогенерации. Макросам Herb Sutter объявил войну: они небезопасны, игнорируют семантику, пространства имён и т.д. Как выглядит решение на метаклассах:
constexpr void guarded_with_mutex() {
__generate __fragment class {
TestableMutex m_;
// lock, unlock
}
}
template <typename T, typename U>
constexpr void guarded_member(T type, U name) {
auto field = …;
__generate field;
auto getter = …;
__generate getter;
}
template <typename T>
constexpr void guarded(T source) {
guarded_with_mutex();
for... (auto o : source.member_variables()) {
guarded_member(o.type(), o.name());
}
}
Как это использовать:
class(guarded) MyData {
vector<int> v;
Widget* w;
};
MyData& x = findData("foo");
x.v().clear(); // assertion failed: m_.is_held()
actor
Хорошо, пусть мы защитили какой-то объект мьютексом, теперь всё потокобезопасно, претензий к корректности нет. Но если к объекту могут часто параллельно обращаться множество потоков, то мьютекс перегрузится, и на его взятие будет большой оверхед.
Принципиальное решение проблемы глючных мьютексов — концепция акторов, когда у объекта есть очередь запросов, все обращения к объекту ставятся в очередь и выполняются один за другим в специальном потоке.
Пусть класс Active содержит реализацию всего этого — по сути, thread pool/executor с одним потоком. Ну а метаклассы помогут избавиться от дублирующегося кода и ставить в очередь все операции:
class(active) ImageFilter {
public:
ImageFilter(std::function<void(Buffer*)> w) : work(std::move(w)) {}
void apply(Buffer* b) { work(b); }
private:
std::function<void(Buffer*)> work;
}
// Преобразуется в:
class ImageFilter {
public:
ImageFilter(std::function<void(Buffer*)> w) : work(std::move(w)) {}
void apply(Buffer* b) {
a.send([=] { work(b); }).join();
}
private:
std::function<void(Buffer*)> work;
Active a; // обязан быть последним, чтобы начать удаляться до work
}
class(active) log {
std::fstream f;
public:
void info(…) { f << …; }
};
property
Свойства есть почти во всех современных языках программирования, и их кто только не реализовывал на базе C++: Qt, C++/CLI, всякие уродливые макросы. Однако они никогда не будут добавлены в стандарт C++, так как сами по себе они считаются слишком узкой фичей, и всегда была надежда, что какой-то proposal реализует их в качестве частного случая. Что же, их можно реализовать на метаклассах!
// Пишем
class X {
public:
class(property<int>) WidthClass { } width;
};
// Получаем
class X {
public:
class WidthClass {
int value;
int get() const;
void set(const int& v);
void set(int&& v);
public:
WidthClass();
WidthClass(const int& v);
WidthClass& operator=(const int& v);
operator int() const;
// Бесплатная поддержка move!
WidthClass(int&& v);
WidthClass& operator=(int&& v);
} width;
};
Можно задать собственные getter и setter:
class Date {
public:
class(property<int>) MonthClass {
int month;
auto get() { return month; }
void set(int m) { assert(m > 0 && m < 13); month = m; }
} month;
};
Date date;
date.month = 15; // assertion failed
В идеале хочется писать property int month { … }
, но и такая реализация заменит зоопарк расширений C++, изобретающих свойства.
Заключение по метаклассам
Метаклассы — большая новая фича для и без того сложного языка. Стоит ли оно того? Вот некоторые их преимущества:
- Позволят программистам более ясно выражать свои намерения (хочу написать actor)
- Уменьшат дублирование кода и упростят разработку и поддержку кода, следующего определённым паттернам
- Устранят некоторые группы часто встречающихся ошибок (достаточно будет один раз позаботиться обо всех тонкостях)
- Позволят избавиться от макросов? (Herb Sutter настроен очень воинственно)
Вопросы из зала
Как отлаживать метаклассы?
Как минимум для Clang есть intrinsic-функция, которая, если её вызвать, напечатает во время компиляции реальное содержимое класса, то есть то, что получается после применения всех метаклассов.
Раньше говорилось о возможности объявлять не-члены вроде swap и hash в метаклассах. Куда она делась?
Синтаксис будет дорабатываться.
Зачем нужны метаклассы, если уже приняты для стандартизации концепты (Concepts)?
Это разные вещи. Метаклассы нужны для определения частей класса, а концепты проверяют, соответствует ли класс некоему шаблону, при помощи примеров использования класса. На самом деле, метаклассы и концепты отлично сочетаются. Например, можно определить концепт итератора и метакласс "типичного итератора", который определяет некоторые избыточные операции через остальные.
Комментарии (19)
DareDen
10.10.2018 09:15Мдя, на вопрос об отладке метаклассов ответил уклончиво… Макрос можно найти тупо поиском и тут же понять, что он делает, а вот с метаклассом такое не прокатит: сначала надо собрать (а если ошибка в метаклассе — привет горячий и листинг на 10 страниц, вспоминаем ошибки с шаблонами ;)), потом увидеть в логе компиляции собственно код (напечатаный загадочным intrinsic который «вроде есть»), а потом думать как отладить, поскольку точку остановки внутрь точно не поставить.
Итого: неотлаживаемое на данном этапе развития решение сомнительной полезности. Я бы лично стал бить по рукам всем, кто применял бы метаклассы в проекте, над которым я работаю.mayorovp
10.10.2018 14:36Если кто-то реализует функциональность сложного метакласса на макросах — то вы никак не поймете что все эти макросы делают просто глядя на них.
DareDen
10.10.2018 14:44Я в общем-то и не спорю (хотя думаю, что все же понял бы с большой долей вероятности, «давно здесь сидим»). Сам макросы не люблю и стараюсь не злоупотреблять ;).
Новые технологии это хорошо и нужно, но надо (именно надо, а не можно и т.д.) чтоб они легко ложились на мозги разработчиков. Хотите кодогенерацию в виде метаклассов? Обеспечьте 1) чтоб я точно знал, что будет на выходе и мог легко посмотреть, что на этом самом выходе; 2) отладку. Без каждого из этих пунктов любая новая фича бесполезна именно для меня как программера. Пока такой поддержки в метаклассах я не вижу.Antervis
11.10.2018 10:54Обеспечьте 1) чтоб я точно знал, что будет на выходе и мог легко посмотреть, что на этом самом выходе
Пока такой поддержки в метаклассах я не вижу.
А зря. Еще на презентации в том году годболт уже умел разворачивать тот вариант метаклассов.
2) отладку
Это же в конце концов просто кодогенерация. В стеке напечатает метод экземпляра метакласса, отладчик ткнет в сгенерированный заголовочник
Ariox41
10.10.2018 18:46+2Ошибка в метаклассе аналогична ошибке в метафункции, но тут хотя бы можно писать нормальные сообщения времени компиляции. Хуже точно не станет, если только вы не запрещаете использование шаблонны библиотек в своих проектах.
А вот на счет отладки использующего метакласс класса действительно возникает вопрос. Но если метаклассы будут добавлять реализации функций — на этих реализациях можно будет ставить точку останова, как на привычных шаблонных функциях (почему нет?), а добавление новых членов, как в примере с актором, принципиально не отличается от шаблонных аналогов, наподобие std::bind. Да и тот же std::bind на метаклассах, на мой взгляд, можно сделать более отлаживаемым как во время компиляции, так и во время выполнения.
jahr
10.10.2018 13:16С метаклассами плюсы с хорошей вероятностью превратятся в write-only язык типа перла. Ты можешь видеть на экране любой код, но это будет слабо связано с тем, что на самом деле выполняется. А программист-плюсовик гораздо больше читает код, чем пишет его.
eao197
10.10.2018 18:31+1С метаклассами плюсы с хорошей вероятностью превратятся в write-only язык типа перла.
По мнению очень и очень многихболтуновзавсегдатаев профильных форумов в Интернетах, с C++ это уже произошло. Давно и бесповоротно. Даже еще до C++11.A1ien
11.10.2018 15:31Скоррее до C++11. Яркий пример тому — буст с его вывертами для создания лямбд… Сейчас код на C++ гораздо более читаем и понятен нежели раньше.
eao197
11.10.2018 15:40Ну, если неподготовленному человеку показать самодельную реализацию std::apply из C++17 с самодельной же реализацией std::index_sequence из C++14, но сделанную средствами C++11, то вряд ли скажет, что «код на C++ гораздо более читаем и понятен нежели раньше» :)
Это я к тому, что в адрес C++ всегда (еще даже до C++98) раздавались жалобы о том, что код на C++ нечитаем и мало кто в состоянии его поддерживать. И, действительно, есть моменты, когда в C++ коде сложно разобраться. Только вот, обычно, это либо проблема разработчика (он, как настоящий творец, так видит), либо проблема сложности самой предметной области или задачи. И в меньшей степени языка C++.A1ien
11.10.2018 15:54+2Так надо сравнивать их с альтернативой до C++11, например с функциональными элементами и списками типов из библиотеки Loki от Александреску. И все вопросы отпадут:))))
eao197
11.10.2018 17:52Так-то оно так. Но раньше навороты от Александреску — это был высший пилотаж. А сейчас каждый может использовать лямбды, auto и variadic templates. Поэтому сейчас часто слышны возгласы вроде «налепили лямбду на лямбде, невозможно понять, что тут происходит». Так что, ИМХО, количество жалоб на «нечитабельность» C++ только растет по мере развития C++. Причем, подозреваю, большинство жалующихся с C++ толком-то и не работают.
A1ien
12.10.2018 10:33Лямбды можно и в шарпе лепить, и в Java уже и в питоне… На самом деле, при грамотном использовании весь ужас метапрограммирования прячется где-то под капотом, и если в команде есть люди которые это с трудом воспринимают, то как правило им это и не надо, они пользуются уже следствиями всего этого, а там уже все намного проще. Вот например захотел я сделать что то типа Asp.Net WebApi на плюсах, конечно один в один не вышло, но сделал так что при определении класса можно определить (а можно не определить методы get post put delete итд...), хотел чтобы н было наследования, чтобы класс был чистым, без полиморфизма, и что бы если класс не содержит методов обработки соответствующих запросов, ервер отвечал что то вразумительное. Под капотом сущий ад из вариативных шаблонов SFINAE, traits итд… Но с наружи пользоваться этим легко и просто, создал реализуаци, определил статический метод который определяет путь обработки запроса — типа «api/device» и реализвывай что тебе надо — get, post итд… И добавь его в список типов, все. Пользуются и радуются:)
eao197
12.10.2018 11:09При грамотном использовании, как правило, жалоб на write-only язык не появляется. Подобные жалобы, как по мне, появляются либо у тех, кто сталкивается с неправильным использованием, либо у тех, кто не успевает обучаться и не принимает нововведений, либо у тех, кто языком не пользуется, но мнение имеет.
ixSci
11.10.2018 09:43+1С метаклассами стандарт C++ имеет все шансы из трудночитаемого нечто, превратиться в хорошо описанный документ, где трудных мест будет куда меньше, чем сейчас. Кроме того, это позволит реализовывать общие паттерны единообразно и легко. Позволит уменьшить количество кода-повторений (boilerplate). Ума не приложу, как можно воспринимать метаклассы как что-то отрицательное…
И да, на любом языке можно писать в режиме write-only, только не всегда дело в языке. В C++ нет каких-то «врождённых» проблем, который выдают write-only любым программистом. Точнее есть, но их немного, и они постепенно решаются, в том числе всё большим обузданием SFINAE нормальными инструментами. Т.е. язык явно движется в сторону большей выразительности, а не наоборот.
Zarathu5trA
10.10.2018 20:23+1Мое ЛИЧНОЕ мнение: лучшее что могут сделать разработчики стандарта С++ это либо разбить язык на модули либо принять идею «стандарт языка С++ не может быть больше 1000 страниц» как святую истину. Уже ну очень много всего намешано и все со своими нюансами присыпанное тоннами undefined behavior. Язык превращается в свалку. Человеку который говорит я знаю С++ можно смело плевать в лицо. Сам Страуструп не знает всех нюансов описанных на over 1500 страниц мелким шрифтом. И все добавляют и добавляют… Это хорошо что добавляют… Но очень-очень плохо, что не упорядочивают и не убирают.
dipsy
11.10.2018 12:35А ещё каждый уважающий себя создатель компилятора С++ добавляет свои улучшения (особо отличился Borland/Embarcadero). И получаем по итогу всего например в каких-то мультиплатформенных библиотеках #ifdef на #endif-e и #define-oм погоняет. Да ещё с гарантией того, что на нужной вам платформе оно всё равно без танцев с бубном не соберется.
dipsy
Глядя на процесс развития некоторых языков программирования почему-то возникают параллели с эволюцией многих программных продуктов, вроде Nero Burning.
Хорошо что С++ это не грозит.