Языковые новшества C++17. Часть 2. Constexpr и привязки

Продолжаем знакомиться с нововведениями в языке, принятыми в стандарте C++17. В настоящей статье речь пойдет о constexpr if и структурных привязках (structured bindings). Как и в прошлой статье напоминаю, что не все компиляторы могут поддерживать описанное в статье.

Constexpr if

Код этого раздела может быть найден в этой фиксации.

Разговоры про static if в C++ идут давно, и лагерь ратующих за его включение вряд ли можно назвать маленьким. Действительно, почему можно не хотеть включения в язык функционала, который по мощи не только мало чем уступает макросам, но, являясь частью процесса компиляции, превосходит их на голову. Почему? Потому что позволяет принимать решения на основании аргументов шаблона, а не является слепым инструментом подмены текста. С появлением в стандарте C++17 constexpr if я неоднократно слышал довольные возгласы подобного плана: «static if появился в C++!». Так это или нет, мы сейчас и посмотрим.

Итак, начнём с основ: что же всё-таки добавили в C++17? В стандарте C++17 расширили понятие условного оператора if, позволив ему включать и выключать ветки из компиляции. Т.е. теперь, используя синтаксис вида [else] if constexpr((условие)), мы можем включать/выключать часть кода функции. Давайте рассмотрим простой пример:

template <typename T>
void simpleExample(T val)
{
    if constexpr(std::is_integral<T>::value)
        std::cout << "Integral passed.";
    else
        std::cout << "Non integral passed.";
    std::cout << "\n";
}

Теперь, если мы вызываем функцию с объектом интегрального типа, тогда отработает первая ветка, в противном случае — вторая. «И что здесь нового, я и со старым if так могу?!», скажет внимательный читатель. Он, несомненно, будет прав, но в том не моя вина, а простоты примера. Поэтому давайте начнём усложнять пример, чтобы показать, что же такого может if constexpr, чего не может if обычный. Для этого приведём следующий код:

template <typename T>
void mixStaticWithDynamicIncorrect(T val)
{
    if constexpr(std::is_integral<T>::value)
        std::cout << "Integral passed.";
    else if(val == std::string{"clone"})
        std::cout << "Known string passed.";
    else if constexpr(std::is_same_v<T, std::string>)
        std::cout << "General string passed.";
    else
        std::cout << "Unknown type variable passed.";
    std::cout << "\n";
}

Здесь мы видим уже 4 ветки, причём только первая и третья содержат в себе constexpr. Это говорит нам о том, что if constexpr не является каким-то специальным случаем и может быть использован совместно с не-constexpr if. Теперь давайте вызовем нашу функцию:

mixStaticWithDynamicIncorrect(1);
mixStaticWithDynamicIncorrect("clone"s);
mixStaticWithDynamicIncorrect("unique"s);

Такой код ожидаемо даст следующий вывод:

Integral passed.
Known string passed.
General string passed.

Если вы всё ещё думаете, что вы можете добиться того же результата с простым if, рекомендую убрать constexpr из предыдущего кода и убедиться в ошибочности своих суждений. Почему вышеприведённый код не будет работать с простым if? Потому что когда мы будет вызывать этот код с чем-либо отличным от std::string (не совсем так), мы получим ошибку компиляции. Это произойдёт из-за того, что у нас в коде есть вот эта строчка: else if(val == std::string{"clone"}), которая означает, что что бы не передали в качестве аргумента функции, оно должно быть сравнимо с объектом std::string. Разумеется, такой наш вызов mixStaticWithDynamicIncorrect(1) приведёт к ошибке, ведь единицу нельзя сравнить со строкой.

Разобравшись с этим, давайте теперь разбираться почему же наш код с constexpr работает. Всё дело в специальном правиле, которое гласит, что если одна из constexpr веток выбрана, все другие ветки отбрасываются (discarded). Т.е. получается, что если мы передали в нашу функцию единицу, то второе условие не будет вообще участвовать в компиляции, оно будет выброшено! И совершенно неважно, является второе условие constexpr или нет — важно чем является первое условие.

Я полагаю, что внимательные читатели обратили внимание на имя функции — он написана неправильно, и сейчас я поясню почему. Хотя с теми вызовами, что я привел в примере наша функция и работает, она сломается на таком простом вызове:

