В статье разработчик Барри Ревзин* объясняет, как можно определить и проверить свойства типов для обеспечения тривиального перемещения, разбирая нюансы эффективности, удобства и точности реализации. Основная проблема здесь в необходимости учёта всех особенностей типов, включая пользовательские функции, наследование и перегрузку. Это требует как сложных эвристик, так и новых инструментов языка. 

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

*Обращаем ваше внимание, что позиция автора может не всегда совпадать с мнением МойОфис


Одна из причин, по которой я с большим энтузиазмом отношусь к вводу рефлексии в C++, заключается в том, что она позволяет реализовать в виде библиотеки то, для чего ранее требовались языковые возможности. В этой статье я расскажу, как реализовать предложение (proposal) P2786R8 («Тривиальная перемещаемость для C++26»).

Или, по крайней мере, опишу трейт тривиального перемещения. Все остальное в библиотеке строится на его основе.

Цель этого текста — не рассуждать о правильном или неправильном дизайне (хотя синтаксис определенно вызывает вопросы), а показать, какие задачи можно решить с помощью рефлексии.

Давайте сразу перейдем к формулировке и переведем ее в код по ходу дела:

Тривиально перемещаемые типы

Скалярные типы, тривиально перемещаемые типы классов (11.2 [class.prop]), массивы таких типов и cv-квалифицированные версии этих типов в совокупности называются тривиально перемещаемыми типами.

Звучит как типовой трейт. Однако в мире рефлексии это всего лишь функции. Как можно было бы реализовать что-то подобное?

Можно начать с такого подхода:

consteval auto is_trivially_relocatable(std::meta::info type)
    -> bool
{
    type = type_remove_cv(type);

    return type_is_scalar(type)
        or (type_is_array(type)
            and is_trivially_relocatable(
                type_remove_all_extents(type)
            ))
        or is_trivially_relocatable_class_type(type);
}

Это довольно буквальный перевод текста предложения (proposal) в код, где is_trivially_relocatable_class_type подразумевает то, что мы напишем позже. Однако интересная особенность, связанная с трейтом типа type_remove_all_extents (то есть std::remove_all_extents), заключается в том, что она также работает для типов, не являющихся массивами, просто возвращая тот же тип. Таким образом, мы можем сделать всё ещё проще:

consteval auto is_trivially_relocatable(std::meta::info type)
    -> bool
{
    type = type_remove_cv(type_remove_all_extents(type));

    return type_is_scalar(type)
        or is_trivially_relocatable_class_type(type);
}

Отлично, идём дальше.

Обратите внимание, что каждая функция std::meta::type_meow представляет собой прямой перевод трейта типа std::meow в домен рефлексии consteval. Например, type_remove_cv(type) выполняет ту же операцию, что и std::remove_cv_t, за исключением того, что первая принимает info и возвращает info, а вторая принимает и возвращает тип.

К сожалению, мы не можем просто использовать эти функции с сохранением всех имен из-за нескольких конфликтов. Например, is_function(f) должна возвращать результат, является ли f рефлексией функции, тогда как трейт типа std::is_function проверяет, является ли F типом функции.

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

Допустимость тривиального перемещения

Класс допустим для тривиального перемещения, если он не содержит:

  • виртуальных базовых классов, или

  • базового класса, который не является тривиально перемещаемым классом, или

  • нестатического члена данных не ссылочного типа, который не является тривиально перемещаемым типом.

Это ещё один трейт… или, точнее, функция:

consteval auto is_eligible_for_trivial_relocation(std::meta::info type)
    -> bool
{
    return std::ranges::none_of(bases_of(type),
                                [](std::meta::info b){
            return is_virtual(b)
                or not is_trivially_relocatable(type_of(b));
        })
        and
        std::ranges::none_of(nonstatic_data_members_of(type),
                             [](std::meta::info d){
            auto t = type_of(d);
            return not type_is_reference(t)
               and not is_trivially_relocatable(t);
        });
}

Снова довольно буквальный перевод идеи предложения (proposal) в код. Я использовал is_trivially_relocatable вместо is_trivially_relocatable_class_type в первом случае просто потому, что это короче. Ваш опыт может отличаться в зависимости от того, считаете ли вы это более читабельным как вызов none_of() или отрицательный вызов any_of(), особенно для проверки нестатических членов данных.

Переходим к следующему пункту.

Тривиально перемещаемый класс

Наш последний термин — самый сложный:

