Передача по ссылке или по значению?

Когда я начинал программировать на C++ и усиленно штудировал книги и статьи, то неизменно натыкался на один и тот же совет: если нам нужно передать в функцию какой-то объект, который не должен изменяться в функции, то он всегда должен передаваться по ссылке на константу (ППСК), за исключением тех случаев, когда нам нужно передать либо примитивный тип, либо сходную с оными по размеру структуру. Т.к. за более чем 10 лет программирования на C++ я очень часто встречался с этим советом (да и сам его давал неоднократно), он давно «впитался» в меня — я на автомате передаю все аргументы по ссылке на константу. Но время идёт и уже прошло 7 лет, как мы имеем в своём распоряжении C++11 с его семантикой перемещения, в связи с которой я всё больше и больше слышу голосов, подвергающих старую добрую догму сомнениям. Многие начинают утверждать, что передача по ссылке на константу это прошлый век и теперь нужно передавать по значению (ППЗ). Что стоит за этими разговорами, а также какие выводы мы можем из этого всего сделать, я и хочу обсудить в этой статье.

Книжная мудрость

Для того, чтобы понять, какого же правила нам стоит придерживаться, предлагаю обратиться к книгам. Книги это отличный источник информации, которую мы не обязаны принимать, но прислушаться к которой, несомненно, стоит. И начнём мы с истории, с истоков. Я не буду выяснять кто был первым апологетом ППСК, просто приведу в пример ту книгу, что лично на меня оказала наибольшее влияние, в вопросе использования ППСК.

Мэйерс

Итак, в своей книге «Effective C++» Скотт Мэйерс даёт совет, находящийся в главе «Item 20», который называется «Prefer pass-by-reference-to-const to pass-by-value». Все этот совет прекрасно знают, и на нём выросло не одно поколение C++-программистов. Вкратце вспомним основные доводы Скотта, почему же стоит использовать ссылку:

  • Исключает избыточные копирование и удаление объектов.

  • Исключает «срез» (slicing) типов.

Всегда ли он советует использовать ссылку? Нет, он предлагает передавать по значению интегральные (built-in) типы, итераторы стандартной библиотеки (т.к. они специально созданы «лёгкими») и объекты-функторы.

Страуструп

Понятно, что книга, написанная в 2005 году, за 6 лет до появления семантики перемещения в стандарте, вряд ли может быть использована как серьёзный довод. Поэтому перейдём к более свежей книге, а именно «The C++ Programming Language» Бьярна Страуструпа, которая вышла уже в 2013 году. В главе 12.2.1 Страуструп повторяет старую мантру и советует использовать передачу по значению только для маленьких типов. Он не уточняет, что является маленьким типом, но обычно имеется в виду что-то размером не превышающим двух указателей на целевой платформе. Правда, он не обходит стороной и семантику перемещения, советуя использовать для этого передачу по rvalue-ссылке. Т.е. он чётко разделяет эти два случая, выделяя 2 различных интерфейса: один под копирование, второй под перемещение.

Мэйерс 2.0

Две книги позади, идём к третьей: «Effective Modern C++» всё того же Скотта Мэйерса. Эта книга выпущена в 2014 году — на 9 лет позже той, что мы обсудили ранее и вмещает в себя информацию как по C++11, так и по C++14. Основные рассуждения по интересующей нас теме находятся в главе «Item 41», которая озаглавлена несколько скромнее, рассмотренной нами ранее: «Consider pass by value for copyable parameters that are cheap to move and always copied». Видно, что Скотт осторожничает и помещает целых 2 условия прямо в заголовок главы. Конечно, с моей стороны не очень вежливо переписывать всё содержание главы здесь, но в ней содержится такое количество аргументов и контраргументов, что я считаю подходящим привести хотя бы суть аргументов прямо здесь. Тем не менее я всё же советую ознакомиться с оригинальной главой, если по какой-то причине вы этого ещё не сделали.

Итак, откуда вообще пошёл весь этот сыр-бор, почему вдруг старая максима потребовала нового обсуждения? Для лучшего понимания давайте рассмотрим такой класс:

template<typename T>
class Holder
{
public:
    explicit Holder(const T& value):
        m_Value{value}
    {
    }
    
    void setValue(const T& value)
    {
        m_Value = value;
    }
    
    const T& value() const noexcept
    {
        return m_Value;
    }
private:
    T m_Value;
};

Это ничем не примечательный класс, который хранит в себе объект, который ему передали. Подобный код вряд ли вызывал вопросы раньше, но теперь, с появлением семантики перемещения, возникает резонный вопрос: а если T является типом, который накладно копировать, почему бы не добавить возможность перемещения? Сказано — сделано:

template<typename T>
class Holder
{
public:
    explicit Holder(const T& value):
        m_Value{value}
    {  
    }
        
    explicit Holder(T&& value):
        m_Value{move(value)}
    {
    }
    
    void setValue(const T& value)
    {
        m_Value = value;
    }
    
    void setValue(T&& value)
    {
        m_Value = move(value);
    }
    
    const T& value() const noexcept
    {
        return m_Value;
    }
private:
    T m_Value;
};

Такой класс, безусловно, лучше, т.к. позволяет свести копирование к минимуму. К примеру, если у нас есть такой код использования класса: Holder holder{string{"me"}};, то, используя первую версию класса, мы получим одно копирование, а используя вторую версию, у нас будет одно перемещение, что в общем случае должно быть производительнее [1].

