Это небольшая техническая иллюстрация, как можно добиться RVO/NRVO при возврате объектов из корутин в co_return, co_yield.
Как вообще объекты возвращаются из функций?
// Пусть имеется такой тип объекта:
struct object {
object(int _a, int _b, int _c) : a(_a), b(_b), c(_c) {}
int a, b, c;
};
// Пусть имеется функция, возвращающая такой объект:
object foo(int k) {
....
object obj(1, 2, k);
....
modify(obj);
....
return obj;
}
void bar() {
object obj = foo(3);
use(obj);
}
// Как на внутреннем уровне вообще объекты возвращаются из функций?
// В стековом фрейме вызывающей стороны отводится место под возвращаемый объект.
// В вызываемую функцию неявным дополнительным параметром передается указатель на это место.
// Вызываемая функция помещает свой ответ в это место.
// То есть на самом деле в общем случае реализация внутри выглядит примерно так:
object* foo(object* ret_value, int k) {
....
object obj(1, 2, k);
....
modify(obj);
....
// Вместо return obj размещаем возвращаемый объект
new (ret_value) object(std::move(obj));
return ret_value;
}
// Вызывающая сторона
void bar() {
alignas(object) char place_for_object[sizeof(object)];
object& obj = *foo(reinterpret_cast<object*>(place_for_object), 3);
// Ну тут компилятор конечно не заводит отдельную переменную, а просто начинает трактовать
// place_for_object как obj.
use(obj);
}
RVO и NRVO - это оптимизации, которые позволяют функциям, возвращающим объекты, не создавать эти объекты сначала в своём фрейме стека а после копировать их во фрейм вызывающей функции, а сразу оперировать с объектами на стеке вызывающей стороны. То есть внутри получается такой псевдо-код:
object* foo(object* ret_value, int k) {
....
object& obj = *new (ret_value) object(1, 2, k);
....
modify(obj);
....
return ret_value;
}Эти оптимизации позволяют избежать излишнего копирования или перемещения объектов. Возврат pr-values вообще обязывает компилятор применять RVO настолько, что возвращаемый тип может быть вообще не копируемым и не перемещаемым. И это всё отлично работает, пока мы пользуемся функциями.
Однако когда речь заходит о корутинах, об этих оптимизациях можно забыть. Если вы немного разбираетесь в корутинах, то знаете,
что "co_return expression;" не является настоящим возвратом, а всего лишь синтаксическим сахаром над "promise.return_value(expression)".
То есть корутина должна передать возвращаемый объект в эту функцию, та его должна куда-то скопировать/переместить, а затем
скопировать/переместить сохранённое значение пользователю корутины. Возникает как минимум два копирования/перемещения промежуточных объектов.
// Пусть корутина представлена неким типом task, в котором есть метод exec, возобновляющий корутину
// до полного выполнения, сохраняющий результат корутины и возвращающий его из result()
template<typename T>
struct task {
struct promise_type {
....
std::optional<T> result_;
....
template<typename V> requires std::is_constructible_v<T, V>
void return_value(V&& v) noexcept(std::is_nothrow_constructible_v<T, V>) {
result_.emplace(std::forward<V>(v));
}
....
};
....
task&& exec()&& {
while(!coro_.done()) {
coro_.resume();
}
return std::move(*this);
}
T&& result() && {
return std::move(coro_.promise().result_).value();
}
std::coroutine_handle<promise_type> coro_;
};
task<std::vector<int>> coro_foo() {
std::vector<int> result;
....
fill(result);
...
co_return result;
}
void bar() {
std::vector<int> v = coro_foo().exec().result();
}
Если task написан хорошо, в этом коде создастся один вектор в coro_foo, который затем будет перемещён в promise
как результат корутины, и затем ещё раз перемещён в bar. Если же task написан не очень хорошо, вектор из coro_foo
будет в bar два раза скопирован, а не перемещён.
Но даже в лучшем случае, хотелось бы избежать и этих двух перемещений, мы же любим "zero cost".
Некоего подобия частичного RVO можно добиться, если мы сразу возвращаем конструируемый объект, и у возвращаемого типа один параметр конструктора. Тогда хранимый где-то результат можно сразу инициализировать нужным конструктором, и мы избежим одного перемещения/копирования:
struct object {
object(int p);
....
};
task<object> coro_foo(int k) {
....
// Здесь мы сразу конструируем хранимый возвращаемый объект "по месту", без излишнего копирования
co_return k + 1;
}Однако, это довольно частный случай, мы не можем через co_return вызвать конструктор, в котором не 1 параметр.
Кроме того, перемещение/копирование из промежуточного хранилища в вызывающую функцию всё-равно остаётся.
Всё вышеперечисленное относится и к co_yield, которое как и co_return на самом деле является вызовом функции, которая
должна где-то сохранить переданное значение.
Можно ли как-то улучшить это поведение? Да, для этого нужно как-то передать корутине указатель на неинициализированное место, где нужно сохранить результат её выполнения. А в самой корутине иметь возможность получить доступ к этой информации, чтобы сразу разместить возвращаемое значение в нужном месте, с любым из его конструкторов. Примерно так:
job<std::vector<int>> return_vector() {
// Корутина вызывает нужный конструктор std::vector на месте, где лежит неинициализированное возвращаемое
// значение и получает ссылку на него
auto& result = co_emplace(3, 5);
// Может даже модифицировать его
result.emplace_back(8);
// Это вместо co_return result, мы же ведь уже установили возвращаемое значение.
co_done;
}
// Вот так бы хотелось чтобы работал вызов, но пока так не работает.
// Чтобы корутина конструировала сразу v во фрейме этой функции, без промежуточных объектов и перемещений.
void bar() {
std::vector<int> v = return_vector().exec().result();
std::cout << v.size();
use(v);
}
Можно ли на текущем уровне C++ добиться такого поведения? Почти можно, но с небольшими плясками.
Чтобы корутину можно было так вызывать, используем тот факт, что переменная становиться видна компилятору и позволяется
использовать, как только он видит её имя, ещё даже до инициализации.
Создан "хитрый" тип-обёртка, uninit<T>, через который можно заставить этот метод работать вот в таком виде:
template<typename T>
struct uninit {
uninit() = default;
uninit(uninit&& o) noexcept {
// Так как этот тип предполагается использовать только для передачи неинициализированных
// переменных в левую часть присваивания/инициализации самому себе же, сделаем конструктор
// перемещения, в котором ничего не делаем.
// Только проверим, что вызываемся сами для себя.
assert(&o == this);
}
~uninit() {
reinterpret_cast<T*>(value_)->~T();
}
// Не копируемый, не присваиваемый.
uninit(const uninit& o) = delete;
uninit& operator=(const uninit&) = delete;
// Конструирование значения
template<typename...Args> requires std::is_constructible_v<T, Args...>
T& emplace(Args&&...args) noexcept(std::is_nothrow_constructible_v<T, Args...>) {
new (value_) T(std::forward<Args>(args)...);
return *reinterpret_cast<T*>(value_);
}
T& operator*() {
return *reinterpret_cast<T*>(value_);
}
const T& operator*() const {
return *reinterpret_cast<const T*>(value_);
}
T* operator->() {
return reinterpret_cast<T*>(value_);
}
const T* operator->() const {
return reinterpret_cast<const T*>(value_);
}
private:
alignas(T) char value_[sizeof(T)];
};
job<std::vector<int>> return_vector() {
auto& result = co_emplace(3, 5);
result.emplace_back(8);
co_done;
}
TEST(Job, ReturnVector) {
uninit<std::vector<int>> vec = return_vector().exec_to(vec).result();
std::cout << "Get return vector in " << &*vec << ", size " << v->size() << "\n";
EXPECT_EQ(*vec, (std::vector<int>{5, 5, 5, 8}));
}Как вы видите, мы объявляем переменную vec, и в её инициализацию передаём ссылку на неё саму же.
Метод result возвращает r-value ссылку снова на неё же. После чего переменная vec инициализируется
конструктором перемещения, который просто ничего не делает, так как объект уже был инициализирован в
корутине return_vector посредством co_emplace.
В данном примере результирующий объект конструируется сразу в месте использования, никаких промежуточных объектов
с перемещением/копированием не создаётся.
Работающий код можно сразу посмотреть на godbolt.
Да, сейчас код для оптимального возврата объектов из корутин выглядит не очень хорошо: переменная объявляется через
обёртку вместо чистого типа, нельзя использовать auto, для доступа к объекту приходится использовать методы обёртки
(хотя и ничего не стоящие, но пачкающие код * и ->).
Но сам принцип, как корутины технически возможно улучшить и так же добиться в них RVO/NRVO, как и для
обычных функций - вполне виден.
Имхо, при доработках компилятора это можно будет сделать и без использования этой uninit обёртки.