mixStaticWithDynamicIncorrect(1.0);

Это происходит потому, что первая ветка отброшена (std::is_integral<T>::value вернул false), и компилятор пытается собрать второе условие, но у него ничего не получается, т.к. double нельзя сравнить с std::string. Чтобы решить эту проблему, нам необходимо спрятать это условие под другой if constexpr. Вот как может выглядеть правильно написанная функция:

template <typename T>
void mixStaticWithDynamicCorrect(T val)
{
    if constexpr(std::is_integral<T>::value)
        std::cout << "Integral passed.";
    else if constexpr(std::is_same_v<T, std::string>)
    {
        if(val == std::string{"clone"})
            std::cout << "Known string passed.";
        else
            std::cout << "General string passed.";
    }
    else
        std::cout << "Unknown type variable passed.";
    std::cout << "\n";
}

Т.е. мы спрятали динамическое (времени исполнение) условие под статическим (времени компиляции), решив проблему на корню — теперь наша функция будет компилироваться с любыми аргументами. Почему это работает? Потому что тело под условием else if constexpr(std::is_same_v<T, std::string>) будет рассмотрено компилятором тогда и только тогда, когда std::is_same_v<T, std::string> будет true. Во всех остальных случаях код внутри этого условия будет выброшен и в компиляции участвовать не будет. В этом, собственно, и заключается основная идея if constexpr: включать и выключать код в зависимости от константы времени компиляции.

Не макроподстановка

Когда я сказал, что код, который будет выброшен, не участвует в компиляции, я был не совсем точен. Он действительно не появится в результирующем коде, но это не значит, что мы можем писать там всё, что угодно. Это не макросы, которые просто отключают один кусок текста — здесь замешан компилятор, а это значит, что код в любой ветке должен быть корректным. Корректным является синтаксически корректный код, в котором отсутствует static_assert, делающий код безусловно некорректным. Т.е. если мы поместим static_assert(false) внутри ветки, которая никогда не должна сработать, мы всё равно получим ошибку компиляции, т.к. согласно стандарту этот код некорректен (ill-formed). С другой стороны, когда наш static_assert не является безусловным, а зависит от параметра шаблона, тогда он будет вычислен только в том случае, если ветка с ним будет выбрана. К примеру, такой код всегда будет давать ошибку компиляции (даже если нет ни единого вызова этой функции):

template <typename T>
void testStaticAssert(T val)
{
    if constexpr(std::is_integral<T>::value)
        std::cout << "Integral passed.";
    else
        static_assert(false);
}

А такой только тогда, когда есть вызов функции с аргументом неинтегрального типа:

template <typename T>
void testStaticAssert(T val)
{
    if constexpr(std::is_integral<T>::value)
        std::cout << "Integral passed.";
    else
        static_assert(std::is_integral<T>::value);
}

Возвращаемый тип

Ещё одним интересным местом нового функционала, является возможность возвращения совершенно разных типов из одной функции (естественно, не одновременно). Т.е. с использованием if constexpr мы можем иметь несколько return выражений, каждое из которых возвращает объект типа, который не конвертируется в другой. Разумеется, все такие return должны быть спрятаны в блоки кода, которые будут выбрасываться на этапе компиляции. Пример:

template<typename T>
auto returnHeadache(T val)
{
    if constexpr(std::is_same_v<T, std::string>)
        return 0;
    else
        return std::string{"str"};
}

Очевидно, что для различных веток, функция returnHeadache будет иметь разный тип возвращаемого значения: в первом случае int, а во втором std::string. И это работает! Правда, мы легко можем всё сломать, для этого достаточно добавить ещё один return, находящийся вне условий, который будет несовместим с двумя прочими:

template<typename T>
auto returnHeadache(T val)
{
    if constexpr(std::is_same_v<T, std::string>)
        return 0;
    else
        return std::string{"str"};
    return std::vector{1, 2, 3};
}

Такой код уже, очевидно, не соберётся.

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

Применение

В примерах, что я приводил в предыдущем подразделе, я смешивал константные условные операторы с не константными. Делал я это просто для того, чтобы показать, что так делать можно. Я не сильно представляю где такое может пригодиться на практике, хотя отметать возможность применения не стану — всякие ситуации бывают. В этой же части я сосредоточусь на применении сугубо константных условий.