Хорошо, вот мы имеем класс, в котором все параметры передаются по ссылке, есть ли с этим классом какие-то проблемы? К сожалению, есть, и эта проблема лежит на поверхности. У нас в классе функционально 2 сущности: первая принимает значение на этапе создания объекта, а вторая позволяет изменить ранее установленное значение. Сущности-то у нас две, а вот функции четыре. А теперь представьте, что у нас может быть не 2 подобных сущности, а 3, 5, 6, что тогда? Тогда нас ждёт сильное раздувание кода. Поэтому, чтобы не плодить массы функций, появилось предложение отказаться от ссылок в параметрах вообще:

template<typename T>
class Holder
{
public:
    explicit Holder(T value):
        m_Value{move(value)}
    {  
    }
    
    void setValue(T value)
    {
        m_Value = move(value);
    }
    
    const T& value() const noexcept
    {
        return m_Value;
    }
private:
    T m_Value;
};

Первое преимущество, которое сразу бросается в глаза, заключается в том, что кода стало значительно меньше. Его даже меньше чем в самом первом варианте, за счёт удаления const и & (правда, добавили move). Но ведь нас всегда учили, что передача по ссылке производительнее, чем передача по значению! Так оно было до C++11, так оно и есть до сих пор, но теперь, если мы посмотрим на этот код, то увидим, что копирования здесь не больше чем в первом варианте, при условии, что у T есть конструктор перемещения. Т.е. сама по себе ППСК была и будет быстрее ППЗ, но ведь код как-то использует переданную ссылку, и зачастую этот аргумент копируется.

Однако, это не вся история. В отличии от первого варианта, где у нас есть только копирование, тут добавляется ещё и перемещение. Но ведь перемещение это дешёвая операция, правда? На эту тему, у рассматриваемой нами книги Мэйерса, тоже есть глава («Item 29»), которая озаглавлена так: «Assume that move operations are not present, not cheap and not used». Основная мысль должна быть ясна из названия, но если хочется подробностей, то всенепременно ознакомьтесь — я на этом останавливаться не буду.

Здесь было бы уместным провести полный сравнительный анализ первого и последнего методов, но я не хотел бы отступать от книги, поэтому анализ отложим для других разделов, а тут продолжим рассматривать аргументы Скотта. Итак, помимо того факта, что третий вариант очевидно короче второго, в чём Скотт видит преимущество ППЗ над ППСК в современном коде?

Видит он его в том, что в случае передачи rvalue, т.е. какого-то такого вызова: Holder holder{string{"me"}};, вариант с ППСК даст нам копирование, а вариант с ППЗ даст нам перемещение. С другой стороны, если передача будет такой: Holder holder{someLvalue};, то ППЗ однозначно проигрывает за счёт того, что он выполнит и копирование, и перемещение, тогда как в варианте с ППСК будет только одно копирование. Т.е. получается, что ППЗ, если рассматривать сугубо эффективность, это некоторый компромисс между количеством кода и «полноценной» (через &&) поддержкой семантики перемещения.

Именно поэтому Скотт так тщательно сформулировал свой совет и так осторожно его продвигает. Мне даже показалось, что он приводит его нехотя, как бы под давлением: он не мог не разместить рассуждения на эту тему в книге, т.к. она довольно широко обсуждалась, а Скотт всегда был сборщиком коллективного опыта. Кроме того, уж очень мало доводов он приводит в защиту ППЗ, а вот тех, что ставят эту «технику» под сомнение, он приводит немало. Мы ещё рассмотрим его доводы «против» в последующих разделах, здесь же мы кратко повторим аргумент, который Скотт приводит в защиту ППЗ (мысленно добавляем «если объект поддерживает перемещение и оно дёшево»): позволяет избежать копирования при передаче rvalue-выражения в качестве аргумента функции. Но хватит мучить книгу Мэйерса, давайте уже перейдём к другой книге.

Кстати, если кто-то читал книгу и удивляется, что я не привожу здесь вариант с тем, что Мэйерс называл универсальными ссылками (universal references) — теперь они известны как пробрасывающие ссылки (forwarding references), — то это легко объясняется. Я рассматриваю только ППЗ и ППСК, т.к. вводить шаблонные функции для методов, которые шаблонами не являются, только ради того, чтобы поддержать передачу по ссылкам обоих типов (rvalue/lvalue) считаю дурным тоном. Не говоря уже о том, что код получается другим (больше нет константности) и несёт с собой другие проблемы.

Джосаттис и компания

Последней книгой мы рассмотрим «C++ Templates», она же является наиболее свежей из всех упомянутых в этой статье книг. Вышла она под конец 2017 года (а внутри книги вообще 2018 указан). В отличии от других книг, эта целиком посвящена шаблонам, а не советам (как у Мэйерса) или C++ в целом, как у Страуструпа. Поэтому и плюсы/минусы тут рассматриваются с точки зрения написания шаблонов.

Данной теме посвящена целая глава 7, которая имеет красноречивое название «By value or by reference?». В этой главе авторы довольно кратко, но ёмко описывают все методы передачи со всеми их плюсами и минусами. Анализ эффективности здесь практичеки не приводится, и как должное принимается то, что ППСК будет быстрее ППЗ. Но при всём при этом в конце главы авторы рекомендуют использовать ППЗ для шаблонных функций по умолчанию. Почему? Потому что используя ссылку, шаблонные параметры выводятся полностью, а без ссылки «разлагаются» (decay), что благоприятно сказывается на обработке массивов и строковых литералов. Авторы считают, что если уж для какого-то типа ППЗ окажется неэффективным, то всегда можно использовать std::ref и std::cref. Такой себе совет, честно говоря, много вы видели желающих использовать вышеозначенные функции?

