[Перевод] Константные ссылки — не всегда ваши друзья

4af2846f2f81891b77be535bb8442ba8.png

Когда мы преподаем современный C++, в самом начале мы учим, что все, что не подпадает под критерии малых данных (small data)1, по умолчанию должно передаваться через константные ссылки:

void my_function(const MyType & arg);

Это позволяет избежать копирования этих параметров в случаях, когда нам это не нужно.

Для других случаев и подходы другие, ну, а сегодня мы сосредоточимся на константных ссылках.

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

Но так ли это? А что насчет альтернатив? И какие подводные камни могут нас поджидать, когда мы используем константные ссылки?

Примечание: в этой статье я использую словосочетание «константная ссылка» («constant reference») в отношении того, что на самом деле является ссылкой на константу. Это условное обозначение, хоть и несколько неточное с технической точки зрения, намного упрощает повествование.

Случай первый: константная ссылка в качестве параметра

Это своего рода хрестоматийный пример, который, однако, при определенных обстоятельствах может послужить примером неоптимального использования константных ссылок.

Давайте рассмотрим следующий класс:

struct MyString
{
     // Empty constructor
    MyString()
    { std::cout << "Ctor called" << std::endl; }
     
    // Cast constructor
    MyString(const char * s): saved_string(s) 
    { std::cout << "Cast ctor called" << std::endl; }
     
    std::string saved_string;
};

По сути, это обертка std::string с выводом, который позволяет нам увидеть, когда и какие конструкторы вызываются. Мы будем использовать его, чтобы отследить какие-либо неявные преобразования3 и нецелесообразные вызовы конструкторов. В рамках этой статьи мы будем считать, что конструирование MyString затратно и, как следствие, нежелательно.

Использование константной ссылки

Давайте рассмотрим функцию, которая принимает в качестве параметра константную ссылку на MyString:

void foo(const MyString &)
{
    // ...
}

А теперь давайте вызовем ее со строковым литералом:

int main()
{
    foo("toto");
}

Код компилируется, работает и выводит следующее сообщение в стандартный вывод:

Cast ctor called

Вызывается конструктор преобразования. Но почему?

Все дело в том, что const MyString & не может ссылаться на "toto", которую мы передаем в foo(), потому что "toto" — это const char[]. Так что, наивно полагая, он вообще не должен компилироваться. Однако, поскольку ссылка является константной, т.е. не изменяет исходный объект, компилятор считает, что его можно скопировать где-нибудь в памяти с правильным типом. Таким образом, он выполняет неявное преобразование (implicit conversion).

Это не очень хорошо, потому что для многих типов это преобразование затратно, а в коллективном бессознательном передача константной ссылки не копирует объект. Именно тот факт, что это происходит неявно (поэтому и не очевидно), является нежелательным.

Использование ключевого слова explicit

В C++ мы можем использовать ключевое слово explicit, чтобы указать, что конструктор или функция преобразования не могут использоваться неявно.

explicit MyString(const char * s): saved_string(s) 
{ std::cout << "Cast ctor called" << std::endl; }

Добавив это ключевое слово вы больше не сможете использовать foo() со строковыми литералами:

foo("toto"); // Does not compile

Вам нужно будет использовать явное преобразование типа:

foo(static_cast("toto")); // Does compile

Тем не менее, здесь есть один существенный недостаток: вы не можете использовать explicit для типов STD (таких как std::string) или типов, которые вы импортируете из сторонних библиотек. Что мы можем сделать в таком случае?

Использование простой ссылки

Давайте отложим в сторону ключевое слово explicit и будем считать, что MyString является сторонней функцией и не может быть отредактирована.

Мы настроим foo() так, чтобы ссылка, которую она принимает в качестве параметра, больше не была константной:

void foo(MyString &)
{
    // ...
}

Итак, что произойдет теперь? Если мы попытаемся вызвать foo() со строковым литералом, мы получим следующую ошибку компиляции:

main.cpp: In function 'int main()':
main.cpp:24:9: error: cannot bind non-const lvalue reference of type 'MyString&' to an rvalue of type 'MyString'
   24 |     foo("toto");
      |         ^~~~~~
