Как программист на C++, я со временем понял, что компилятор может быть очень умным программным обеспечением. Достаточно умный, чтобы выполнять многие оптимизации намного лучше, чем люди. В этой статье я хотел бы показать вам, насколько хорошим может быть компилятор, когда дело доходит до оптимизации исключения копирования и возвращаемого значения.

Примечание. Я предполагаю, что читатель знаком с терминами "исключение копирования" и "оптимизация возвращаемого значения" до прочтения этой статьи.

Я разработал эксперимент для этой статьи. Я начал с двух классов разного размера (8B против 800B) и зарегистрировал каждый из их конструкторов и операторов присваивания, чтобы увидеть, когда они выполняются.

Потом я написал кучу сценариев того, что может произойти с объектами этих двух классов:

  • Различные способы инициализации и переназначения объекта
  • Создайте объект, вызвав фабричную функцию
  • Переназначить уже созданный объект результату фабричной функции
  • Передача результата фабричной функции в параметр передачи по значению
  • Передача результата фабричной функции в параметр передачи по rvalue
  • Прием параметра передачи по значению, его изменение и возврат
  • Переход к параметру передачи по значению

Каждый сценарий выполнялся в трех средах:

  • Все вспомогательные функции, созданные в одной единице компиляции (один и тот же файл .cpp)
  • Все вспомогательные функции, созданные в одной статической библиотеке
  • Все вспомогательные функции, созданные в одной динамической библиотеке

Наконец, я скомпилировал их под MSVC 17.4.4, Clang 12 и GCC 9.4, чтобы посмотреть, что получится. Цель состояла в том, чтобы увидеть, когда значения копируются и когда они перемещаются.

И результаты были ошеломляющими.

Искусство не копировать возвращаемые значения

Рассмотрим простой фабричный метод:

Object CreateObject(int param)
{
   auto result = Object(param);
   // something happens to 'result'
   return result;
}

auto object = CreateObject(42);

Эта функция связана с созданием объекта, и этот объект необходимо скопировать в целевую переменную. Если объект содержит динамически размещенные данные, у вас может возникнуть соблазн переместить его. Это неправильное искушение. Начиная с C++17, такие возвращаемые значения гарантированно исключаются при копировании, поэтому в этом сценарии выполняется только обычный конструктор Object и выполняется ровно один раз. Результат создается непосредственно в пространстве стека, выделенном для объекта.

Это также означает, что вызов move будет медленнее. На самом деле это называется «пессимизирующим ходом», и у GCC и Clang есть флаг для его обнаружения (надеюсь, MSVC скоро догонит).

Если возвращаемое значение изначально было параметром функции, переданным по значению или по ссылке rvalue, то исключение копирования невозможно.

Object UpdateObject(Object o)
{
   o.data = Something();
   return o;
}

auto object = UpdateObject(CreateObject(number));

В этом случае все компиляторы автоматически выдавали команду перемещения. Однако, когда параметр был передан как ссылка rvalue, GCC выдает копию (обе версии 9.4 и 12.2), Clang выдает копию в версии 12 и перемещает в версии 16. MSVC в версии 17.4 выдает перемещение.

Важно отметить, что исключение копирования работает только в том случае, если класс не удалил свои конструкторы копирования/перемещения. Даже если эти конструкторы не вызываются, код должен быть действительным, как если бы они были вызваны.

Искусство не копировать параметры

Что, если моей функции нужно изменить один из своих параметров? А что, если в месте вызова этот параметр либо создается непосредственно в списке параметров, либо является результатом какой-то другой функции?

void Compute(Object o, int value)
{
   o.Transform(value);
   DoSomething(o);
}

Compute(Object(), 42);
Compute(CreateObject(42), 69);

Хотя это и не обязательно, эти вхождения были оптимизированы всеми компиляторами таким же образом, как и возвращаемые значения выше: для каждого вызова Compute выполнялся только один конструктор, и ничего не копировалось и не перемещалось.

То же самое применимо, если Object был передан ссылкой rvalue.

Когда компилятор не умный?

Object object1;
Object object2;

Compute(object1);
Compute(std::move(object2));

Компилятор не может оптимизировать объекты, созданные до вызова функции, в которую они передаются. Единственное, что вы можете сделать, это вручную указать move. Для меня это возможность для будущих улучшений — если бы компилятор смог доказать, что у этих объектов нет псевдонимов и что они больше не используются после вызова, то он мог бы сам выдать инструкцию перемещения.

Компиляторы также не могут оптимизировать переназначение существующего объекта, как показывает этот пример:

Object object1;
Object object2;
object2 = object1; // calls copy assignment operator
object2 = CreateObject(42); // calls move assignment operator

Случай с назначением копирования можно оптимизировать так же, как обсуждалось выше, — определить, что object1 больше не используется, и вызвать назначение перемещения. Вторую команду нельзя оптимизировать, потому что нам все еще нужно запустить деструктор исходных данных.

Заключение

Удивительно, как все три компилятора дали мне почти одинаковые результаты, независимо от того, насколько большими были тестируемые объекты или как методы были связаны с двоичным файлом. Снимаю шляпу перед разработчиками компилятора.

Я хочу, чтобы вы извлекли из этой статьи несколько уроков:

  1. Если ваш компилятор поддерживает это, включите пессимизирующее обнаружение перемещений. Большинство компиляторов в последних версиях поступают правильно. Но если вы застряли с GCC или более старыми версиями других компиляторов, вам нужно поэкспериментировать с ручным размещением перемещения и удалить его, если компилятор скажет вам, что это неоптимально.
  2. Используйте фабричные функции и старайтесь не хранить их результаты в именованных временных файлах.
  3. Избегайте искушения быть похожим на Rust и не проектируйте свои общедоступные API так, чтобы они принимали параметры по ссылкам rvalue. Пользователь вашего кода может оптимизировать его, если захочет. Не заставляйте их потенциально стрелять себе в ногу неуместным движением.