[Перевод] Три причины передавать std::string_view по значению

eb3b3ca0625e86160cfea6837cd90d5e.png

Передавать std::string_view по значению — идиоматично. Давайте разберемся, почему.

Начну с небольшой предыстории. В C++ по умолчанию все передается по значению. Когда мы пишем Widget w, мы получаем совершенно новый объект Widget. Копирование больших объектов может быть достаточно дорогим, поэтому в качестве оптимизации «передачи по значению» была введена «передача по константной ссылке», и мы советуем людям передавать большие и/или дорогие объекты, такие как std::string по константной ссылке, а не по значению.

Но для небольших дешевых объектов, таких как int, char*, std::pair, std::span, мы по-прежнему предпочитаем вполне целесообразное поведение по умолчанию — передачу по значению.

По сравнению с передачей по (константной) ссылке передача по значению имеет как минимум три преимущества с точки зрения производительности. В этом посте я собираюсь проиллюстрировать эти преимущества на примере string_view.

Важно! Все фрагменты кода, представленные ниже, следует рассматривать изолированно друг от друга, так как там должен присутствовать либо только вызываемый объект (callee), либо только вызывающая сторона (caller). Если компилятор увидит и вызывающую сторону, и вызываемый объект, ему был дан флаг -О2, и он решит встроить вызываемый объект в вызывающую сторону, то он с большой долей вероятности сможет устранить весь вред, причиненный неидиоматичной передачей по ссылке. Таким образом, довольно часто вы можете передавать string_view по ссылке, умудряясь не получать за это по рукам. Но вы сами должны передавать string_view по значению, чтобы компилятору не приходилось совершать этот геройский труд от вашего имени. И чтобы вашему код-ревьюеру не пришлось терять понапрасну нервные клетки, пытаясь понять ваше неидиоматичное решение передавать по ссылке. Если кратко: Передавайте небольшие дешевые типы только по значению! От этого вы только выиграете!

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

1. Позволяет избежать косвенной адресации из-за использования указателя для вызываемого объекта

Передача по константной ссылке означает, что вы передаете адрес объекта. Передача по значению означает, что вы передаете сам объект в регистрах, когда это возможно. (Если объект, которую вы передаете, является «нетривиальным для целей ABI», например, если у него есть нетривиальный деструктор, тогда «сам объект» в конечном итоге будет передана в стек, поэтому косвенная адресация будет применена в любом случае. Но с тривиальными типами, такими как int и string_view и span не нужно об этом беспокоиться; эти типы передаются в регистрах.)

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

int byvalue(std::string_view sv) { return sv.size(); }

int byref(const std::string_view& sv) { return sv.size(); }

---

byvalue:
    movq %rsi, %rax
    retq

byref:
    movl 8(%rdi), %eax
    retq

в случае byvalue, string_view передается в паре регистров (% rsi, % rsi), так что возврат члена «size» — это простое перемещение из одного регистра в другой в отличии от byref, которая получает ссылку на string_view, переданную в регистре %rdi, и должен выполнить загрузку из памяти, чтобы извлечь член «size».

2. Избежать spill«а в вызывающей стороне

Когда вы передаете по ссылке, вызывающая сторона должна поместить адрес объекта в регистр. Так что объект должен иметь адрес. Даже если все остальное касательно вызываемого объекта на вызывающей стороне можно было бы сделать с вещью в регистрах, сам акт передачи объекта вынуждает вызывающую сторону spill«ить ее в стек.

Передача по значению устраняет необходимость spill«а аргумента, что иногда вообще устраняет необходимость в стекфрейме на вызывающей стороне. Godbolt:

int byvalue(std::string_view sv);
int byref(const std::string_view& sv);

void callbyvalue(std::string_view sv) { byvalue("hello"); }

void callbyref(std::string_view sv) { byref("hello"); }

---

.Lhello:
    .asciz "hello"

callbyvalue:
    movl $.Lhello, %edi
    movl $5, %esi
    jmp byvalue    # хвостовая рекурсия (tail call)