Класс C считается тривиально перемещаемым, если он допустим для этого действия, при этом, одно из условий:

  • он имеет спецификатор class-trivially-relocatable, или

  • является объединением (union) без пользовательских специальных функций-членов, или

  • удовлетворяет всем следующим условиям:

    • когда объект типа C инициализируется напрямую из xvalue типа C, разрешение перегрузки выбирает конструктор – не пользовательский и не удалён, и

    • когда xvalue типа C присваивается объекту типа C, разрешение перегрузки выбирает оператор присваивания, который не является пользовательским и не удалён, и

    • деструктор класса не является пользовательским и не удалён.

Итак, вводная часть здесь простая, давайте разбираться:

consteval auto is_trivially_relocatable_class_type(std::meta::info type)
    -> bool
{
    if (not is_eligible_for_trivial_relocation(type)) {
        return false;
    }

    // TODO
}

Случай 1

В статье спецификатор class-trivially-relocatable — это контекстно-зависимое ключевое слово memberwise_trivially_relocatable, которое вы ставите после объявления класса. Однако оно позволяет только безусловно включить эту возможность и выглядит как просто "плавающее" слово после имени класса, так что здесь мы провернем кое-что получше.

Мы собираемся ввести аннотацию (P3394), но также разрешим ей иметь дополнительное значение bool:

struct TriviallyRelocatable {
    bool value;

    constexpr auto operator()(bool v) const -> TriviallyRelocatable {
        return {v};
    }
};

inline constexpr TriviallyRelocatable trivially_relocatable{true};

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

// true
struct [[=trivially_relocatable]] A { ... };

// also true, just explicitly
struct [[=trivially_relocatable(true)]] B { ... };

// false
struct [[=trivially_relocatable(false)]] C { ... };

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

consteval auto is_trivially_relocatable_class_type(std::meta::info type)
    -> bool
{
    if (not is_eligible_for_trivial_relocation(type)) {
        return false;
    }

    // case 1
    if (auto specifier = annotation_of<TriviallyRelocatable>(type)) {
        return specifier->value;
    }

    // TODO
}

Вызов annotation_of(type) возвращает optional, который либо ссылается на значение TriviallyRelocatable, аннотированное для этого типа, либо является пустым optional, если такой аннотации нет.

Это не совсем то, что указано в предложении, так как здесь я также допускаю возможность явного отказа (explicit opt-out), поскольку это легко реализовать, а в предложении ясно демонстрируется необходимость подобной функциональности.

Случай 2

Отлично, перейдём ко второму случаю:

это объединение (union) без пользовательских специальных функций-членов.

Его довольно просто реализовать с помощью доступных запросов:

consteval auto is_trivially_relocatable_class_type(std::meta::info type)
    -> bool
{
    if (not is_eligible_for_trivial_relocation(type)) {
        return false;
    }

    // case 1
    if (auto specifier = annotation_of<TriviallyRelocatable>(type)) {
        return specifier->value;
    }

    // case 2
    if (type_is_union(type)
        and std::ranges::none_of(members_of(type),
                                 [](std::meta::info m){
            return is_special_member_function(m)
               and is_user_declared(m);
        })) {
        return true;
    }

    // TODO
}

Случай 3

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

необходимо выполнение всех следующих условий:

  • когда объект типа C инициализируется напрямую из xvalue типа C, разрешение перегрузки выбирает конструктор, который не является пользовательским и не удалён, и

  • когда xvalue типа C присваивается объекту типа C, разрешение перегрузки выбирает оператор присваивания, который не является пользовательским и не удалён.

  • у него есть деструктор, который не является пользовательским и не удалён.

Давайте разберёмся, что такое возможность инициализировать объект типа C из временного значения C с помощью конструктора, который не является ни пользовательским, ни удален? Это значит, что нужно вызвать либо конструктор копирования, либо конструктор перемещения. Если конструктор перемещения существует, то достаточно просто проверить его (так как он всегда будет наилучшим выбором). Реальная проблема возникает в этом случае:

struct Bad {
    Bad(Bad const&) = default;
    // no move constructor

    template <class T>
    Bad(T&&);
};

