Пропуск копий (copy elision) – это оптимизация компилятора, которая, как и следует из имени, устраняет лишние операции копирования и перемещения. Она аналогична классической оптимизации размножения копий, но выполняется конкретно для объектов C++, которые могут иметь нестандартные конструкторы копирования и перемещения. В этой статьей я продемонстрирую пример, в котором очевидная ожидаемая от компилятора оптимизация на практике не происходит.

Ввод дополнительной переменной для разрыва строки


Предположим, что у нас есть длинный вызов функции, возвращающий объект, который нужно мгновенно передать другой функции так:

#include <string>
#include <string_view>

// Тип данных, который дорого копировать, непросто удалить и невозможно переместить
struct Widget {
  std::string s;
};

void consume(Widget w);

Widget doSomeVeryComplicatedThingWithSeveralArguments(
  int arg1, std::string_view arg2);

void someFunction() {
    consume(doSomeVeryComplicatedThingWithSeveralArguments(123, "hello"));
}

Как видно из сгенерированного кода ассемблера, здесь все отлично:

someFunction():                      # @someFunction()
        pushq   %rbx
        subq    $32, %rsp
        movq    %rsp, %rbx
        movl    $5, %edx
        movl    $.L.str, %ecx
        movq    %rbx, %rdi
        movl    $123, %esi
        callq   doSomeVeryComplicatedThingWithSeveralArguments(int, std::basic_string_view<char, std::char_traits<char> >)
        movq    %rbx, %rdi
        callq   consume(Widget)
        movq    (%rsp), %rdi
        leaq    16(%rsp), %rax
        cmpq    %rax, %rdi
        je      .LBB0_2
        callq   operator delete(void*)
.LBB0_2:
        addq    $32, %rsp
        popq    %rbx
        retq
.L.str:
        .asciz  "hello"

Временный Widget, возвращаемый из doSomeVeryComplicatedThingWithSeveralArguments, создается в области стека, которую под него выделила someFunction. Затем, как объяснялось в статье о правилах передачи параметров (англ.), указатель на эту область стека передается напрямую для использования.

Теперь представьте, что строка функции someFuncton показалась вам слишком длинной, или что вы хотите дать результату doSomeVeryComplicatedThingWithSeveralArguments описательное имя, для чего меняете код:

void someFunctionV2() {
    auto complicatedThingResult =
        doSomeVeryComplicatedThingWithSeveralArguments(123, "hello");
    consume(complicatedThingResult);
}

Естественно, все съезжает:

someFunctionV2():                    # @someFunctionV2()
        pushq   %r15
        pushq   %r14
        pushq   %r12
        pushq   %rbx
        subq    $72, %rsp
        leaq    40(%rsp), %rdi
        movl    $5, %edx
        movl    $.L.str, %ecx
        movl    $123, %esi
        callq   doSomeVeryComplicatedThingWithSeveralArguments(int, std::basic_string_view<char, std::char_traits<char> >)
        leaq    24(%rsp), %r12
        movq    %r12, 8(%rsp)
        movq    40(%rsp), %r14
        movq    48(%rsp), %rbx
        movq    %r12, %r15
        cmpq    $16, %rbx
        jb      .LBB1_4
        testq   %rbx, %rbx
        js      .LBB1_13
        movq    %rbx, %rdi
        incq    %rdi
        js      .LBB1_14
        callq   operator new(unsigned long)
        movq    %rax, %r15
        movq    %rax, 8(%rsp)
        movq    %rbx, 24(%rsp)
.LBB1_4:
        testq   %rbx, %rbx
        je      .LBB1_8
        cmpq    $1, %rbx
        jne     .LBB1_7
        movb    (%r14), %al
        movb    %al, (%r15)
        jmp     .LBB1_8
.LBB1_7:
        movq    %r15, %rdi
        movq    %r14, %rsi
        movq    %rbx, %rdx
        callq   memcpy
.LBB1_8:
        movq    %rbx, 16(%rsp)
        movb    $0, (%r15,%rbx)
        leaq    8(%rsp), %rdi
        callq   consume(Widget)
        movq    8(%rsp), %rdi
        cmpq    %r12, %rdi
        je      .LBB1_10
        callq   operator delete(void*)