Самый первый пример применения нового функционала напрашивается сам собой: мы возьмём функцию sort, из моей статьи по SFINAE:

template<typename Container>
std::enable_if_t<HasFunctionSort_v<Container>>
sort(Container& container)
{
    std::cout << "Calling member sort function\n";
    container.sort();
}
 
template<typename Container>
std::enable_if_t<!HasFunctionSort_v<Container>>
sort(Container& container)
{
    std::cout << "Calling std::sort function\n";
    sort(begin(container), end(container));
}

и перепишем её с помощью if constexpr:

template<typename Container>
void sort(Container& container)
{
    if constexpr(HasFunctionSort_v<Container>)
    {
        std::cout << "Calling member sort function\n";
        container.sort();
    }
    else
    {
        std::cout << "Calling std::sort function\n";
        sort(begin(container), end(container));
    }
}

Кода не стало сильно меньше, но теперь он выглядит куда более опрятным и понятным, при этом необходимый нам функционал сохраняется. Таким образом, if constexpr становится отличной заменой так сильно расплодившемуся коду с std::enable_if. Всё дело в том, что SFINAE это на самом деле просто костыль, который C++ программисты применяют за неимением лучших средств. С появлением if constexpr мест, где нужно применять этот костыль становится ещё меньше, и я ожидаю снижения количества кода, где используется std::enable_if. Праздник в C++ мире наступит тогда, когда мы вообще сможем отказаться от этой метафункции, но пока до этого далеко.

Диспетчеризация

Помимо std::enable_if, в современном C++ коде не редко можно встретить методику так называемой диспетчеризации по меткам (tag dispatching). Её суть заключается в следующем: если у нас есть набор некоторых функций, которые должны вызываться в зависимости от шаблонного параметра, то мы создаём единую точку входа (функцию) из которой уже вызываем нужную функцию. По-русски это звучит куда сложнее, чем это получается на самом деле. Для простоты давайте перепише�� изначальный пример с sort, применив эту методику:

template<typename Container>
void sort(Container& container)
{
    sortImpl(container, HasFunctionSort<Container>{});
}

template<typename Container>
void sortImpl(Container& container, std::true_type)
{
    std::cout << "Calling member sort function\n";
    container.sort();
}

template<typename Container>
void sortImpl(Container& container, std::false_type)
{
    std::cout << "Calling std::sort function\n";
    sort(begin(container), end(container));
}

Тоже неплохо, но с if constexpr всё равно лучше. Что можно вынести из этого примера: для применения этого метода нам необходимо N меток (т.е. N типов, т.к. каждая метка должна являться отдельным типом) на N различных функций. Плюс какая-то метафункция, которая будет нам возвращать соответствующую метку, согласно типу T. В примере выше всё просто: у нас всего две функции, поэтому мы просто используем стандартные std::true_type/false_type в качестве типов-меток, и организуем перегрузку на их основании. В общем случае, для реализации диспетчеризации нам придётся строить некоторую инфраструктуру. И какой из этих подходов окажется лучше — не известно. С одной стороны, с if constexpr у нас всё в одном месте, с другой стороны масса ifов это не просто плохо, это ужасно. Поэтому тут я от оценки воздержусь, но я больше склоняюсь к тому, что подход с диспетчеризацией лучше использования if constexpr, в случае большого количества вариантов.

В любом случае, наличие альтернативы это всегда хорошо.

Static if?

Так всё-таки, является if constexpr static if или нет? Мне сложно ответить на этот вопрос полноценно, т.к. со static if я знаком весьма поверхностно — никогда не разбирался в D. Но из того поверхностного знакомства с документацией, что я имел, я могу сделать однозначный вывод, что if constexpr обладает лишь частью возможностей static if и не может им называться. Почему? Мы уже видели, как с помощью if constexpr мы могли манипулировать тем, каким будет тип возвращаемого значения. Но напоминаю, что тип возвращаемого значения не является частью сигнатуры функции, а вот сигнатурой функции мы манипулировать с помощью if constexpr как раз и не сможем.