Что же они советуют касательно ППСК? Они советуют использовать ППСК тогда, когда производительность критична или есть другие весомые причины не использовать ППЗ. Конечно, мы здесь говорим только о шаблонном коде, но этот совет ��рямо противоречит всему, чему учили программистов на протяжении десятка лет. Это не просто совет рассмотреть ППЗ как альтернативу — нет, это совет альтернативой сделать ППСК.

На этом завершим наш книжный тур, т.к. мне не известны другие книги, с которыми нам стоило бы ознакомиться по данному вопросу. Перейдём в другое медиапространство.

Сетевая мудрость

Т.к. живём в век интернета, то на одну книжную мудрость полагаться не стоит. Тем более, что многие авторы, которые раньше писали книги, теперь просто пишут блоги, а от книг отказались. Одним из таких авторов является Герб Саттер, который в мае 2013 года опубликовал в своём блоге статью «GotW #4 Solution: Class Mechanics», которая хоть и не является целиком посвящённой освещаемой нами проблеме, всё-таки задевает её.

Итак, в первоначальном варианте статьи Саттер просто повторил старую мудрость: «передавайте параметры по ссылке на константу», но этого варианта статьи мы уже не увидим, т.к. в статье находится обратный совет: «если параметр всё равно будет скопирован, тогда передавайте его по значению». Опять пресловутое «если». Почему Саттер изменил статью, и откуда я об этом узнал? Из комментариев. Почитайте комментарии к его статье, они, кстати, интереснее и полезнее самой статьи. Правда, уже после написания статьи, Саттер всё-таки поменял своё мнение и такого совета он больше не даёт. Изменившееся мнение можно обнаружить в его выступлении на CppCon в 2014 году: «Back to the Basics! Essentials of Modern C++ Style». Посмотрите обязательно, мы же перейдём к следующей интернет-ссылке.

А на очереди у нас главный программистский ресурс 21 века: StackOverflow. А точнее ответ, с количеством положительных реакций превышающим 1700 на момент написания этой статьи. Вопрос звучит так: What is the copy-and-swap idiom?, и, как должно быть понятно из названия, не совсем по теме, что мы рассматриваем. Но в своём ответе на этой вопрос, автор затрагивает и интересующую нас тему. Он тоже советует использовать ППЗ «если аргумент всё равно будет скопирован» (пора уже и на это аббревиатуру вводить, ей Богу). И в целом этот совет выглядит вполне уместным, в рамках его ответа и обсуждаемого там operator=, но автор берёт на себя смелость давать подобный совет в более широком ключе, а не только в этом частном случае. Более того, он идёт дальше всех рассмотренных нами ранее советов и призывает делать это даже в C++03 коде! Что же подвигло автора на подобные умозаключения?

Судя по всему, основное вдохновение автор ответа черпал из статьи ещё одного книжного автора и по совместительству разработчика Boost.MPL — Дэйва Абрахамса. Статья называется «Want Speed? Pass by Value.», и была она опубликована ещё в августе 2009 года, т.е. за 2 года до принятия C++11 и введения семантики перемещения. Как и в предыдущих случаях, рекомендую читателю самостоятельно ознакомиться со статьей, я же приведу основные доводы (довод, в сущности, один), которые Дэйв приводит в пользу ППЗ: нужно использовать ППЗ, потому что с ним хорошо работает оптимизация «пропуск копирования» (copy elision), которая отсутствует при ППСК. Если почитать комментарии к статье, то можно увидеть, что продвигаемым им совет не является универсальным, что подтверждает сам автор, отвечая на критику комментаторов. Тем не менее статья содержит явный совет (guideline) использовать ППЗ, если аргумент всё равно будет скопирован. Кстати, кому интересно, можете почитать статью «Want speed? Don’t (always) pass by value.». Как должно быть ясно из названия, это статья является ответом на статью Дэйва, так что если прочли первую, то и эту прочтите обязательно!

К сожалению (для кого-то к счастью), такие вот статьи и (тем более) популярные ответы на популярных сайтах порождают массовое применение сомнительных техник (банальный пример) просто потому, что так нужно меньше писать, а старая догма более не является незыблемой — всегда можно сослаться на «вон тот популярный совет», если тебя припрут к стенке. Теперь предлагаю ознакомиться с тем, что нам предлагают различные ресурсы с рекомендациями по написанию кода.

Методические рекомендации

Т.к. различные стандарты и рекомендации сейчас тоже размещаются в сети, то я решил отнести этот раздел к «сетевой мудрости». Итак, здесь я хотел бы поговорить о двух источниках, назначение которых — сделать код C++ программистов лучше, путём предоставления последним советов (guidelines) по тому, как этот самый код писать.

