Перемещение — прошлый век! Альтернативы std::move в «C++ будущего»

в 13:41, , рубрики: autoclosure, c++, closure, lambda, lazy, move semantics, proposal, relocation, замыкание, ленивые вычисления, лямбда, перемещение, Программирование

Каждый раз, когда мы пишем класс, управляющий ресурсами, мы задумываемся о том, что, скорее всего, для него придётся писать move-конструктор и move-присваивание. Ведь иначе объекты такого типа становятся неуклюжими, как std::mutex, ими тяжело пользоваться на практике: ни вернуть из функции, ни передать в функцию по значению, ни положить в вектор — а если положить его в другой класс как один из членов, то тот класс также «заболевает».

Положим, мы преодолели свою лень (хотя в Rust таких проблем нет!) и садимся писать move-операции для нашего класса. Проблема в том, что move-семантика в C++ имеет фундаментальное ограничение: каждый владеющий ресурсами тип с move-операциями должен иметь пустое состояние, то есть состояние с украденными ресурсами. Его нужно описывать в документации и предоставлять ему поддержку, то есть тратить время и силы на то, что нам не нужно.

Для абстрактных типов данных пустое состояние обычно бессмысленно — если у объекта украли его ресурсы, то он не сможет выполнять свои обычные функции. Но мы вынуждены это делать, чтобы реализовать move-семантику. Для некоторых типов пустое состояние недопустимо: open_file (в противовес теоретическому file), not_null_unique_ptr<T> (в противовес unique_ptr<T>).

Говоря словами Arthur O'Dwyer, мы заказывали телепорт, а нам дали «вас клонируют и убивают первоначальную копию». Чтобы вернуть себе телепорт, проходите под кат!

Я опишу несколько предложений к стандарту C++, которые объединены одной темой: свести к минимуму число перемещений. Но для начала, ещё раз: почему меня должно это заботить?

  1. Я не хочу тратить усилия на реализацию move-семантики для всех типов, владеющих ресурсами
  2. Я не хочу иметь во всех своих типах пустое состояние. Часто оно не к месту. Бывает, что его сложно или невозможно добавить. И всегда это лишние усилия на поддержку
  3. Даже если move-семантика реализуема, она может быть непозволительна из-за того, что мы хотим раздать указатели на этот объект
  4. Даже если перемещение допустимо, будет затрачено время на то, чтобы «занулить» первоначальный объект, и потом удалить его по всем правилам. И нет, компиляторы не могут это оптимизировать: раз, два

Итак, поехали.

P1144: Trivially relocatable

Это предложение к стандарту, за авторством Arthur O'Dwyer, добавляет новый атрибут [[trivially_relocatable]], которым можно пометить типы, которые можно передавать более эффективно, чем через move. А именно, мы копируем объект на новое место через memcpy и забываем про первоначальный объект, не вызывая для него деструктор. Правда, таким образом нельзя перемещать локальные переменные, так как компилятор вызывает их деструкторы за нас, не спрашивая, и у этой проблемы нет простого решения.

Атрибут можно применить к вашим классам при их определении. На практике атрибут будет нужен нечасто: компилятор автоматически помечает класс [[trivially_relocatable]], если все его члены являются таковыми, и вы не определили кастомные move-конструктор с деструктором (rule of zero). Классы стандартной библиотеки будут помечены [[trivially_relocatable]] для повышения производительности существующего кода, однако какие именно будут помечены, оставляется на усмотрение реализации. std::vector и прочие будут использовать новую функцию relocate_at, которая делает relocation или move, в зависимости от того, что тип поддерживает.

template <typename T>
class [[trivially_relocatable]] unique_ptr { ... };

std::vector<unique_ptr<widget>> v;
for (auto x : ...) {
  // Старые unique_ptr перемещаются через relocation, а не move
  v.push_back(std::make_unique<widget>(x));
}

С proposal есть несколько проблем, которые обсуждаются:

  • Можно пометить класс как [[trivially_relocatable]], даже если его члены таковыми не являются. Например, таким образом можно сломать std::mutex, обернув его в свой [[trivially_relocatable]] класс
  • У класса всё равно должен быть реализован конструктор копирования (будем добиваться отмены ограничения)
  • Trivially relocatable типы всё равно нельзя передавать в регистрах. Например, std::unique_ptr<T> по-прежнему будет передаваться в функции как указатель на указатель

P2025: Guaranteed NRVO

Рассмотренный выше proposal применим тогда, когда объект приходится перемещать, но можно сделать это эффективнее, чем сейчас. Тем не менее, в том случае указатели на объект всё равно «ломаются». В отличие от него, P2025 позволяет устранить саму причину перемещений в некоторых случаях.

C++17 исключил перемещения, когда мы вычисляем значение в return и тут же возвращаем его. Это называется Return Value Optimization (RVO). P2025 исключает также перемещения, когда мы возвращаем локальную переменную (NRVO). При этом она может быть не-перемещаемой, вроде std::mutex или наших абстрактных типов данных:

widget setup_widget(int x) {
  return widget(x);  // OK, C++17
}

widget setup_widget(int x) {
  auto w = widget(x);
  w.set_y(process(x));
  return w;  // OK, P2025
}

Кстати, proposal мой :) В течение пары дней перенаправлю ссылку на wg21.link.

P0927: Lazy parameters

Фактически, предлагается аналог @autoclosure из Swift. Параметр функции может быть помечен специальным образом, чтобы соответствующий аргумент при вызове автоматически оборачивался в лямбду. Перемещение при таком способе передачи параметров не происходит, объект создаётся сразу там, где нужно:

void vector<T>::super_emplace_back([] -> T value) {
  void* p = reserve_memory();
  new (p) T(value());
}

vector<widget> v;
v.super_emplace_back(widget());  // нет move
v.super_emplace_back([&] { return widget(); });  // под капотом

P0573: Abbreviated lambdas

Это решение более общее, чем предыдущее, и затрагивает также другие проблемные темы. Сокращённый синтаксис лямбда-выражений сделает работу с коллекциями и «ленивыми параметрами» в C++ такой же приятной, как и в нормальных других языках. Правда, с синтаксисом P0573 есть проблемы, но я готов предложить несколько других вариантов, к тому же, более коротких:

// Текущий синтаксис
auto add = [&](auto&& x, auto&& y) { return x + y; };
auto dbl = [&](auto&& x) { return x * 2; };
auto life = [&] { return 42; };

// P0573
auto add = [&](x, y) => x + y;
auto dbl = [&](x) => x * 2;
auto life = [&]() => 42;

// Мой #1: из Rust
auto add = |x, y| x + y;
auto dbl = |x| x * 2;
auto life = || 42;

// Мой #2
auto add = x y: x + y;
auto dbl = x: x * 2;
auto life = :42;

На этом всё! Желаю всем предложениям исправить пробелы и быть принятыми в C++23. Любые вопросы, замечания, пожелания оставляйте в комментариях.

Автор: Anton3

Источник

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


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