Для того, чтобы понять, какого же правила нам стоит придерживаться, предлагаю обратиться к книгам. Книги это отличный источник информации, которую мы не обязаны принимать, но прислушаться к которой, несомненно, стоит. И начнём мы с истории, с истоков. Я не буду выяснять кто был первым апологетом ППСК, просто приведу в пример ту книгу, что лично на меня оказала наибольшее влияние, в вопросе использования ППСК.
Мэйерс 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"}};, то, используя первую версию класса, мы получим одно копирование, а используя вторую версию, у нас будет одно перемещение, что в общем случае должно быть производительнее .
Хорошо, вот мы имеем класс, в котором все параметры передаются по ссылке, есть ли с этим классом какие-то проблемы? К сожалению, есть, и эта проблема лежит на поверхности. У нас в классе функционально 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. Такой себе совет, честно говоря, много вы видели ж��лающих использовать вышеозначенные функции?
Что же они советуют касательно ППСК? Они советуют использовать ППСК тогда, когда производительность критична или есть другие весомые причины не использовать ППЗ. Конечно, мы здесь говорим только о шаблонном коде, но этот совет прямо противоречит всему, чему учили программистов на протяжении десятка лет. Это не просто совет рассмотреть ППЗ как альтернативу — нет, это совет альтернативой сделать ППСК.
На этом завершим наш книжный тур, т.к. мне не известны другие книги, с которыми нам стоило бы ознакомиться по данному вопросу. Перейдём в другое медиапространство.