Предположим, что в программе на C++ вы возвращаете из функции локальную переменную. Что происходит при вызове оператора return
: копирование, перемещение или ни то, ни другое? От этого зависит длительность вызова функции и эффективность наших программ. Я постарался разобраться с этим вопросом и дам рекомендации по написанию функций так, чтобы повысить шансы на применение этой оптимизации компиляторами. Ну, а сокращения в названии статьи — это Return Value Optimization (RVO) и Named Return Value Optimization (NRVO).
Определение NRVO и RVO
Давайте договоримся о терминах. Предположим, мы написали функцию:
C f() {
C local_variable;
// Действия с local_variable
return local_variable;
}
где C
— некий пользовательский класс. Что произойдет при её вызове?
C result = f();
Кажется, должна выполниться такая последовательность действий:
создание
local_variable
при помощи вызова конструктора по умолчанию классаC
;вызов конструктора копии класса
C
, чтобы копироватьlocal_variable
вresult
;вызов деструктора для
local_variable
.
Действительно, это так и произойдёт, если не будет использована Named Return Value Optimization (NRVO). А если будет, то вместо создания local_variable
компилятор сразу создаст result
конструктором по умолчанию в точке вызова функции f()
. А функция f()
будет выполнять действия сразу с переменной result
. То есть в этом случае не будет вызван ни конструктор копии, чтобы скопировать local_variable
в result
, ни деструктор local_variable
. Можно представить это так:
компилятор создаёт конструктором по умолчанию до вызова функции
f()
переменнуюresult
;затем неявно передаёт в функцию
f()
указатель наresult
;в рамках функции
f()
не создаётlocal_variable
, а вместо этого работает с указателем наresult
;в
return
ничего не копируется, поскольку данные уже там.
Что же касается Return Value Optimization, то это такая же оптимизация, как NRVO, но для случаев, когда экземпляр возвращаемого класса создаётся прямо в операторе return
:
C f() { return C(); }
В таких ситуациях компилятору легче выполнить оптимизацию, чем в случае с NRVO.
Обычно компилятор, даже со всеми включёнными оптимизациями, не обязан применять RVO/NRVO, а лишь имеет на это право. Поговорим о том, что мешает компилятору применять эти оптимизации, а что помогает, и о том, как повышать шансы на их применение.
Чтобы не загромождать статью, я разберу только два случая, упомянутых выше:
Локальная переменная возвращается из функции (NRVO).
Объект, созданный в точке вызова
return
, возвращается из функции (RVO).
Отмечу также, что стандарт и некоторые другие источники предпочитают вместо RVO/NRVO употреблять более общий термин copy elision (пропуск копии). Пару слов о нём скажу в конце статьи.
Случаи, когда компилятор обязан применить RVO
В C++17 есть два случая, когда компилятор обязан применить RVO.
1. Функция возвращает prvalue.
Во-первых, RVO будет применяться, когда возвращается prvalue
того же типа, что описан в сигнатуре функции, или когда возвращается тип, из которого может быть сконструирован (явно или не явно) тот тип, который задан в сигнатуре. При этом игнорируются квалификаторы const
и volatile
. А операторов return
может быть несколько, но все должны возвращать prvalue
. Например, в этом случае будет всегда применено RVO:
C f() { return C(); }
C result = f();
Также всегда будет применено RVO в таком случае:
C f() {
int n = 1;
return n;
}
при условии, что у класса C
есть конструктор от int
, который не объявлен как explicit. Именно RVO, поскольку экземпляр класс C
будет сконструирован неявно из n
в операторе return
.
До 17-го стандарта это было рекомендацией для компилятора, а в C++17 стало обязательной «оптимизацией». Я взял термин в кавычки, поскольку с точки зрения C++17 это уже не оптимизация, а обязательная часть работы компилятора. Более того, согласно новому стандарту, чтобы код выше скомпилировался классу C
не требуются конструктор копии и перемещающий конструктор, поскольку эти конструкторы гарантированно не будут использованы при вызове f()
. То есть в примере result
будет создан сразу в точке вызова функции f()
.
Конструктор копии и перемещающий конструктор могут отсутствовать только в том случае, если компилятор обязан применить RVO. Если компилятор не обязан применять RVO/NRVO, а лишь имеет на это право, то перемещающий конструктор или конструктор копии будет нужен для того, чтобы сработать, если оптимизация не получится.
2. Constant expression и constant initialization.
Также компилятор обязан применить RVO в функциях времени компиляции (constexpr
) и при инициализации глобальных, статических и thread-local переменных (constant initialization). Интересно, что в этих же случаях применение NRVO гарантированно не случится. Рассмотрим на примерах:
struct S {
constexpr S() {}
};
constexpr S rvo() { return S(); }
constexpr S nrvo() {
S s;
return s;
}
S global_rvo = rvo();
S global_nrvo = nrvo();
Здесь при инициализации global_rvo
гарантированно применится RVO. Строчка S global_rvo = rvo();
скомпилируется, даже если у структуры S
не будет конструкторов копии или перемещения. А вот для инициализации global_nrvo
необходимо, чтобы у структуры S
были конструкторы копии или перемещения, поскольку один из них должен быть вызван в обязательном порядке. Ведь, как отмечено выше, в случае constant initialization NRVO применять нельзя.
Теперь поговорим про случаи, когда компилятор сам решает, применять ли ему RVO/NRVO.
Необходимые условия для применения RVO/NRVO
Необходимые условия для применения этой оптимизации:
Тип объекта, возвращаемого из функции согласно сигнатуре, должен совпадать с типом локального объекта или конструироваться неявно из типа локального объекта. Допустимо отличаться на константность.
Возвращаться должен именно локальный объект, а не ссылка на него или какая-то его часть.
В случае NRVO возвращаемый объект не должен быть volatile.
Поясню на простом примере:
struct N {
N() {}
N(int) {}
};
N k(int i) {
int n = 1;
return n;
}
Здесь NRVO может быть применено, поскольку N
конструируется из p
.
Случаи, когда компилятору сложно применить RVO/NRVO
Предположим, выполнены все необходимые условия. Тем не менее, обычно оптимизация не применяется в следующих случаях:
Есть несколько путей выхода из функции, которые возвращают разные локальные объекты.
Возвращаемый локальный объект ссылается на встроенный asm-блок.
Не стоит писать return std::move(local_value)
Рассмотрим пример из начала статьи:
C f() {
C local_variable;
// Действия с local_variable
return local_variable;
}
В нём оптимизация NRVO, скорее всего, сработает. Допустим, мы уверены, что у класса C
есть перемещающий конструктор. Тогда может возникнуть соблазн написать что-то вроде:
C f() {
C local_variable;
// Действия с local_variable
return std::move(local_variable); // Так писать не стоит
}
Так делать ни в коем случае нельзя, иначе компилятор не сможет применить NRVO. В таком случае в операторе return
будет вызван перемещающий конструктор класса C
. На его вызов требуются ресурсы. А если перемещающего конструктора нет, то будет вызван конструктор копирования. Поясню подробнее.
В случае, если необходимые условия для применения NRVO выполнены (пример без std::move()
), но оптимизация не применена по каким-то причинам, то возвращаемый объект обязан быть рассмотрен как rvalue
. То есть возвращаемый объект будет рассмотрен так, как если бы к нему было применено std::move()
. Выражаясь иначе, если NRVO не применено, то при наличии у возвращаемого объекта перемещающего конструктора будет вызван он. А если перемещающий конструктор отсутствует, то будет вызван конструктор копирования. И для этого не нужно писать std::move(local_variable)
.
Эти рассуждения приводят нас к тому, что применение std::move()
к возвращаемому локальному объекту не приносит никакой пользы. Более того, это вредит: std::move()
меняет тип возвращаемого объекта. По сути, функция возвращает rvalue-ссылку на тип объекта, которые ей передаётся в качестве аргумента. То есть в нашем случае это будет rvalue-ссылка на локальный объект типа C
. А, как отмечено выше, это препятствует применению NRVO, поскольку тип возвращаемого объекта будет rvalue-ссылка на тип C
, в то время как f()
возвращает просто тип C
.
То есть при возвращении из функции локального объекта в операторе return
не стоит писать std::move()
. Однако нередко бывает оправдано применение std::move()
к возвращаемому из функции параметру, если он передан по rvalue-ссылке. Но это тема для отдельного обсуждения.
Как включить и выключить RVO/NRVO-оптимизацию в компиляторах
Оптимизация RVO/NRVO по умолчанию включена. Отключить её можно при помощи флага компиляции -fno-elide-constructors
. Важно отметить, что флаг отключает именно оптимизацию, то есть в случаях, когда стандарт гарантирует отсутствие копирования возвращаемого значения это копирование не происходит даже с этим флагом.
Проверка, сработает ли RVO/NRVO в конкретном случае
Иногда хочется узнать, сработает ли RVO/NRVO в конкретном случае. Сделать это очень просто: достаточно вставить в возвращаемый класс записи в лог в некоторые специальные методы, а именно в конструкторы, в перемещающий конструктор, в конструктор копии и деструктор. Вот пример класса, который можно вернуть из функции и по логам понять, сработает ли RVO/NRVO:
class NRVOCheck {
public:
NRVOCheck() { std::cout << "constructor. address: " << this << '\n'; }
NRVOCheck(NRVOCheck const&) { std::cout << "copy constructor\n"; }
NRVOCheck(NRVOCheck&&) { std::cout << "move constructor\n"; }
~NRVOCheck() { std::cout << "destructor. address: " << this << '\n'; }
};
Другие случаи отмены копирования (copy elision)
В C++ копирование иногда отменяется и в других случаях. Например, в этом коде вызывается только конструктор по умолчанию для инициализации C:
C f() {
return C();
}
C x = C(C(f()));
Копирование также отменяется при запуске и перехвате исключений. Это темы для отдельного обсуждения.
И еще одно важное замечание. В C++ есть правило "as-if": компилятору разрешено не генерировать код для операций, если их отсутствие не изменит поведение программы, наблюдаемое со стороны. Например, конструктор копии, который компилятор генерирует автоматически, может быть не вызван, если это не изменит поведение программы. Но если мы вместо автоматически сгенерированного конструктора копии напишем свой конструктор копии, в котором выведем что-то в лог, то внешне наблюдаемое поведение изменится, потому что при его вызове что-то будет выведено в лог. И после этого он станет вызываться.
Так вот, в статье были описаны только случаи, когда компилятор обязан применить RVO или может применить RVO/NRVO несмотря на изменение внешне наблюдаемого поведения. То есть, допустим, мы в специальных методах (конструкторе по умолчанию, конструкторе копии, перемещающем конструкторе и т.д.) выводим информацию в std::cout
, меняя внешне наблюдаемое поведение. Эти специальные методы компилятор всё равно сможет удалить при применении RVO/NRVO.
Вызов кода конструктора копии и перемещающего конструктора
Всё вышесказанное имеет очевидное следствие, на которое я хотел бы обратить внимание. В ряде случаев компилятор сам решает, применять ли RVO/NRVO. Если применяет, то конструктор копии или перемещения не будет вызван, а если не применяет — то будет. Это зависит от компилятора и платформы. Поэтому не стоит полагаться на вызов этого кода и размещать в нём что-то важное кроме копирования или перемещения объекта.
Выводы
RVO/NRVO не новые оптимизации. Компилятор имел право применять их ещё в стандарте C++98. По прошествии времени стандарт стал строже регламентировать применение этих оптимизаций. Однако по-прежнему остаётся довольно большая серая зона, в которой компилятор решает, применять ли RVO/NRVO. И если нет возможности гарантировать применение RVO, то стоит хотя бы постараться повысить шансы на её применение.
Рекомендации
Целесообразно прежде всего рассмотреть возможность вернуть prvalue
, то есть создать экземпляр класса прямо в операторе return
. Это будет гарантировать отсутствие копирований и перемещений, а также не потребует от создаваемого объекта конструкторов копии и перемещения. Даже если операторов return
, в которых создаются объекты, будет несколько.
Допустим, так писать неудобно или невозможно. Тогда желательно сделать так, чтобы несколько путей выхода из функции возвращали один и тот же локальный объект.
Также в случаях, когда мы рассчитываем на применения RVO/NRVO, важно следить за тем, чтобы тип создаваемого в операторе return
объекта или тип локального объекта, возвращаемого из функции, точно совпадал с типом, прописанным в сигнатуре функции. Ну, или, как минимум, чтобы из типа, возвращаемого из функции, мог быть сконструирован тип, прописанный в сигнатуре функции.
Важно также следить за тем, чтобы в операторе return
, возвращающем локальный объект, не стояло std::move()
.
Статьи и лекции
Стандарт C++17. Раздел: 15.8.3 Copy/move elision
Хорошая лекция про NRVO/RVO и про прочие случаи возвращаемых значений из функций.
Комментарии (22)
Mingun
27.05.2022 16:45+4Порой мне кажется, что С++ развивают форменные мазохисты. Ведь уже не один десяток лет понятно, что есть куча мест, где необходимо гарантировать определенное поведение и является допустимым просто не компилировать код, если эти гарантии невозможно выполнить. Тем не менее, вместо того, чтобы дать в руки программисту инструмент для явной отметки таких мест, в стандарт добавляют еще одну фичу, которая в некоторых случаях может поможет, а может и нет. Через несколько лет поняв, что введенная фича по прежнему не решает проблемы, вводят еще одну, опять по тому же сценарию.
Ведь уже появился синтаксис атрибутов, что мешает наконец завести атрибуты для включения гарантий нужного поведения?
BykoIanko Автор
27.05.2022 18:06+1Ведь уже появился синтаксис атрибутов, что мешает наконец завести атрибуты для включения гарантий нужного поведения?
Тут традиционный компромис:
Гарантированное поведение на одной чаше весов.
На другой, эффективность на разных платформах.
С++ решает обычно решает такие штуки в строну эффективности. С другой стороны, синтаксис языка и так сложен. Кажется, что нежелание добавлять еще опций по указанию таких штук (например, через атрибуты), тоже можно понять. Думаете пользовался бы популярностью атрибут "Не применять RVO/NRVO"?
Ну и в дополнение, можно вспомнить о том, как принимаются решения о добавлении новых особенностей в C++ :)
Как следствие получаем C++, как весьма сложную конструкцию, с очень высоким порогом входа. На мой взгляд, сейчас язык непомерно сложен, но стал по современнее последние годы.
KanuTaH
27.05.2022 18:27+1Думаете пользовался бы популярностью атрибут "Не применять RVO/NRVO"?
Такой - точно нет, с другой стороны, возможно, некоторой популярностью бы пользовался атрибут "выдавать ошибку, если в данном месте не удается применить copy elision". С третьей стороны, это всего лишь оптимизация, и тогда таких атрибутов надо наделать вообще для любых оптимизаций, потому что чем они хуже? Мы утонем в атрибутах с таким подходом.
BykoIanko Автор
28.05.2022 06:15некоторой популярностью бы пользовался атрибут "выдавать ошибку, если в данном месте не удается применить copy elision".
Такой, кажется, что мог бы использоваться. Для уверенности, что сработает.
Мы утонем в атрибутах с таким подходом.
Да. Есть такое дело :)
Mingun
27.05.2022 19:21Думаете пользовался бы популярностью атрибут "Не применять RVO/NRVO"?
Наоборот, было бы полезно иметь "вот здесь должен быть rvo", "вот здесь только хвостовая рекурсия", если разработчик переписывает код и это уже не получается гарантировать, то бить по рукам, пусть думает дальше.
Вижу мне уже вроде намекают, что такие атрибуты есть. Не знаю, я вообще на C++-сник уже давно.
BykoIanko Автор
28.05.2022 06:37Наоборот, было бы полезно иметь "вот здесь должен быть rvo", "вот здесь только хвостовая рекурсия", если разработчик переписывает код и это уже не получается гарантировать, то бить по рукам, пусть думает дальше.
Да, это отличный подход! Много таких штук в C++, которые не понятно сработают или нет.
Вижу мне уже вроде намекают, что такие атрибуты есть. Не знаю, я вообще на C++-сник уже давно.
Если не сложно, уточните п-та, о чем речь. Мне про них не известно, а если есть я бы хотел знать. Если информация не очень точная, можно и в личку. Спасибо!
tbl
27.05.2022 18:07+1[[fail_if_not(rvo, nrvo)]]
BykoIanko Автор
28.05.2022 06:45+1Что-то я такого атрибута ни где найти не могу. Это пример, как могло бы выглядеть? Вообщем ссылочку пришлите п-та, если знаете про такой стандартный или не стандартный атрибут.
tbl
28.05.2022 14:11Это ирония по поводу того, что разработчики C++ предлагают или ищут новые атрибуты на каждый новый чих стандарта. Например, недавно всплывало при обсуждении лайфтаймов и временных объектов, чтобы починить поломанные "by design" range-based for loop в C++23:
[[short_and_concise_lifetime_annotation]]
и[[probably_broken_if_this_is_ignored]]
Videoman
28.05.2022 17:01+1А если будет, то вместо создания local_variable компилятор сразу создаст result конструктором по умолчанию в точке вызова функции f(). А функция f() будет выполнять действия сразу с переменной result.
Попытка максимально упростить описание может привести к неправильному понимаю того, что на самом деле происходит. На практике компилятор просто выделяет память под объект, никакого конструктора по умолчанию не вызывается (его вообще может не быть), а объект создается уже поверх выделенной памяти внутри функции, в которую передается указатель на выделенную память, тем конструктором, который используется в RVO.
Вот пример:#include <iostream> class A { public: A() noexcept { std::cout << "A()"; } A(const A& that) = delete; A(A&& that) noexcept = delete; A(int x, int y) noexcept : x(x), y(y) { std::cout << "A(int, int)"; }; A& operator=(const A& that) = delete; A& operator=(A&& that) noexcept = delete; int x; int y; }; A func(int x, int y) { return A(x, y); } int main() { A a = func(5, 5); return a.x + a.y; }
vanxant
29.05.2022 12:30Мне кажется, для начала было бы неплохо копнуть чуть-чуть глубже. А именно, рассмотреть вопрос, а как физически из функций возвращаются объекты, которые не влезают в машинное слово (регистр процессора). Т.е. результат
int f()
возвращается в регистре-аккумуляторе, но как быть сstd::string f()
? Строка может быть любой длины и потому размещается в куче; но кто тогда должен отвечать за освобождение этой памяти, когда результат больше не требуется? Сама функцияf
очевидно этим заниматься не может, значит, это должна делать вызывающая функция. Но тогда и выделять память должен тот, кто её освобождает (у них с f, в принципе, аллокаторы могут и не совпадать).И тут мы приходим к тому, что объявление
C f()
для крупных типов есть всего лишь синтаксический сахар дляvoid f(C * result)
. Это именно указатель на память, не ссылка на объект; обязанность вызвать at-конструктор в этой памяти лежит на функцииf
. Но если знать про наличие этого указателя, вся "оптимизация" rvo/nrvo сводится к тому, чтобы компилятор не тупанул и не завёл ещё одну переменную для временного хранения результата.Кстати, в языке Си вариант
C f()
разрешён синтаксисом и в С++, собственно, появился оттудова. Но в Си подобный неявный сахар считается табу, атата и харам, причём настолько, что такой синтаксис ставит многих знакомых мне сишников в ступор. Хочешь вернуть структуру — передавай указатель. Точка.tbl
29.05.2022 20:47Нет, это не сахар, это именно работа над объектами (известной на момент компиляции длины) на стеке, а не в куче. И std::string - это объект известной длины, содержащий в себе длину строки и указатель на область памяти в куче, содержащей само содержимое строки. *RVO для std::string - это ожидание, что сохрантсяя оригинальная строка в куче, а не занятие байтоперекладыванием. Т.е. при *RVO компилятор выделяет место на стеке именно в том месте, где будет указатель вершины стека при возврате из функции. А если это невозможно, то используются move-конструкторы (поэтому отсутствие запрета существования move-конструктора нужно в этих случаях для *RVO)
vanxant
29.05.2022 22:59Когда я последний раз смотрел на соглашения о вызовах в основных архитектурах, там было именно так как я написал - неявно передаётся указатель на результат. Оно может конечно лежать в стеке вызывающей функции, и чаще всего так и есть, но совершенно не обязательно на вершине этого стека. Это просто какая-то локальная переменная вызывающей функции, совершенно не обязательно крайняя.
jimmy_b
Было бы интересно услышать комментарии по поводу распространенного сочетания:
BykoIanko Автор
Как мне кажется тут как раз серая зона. RVO/NRVO может сработать, а может и нет. Зависит от компилятора, платформы и даже еще от одного факта. От того, всегда или нет выполнено условие
/*condition*/
. Если выполнено всегда, компилятор может понять это, и оптимизировать код, оставив одну веткуif()
.Думаю, что если мы перепишем пример так:
шансов на NRVO прибавится.
jimmy_b
да, интересен случай, когда condition заранее неизвестен.
кажется, что RVO для компилятора понятнее и удобнее, чем NRVO.
поэтому обычно считается, что после проверки if(/*condition*/), быстрый возврат return {}; — это хорошо.
и еще есть принцип объявления переменной как можно ближе к месту использования.
в совокупности эти два совета приводят нас к указанному мной варианту кода.
вроде бы, он хорош со всех сторон. но получается, что шансы на получения оптимизации для нас уменьшаются?
BykoIanko Автор
Похоже что так. RVO от части перекочевало из оптимизации в правила обязательной отмены копирования. Похоже, тенденция от стандарта к стандарту в том, чтоб уменьшать серую зону, в которой компилятор сам решает применять ли RVO/NRVO или нет.
Кроме того, отмечу, что при таком подходе (return early pattern), код становится менее громоздким и часто лучше читается, на мой взгляд.
Кажется, что да. Я проверил на clang (MacOS X). На нашем втором примере (где в двух местах
return local_variable;
) NRVO был применен. В нашем первом примере, если вызвать вот так:NRVO не сработал.
me21
А бывает такое, чтобы в первом return был применён RVO, а во втором - NRVO? Или оптимизация одна на всю функцию, либо так либо так?
BykoIanko Автор
Думаю, в одной функции может быть, чтоб иногда было RVO, иногда NRVO, а иногда было вызвано копирование. Т.е. если говорить языком стандарта, то и RVO, и NRVO это частные случаи отмены копирования (copy elision). Крайняя точка принятия решения о том, случится ли отмена копирования - это точка вызова ф-ции в Run Time. Технически даже можно представить, что в ряде ситуаций, компилятор может оставить место под результат работы функции на стеке и начать ее исполнять. И уже позже, создать в оставленном месте результат работы функции. А если так не будет получатся, то по возможности выполнить перемещение в оставленное место. А если и это не возможно, то копирование.