Первый набор правил, который я хочу рассмотреть, явился последней каплей, заставившей меня всё-таки взяться за эту статью. Этот набор является частью утилиты clang-tidy и вне её не существует. Как и всё, что связано с clang, эта утилита весьма популярна и уже получила интеграцию с CLion и Resharper C++ (именно так я с ней и столкнулся). Итак, clang-tydy содержит правило modernize-pass-by-value, которое срабатывает на конструкторах, принимающих аргументы посредством ППСК. Это правило предлагает нам заменить ППСК на ППЗ. Более того, на момент написания статьи в описании данного правила содержится ремарка, что это правило пока работает только для конструкторов, но они (кто они?) с удовольствием примут помощь от тех, кто распространит это правило на другие сущности. Там же, в описании, есть и ссылка на статью Дэйва — понятно откуда ноги растут.

Наконец, в завершении рассмотрения чужой мудрости и авторитетных мнений, предлагаю посмотреть на официальные рекомендации по написанию C++ кода: C++ Core Guidelines, основными редакторами которых являются Герб Саттер и Бъярн Страуструп (неплохо, правда?). Так вот, эти рекомендации содержат следующее правило: «For “in” parameters, pass cheaply-copied types by value and others by reference to const», которое полностью повторяет старую мудрость: ППСК везде и ППЗ для небольших объектов. В описании этого совета приводятся несколько альтернатив, которые предлагается рассмотреть в случае если передача аргументов нуждается в оптимизации. Но в списке альтернатив ППЗ не представлена!

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

Анализ

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

А есть ли преимущество у ППЗ?

Код для этого примера может быть найден в этой фиксации.

Итак, прежде чем рассматривать аргументы «за» и «против», я предлагаю посмотреть какое и в каких случаях преимущество нам даёт передача по значению. Пусть у нас есть какой-то такой класс:

class CopyMover
{
public:
    void setByValuer(Accounter byValuer)
    {
        m_ByValuer = std::move(byValuer);
    }

    void setByRefer(const Accounter& byRefer)
    {
        m_ByRefer = byRefer;
    }
    
    void setByValuerAndNotMover(Accounter byValuerAndNotMover)
    {
        m_ByValuerAndNotMover = byValuerAndNotMover;
    }

    void setRvaluer(Accounter&& rvaluer)
    {
        m_Rvaluer = std::move(rvaluer);
    }
};

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

Класс Accounter — это простой класс, который считает сколько раз он был скопирован/перемещён. А в классе CopyMover у нас реализованы функции, которые позволяют рассмотреть следующие варианты:

  1. Передача по значению, с последующим перемещением переданного аргумента.

  2. Передача по lvalue-ссылке на константу, с последующим копированием переданного аргумента.

  3. Передача по значению, с последующим копированием переданного аргумента.

  4. Передача по rvalue-ссылке, с последующим перемещением переданного аргумента.

Теперь, если мы передадим lvalue в каждую из этих функций, к примеру вот так:

Accounter byRefer;
Accounter byValuer;
Accounter byValuerAndNotMover;
CopyMover copyMover;

copyMover.setByRefer(byRefer);
copyMover.setByValuer(byValuer);
copyMover.setByValuerAndNotMover(byValuerAndNotMover);

то получим следующие результаты:

Копии

Перемещения

По значению с перемещением

1

1

По ссылке на константу

1

0

По значению с копированием

2

0

Очевидным победителем является ППСК, т.к. даёт всего одно копирование, тогда как ППЗ даёт одно копирование и одно перемещение.

Теперь попробуем передать rvalue:

CopyMover copyMover;

copyMover.setByRefer(Accounter{});
copyMover.setByValuer(Accounter{});
copyMover.setByValuerAndNotMover(Accounter{});
copyMover.setRvaluer(Accounter{});

Получим следующее:

Копии

Перемещения

По значению с перемещением

0

1

По ссылке на константу

1

0

По значению с копированием

1

0

По rvalue-ссылке

0

1

Тут уже однозначного победителя нет, т.к. и у ППЗ, и у ППСК по одной операции, но в силу того, что ППЗ использует перемещение, а ППСК — копирование, можно отдать победу ППЗ.

Но на этом эксперименты наши не заканчиваются, давайте добавим следующие функции для имитации косвенного вызова (с последующей передачей аргумента):

void setByValuer(Accounter byValuer, CopyMover& copyMover)
{
    copyMover.setByValuer(std::move(byValuer));
}
void setByRefer(const Accounter& byRefer, CopyMover& copyMover)
{
    copyMover.setByRefer(byRefer);
}
...

Использовать их мы будем точно так же, как делали без них, поэтому повторять код не стану (посмотрите в хранилище, если нужно). Итак, для lvalue результаты будут такими:

Копии

Перемещения

По значению с перемещением

1

2

По ссылке на константу

1

0

По значению с копированием

3

0

Заметьте, что ППСК увеличивает разрыв с ППЗ, оставаясь с единственной копией, тогда как у ППЗ уже целых 3 операции (на одно перемещение больше)!

Теперь передаём rvalue и получаем такие результаты:

Копии

Перемещения

По значению с перемещением

0

2

По ссылке на константу

1

0

По значению с копированием

2

0

По rvalue-ссылке

0

1

Теперь ППЗ имеет 2 перемещения, а ППСК всё то же одно копирование. Можно ли теперь выдвинуть ППЗ в победители? Нет, т.к. если одно перемещение должно быть как минимум не хуже, чем одно копирование, про 2 перемещения мы подобного сказать уже не можем. Поэтому победителя в этом примере не будет.

