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