Каждый раз, когда мы пишем класс, управляющий ресурсами, мы задумываемся о том, что, скорее всего, для него придётся писать 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++, которые объединены одной темой: свести к минимуму число перемещений. Но для начала, ещё раз: почему меня должно это заботить?
- Я не хочу тратить усилия на реализацию move-семантики для всех типов, владеющих ресурсами
- Я не хочу иметь во всех своих типах пустое состояние. Часто оно не к месту. Бывает, что его сложно или невозможно добавить. И всегда это лишние усилия на поддержку
- Даже если move-семантика реализуема, она может быть непозволительна из-за того, что мы хотим раздать указатели на этот объект
- Даже если перемещение допустимо, будет затрачено время на то, чтобы «занулить» первоначальный объект, и потом удалить его по всем правилам. И нет, компиляторы не могут это оптимизировать: раз, два
Итак, поехали.
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. Любые вопросы, замечания, пожелания оставляйте в комментариях.