Мне могут возразить: «Автор, у Вас предвзятое мнение и Вы притягиваете за уши то, что Вам выгодно. Даже 2 перемещения будут дешевле чем копирование!». Я не могу согласиться с подобным утверждением в общем, т.к. то, насколько перемещение быстрее копирования зависит от конкретного класса, но мы ещё рассмотрим «дешёвое» перемещение в отдельном разделе.

Тут мы затронули интересную вещь: мы добавили один косвенный вызов, и ППЗ прибавило в «весе» ровно на одну операцию. Думаю, что не нужно иметь диплом МГТУ для понимания того, что чем больше косвенных вызовов мы имеем, тем больше операций будет выполнено при использовании ППЗ, тогда как для ППСК количество будет оставаться неизменным.

Всё рассмотренное выше вряд ли стало для кого-то откровением, мы могли даже не проводить экспериментов — всё эти числа должны быть очевидны большинству C++ программистов с первого взгляда. Правда, один момент всё же заслуживает пояснения: почему в случае с rvalue у ППЗ нет копирования (или ещё одного перемещения), а есть только одно перемещение.

Дело в том, что стандарт C++ 2014 года (и более ранние версии) в некоторых случаях разрешал пропускать копирование, т.е. оптимизирующий компилятор, видя такие случаи, мог исключить копирование. Именно такой случай мы и имеем в нашем примере, но я специально не стал это упоминать в сравнении, потому что это больше не важно — начиная с 2017 года, компилятор обязан не делать копию в случае передачи rvalue. Подробнее об этом я писал в предыдущей статье.

Что ж, мы рассмотрели разницу в передаче между ППЗ и ППСК, воочию понаблюдав за количеством копий и перемещений. Хотя очевидно, что преимущество ППЗ над ППСК даже в таких простых примерах мягко говоря не очевидно, я всё же, немного кривя душой, сделаю следующий вывод: если мы всё равно будем копировать аргумент функции, то имеет смысл рассмотреть передачу аргумента в функцию по значению. Зачем я сделал этот вывод? Чтобы плавно перейти к следующему разделу.

Если копируем...

Итак, мы добрались до пресловутого «если». Большинство встреченных нами аргументов не призывали повсеместно внедрять ППЗ вместо ППСК, они лишь призывали делать это «если всё равно аргумент будет скопирован». Пришло время разобраться, что не так с этим аргументом.

Хочу начать с небольшого описания того, как я пишу код. В последнее время мой процесс написания кода всё больше походит на TDD, т.е. написание любого метода класса начинается с написания теста, в котором этот метод фигурирует. Соответственно, начиная писать тест, и создавая после написания теста метод, я ещё не знаю буду ли я копировать аргумент. Конечно, не все функции создаются таким образом, часто ещё в процессе написания теста ты совершенно точно знаешь, что там будет за реализация. Но так происходит не всегда!

Кто-то может мне возразить, что не важно как метод был написан изначально, мы можем изменить то, как мы передаём аргумент тогда, когда метод обрёл плоть и нам совершенно ясно, что там происходит (т.е. есть или нет у нас копирование). Я с этим частично соглашусь — действительно, можно поступать и так, но это вовлекает нас в какую-то странную игру, где мы должны менять интерфейсы только потому, что реализация изменилась. Что приводит нас к следующей дилемме.

Получается, что мы модифицируем (либо же вообще планируем) интерфейс исходя из того, как он будет реализован. Не считаю себя экспертом по ООП и прочим теоретическим выкладкам архитектуры ПО, но подобные действия явно противоречат базовым правилам, когда реализация не должна влиять на интерфейс. Конечно, определённые детали реализации (будь то особенности языка или целевой платформы) так или иначе всё равно просачиваются через интерфейс, но нужно стараться сокращать, а не увеличивать количество таких вещей.

Ну да Бог с ним, пусть мы пошли по этому пути и всё-таки меняем интерфейсы в зависимости от того, что мы делаем в реализации в части копирования аргумента. Предположим мы написали такой метод:

void setName(Name name)
{
    m_Name = move(name);
}

и зафиксировали наши изменения в хранилище. Шло время, наш программный продукт обрастал новым функционалом, интегрировались новые фреймворки, и появилась задача сообщать внешнему миру об изменениях в нашем классе. Т.е. к нашему методу добавится некоторый механизм уведомления, пусть это будет что-то похожее на сигналы Qt:

void setName(Name name)
{
    m_Name = move(name);
    emit nameChanged(m_Name);
}

Есть ли в этом коде проблема? Есть. На каждый вызов setName мы посылаем сигнал, так что сигнал будет послан даже тогда, когда значение m_Name не изменилось. Помимо вопросов производительности, такая ситуация может привести к бесконечному циклу из-за того, что код, который получает вышеозначенное уведомление, каким-то образом приходит к тому, чтобы вызвать setName. Чтобы избежать всех этих проблем подобные методы чаще всего выглядят примерно так:

void setName(Name name)
{
    if(name == m_Name)
        return;
    m_Name = move(name);
    emit nameChanged(m_Name);
}

Мы избавились от вышеописанных проблем, но теперь наше правило «если всё равно копируем...» дало сбой — больше нет безусловного копирования аргумента, теперь мы его копируем только при условии изменения! И что нам теперь делать? Менять интерфейс? Хорошо, давайте изменим интерфейс класса из-за этого исправления. А что если наш класс унаследовал этот метод из некоторого абстрактного интерфейса? Поменяем и там! Не много ли изменений из-за того, что изменилась реализация?