Представьте себе, что у нас есть некий шаблон, внутри которого есть функция, сигнатуру которой мы хотим определять в зависимости от того, с каким аргументом наш шаблон инстанциирован. Мы можем иметь разные типы аргументов функции, можем иметь разное количество аргументов — неважно, важно то, что мы хотим иметь разные сигнатуры. В качестве примера, давайте рассмотрим такой вариант: мы хотим иметь функцию branch, которая принимает int, если шаблон инстанциирован с неинтегральным типом и std::string в обратном случае. Вот как можно это сделать:

template<typename T>
class StaticIf
{
public:
    template<typename U = T, 
        typename = std::enable_if_t<std::is_integral_v<U>>>
    static void branch(std::string str)
    {
        std::cout << "String branch.\n";
    }

    template<typename U = T,
        typename = std::enable_if_t<!std::is_integral_v<U>>>
    static void branch(int)
    {
        std::cout << "Integer branch.\n";
    }
};

Подобная задача никак не решается с помощью if constexpr, в силу его ограниченности. Конечно, можно мне возразить и сказать, что такие вещи решаются с помощью частичной специализации шаблона. Но что если у нас есть большое количество кода, и всего одна-две функции должны отличаться? Тоже создавать частичную специализацию? Не спорю, практически любую проблему в C++ можно решить тем или иным способом, но хочется иметь в наличии инструмент, который бы позволил решать такие задачи более элегантно. Например, вот так:

template<typename T>
class StaticIf
{
public:
    static if(std::is_integral_v<U>) 
    {
        static void branch(std::string str)
        {
            std::cout << "String branch.\n";
        }
    }
    else
    {
        static void branch(int)
        {
            std::cout << "Integer branch.\n";
        }
    }
};

Не могу сказать, что вышеприведённый код мне во всём нравится, но он определённо мне больше нравится варианта с std::enable_if.

Таким образом, можно сделать вывод, что введение if constexpr действительно несколько облегчит жизнь [мета]программистам C++, но не настолько, чтобы считаться каким-то прорывом. Лично для себя я вижу его использование оправданным только в простых случаях, в более сложных я всё равно стану использовать диспетчеризацию.

Structured bindings

Код для этого раздела может быть найден в этой фиксации.

Другим интересным новшеством в C++17 является появление так называемых структурных привязок (structured bindings). Суть их заключается в следующем: язык представляет нам возможность разбора структур (классов) на части, этакая декомпозиция. Если вы знакомы с такими языками как Javascript и C#, то этот функционал является обрезанным подобием деструктурирования (destructuring) из первого и деконструирования (deconstructing) из второго.

