Что не так с std::visit в современном C++

в 5:29, , рубрики: c++, variant, visit, visitor pattern, Программирование, Совершенный код

Сигма-тип и вы

Давайте поговорим о простой, но мощной концепции в программировании — сигма-типах.

Сигма-тип (тип-сумма, меченное объединение) может содержать значения одного и только одного из нескольких типов. Например, рассмотрим настройки в INI-подобном файле конфигурации. Пусть каждая настройка может быть строкой, целым или булевым значением. Если бы мы хотели сделать свою библиотеку на C++, мы бы написали что-то вроде этого:

struct Setting {
    union {
        string str;
        int num;
        bool b;
    };
    enum Type { Str, Int, Bool };
    Type tag;
};

// Отображение настроек на их имена
using Settings = unordered_map<string, Setting>;

Это опасный путь, ибо всегда нужно помнить о нескольких вещах:

  • Обновить tag при присваивании нового значения.
  • Читать из объединения только корректный тип в соответствии с тегом.
  • Вовремя вызывать конструкторы и деструкторы для всех нетривиальных типов. В данном примере это только string, но их могло быть и больше.

Если забыть хоть один из этих пунктов, объект окажется в некорректном состоянии, и там будет плач и скрежет зубов. Всю эту магию можно инкапсулировать и работать с типом через набор методов вроде getType(), asBool(), asString(), которые тоже выглядят громоздко. Тем более, такое решение всего лишь перекладывает проблему на того, кто будет реализовывать эти методы. Ему всё равно придётся поддерживать инварианты без какой либо помощи со стороны языка.

Было бы куда лучше, если бы сигма-тип общего назначения был в стандартной библиотеке. В C++17 мы наконец его получили! Он называется std::variant, и сейчас мы познакомимся с ним поближе.

Использование std::variant

variant — это шаблон класса, который в качестве шаблонных параметров принимает типы, которые он может содержать. Вместо кода из примера выше мы могли бы определить тип настройки как variant<string, int, bool>. Присваивание значения в variant работает вполне ожидаемо:

variant<string, int, bool> mySetting = string("Hello!"); // или
mySetting = 42; // или
mySetting = false;

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

match (theSetting) {
    Setting::Str(s) =>
        println!("A string: {}", s),
    Setting::Int(n) =>
        println!("An integer: {}", n),
    Setting::Bool(b) =>
        println!("A boolean: {}", b),
};

но это не про C++17(*). Вместо этого нам дали вспомогательную функцию std::visit. Она принимает variant, который нужно обработать, и объект-посетитель, который является вызываемым для любого типа в переданном variant.

Как определить объект-посетитель? Один из способов — создать объект, в котором оператор вызова перегружен для всех нужных типов:

struct SettingVisitor {
    void operator()(const string& s) const {
        printf("A string: %sn", s.c_str());
    }

    void operator()(const int n) const {
        printf("An integer: %dn", n);
    }

    void operator()(const bool b) const {
        printf("A boolean: %dn", b);
    }
};

Выглядит ужасно многословно, и становится ещё хуже, если нам нужно захватить или изменить какое-то внешнее состояние. Хмм… лямбды великолепно справляются с захватом состояния. Может можно сделать из них объект-посетитель?

make_visitor(
    [&](const string& s) {
        printf("string: %sn", s.c_str());
        // ...
    },
    [&](const int d) {
        printf("integer: %dn", d);
        // ...
    },
    [&](const bool b) {
        printf("bool: %dn", b);
        // ...
    }
)

Уже лучше, но в стандартной библиотеке нет ничего похожего на функцию make_visitor, которая бы сделала вызываемый объект из нескольких лямбд. Давайте напишем её самостоятельно.

template <class... Fs>
struct overload;

template <class F0, class... Frest>
struct overload<F0, Frest...> : F0, overload<Frest...>
{
    overload(F0 f0, Frest... rest) : F0(f0), overload<Frest...>(rest...) {}

    using F0::operator();
    using overload<Frest...>::operator();
};

template <class F0>
struct overload<F0> : F0
{
    overload(F0 f0) : F0(f0) {}

    using F0::operator();
};

template <class... Fs>
auto make_visitor(Fs... fs)
{
    return overload<Fs...>(fs...);
}

Здесь мы воспользовались шаблонами с переменным количеством параметров, которые появились в C++11. Их нужно определять рекурсивно, поэтому мы сначала объявляем базу рекурсии F0, а затем объявляем набор конструкторов, каждый из которых откусывает один элемент из списка параметров шаблона и добавляет его к типу в качестве оператора вызова.

Это выглядит хлопотно, но не отчаивайтесь! C++17 вводит новый синтаксис, который сократит весь этот код до

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

Просто, не правда ли? Если же вам не по душе ни один из этих вариантов, воспользуйтесь условным оператором времени компиляции (if constexpr) из стандарта C++17:

[](auto& arg) {
    using T = std::decay_t<decltype(arg)>;

    if constexpr (std::is_same_v<T, string>) {
        printf("string: %sn", arg.c_str());
        // ...
    }
    else if constexpr (std::is_same_v<T, int>) {
        printf("integer: %dn", arg);
        // ...
    }
    else if constexpr (std::is_same_v<T, bool>) {
        printf("bool: %dn", arg);
        // ...
    }
}