Опять мне могут возразить, мол автор, ты чего тут на спичках экономить вздумал, когда там это условие отработает? Да большую часть вызовов оно будет ложным! А в этом есть уверенность? Откуда? И если я решил экономить на спичках, так разве сам факт того, что мы использовали ППЗ не явился ли следствием именно такой экономии? Я лишь продолжаю «линию партии», ратующую за эффективность.

Конструкторы

Кратко пройдёмся и по конструкторам, тем более что для них есть специальное правило в clang-tidy, которое для других методов/функции пока не работает. Предположим, у нас есть такой класс:

class JustClass
{
public:
    JustClass(const string& justString):
        m_JustString{justString}
    {
    }
private:
    string m_JustString;
};

Очевидно, что параметр копируется, и clang-tidy нам сообщит, что было бы неплохо переписать конструктор на такой:

JustClass(string justString):
    m_JustString{move(justString)}
{
}

И мне, честно говоря, сложно тут возразить — ведь и правда всегда копируем. И чаще всего, когда мы передаём что-либо через конструктор, мы это что-то копируем. Но чаще не значит всегда. Вот вам другой пример:

class TimeSpan
{
public:
    TimeSpan(DateTime start, DateTime end)
    {
        if(start > end)
            throw InvalidTimeSpan{};
        m_Start = move(start);
        m_End = move(end);
    }
private:
    DateTime m_Start;
    DateTime m_End;
};

Здесь мы копируем не всегда, а только тогда, когда даты представлены корректно. Конечно, в подавляющем большинстве случаев так и будет. Но не всегда.

Можно привести ещё один пример, но на этот раз без кода. Представьте, что у вас есть класс, который принимает большой объект. Класс существует давно, и вот пришло время его реализацию подновить. Мы осознаем, что от большого объекта (который вырос за эти годы) нам нужно не более половины, а может и того меньше. Можем ли мы что-то с этим сделать имея передачу по значению? Нет, мы ничего сделать не сможем, потому что копия всё равно будет создаваться. А вот если бы мы использовали ППСК, то просто изменили бы то, что мы делаем внутри конструктора. И это ключевой момент: используя ППСК мы контролируем, что и когда происходит в реализации нашей функции (конструктора), если же мы используем ППЗ, то мы лишаемся любого контроля над копированием.

Что можно вынести из этого раздела? То, что аргумент «если всё равно копируем...» является весьма спорным, т.к. далеко не всегда мы знаем, что копировать будем, а даже когда знаем, мы очень часто не уверены в том, что это так и будет продолжаться в дальнейшем.

Перемещение дёшево

С самого момента появления семантики перемещения, она начала оказывать серьёзное влияние на то, как пишется современный C++-код, и за прошедшее время это влияние только усилилось: оно и немудрено, ведь перемещение так дёшево по сравнению с копированием. Но так ли это? Правда ли, что перемещение это всегда дешёвая операция? Вот с этим мы и попытаемся разобраться в этом разделе.

Код для этого примера может быть найден в этой фиксации.

Большой двоичный объект

Начнём с банального примера, пусть у нас есть такой класс:

struct Blob
{
    std::array<std::byte, 4096> data;
};

Обычный большой двоичный объект (БДО, англ. BLOB), который может применяться в самых разных ситуациях. Давайте рассмотрим, что же нам будет стоить передача по ссылке и по значению. Использоваться наш БДО будет примерно так:

void Storage::setBlobByRef(const Blob& blob)
{
    m_Blob = blob;
}

void Storage::setBlobByVal(Blob blob)
{
    m_Blob = move(blob);
}

А вызывать эти функции будем так:

const Blob blob{};
Storage storage;
storage.setBlobByRef(blob);
storage.setBlobByVal(blob);

Код для других примеров будет идентичен этому, только с другими именами и типами, поэтому приводить для оставшихся примеров я его не стану — всё есть в хранилище.

Прежде чем перейдём к измерениям, давайте попробуем предсказать результат. Итак, у нас есть std::array размером в 4 Кб, который мы хотим сохранить в объекте класса Storage. Как мы выяснили ранее, для ППСК у нас будет одно копирование, тогда как для ППЗ будет одно копирование и одно перемещение. Исходя из того, что array переместить невозможно, для ППЗ будет 2 копирования, против одного для ППСК. Т.е. мы вправе ожидать двукратного превосходства в производительности для ППСК.

Теперь давайте взглянем на результаты тестирования:

Тип передачи

Время

По значению с перемещением

179 нс

По ссылке на константу

82 нс

Этот и все последующие тесты выполнялись на одной машине с использованием MSVS 2017 (15.7.2) и с флагом /O2.

Практика совпала с предположением — передача по значению получается в 2 раза дороже, потому что для array перемещение полностью эквивалентно копированию.

Строка

Рассмотрим другой пример, обычную строку std::string. Что мы можем ожидать? Мы знаем (я рассматривал это в статье Работа со строками в C++. Часть 1: Основы), что современные реализации различают string двух типов: короткие (в районе 16 символов) и длинные (те, что больше коротких). Для коротких используется внутренний буфер, который представляет собой обычный C-массив из char, а вот длинные уже будут размещаться в куче. Короткие строки нас не интересуют, т.к. результат там будет тот же, что и с БДО, поэтому сосредоточимся на длинных строках.