callbyref:
    subq $24, %rsp
    movq $.Lhello, 8(%rsp)
    movq $5, 16(%rsp)
    leaq 8(%rsp), %rdi
    callq byref
    addq $24, %rsp
    retq

В callbyvalue мы только устанавливаем указатель данных аргумента string_view и члена size в %rdi и %rsi соответственно, а затем переходим к byvalue. В callbyref, с другой стороны, нам нужно передать адрес аргумента string_view; поэтому мы подготавливаем место в стеке. А потом, когда происходит возврат из byref, нам нужно очистить это пространство, которое мы подготовили.

Ранее в этом блоге: «Не всегда очевидно, когда tail-call optimization допустима» (2021–01–09).

3. Устранение алиасинга

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

Передача по значению дает вызываемому объекту совершенно новую копию объекта — копию, которая определенно не связана ни с каким другим объектом в программе. Таким образом, вызываемый объект имеет больше возможностей для оптимизации. Godbolt:

void byvalue(std::string_view sv, size_t *p) {
    *p = 0;
    for (size_t i=0; i < sv.size(); ++i) *p += 1;
}

void byref(const std::string_view& sv, size_t *p) {
    *p = 0;
    for (size_t i=0; i < sv.size(); ++i) *p += 1;
}

---

byvalue:
    movq %rsi, (%rdx)
    retq

byref:
    movq $0, (%rsi)
    cmpq $0, 8(%rdi)
    je   .Lbottom
    movl $1, %eax
 .Ltop:
    movq %rax, (%rsi)
    leaq 1(%rax), %rcx
    cmpq 8(%rdi), %rax
    movq %rcx, %rax
    jb   .Ltop
 .Lbottom:
    retq

Clang достаточно сообразителен, чтобы понять, что в byvalue цикл инкремента *p на 1 sv.size() раз, начиная с нуля, равносильно простому присваиванию *p = sv.size(). Но в byref, Clang не может сделать такую оптимизацию. Почему? Ну, потому что byref должен вести себя «правильно», даже когда вызывается следующим образом:

std::string_view sv = "hello";
size_t *size_p = &sv.__size_;  // адрес члена "size" sv
byref(sv, size_p);

В этой ситуации каждый инкремент size_p изменяет результат sv.size(), заставляя цикл выполняться бесконечно (точнее, до тех пор, пока значение sv._size сбросится до нуля и не остановит цикл). Так что цикл в byref, в отличие от цикла в byvalue, не является эквивалентным простому присваиванию! Компилятор должен генерировать машинный код, соответствующий его более сложному поведению.

byvalue не нужно беспокоиться об этой зловредной вызывающей стороне, потому что у вызывающей стороны нет (четко определенного) способа передать копию string_view вместе с указателем, указывающим внутрь этой копии.

В этом примере мы говорим именно о возможности алиасинга на собственные элементы данных объекта string_view, а не представляемые им символы. Эти символы, конечно, могут быть псевдонимами указателей в другом месте программы;, но мы не концентрируемся на этом в этом конкретном примере. Не позволяйте этому сбить вас с толку!

Подытожим:

  • С++ по умолчанию передает все по значению.

  • Передача по значению по умолчанию оптимальна для небольших и дешевых для копирования типов, таких как int, char, и pair.

  • Передача по значению имеет как минимум три преимущества в производительности, подробно описанные выше. Но если стоимость копирования перевешивает все эти преимущества (для больших и/или дорогостоящих типов, таких как string или vector), то тогда лучше передавать по константной ссылке. Передача по константной ссылке — это оптимизация передачи по значению.

  • Небольшие, тривиально копируемые «parameter-only» типы, такие как string_view из C++17, span из С++ 20 и function_ref из С++2b явно предназначены для того, чтобы занимать ту же категорию, что и int и char. Передавайте их по значению!

Почему нужно помнить об исключениях при работе над кодом, даже если их не видно? Как с помощью noexcept можно ускорить работу приложения? Обсудим это на открытом уроке, который пройдет в рамках онлайн-курса «C++ Developer. Professional».

© Habrahabr.ru