Здесь по умолчанию используется копирующий конструктор, который блокирует неявный конструктор перемещения. Однако инициализация объекта Bad из Bad&& вызовет конструктор переадресации ссылок, а не копирующий конструктор. Мы бы хотели, чтобы Bad не соответствовал третьему случаю, но у нас нет изящного способа это сделать. Лучший обходной путь, который я могу предложить, выглядит так:

  • Если есть конструктор перемещения, то он должен быть задан по умолчанию.

  • В противном случае, если существует копирующий конструктор, то он должен быть задан по умолчанию – шаблонного конструктора быть не должно.

  • В противном случае — false.

Это, конечно, не совсем корректное, но достаточно неплохое решение. Здесь могут быть ложноотрицательные случаи (например, шаблонный конструктор может быть не пригоден для перемещения, он даже может не быть унарным), но их может и не быть. По крайней мере, я не могу привести ни одного похожего примера. Отсутствие ложноположительных случаев — это уже хорошо, так как ошибочное положительное срабатывание было бы критичным.

И похожий обходной путь для операторов присваивания, с тем отличием, что мы просто проверяем отсутствие других операторов присваивания. Вероятно, можно быть более точными, но это — неплохое начало для эвристики. Единственное раздражающее обстоятельство — необходимость аккуратно собрать всю информацию о состоянии специальных функций-членов. Утомительно, но выполнимо:

consteval auto is_trivially_relocatable_class_type(std::meta::info type)
    -> bool
{
    if (not is_eligible_for_trivial_relocation(type)) {
        return false;
    }

    // case 1
    if (auto specifier = annotation_of<TriviallyRelocatable>(type)) {
        return specifier->value;
    }

    // case 2
    if (type_is_union(type)
        and std::ranges::none_of(members_of(type),
                                 [](std::meta::info m){
            return is_special_member_function(m)
               and is_user_declared(m);
        })) {
        return true;
    }

    // case 3
    std::optional<std::meta::info> move_ctor, copy_ctor,
                                   move_ass, copy_ass,
                                   dtor;
    std::vector<std::meta::info> other_ctor, other_ass;

    for (std::meta::info m : members_of(type)) {
        // ... update that state ...
    }

    auto is_allowed = [](std::meta::info f){
        return not is_user_provided(f)
           and not is_deleted(f);
    };

    auto p31 = [&]{
        if (move_ctor) {
            return is_allowed(*move_ctor);
        } else {
            return copy_ctor
               and is_allowed(*copy_ctor)
               and other_ctor.empty();
        }
    };

    auto p32 = [&]{
        if (move_ass) {
            return is_allowed(*move_ass);
        } else {
            return copy_ass
               and is_allowed(*copy_ass)
               and other_ass.empty();
        }
    };

    auto p33 = [&]{
        return dtor and is_allowed(*dtor);
    };

    return p31() and p32() and p33();
}

Я использовал здесь лямбда-функции, так как считаю, что это более выразительный способ показать три подпункта без потери ленивых вычислений.

Заключение

Я не могу точно реализовать дизайн, предложенный в P2786. Последняя эвристика основана на разрешении перегрузок, что пока невозможно в текущем дизайне рефлексии. Однако может быть мне удастся создать достаточно приближенную реализацию для практического использования, уложившись примерно в 125 строк кода (то, что находится в namespace N). Другое отличие от оригинального дизайна состоит в том, что, поскольку добавить возможность явного включения и исключения оказалось легко, я это сделал.

Многие библиотеки уже предлагают какой-то подход к реализации тривиальной перемещаемости с возможностью явного включения или исключения. Таким образом, предоставление способа, чтобы, например, std::unique_ptr<T> стал тривиально перемещаемым, само по себе не кажется чем-то особенно впечатляющим:

// a unique_ptr-like type, which has to opt-in
// to being trivially relocatable since it has
// user-provided move operations and destructor
class [[=N::trivially_relocatable]] C {
    int* p;
public:
    C(C&&) noexcept;
    C& operator=(C&&) noexcept;
    ~C();
};

// would be false without the annotation
static_assert(N::is_trivially_relocatable(^^C));

Но сделать тип, подобный этому, автоматически (правильно) тривиально перемещаемым, без какой-либо аннотации — это совершенно новая задача:

struct F {
    int i;
    C c;
};
static_assert(N::is_trivially_relocatable(^^F));

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

Комментарии (17)


  1. NickSin
    08.12.2024 13:37

    Вот смотрю я на это дело и думаю...куда-то не туда С++ идет.
    С элегантного языка он превращается в какого-то монстра, 90% функционала которого будут использовать 2-4% разрабов. Остальные дальше С++11 и с++14 никуда не пойдут.