Итак, имея длинную строку, очевидно, что её перемещение должно быть довольно дёшево (просто переместить указатель), поэтому можно рассчитывать на то, что перемещение строки не должно вообще никак сказаться на результатах, и ППЗ должна дать результат не хуже ППСК. Проверим на практике и получим следующие результаты:

Тип передачи

Размер строки (символов)

Время

По значению с перемещением

15

8 нс

64

64 нс

512

80 нс

1024

89 нс

По ссылке на константу

15

6 нс

64

8 нс

512

26 нс

1024

42 нс

Как мы и предполагали, для маленьких строк (15 символов) рез��льтаты схожи (2 нс это как раз лишнее копирование), но что происходит со строками размером в 64 символа? ППСК оказывается быстрее в 8 раз! Интересно, кто-нибудь из читателей, кто раньше не видел подобного теста, смог предугадать подобное? Если вы не знаете почему так произошло, попробуйте поразмышлять, прежде чем читать дальше.

Мы же перейдём к объяснению этого «феномена». Итак, что происходит когда мы копируем существующую строку в уже существующую строку? Давайте рассмотрим банальный пример:

string first{64, 'C'};
string second{64, 'N'};
//...
second = first;

У нас две строки размером в 64 символа, поэтому при их создании внутреннего буфера недостаточно, в результате обе строки размещаются в куче. Теперь мы копируем first в second. Т.к. размеры строк у нас одинаковые, очевидно, что в second выделено достаточно места, чтобы вместить все данные из first, поэтому second = first; будет представлять собой банальный memcpy, не более того. Но если мы рассмотрим слега изменённый пример:

string first{64, 'C'};
string second = first;

то здесь уже не будет вызова operator=, но будет вызван конструктор копирования. Т.к. мы имеем дело с конструктором, то существующей памяти в нём нет. Её сначала надо выделить и только потом скопировать first. Т.е. это выделение памяти, а потом memcpy. Как мы с вами знаем, выделение памяти в глобальной куче это, как правило, дорогая операция, поэтому копирование из второго примера будет дороже копирования из первого. Дороже на одно выделение памяти в куче.

Какое это отношение имеет к нашей теме? Самое прямое, ведь первый пример показывает ровно то, что происходит при ППСК, а второй то, что происходит при ППЗ: для ППЗ всегда создаётся новая строка, тогда как для ППСК происходит переиспользование существующей. Разницу во времени выполнения вы уже видели, так что тут добавить нечего.

Здесь мы опять столкнулись с тем, что при использовании ППЗ мы работаем вне контекста, поэтому не можем пользоваться всеми благами, которые оный может предоставить. И если раньше мы рассуждали в рамках теоретических будущих изменений, то здесь мы наблюдаем вполне конкретный провал в производительности.

Мне, безусловно, могут возразить, что, мол, string стоит особняком, и большинство типов так не работают. На что я могу ответить следующее: всё, что описано ранее будет справедливо для любого контейнера, который выделяет память в куче сразу под пачку элементов. Кроме того, кто знает какие другие контекстно-зависимые оптимизации применяются в других типах?

Что стоит почерпнуть из этого раздела? То, что даже если перемещение действительно дёшево, не означает того, что замещение копирования на копирование+перемещение всегда будет давать результат сравнимый по производительности.

Сложный тип

Наконец, давайте рассмотрим тип, который будет состоять из нескольких объектов. Пусть это будет класс Person, который состоит из данных присущей какой-либо личности. Обычно это имя, фамилия, почтовый индекс и т.п. Всё это можно представить в виде строк и предположить, что строки, помещаемые в поля класса Person, скорее всего, будут короткими. Хотя я и считаю, что для реальной жизни наиболее полезным будет измерение именно коротких строк, мы всё же рассмотрим строки разных размеров, чтобы картина получилась более полной.

Также я буду использовать Person с 10-ю полями, но для этого не буду создавать 10 полей прямо в теле класса. Реализация Person скрывает в своих недрах контейнер — так удобнее менять параметры тестов, практически не уходя от того, как это бы работало, будь Person реальным классом. Тем не менее реализация доступна и вы всегда можете проверить код и указать мне, если я что-то не так сделал.

Итак, поехали: Person с 10-ю полями типа string, который мы передаём с помощью ППСК и ППЗ в Storage:

Тип передачи

Размер строки (символов)

Время

По значению с перемещением

15

165 нс

64

727 нс

512

875 нс

1024

1051 нс

По ссылке на константу

15

65 нс

64

75 нс

512

215 нс

1024

278 нс

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

Кстати, когда я готовил эту статью, я подготовил ещё один пример: класс, который использует несколько объектов std::function. По моей задумке он тоже должен был показать преимущество в производительности ППСК над ППЗ, но получилось ровно наоборот! Но я не привожу этот пример здесь не потому, что мне не понравились результаты, а потому, что у меня не нашлось времени разобраться почему же такие результаты получаются. Тем не менее код в хранилище есть (Printers), тесты — тоже, если у кого-то есть желание разобраться, я был бы рад услышать о результатах исследования. Я же планирую вернуться к этому примеру позже, и если до меня никто этих результатов не опубликует, то я рассмотрю их в отдельной статье.

Итоги

