Pull to refresh

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

ProgrammingC++

Каждый раз, когда мы пишем класс, управляющий ресурсами, мы задумываемся о том, что, скорее всего, для него придётся писать 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 мой :)


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. Любые вопросы, замечания, пожелания оставляйте в комментариях.

Tags:c++move semanticsrelocationautoclosurelambdaclosurelazyproposalперемещениелямбдазамыканиеленивые вычисления
Hubs: Programming C++
Total votes 24: ↑22 and ↓2 +20
Views13K

Popular right now

Разработка на C#
July 15, 202180,000 ₽GeekBrains
Курс C# Junior Developer
July 20, 202123,990 ₽Level UP
Факультет Java-разработки
June 15, 2021180,000 ₽GeekBrains
Аналитик BI
June 15, 202160,000 ₽Нетология