Исключение копирования и перемещения — это метод оптимизации, используемый многими компиляторами C++, чтобы избежать ненужного копирования и перемещения временных переменных.
Это ускоряет процессы, которые в противном случае могли бы быть медленными и неэффективными, особенно с дорогими объектами, такими как контейнеры или сложные пользовательские типы.
Давайте рассмотрим пример, чтобы понять, что делает эта оптимизация.
#include <iostream> class Foo { public: // Member variable int x = 0; // Default constructor Foo() { std::cout << "Default ctor\n"; } // Copy constructor Foo(const Foo& rhs) { std::cout << "Copy ctor\n"; } }; Foo CreateFooA() { return Foo(); } Foo CreateFooB() { Foo temp; temp.x = 42; return temp; } int main() { Foo t1(CreateFooA()); Foo t2(CreateFooB()); return 0; }
А для всех ботанов я буду использовать g++ 11.4.0.
$ g++ --version g++ (GCC) 11.4.0
Если мы скомпилируем приведенный выше код и запустим исполняемый файл, обратите внимание на наш результат. (Также обратите внимание, что мы компилируем на C++14, подробнее об этом позже).
$ g++ -std=gnu++14 copyelision.cpp -o app $ ./app Default ctor Default ctor
С другой стороны, если мы скомпилируем код, явно указав нашему компилятору не исключать конструкторы, используя -fno-elide-constructors
, мы получим следующий вывод.
$ g++ -std=gnu++14 -fno-elide-constructors copyelision.cpp -o appnoelide $ ./appnoelide Default ctor Copy ctor Copy ctor Default ctor Copy ctor Copy ctor
Ого, это намного больше, чем когда мы скомпилировали его с настройками по умолчанию! Давайте посмотрим, почему это так.
Foo t1(CreateFooA());
Когда мы объявляем t1
и инициализируем его, возвращая rvalue из CreateFooA()
. Произойдёт следующее:
CreateFooA()
создает временный объектFoo
для возврата.- Временный объект затем будет скопирован в объект, который будет возвращен
CreateFooA()
. - Значение, возвращаемое
CreateFooA()
, будет затем скопировано вt1
.
Это утомительно и отрицательно скажется на производительности, если копирование объекта, с которым мы имеем дело, будет дорогостоящим!
Foo t2(CreateFooB());
Инициализация `t2` проходит аналогичный процесс:
CreateFooB()
создает временную переменнуюFoo
,temp
.- Делайте что-нибудь с
temp
. В нашем случае мы обновляем переменную-членtemp.x
. - Затем
temp
необходимо скопировать в значение, которое будет возвращеноCreateFooB()
. - Значение, возвращаемое
CreateFooB()
, затем будет скопировано вt2
.
Опять же, очень утомительно и неэффективно.
$ ./appnoelide Default ctor # construct temporary variable to return in CreateFooA() Copy ctor # copying temporary variable to returning object of CreateFooA() Copy ctor # copying rvalue returned by CreateFooA() into t1 Default ctor # construct 'temp' inside CreateFooB() Copy ctor # copy 'temp' into to returning object of CreateFooB() Copy ctor # copying rvalue returned by CreateFooB() into t2
Семантика перемещения C++11 помогает улучшить эту ситуацию, избегая копирования ресурсов и вместо этого перемещая их.
// Move constructor Foo::Foo(Foo&& rhs) { std::cout << "Move ctor\n"; }
Добавив конструктор перемещения, мы получим следующий результат без исключения конструкторов.
$ ./appnoelide Default ctor Move ctor Move ctor Default ctor Move ctor Move ctor
Но подождите, CreateFooB()
возвращает именованную переменную, а это значит, что это lvalue. Почему он может вызывать конструктор перемещения?
Это связано с правилом стандарта C++, а именно 12.8. В нем говорится, что если функция имеет тип возвращаемого значения класса и критерии исключения копирования соблюдены, и если возвращаемое выражение является именованной переменной (lvalue), объект рассматривается как rvalue при выборе конструктора для копии т.е. будет использоваться конструктор перемещения, если он доступен.
Благодаря этому некопируемыеобъекты, такие как std::unique_ptr
, могут быть возвращены по значению, даже если это именованная переменная.
std::unique_ptr<int> CreateUnique() { auto ptr = std::make_unique<int>(0); return ptr; // This compiles! }
Однако важно отметить, чтоесли конструктор копирования или перемещения исключен, этот конструктор все равно должен существовать —Это гарантирует, что оптимизация по-прежнему учитывает, принадлежит ли объект к классу, который некопируем. или неподвижный.
Теперь, если мы вернемся к выводу исполняемого файла с включенной оптимизацией исключения копирования (помните, что она включена по умолчанию!).
$ ./app Default ctor Default ctor
Здесь мы устраняем дополнительные вызовы конструкторов и напрямую конструируем объекты, которые должны быть возвращены, для переменной, которой они должны быть присвоены.
Сравнивая объемы выпуска, этот метод оптимизации повышает скорость и эффективность. Оптимизации, выполняемые на CreateFooA()
и CreateFooB()
, официально называются RVO (оптимизация возвращаемого значения) и N(Named)RVO соответственно.
Поскольку эта оптимизация включена по умолчанию, важно помнить, что это исключение будет применяться, даже если это означает отказ от вызова какого-либо кода, который может присутствовать в конструкторах. Следовательно, следует избегать критической логики внутри конструкторов копирования/перемещения, поскольку мы не можем полагаться на их вызов!
Гарантия C++17 на RVO
И последнее замечание в этой статье: помните, как мы ранее компилировали код на C++14? Это связано с тем, что C++17 предоставляет нам гарантию исключения копирования для RVO. Однако это не относится к NRVO.
Это означает, что CreateFooA()
гарантированно исключает копирование/перемещение, тогда как CreateFooB()
может исключать, а может и не исключать их.
$ g++ -std=gnu++17 -fno-elide-constructors copyelision.cpp -o appnoelide $ g++ -std=gnu++17 copyelision.cpp -o app # Similar output to C++14 $ ./app Default ctor Default ctor # Even if we disable copy/move elision, it will still be performed for # RVO situations $ ./appnoelide Default ctor Default ctor Move ctor
Надеюсь, это проясняет ситуацию с копированием/перемещением и RVO/NRVO в C++. Не стесняйтесь оставлять комментарии, если у вас есть какие-либо сомнения или вы хотите что-то добавить!