Итак, мы рассмотрели различные плюсы и минусы передачи по значению и по ссылке на константу. Рассмотрели некоторые примеры и посмотрели на производительность обоих методов в этих примерах. Разумеется, эта статья не может и не является исчерпывающей, но, на мой взгляд, в ней достаточно информации, чтобы принять самостоятельное и взвешенное решение по тому, какой же способ лучше использовать. Кто-то может возразить: «зачем использовать один способ, давайте отталкиваться от задачи!». Хотя я согласен с этим тезисом в общем виде, я не согласен с ним в данной ситуации. Я считаю, что в языке может быть только один способ передачи аргументов, который используется по умолчанию.

Что значит по умолчанию? Это значит, что когда я пишу функцию, я не думаю о том, как мне передавать аргумент, я просто использую «умолчание». Язык C++ является довольно сложным языком, который многие обходят стороной. И по моему мнению, сложность вызвана не столько сложностью языковых конструкций, которые есть в языке (типичный программист может с ними никогда не столкнуться), сколько тем, что язык заставляет очень много думать: освободил ли я память, не дорого ли использовать здесь эту функцию и т.п.

Многие программисты (C, C++ и прочие) с недоверием и страхом относятся к тому C++, который стал проявляться после 2011 года. Я слышал немало критики, что язык становится сложнее, что писать на нём теперь могут только «гуру» и т.п. Лично я считаю, что это не так — комитет наоборот много времени уделяет тому, чтобы язык стал дружелюбнее к новичкам и чтобы программистам меньше нужно было думать над особенностями языка. Ведь если нам не нужно бороться с языком, то остаётся время подумать над задачей. К этим упрощениями я отношу и умные указатели, и лямбда-функции и многое другое, что появилось в языке. При этом я не отрицаю того факта, что изучать теперь нужно больше, но что плохого в учении? Или в других популярных языках не происходит изменений, которые нужно изучать?

Дальше, я не сомневаюсь, что найдутся снобы, которые могут сказать в ответ: «Думать не хочется? Иди тогда на PHP пиши». Таким людям я даже отвечать не хочу. Приведу лишь пример из игровой действительности: в первой части Starcraft, когда новый рабочий создаётся в здании, то чтобы он начал добывать минералы (или газ), нужно было вручную его туда послать. Более того, у каждой пачки минералов был лимит, при достижении которого наращивание рабочих было бесполезным, и они даже могли мешать друг другу, ухудшая добычу. В Starcraft 2 это изменили: рабочие автоматически начинают добывать минералы (или газ), а также указывается сколько рабочих сейчас добывают и сколько лимит этого месторождения. Это очень сильно упростило взаимодействие игрока с базой, позволив ему сосредоточиться на более важных аспектах игры: построение базы, накопления войск и уничтожение противника. Казалось бы, это просто отличное нововведение, но что началось в сети! Люди (кто они?) начали визжать, что игра «оказуаливается» и «они убили Starcraft». Очевидно, что такие сообщения могли исходить только от «хранителей тайного знания» и «адептов высокого APM», которым нравилось находиться в неком «элитном» клубе.

Так вот, возвращаясь к нашей теме, чем меньше мне нужно думать над тем, как мне писать код, тем больше мне остаётся времени на то, чтобы думать над решением непосредственной задачи. Думать над тем, какой метод мне использовать — ППСК или ППЗ — ни на йоту не приближает меня к решению задачи, поэтому думать над такими вещами я просто отказываюсь и выбираю один вариант: передача по ссылке на константу. Почему? Потому что я не вижу никаких преимуществ у ППЗ в общих случаях, а частные случаи нужно рассматривать отдельно.

Частный случай, он на то и частный, что заметив то, что в каком-то методе ППСК оказывается узким местом, и, изменив передачу на ППЗ, мы получим важный прирост в производительности, я не задумываюсь применю ППЗ. Но по умолчанию я буду применять ППСК как в обычных функциях, так и в конструкторах. И по возможности буду пропагандировать именно этот способ везде, где только можно. Почему? Потому что считаю практику пропаганды ППЗ порочной из-за того, что львиная доля программистов не слишком сведущи (либо в принципе, либо ещё просто не вошли в курс дела), и они просто следуют советам. Плюс, если есть несколько противоречащих друг друг советов, то они выбирают тот, что попроще, а это приводит к тому, что в коде появляется пессимизация просто потому, что кто-то где-то что-то слышал. Ах да, ещё этот кто-то может привести ссылку на статью Абрахамса, чтобы доказать, что он прав. А ты потом сидишь, читаешь код и думаешь: а вот то, что здесь параметр передаётся по значению, это потому что программист, который это писал, пришёл с Java, просто начитался «умных» статей или тут действительно нужно ППЗ?

ППСК читается куда проще: человек явно знает «хороший тон» C++ и мы идём дальше — взгляд не задерживается. Практика применения ППСК преподавалась программистам C++ годами, какая такая причина от неё отказываться? Это приводит меня к ещё одному выводу: если в интерфейсе метода используется ППЗ, значит там же должен находиться комментарий, почему это именно так. В остальных случаях должна применяться ППСК. Разумеется, есть типы-исключения, но я об этом не упоминаю здесь просто потому, что это подразумевается: string_view, initializer_list, различные итераторы и т.п. Но это исключения, список которых может расширяться в зависимости от того, какие типы используются в проекте. Но суть остаётся неизменной со времён C++98: по умолчанию мы всегда применяем ППСК.


[1] Для std::string разницы на маленьких строках скорее всего не будет, мы поговорим об этом позже.