Выглядит лучше?

Нет.

Вся эта канитель для std::visit — полнейшее безумие. Мы начали с простой цели: посмотреть на содержимое сигма-типа. А чтобы завершить эту скромную миссию, нам пришлось:

  1. Определить вызываемый объект, включающий много однообразного кода, или
  2. Определить поведение с помощью лямбд, что потребовало:
    • Понимания шаблонов с переменным количеством параметров во всей полноте их рекурсивного великолепия, или
    • Близкого знакомства с using-объявлениями с переменным количеством параметров, которые только появились в стандарте C++17.

    или

  3. Использовать ветвление времени компиляции, для чего требуется знание и глубокое понимание синтаксиса constexpr if, а так же всяких интересностей из type_traits, вроде std::decay.

Ни одна из этих концепций не является загадкой для опытного C++-разработчика, но это довольно «продвинутые» возможности языка. Что-то явно пошло не так, если требуется столько всего знать чтобы сделать простую вещь.

Как мы вообще тут оказались?

У меня нет цели унизить людей из комитета ISO C++, которые выбрали такой подход. Я пил пиво с некоторыми из них, и могу сказать что это хорошие, разумные и трудолюбивые люди. Я уверен, что упускаю какой-то важный контекст, ибо я ни разу не был на обсуждениях стандарта и не читал всей сопутствующей документации. Но с точки зрения внешнего наблюдателя, несоответствие между сложностью задачи и предложенным решением — это какая-то дичь. Как можно обучить этому, не перегружая новичка кучей… дополнительного материала? Должен ли среднестатистический программист знать всё это? А если целью добавления variant в стандарт не было создание инструмента для массового пользователя, но нужен ли он вообще? Как минимум, если у комитета C++17 не было времени и ресурсов для введения сопоставления с образцом в язык, им следовало хотя бы добавить в стандарт что-то вроде make_visitor. Но и это было оставлено в качестве упражнения для пользователя.

И всё же как мы дошли до жизни такой? Я предполагаю, что дело в психологической склонности человека к подтверждению своей точки зрения. Возможно, когда несколько достаточно грамотных людей, которые знают как работает SFINAE, и не пугаются при виде чего-то такого:

template <typename F>
typename std::enable_if<!std::is_reference<F>::value, int>::type
foo(F f)
{
    // ...
}

собираются вместе, у них получается std::visit. Было бы безумием ожидать, что обычный пользователь языка будет городить с помощью рекурсивных шаблонов перегруженный вызываемый объект, чтобы всего лишь понять, int или string храниться вот в этой штуке.

Я не буду говорить, что переусложнённость C++ является благом, но он явно гораздо сложнее, чем следовало бы. Скотт Мейерс, автор книг Effective C++ и Effective Modern C++, тоже высказал подобные мысли в недавних лекциях. Вслед за Мейерсом, я уверен, что каждый член комитета всеми силами старается избегать ненужной сложности и сделать язык как можно проще в использовании. Но это трудно сказать, глядя на результат их работы. Беспричинная сложность продолжает нарастать.

Куда мы движимся?

Есть причина, по которой C++ используется настолько широко, особенно в системном программировании (*). Он чрезвычайно выразителен, оставляя при этом полный контроль над аппаратурой. Инструментарий, созданный для C++, является наиболее развитым среди всех языков программирования, кроме C. Он поддерживает невообразимое количество платформ.

Но если отставить весь этот исторический багаж, мы увидим некоторые серьёзные недостатки. Поразбирайтесь с D, и вы быстро поймёте, что метапрограммирование не требует самоистязания и безумного синтаксиса. Поиграйте немного с Rust, и вы почувствуете, что unique_ptr и shared_ptr (которые сами по себе были глотком свежего воздуха), выглядят как неудачная шутка. Совсем непристойно в 2017 году работать с зависимостями, букваально копируя содержимое одних файлов в другие с помощью директивы #include.

Глядя на то, что оказывается в стандарте и то, что мы слышим на конференциях, создаётся впечатление, что комитет пытается преодолеть эти недостатки, понемногу перетаскивая хорошие решения из других языков. На первый взгляд хорошая идея, но новые фичи часто приходят в язык недопечёнными. Хотя C++ не собирается на покой в ближайшее время, он будто бы постоянно неуклюже остаётся в роли догоняющего.

Несмотря на всё это, я буду убеждать своих коллег использовать variant, если в нём возникает нужда. Сигма-типы — очень полезная вещь, и они стоят затрачиваемых усилий. Как сказал Джон Кальб, «если вы не можете пользоваться языком с уродливыми бородавками, возможно вам не следует писать на С++».

Примечания

1. Термин "$Sigma$-тип" пришёл из теории типов, с помощью которой можно описать типизацию в языка программирования. Если тип может содержать или значения типа A или значения типа B, то множество его возможных состояний есть теоретико-множественная сумма всевозможных состояний типов A и B. Вам наверняка знакомы «двойники» сигма-типов: типы-произведения, то есть структуры, кортежи и т.п. Например, множество возможных состояний структуры из типов A и B содержит декартово произведение состояний типов A и B.

2. Есть предложение P0095R1 о внесении сопоставления с образцом в Стандарт C++.

3. Обязательный к просмотру доклад Скотта Мейерса

Автор: SergeySib

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js