main.cpp:11:5: note:   after user-defined conversion: 'MyString::MyString(const char*)'
   11 |     MyString(const char * s): saved_string(s)
      |     ^~~~~~~~
main.cpp:17:10: note:   initializing argument 1 of 'void foo(MyString&)'
   17 | void foo(MyString &)
      |          ^~~~~~~~~~

Компилятор больше не может выполнять неявное преобразование. Поскольку ссылка не является константной и, следовательно, может быть изменена внутри функции, он не может копировать и преобразовывать объект.

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

Если мы хотим, чтобы этот код работал, мы должны вызвать конструктор преобразования4 явно:

int main()
{
    MyString my_string("toto");
    foo(my_string);
}

Этот код компилируется и дает нам следующее в стандартном выводе:

Cast ctor called

Но это лучше, чем наш первый вариант, потому что здесь конструктор преобразования вызывается явно. Любой, кто читает этот код, знает, что здесь вызывается конструктор.

Однако у простых ссылок есть и недостатки. Как минимум, они отбрасывают квалификатор const.

Использование специализации шаблона

Наконец, существует еще один способ предотвратить неявное преобразование — использовать специализацию шаблона:

template
void foo(T&) = delete;
 
template<>
void foo(const MyString& bar)
{
    // …
}

Благодаря этому коду, когда вы пытаетесь вызвать foo() с чем-то, что не является MyString, вы попытаетесь вызвать перегрузку базового шаблона foo(). Но эта функция удалена, из-за чего мы получим ошибку компиляции.

Однако, если вы вызовете ее с MyString, будет вызвана именно специализация. Таким образом, вы будете уверены, что неявное преобразование исключено.

Выводы из первого случая

Иногда использование константных ссылок может вылиться в неявное преобразование. В зависимости от типа и контекста это может быть нежелательно.

Чтобы избежать этого, вы можете использовать ключевое слово explicit. Оно запрещает неявное преобразование.

Когда вы не можете использовать ключевое слово explicit (потому что оно вам нужно для внешнего типа), вместо этого вы можете использовать обычную ссылку или специализацию шаблона, как это показано выше, но оба варианта связаны со своими сложностями.

Случай второй: константная ссылка в качестве параметра

Давайте (снова) возьмем обертку для std::string, но на этот раз вместо самого объекта будем хранить константную ссылку на него:

struct MyString
{    
    // Cast constructor
    MyString(const std::string & s): saved_string(s) {}
     
    const std::string & saved_string;
};

Хранение константной ссылки в объекте

Давайте просто попробуем, как это будет работать:

int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

Этот код дает нам следующий стандартный вывод:

Toto

Т.е., похоже, что все работает нормально. Однако если мы отредактируем строку вне функции, например так:

int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    s = "Tata";
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

Вывод поменяется на это:

Tata

Похоже, тот факт, что мы сохранили константную ссылку, не означает, что значение не может быть изменено. В реальности это означает, что он не может быть изменено самим классом. Это огромная разница, которая может вводить в заблуждение.

Попытка переназначить константную ссылку

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

Но в С++ вы не можете переустановить ссылку. Как сказано в вики IsoCpp: Можете ли вы переустановить ссылку? Ни в коем случае. (Источник: Ссылки, Часто задаваемые вопросы по C++ (isocpp.org)).

Так что имейте ввиду, что если вы напишите что-то вроде этого:

int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    std::string s_2 = "Tata";
    my_string.saved_string = s_2;
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

Это не скомпилируется, потому что вы не пытаетесь переустановить my_string.saved_string на ссылку s_2, вы фактически пытаетесь присвоить значение s_2 объекту my_string.saved_string, который является константой с точки зрения MyString (и, следовательно, ему нельзя ничего присвоить).

Если вы попытаетесь обойти это и убрать const из объявления ссылки, хранящейся внутри MyString, вы можете получить следующий код:

struct MyString
{    
    // Cast constructor
    MyString(std::string & s): saved_string(s) {}
     
    std::string & saved_string;
};
 