.LBB1_10:
        movq    40(%rsp), %rdi
        leaq    56(%rsp), %rax
        cmpq    %rax, %rdi
        je      .LBB1_12
        callq   operator delete(void*)
.LBB1_12:
        addq    $72, %rsp
        popq    %rbx
        popq    %r12
        popq    %r14
        popq    %r15
        retq
.LBB1_13:
        movl    $.L.str.2, %edi
        callq   std::__throw_length_error(char const*)
.LBB1_14:
        callq   std::__throw_bad_alloc()
.L.str:
        .asciz  "hello"

.L.str.2:
        .asciz  "basic_string::_M_create"

Теперь берем наш идеальный Widget, complicatedThingResult, и копируем его в новый временный Widget, который будет передаваться в качестве первого аргумента. По завершении всех действий нужно будет удалить два Widget: complicatedThingResult и безымянный временный Widget, который мы передавали для использования. Вы можете ожидать, что компилятор оптимизирует someFunctionV2(), сделав ее подобной someFunction, но этого не произойдет.

Проблема, конечно же, в том, что мы забыли выполнить std::move complicatedThingResult:

void someFunctionV3() {
    auto complicatedThingResult =
        doSomeVeryComplicatedThingWithSeveralArguments(123, "hello");
    consume(std::move(complicatedThingResult));
}

И теперь сгенерированный код ассемблера выглядит в точности, как наш исходный пример. Постойте-ка…что?

someFunctionV3():                    # @someFunctionV3()
        pushq   %r14
        pushq   %rbx
        subq    $72, %rsp
        leaq    8(%rsp), %rdi
        movl    $5, %edx
        movl    $.L.str, %ecx
        movl    $123, %esi
        callq   doSomeVeryComplicatedThingWithSeveralArguments(int, std::basic_string_view<char, std::char_traits<char> >)
        leaq    56(%rsp), %r14
        movq    %r14, 40(%rsp)
        movq    8(%rsp), %rax
        leaq    24(%rsp), %rbx
        cmpq    %rbx, %rax
        je      .LBB1_1
        movq    %rax, 40(%rsp)
        movq    24(%rsp), %rax
        movq    %rax, 56(%rsp)
        jmp     .LBB1_3
.LBB1_1:
        movups  (%rax), %xmm0
        movups  %xmm0, (%r14)
.LBB1_3:
        movq    16(%rsp), %rax
        movq    %rax, 48(%rsp)
        movq    %rbx, 8(%rsp)
        movq    $0, 16(%rsp)
        movb    $0, 24(%rsp)
        leaq    40(%rsp), %rdi
        callq   consume(Widget)
        movq    40(%rsp), %rdi
        cmpq    %r14, %rdi
        je      .LBB1_5
        callq   operator delete(void*)
.LBB1_5:
        movq    8(%rsp), %rdi
        cmpq    %rbx, %rdi
        je      .LBB1_7
        callq   operator delete(void*)
.LBB1_7:
        addq    $72, %rsp
        popq    %rbx
        popq    %r14
        retq
.L.str:
        .asciz  "hello"

У нас по-прежнему есть два Widget, только временный передаваемый аргумент теперь перемещен конструктором. Первая версия someFunction все еще оказывается меньше и быстрее!

Что же здесь происходит?


Суть проблемы пропуска копий в том, что он допускается только в определенном списке случаев. (Говоря коротко, при RVO1 и инициализации из prvalue это происходит обязательно, при NRVO2 и в ряде случаев с исключениями и сопрограммами пропуск считается допустимым. Все.). На то есть философская причина: вы написали специфичный конструктор копирования для вашего класса, в котором могли реализовать всё что угодно. И, конечно же, вы ожидаете, что, согласно правилам С++, этот конструктор будет вызван всякий раз когда объект вашего класса копируется. Но если компиляторы будут непредсказуемо удалять копирование, тем самым удаляя пары вызовов копирующего/перемещающего конструктора и деструктора они могут разрушить всю логику вашего кода.

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

1. RVO (return value optimization) — оптимизация возвращаемого значения.
2. NRVO (named return value optimization) — оптимизация именованного возвращаемого значения.