Pull to refresh

[CppCon 2018] Herb Sutter: На пути к более простому и мощному C++

Reading time10 min
Views10K

В своём выступлении на CppCon 2018 Herb Sutter представил общественности свои наработки по двум направлениям. Во-первых, это контроль времени жизни переменных (Lifetime), который позволит обнаруживать целые классы багов на этапе компиляции. Во-вторых, это обновлённый proposal по метаклассам, которые позволят избежать дублирования кода, один раз описывая поведение категории классов и потом подключая его к конкретным классам одной строчкой.


Предисловие: больше = проще?!


Слышны обвинения C++ в том, что стандарт бессмысленно и беспощадно разрастается. Но даже самые ярые консерваторы не поспорят с тем, что такие новые конструкции, как range-for (цикл по коллекции) и auto (хотя бы для итераторов) делают код проще. Можно выработать примерные критерии, которым (хотя бы одному, в идеале всем) новые расширения языка должны удовлетворять, чтобы упрощать код на практике:


  1. Сокращать, упрощать код, убирать дублирующийся код (range-for, auto, lambda, Metaclasses)
  2. Делать безопасный код проще для написания, предотвращать ошибки и особые случаи (умные указатели, Lifetimes)
  3. Полностью заменять старые, менее функциональные фичи (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)

Указатели и Значения


Для каждого Указателя $p$ отслеживается $pset(p)$ — множество значений, на которые он может указывать. При удалении Значения, его вхождения во все $pset$ заменяются на $invalid$. При обращении к Значению Указателя $p$, такого что $invalid ∈ pset(p)$, выдаём ошибку.


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().


Обращаю внимание, что варнинг выдаётся только в момент доступа к невалидному Указателю. Если Значение удалено, но к этому Указателю больше никто никогда не обратится, то всё в порядке.


Указатели и Владельцы


Если Указатель $p$ указывает на Значение, содержащееся внутри Владельца $o$, то это обозначают $pset(p) = {o'}$.


Методы и функции, принимающие Владельцев, подразделяются на:


  • Операции доступа к Значению Владельца. По умолчанию: *, ->, [], begin(), end()
  • Операции доступа к самому Владельцу, инвалидирующие указатели, вроде v.clear(). По умолчанию, это все остальные не-const операции
  • Операции доступа к самому Владельцу, не инвалидирующие указатели, вроде v.empty(). По умолчанию, это все const-операции

Старое содержимое Владельца объявляется $invalid$ при удалении Владельца или при применении инвалидирующих операций.


Этих правил достаточно, чтобы обнаружить многие типичные баги в коде 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)?


Это разные вещи. Метаклассы нужны для определения частей класса, а концепты проверяют, соответствует ли класс некоему шаблону, при помощи примеров использования класса. На самом деле, метаклассы и концепты отлично сочетаются. Например, можно определить концепт итератора и метакласс "типичного итератора", который определяет некоторые избыточные операции через остальные.

Only registered users can participate in poll. Log in, please.
Нужны ли метаклассы в стандарте C++?
46.62% Да, мне пригодится62
22.56% Да, потому что why not30
2.26% Нет, макросы и кодогенераторы проще3
17.29% Нет, хватит раздувать стандарт всякой ересью!23
11.28% Что такое метаклассы?15
133 users voted. 33 users abstained.
Tags:
Hubs:
Total votes 20: ↑19 and ↓1+18
Comments21

Articles