int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    std::string s_2 = "Tata";
    my_string.saved_string = s_2;
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

Вывод, как и ожидалось, будет Tata. Однако попробуйте вывести значение s — вас ожидает небольшой сюрприз:

std::cout << s << std::endl;

Вы увидите, что программа снова выведет Tata!

В самом деле, как я уже говорил, делая это, вы пытаетесь переназначить значение, указанное my_string.saved_string, которое является ссылкой на s. Таким образом, переназначая my_string.saved_string, вы переназначаете и s.

Выводы из второго случая

В конце концов, ключевое слово const для переменной-члена const std::string & save_string; не означает, что »saved_string не может быть изменена», в реальности это означает только, что »MyString не может изменить значение своей saved_string». Будьте внимательны, потому что const не всегда означает то, что вы думаете.

Типы, которые должны передаваться по значению, а не по ссылке

Использование константных ссылок также является плохой практикой для некоторых типов.

Некоторые типы настолько малы, что передача по константной ссылке вместо передачи по значению на самом деле не является оптимизацией.

Вот примеры типов, которые не должны передаваться через константные ссылки:

  • int (а также short, long, float и т.д.)

  • указатели

  • std::pair (любая пара малых типов)

  • std::span

  • std::string_view

  • … и любой тип, который дешево копировать

Тот факт, что эти типы дешево копировать, говорит нам о том, что мы можем передавать их копированием, но не объясняет нам, почему мы должны передавать их копированием.

На это есть три причины. Эти три причины подробно описаны Артуром О'Дуайером (Arthur O«Dwyer) в следующей статье: Три причины передавать `std: string_view` по значению — Артур О'Дуайер — Всякая всячина в основном о C++ (quuxplusone.github.io)

Краткая версия:

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

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

  3. Устранение алиасинга. Передача значения (т. е. совершенно нового объекта) вызываемому объекту дает больше возможностей для оптимизации.

Заключение

Вот две опасности константных ссылок:

  • Они могут стать причиной неявных преобразований.

  • Когда они хранятся в классе, их можно изменять извне.

Ничто не является хорошим или плохим по своей сути, поэтому по своей сути и нет ничего лучшего или худшего.

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

Все дело в нескольких семантических значениях ключевого слова const. Иногда вы думаете, что оно подразумевает что-то одно, хотя на самом деле в данном контексте означает совсем другое. Но это уже тема совсем другой статьи.

Спасибо за внимание и еще увидимся!

Приложения

Примеры в Godbolt

Первый случай: константная ссылка в качестве параметра: Compiler Explorer (godbolt.org) и Compiler Explorer (godbolt.org)

Второй случай: константная ссылка в качестве атрибута: Compiler Explorer (godbolt.org)

Примечания

  1. «Малые данные» в этом контексте относятся к POD-типам2, которые достаточно малы, чтобы их можно было передавать без потери производительности — например, простые целые числа (int) и числа с плавающей запятой (float).

  2. POD расшифровывается как «Plain Old Data» (простейшие структуры данных) и употребляется в отношении структур данных, которые представляю из себя пассивные наборы значений полей без задействования каких-либо объектно-ориентированный фич.

  3. MyString — это просто заглушка для более тяжелых классов. Существуют множество классов, таких как std::string, создание или копирование которых сопряжено с большими затратами.

  4. То, что я здесь называю «конструктором преобразования» (cast constructor), — это конструктор с одним параметром. Такие конструкторы часто называют cast-конструкторами, потому что именно их использует static_cast.

Научная точность всегда была одним из моих основных приоритетов. Я не всегда достигаю ее (скорее не часто достигаю ее), но я стараюсь изо всех сил. Поэтому впредь я не буду говорить «Увидимся на следующей неделе», так как по статистике я публикую в среднем две целых восемь десятых статей в месяц.

Приглашаем на открытое занятие «Примеры использования Google Test Framework». Unit-тесты — нужны или нет? Обсуждать этот вопрос не будем, а сразу перейдём к использованию библиотеки Google Test для решения повседневных вопросов написания unit-тестов. Регистрируйтесь по ссылке.

© Habrahabr.ru