Наиболее богатым, функционально, является деструктурирование (JS), затем идет деконструирование (C#), и замыкают список структурные привязки (C++). Хотя они и различаются своими возможностями, они служат одной цели — превращение монолитного объекта в разрозненные единицы (объекты). И это несколько ставит меня в тупик: почему в 3-х языках похожие вещи называются настолько по-разному — это только вносит дополнительную путаницу.

Кстати, сначала новый функционал назывался декомпозицией, но затем был переименован в структурные привязки. Причина мне неизвестна.

Давайте рассмотрим пример, чтобы было понятно о чём вообще идёт речь. Для начала возьмём простую структуру:

struct SimpleStruct
{
    std::string first {"first"};
    int second = 2;
    float third = 3.0;
};

И разберём её на кусочки:

auto [f, s, t] = SimpleStruct{};
std::cout << "first: " << f
        << ", second: " << s
        << ", third: " << t << "\n";

Как вы можете видеть, слева от знака равенства и расположились наши привязки: f, s и t. Основными частями определения привязок являются:

  • auto — это ключевое слово здесь обязательно. Оно может быть приправлено const, volatile, а также lvalue- или rvalue-ссылкой.
  • […] — квадратные скобки, содержащие в себе имена привязок.

Количество привязок должно быть строго  равно количеству объектов из которых состоит структура, к которой мы привязываемся. В нашем примере SimpleStruct содержит 3 члена, соответственно мы определили привязки к 3-м членам. Если бы мы написали две или четыре привязки, то получили бы ошибку компиляции — никаких значений по умолчанию!

В примере выше мы использовали имена для привязок, которые отличаются от имён в самой структуре, т.е. нет никакой нужды называть наши привязки идентично тому, к чему они привязываются. Любая привязка подчиняется тем же правилам именования, что и любая другая переменная; никаких дополнительных ограничений на неё не накладывается. Чтобы проще было понять, что они из себя представляют, мы можем переписать пример выше на рельсы C++14:

auto tmp = SimpleStruct{};
auto& f = tmp.first;
auto& s = tmp.second;
auto& t = tmp.third;

И это будет, в сущности, то же самое, что делает компилятор под капотом: копирует объект и создаёт ссылки на каждый элемент скопированного объекта. Чтобы избежать копирования объекта, нам нужно создать ссылку на него. Немного усложним пример, добавив новый тип:

struct ComplexStruct
{
    SimpleStruct simple;
    int answer = 24;
};

И создадим привязки к нему:

ComplexStruct complexStruct{};
auto& [simple, answer] = complexStruct;
complexStruct.simple.third = 3.33f;
std::cout << "simple.third: " << simple.third
<< ", answer: " << answer << "\n";

И такие привязки полностью эквивалентны такому коду:

auto& tmp = complexStruct;
auto& simple = tmp.simple;
auto& answer = tmp.answer;

Т.е., как вы можете видеть, никакой магии; это просто ещё один «синтаксический сахар», который позволяет раскладывать структуры на составляющие.

Массивы

Одной из интересных сторон нового функцио��ала, является то, как она обходится с массивом. Сразу рассмотрим пример:

int array[] = {1, 3, 5, 7, 9};
auto [a1, a2, a3, a4, a5] = array;
std::cout << "Third element before: " 
    << a3 << "(" << array[2] << ")\n";
a3 = 4;
std::cout << "Third element after: " 
    << a3 << "(" << array[2] << ")\n";

После запуска этого кода мы увидим, что элементы в a3 и array[2] отличаются! А это означает, что впервые за всю историю C++ при выполнении присваивания одного C-массива другому, происходит его копирование, а не ошибка компиляции. Да, слева массив явно не указан, но он там всё равно есть. Ограничение с массивом, кстати, ровно такие же как и со структурой: количество привязок должно точно совпадать с размером массива, коим эти привязки инициализируются.

Сложные структуры

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

class Person
{
public:
    Person(const std::string& first, const std::string& last, size_t id):
            m_FirstName{first},
            m_LastName{last},
            m_Id{id}
    {
    }

    const std::string& firstName() const noexcept
    {
        return m_FirstName;
    }

    const std::string& lastName() const noexcept
    {
        return m_LastName;
    }

    size_t id() const noexcept
    {
        return m_Id;
    }
private:
    std::string m_FirstName;
    std::string m_LastName;
    size_t m_Id;
};

вот таким образом:

Person einstain{"Albert", "Einstein", 997};
auto [id, first, last] = einstain;

Разумеется, этот код не соберётся: мы не можем получить доступ к закрытым полям! Чтобы наш код заработал, нам нужно добавить несколько вспомогательных вещей. Первым делом, мы должны сообщить компилятору, сколько наш класс Person поставляет полей для привязки. Чтобы это осуществить, нам нужно добавить специализацию класса std::tuple_size:

namespace std
{
    template<>
    struct tuple_size<Person>
    {
        constexpr static size_t value = 3;
    };
}

У нас 3 элемента, поэтому value устанавливается в 3.

Да, мы действительное добавляем новый тип в пространство имён std. Нет, конкретно такое расширение стандартного пространства имён не запрещено.

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

namespace std
{
    template<>
    struct tuple_element<0, Person>
    {
        using type = size_t;
    };

    template<>
    struct tuple_element<1, Person>
    {
        using type = std::string;
    };

    template<>
    struct tuple_element<2, Person>
    {
        using type = std::string;
    };
}

Наконец, на каждое поле нам нужно добавить функцию get: они должны быть либо в самом классе Person, либо же должны быть свободными функциями, находящимися в том же пространстве имён, что и Person. Добавим их вот так:

template <size_t Position>
auto get(const Person&) = delete;

template <>
auto get<0>(const Person& person)
{
    return person.id();
}

template <>
auto get<1>(const Person& person)
{
    return person.firstName();
}

template <>
auto get<2>(const Person& person)
{
    return person.lastName();
}

После того, как мы всё это добавили наш пример соберётся и выведет нужную информацию. Какие выводы можно из всего этого сделать? Вывод первый: для добавления поддержки привязок к «сложному» классу, нужно добавить довольно много стороннего кода. Вывод второй: создание привязок к полям класса происходит согласно позиции поля, к которому мы привязываемся. Т.е. первая ссылка привяжется к тому, что вернёт get<0>, вторая к get<1> и т.д. Этот же вывод можно было сделать и из примеров с простой структурой, но я специально отложил констатацию данного факта до этого примера — тут всё куда нагляднее.

Кстати, используя if constexpr, мы можем объединить все наши get-функции под одной крышей:

template <size_t Position>
auto get(const Person& person)
{
    if constexpr(Position == 0)
        return person.id();
    else if constexpr(Position == 1)
        return person.firstName();
    else if constexpr(Position == 2)
        return person.lastName();
};

Как по мне, такой код выглядит компактнее массы функций.

Тут будет не лишним напомнить, что такого же фокуса с std::tuple_element у нас не выйдет, т.к. if constexpr это не static if.

Теперь перейдём к описанию одного весомого недостатка в реализации выше. Я специально не стал раскрывать для пользователя моего класса Person внутренние детали, сделав типичные функции-геттеры — обычная практика написания класса, хранящего и представляющего данные наружу. Разумеется, я реализовал привязки через эти самые функции. Но в результате я получил объект, к которому можно привязаться, но нельзя изменить то, к чему привязался. Но из моего кода это вовсе неочевидно, мы ведь легко можем добавить код, который изменит привязку:

Person einstain{"Albert", "Einstein", 997};
auto& [id, first, last] = einstain;
first = "Adolf";

Изменится ли объект einstain? Конечно нет, мы же не дали никакого способа менять внутреннее представление объектов класса Person. Тогда что изменяется? Изменяется временная переменная, которую мы возвращаем из функции get, а возможно это благодаря тому, что вышеозначенный код «переписывается» компилятором на вот такой:

Person einstain{"Albert", "Einstein", 997};
auto tmp = einstain;
auto&& id = get<0>(tmp);
auto&& first = get<1>(tmp);
auto&& second = get<2>(tmp);

А всё потому, что вызов каждой из наших функций get является xvalue выражением. Именно поэтому компилятор вынужден создавать rvalue-ссылки на то, что возвращается из этих функций. Но для пользователя это совершенно не ясно, поэтому он вправе ожидать изменения исходного объекта einstain. Нужно сделать так, чтобы такой иллюзии не было. Т.к. у нас обе функции first() и last() возвращают const std::string&, то всё, что нам осталось сделать это заставить get сохранять тип возвращаемого значения. Мы знаем, что это можно сделать с помощью такого изменения в сигнатуре функции get:

decltype(auto) get(const Person& person)

Теперь, при попытке создания привязок, мы получим ошибку, которую можно будет исправить явным указанием того, что мы собираемся привязываться к константному объекту:

const auto& [id, first, last] = einstain;

Но нужно ли нам такое применение? Разве не хотел бы пользователь изменять поля класса через привязки? Полагаю, что хотел бы. Поэтому мы можем сделать так, чтобы функции-геттеры возвращали неконстантную ссылку. Но тогда теряется весь смысл в этих функциях, и мы с таким же успехом могли бы просто сделать все функции get друзьями класса, дав им доступ напрямую. Правда, раз уж мы дошли до такого шага, то какой вообще смысл держать члены класса приватными, когда к ним всё равно можно достучаться через привязки?

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

Применение

Дабы не прерывать нить повествования я продолжу свою мысль из предыдущего блока. Есть ли смысл в классах, в которых члены являются непубличными, и для которых мы хотим предоставить интерфейс привязок? В одном таком классе есть смысл совершенно точно: std::tuple. Более того, интерфейс, который мы используем для поддержки привязок, нам недвусмысленно намекает откуда ноги растут. Других примеров, я, к сожалению, придумать не могу. Я не вижу ни одной причины, по которой я бы хотел создавать такой интерфейс: пример с константными привязками выглядит натянуто, а с неконстантными просто-напросто убивает весь смысл инкапсуляции. Поэтому на данный момент я придерживаюсь мнения, что за пределами std::tuple, интерфейс привязок для классов вряд ли снискает популярность.

Теперь давайте рассмотрим канонические примеры, которые авторы предложения использовали в тексте оного. Итак, в C++14 мы можем писать так:

#include <iostream>
#include <tuple>

using Vector3d = std::tuple<int, int, int>;

Vector3d getVector()
{
    return std::make_tuple(0, 1, 2);
}

int main()
{
    int x = 0, y = 0, z = 0;
    std::tie(x, y, z) = getVector();
};

А в C++ 17 эти 2 строчки превращаются в одну:

auto [x, y, z] = getVector();

Удобно? Безусловно. Важно? Я так не думаю. Кроме того, с std::tie у нас есть функционал, который на данный момент недоступен в привязках. Мы можем проигнорировать ненужные части вот так:

tie(std::ignore, std::ignore, z) = getVector();

А с привязками мы так сделать не можем: мы обязаны указать привязку с уникальным именем (в данной области видимости) для каждого члена объекта, к которому мы решили привязываться. Полагаю, что в будущем можно ожидать принятия контекстно-зависимого ключевого слова «_», чтобы эквивалентный код можно было писать так:

auto [_, _, z] = getVector();

Пока же, если кто-то собирается использовать этот функционал, придётся прописывать все имена.

Другим примером является работа с std::map:

#include <iostream>
#include <map>
#include <string>

int main()
{
    std::map<std::string, size_t> albums = { 
        {"Coven", 1991},
        {"Fool", 1997}
    };

    auto [position, inserted] = albums.emplace("Outcast", 2005);
    for(const auto& [name, year] : albums)
        std::cout <<  name << ": " << year << "\n";
};

Из кода видно, что структурные привязки действительно делают работу с std::map проще и выразительнее. И это единственный пример, который мне действительно импонирует — всё остальное я вообще не вижу в своём коде. Вообще говоря, вся эта история с привязками в C++ и деконструкцией в C# похожи как две капли воды: и то и то появилось в 2017 году, в обоих случаях большинство примеров посвящено работе с кортежами (tuple). Правда, C# пошёл дальше, и там это сделано удобнее, как и кортежи внедрены непосредственно в язык, а не просто являются частью библиотеки. Всё это подталкивает к большему использованию кортежей в интерфейсах, а я являюсь противником подобного подхода, в результате чего, для своего кода, я особой пользы от этого функционала не вижу. Кстати, когда я писал пример выше, я его сначала написал вот так:

 auto [inserted, position] = albums.emplace("Outcast", 2005);

Что очень показательно: много ли людей помнят, что там идёт в first, а что в second? Поэтому всё удобство от использования структурных привязок, это просто лакмусовая бумажка недостатка в дизайне стандартной библиотеки. Не сделали бы они обезличенные структуры частью интерфейса, не было бы такой проблемы.

Однаком, есть у меня и один хороший пример, когда структурные привязки пришлись как раз в пору. Они используются в C++17 реализации библиотеки Precise and Flat Reflection. Для интересующихся как они там используются, можете посмотреть этот файл.

Люди с окрепшей психикой и небоящиеся трудностей могут посмотреть как тот же функционал реализован для C++14. Для этого понадобится изучить несколько файлов из этой папки. Чтобы несколько облегчить задачу, можно воспользоваться выступлением автора библиотеки на CppCon 2016. В недрах библиотеки используются действительно интересные трюки поэтому рекомендую. Кроме того, после понимания будет хорошо виден контраст между заковыристой, полулегальной реализацией на C++14 и простой, легальной реализацией на C++17.

До чего могут довести привязки в шаблонном коде? Кто знает. Но получить ещё один интересный инструмент, который позволяет малой кровью добраться до содержимого структур дорогого стоит. По крайней мере до тех пор, пока мы не получим в руки нормальный инструмент рефлексии, мы будем уповать на подобные извращения, ведь ничего другого нам не остаётся. Что же касается использования структурных привязок в повседневной коде, то я бы предпочёл, чтобы их там был как можно меньше.


Остальные статьи цикла:

Часть 1. Свёртка и выведение

Часть 3. Порядок и спокойствие

Часть 4. Сборная солянка