Исключение копирования и перемещения — это метод оптимизации, используемый многими компиляторами 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(). Произойдёт следующее:

  1. CreateFooA() создает временный объект Foo для возврата.
  2. Временный объект затем будет скопирован в объект, который будет возвращен CreateFooA().
  3. Значение, возвращаемое CreateFooA(), будет затем скопировано в t1.

Это утомительно и отрицательно скажется на производительности, если копирование объекта, с которым мы имеем дело, будет дорогостоящим!

Foo t2(CreateFooB());

Инициализация `t2` проходит аналогичный процесс:

  1. CreateFooB() создает временную переменную Foo, temp.
  2. Делайте что-нибудь с temp. В нашем случае мы обновляем переменную-членtemp.x.
  3. Затем temp необходимо скопировать в значение, которое будет возвращено CreateFooB().
  4. Значение, возвращаемое 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++. Не стесняйтесь оставлять комментарии, если у вас есть какие-либо сомнения или вы хотите что-то добавить!