Ссылки и ссылочные типы в C++ +32



Продолжаем серию «C++, копаем вглубь». Цель этой серии — рассказать максимально подробно о разных особенностях языка, возможно довольно специальных. Это пятая статья из серии, список предыдущих статей приведен в конце в разделе 6. Серия ориентирована на программистов, имеющих определенный опыт работы на C++. Эта статья посвящена ссылкам и ссылочным типам в C++.


Термин «ссылка» широко используется и в обыденной жизни, в компьютерных и других науках и поэтому его смысл сильно зависит от контекста использования. В языках программирования под ссылкой понимают небольшой объект, главная задача которого обеспечить доступ к другому объекту, расположенному в другом месте, имеющему другой размер и т.д. Объекты ссылки удобно использовать на стеке, они легко копируются, что позволяет получить доступ к объекту, на который эта ссылка ссылается, из разных точек кода. В той или иной форме ссылки поддерживаются во всех языках программирования. В ряде языков программирования, таких как C#, Java, Pyton и многих других, ссылки, по существу, являются концептуальным ядром.



В C роль ссылок играют указатели, но работать с ними не очень удобно и в C++ появилась отдельная сущность — ссылка (reference). В C++11 ссылки получили дальнейшее развитие, появились rvalue-ссылки, универсальные (передаваемые) ссылки, которые играют ключевую роль в реализации семантики перемещения — одном из самых значительных нововведений C++11.



Итак, попробуем рассказать о ссылках в C++ максимально подробно.



Оглавление


Оглавление

1. Основы
  1.1. Определение ссылки
  1.2. Разновидности ссылок
    1.2.1. Ссылки на константу
    1.2.2. Rvalue-ссылки
    1.2.3. Ссылки на массив
    1.2.4. Ссылки на функцию
  1.3. Ссылки и указатели
    1.3.1. Взаимозаменяемость
    1.3.2. Внутреннее устройство ссылок
  1.4. Разное
    1.4.1. Полиморфизм
    1.4.2. Внешнее связывание
    1.4.3. Неполные объявления
2. Правила инициализации ссылок
  2.1. Синтаксис инициализации
  2.2. Члены класса ссылочного типа
  2.3. Категория значения
  2.4. Требования к инициализирующему выражению
  2.5. Инициализация ссылок с использованием автоопределения типа
3. Ссылки в качестве параметров и возвращаемого значения функций
  3.1. Параметры функций
    3.1.1. Специальные функции-члены и перегруженные операторы
    3.1.2. Требования к аргументам
    3.1.3. Перегрузка функций
    3.1.4. Функции с параметром типа rvalue-ссылка
  3.2. Параметры шаблонов функций
    3.2.1. Автоматический вывод аргументов шаблонов функций
    3.2.2. Явное задание аргумента шаблона функции
    3.2.3. Универсальные ссылки и rvalue-ссылки
    3.2.4. Прямая передача
    3.2.5. Перегрузка шаблонов функций
  3.3. Передача параметра по ссылке на константу vs передачи по значению
  3.4. Лямбда-выражения
    3.4.1. Автоопределение типа параметра
    3.4.2. Захват переменной по ссылке
  3.5. Возвращаемое значение функции
    3.5.1. Варианты использования
    3.5.2. Автоопределение типа возвращаемого значения
4. Висячие ссылки
  4.1. Ссылка на rvalue
  4.2. Временные объекты
  4.3. Примеры
  4.4. Стандартные контейнеры
  4.5. Другие языки
5. Ссылочные типы и шаблоны
  5.1. Ссылочные типы
  5.2. Аргументы шаблона ссылочного типа
    5.2.1. Свойства типов
    5.2.2. Свертывание ссылок
    5.2.3. Запрет на использование ссылочных типов
  5.3. Стандартный эмулятор ссылок
    5.3.1. Как устроен
    5.3.2. Использование
6. Список статей серии «C++, копаем вглубь»
7. Итоги
Список литературы




1. Основы



1.1. Определение ссылки


В простейшем случае ссылка определяется так: если T некоторый тип и есть переменная типа T, то переменная типа T со спецификатором & будет ссылкой на эту переменную, если она инициализирована этой переменной.


T x;
T &rx = x; // rx это ссылка на x

После этого rx можно использовать в любом контексте вместо x, то есть rx становится псевдонимом x.



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


В одной инструкции можно определить несколько ссылок, спецификатор & должен быть у каждой из них.


int x = 1, y = 2;
int &rx = x, &ry = y;

Последняя инструкция эквивалентна следующим двум инструкциям:


int &rx = x;
int &ry = y;

Имя типа со спецификатором & будет называться ссылочным типом. Можно объявить псевдоним для ссылочного типа.


using RT = T&;

Также можно использовать более старый способ, через typedef.


typedef T& RT;

После этого ссылки можно определить так:


int x = 1
using RI = int&;
RI rx = x;

Обратим внимание на то, что в C++ смысл термина «ссылочный тип» отличается от смысла, который этот термин имеет в языках программирования со сборкой мусора (C#, Java, etc.). В последних он означает типы, экземпляры которых управляются сборщиком мусора, и доступны исключительно через ссылку. Подробнее о ссылочных типах в разделе 5.1.


Можно определить копию ссылки.


T x;
T &rx = x;
T &rx2 = rx;

После этого на переменную x будут ссылаться две ссылки. Других собственных операций ссылка не поддерживает, все операторы, примененные к ссылке, на самом деле применяются к переменной, на которую она ссылается. Это касается и таких операторов, как = (присваивание), & (получение адреса), sizeof, typeid. Но вот спецификатор decltype, если его применить к ссылке, дает ссылочный тип.


Остановимся подробнее на присваивании. Присваивание ссылок означает присваивание переменных, на которые ссылки ссылаются. Естественно, что тип этих переменных должен поддерживать присваивание.


int x = 1, y = 2;
int &rx= x, &ry = y;
rx = ry;

Последняя инструкция эквивалентна следующей:


x = y;

Ссылки rx, ry продолжат ссылаться на переменные x, y соответственно, только теперь x будет иметь значение 2. Такое поведение не вполне традиционно, в других языках происходит присваивание самих ссылок, то есть ссылка, являющаяся левым операндом, становится копией ссылки, являющейся правым операндом. (Именно так работает эмулятор ссылки — шаблон класса std::reference_wrapper<>, см. раздел 5.3.) Но в силу неизменяемости ссылок, в C++ такое невозможно.


При присваивании в качестве правого операнда допустимо любое выражение, допустимое в качестве правого операнда оператора присваивания для типа, на который ссылка ссылается.


int x = 1;
int &rx = x;
rx = 33;

Последняя инструкция эквивалентна


x = 33;


1.2. Разновидности ссылок


Выше мы определили ссылки, которые можно назвать простые ссылки. Но есть еще другие разновидности.



1.2.1. Ссылки на константу


Если T некоторый неконстантный и нессылочный тип или псевдоним, то можно определить ссылку на константу.


const T d = ini_expression;
const T &rcd = d;

Ссылка на константу представляют отдельный ссылочный тип, для него можно объявить псевдоним.


using RCT = const T&;

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


using CT = const T;
using RCT = CT&;

Сами ссылки теперь можно определить так:


СT d = ini_expression;
СT &rcd = d;
RCT rcd2 = d;

Через ссылку на константу нельзя модифицировать объект, на которой она ссылается. Это означает, что для встроенных типов через такую ссылку запрещено присваивание, инкремент, декремент, а для пользовательских типов запрещен вызов неконстантных функций-членов.


const int d = 42;
const int &rcd = d; 
rcd = 43;         // ошибка

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


const int d = 42;
int &rd = d;      // ошибка
const int &rcd = d;
int &rd2 = rcd;   // ошибка

А вот инициализировать ссылку на константу неконстантной переменной или простой ссылкой можно.


int x = 42;
const int &rcx = x;   // OK
int &rx = х;
const int &rcx2 = rx; // OK

Напомним некоторые правила использования квалификатора const.



Если в одной инструкции объявляется несколько переменных (в том числе ссылок), то const относится ко всем переменным.


const int d1 = 1, d2 = 2;
const int &rcd1 = d1, &rcd2 = d2;

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


const int d1 = 1;
const int d2 = 2;
const int &rcd1 = d1;
const int &rcd2 = d2;


Квалификатор const может стоять как до имени типа, так и после.


const int d = 42;
const int &rcd = d;

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


int const d = 42;
int const &rcd = d;

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


Нельзя быть дважды константным, компиляторы второй квалификатор const игнорируют (иногда с предупреждением).


using CT = const T;
using RCT = const CT&;

Второй const игнорируется.


Ссылку на константу можно превратить в обычную ссылку с помощью оператора const_cast<>(), но это в общем случае потенциально опасное преобразование.


const int d = 42;
const int &rcd = d;
int &rd = const_cast<int&>(rcd); // потенциально опасно

Сделаем теперь одно терминологическое замечания. Ссылки на константу часто называют константными ссылками. Это не вполне точно, ссылки сами по себе являются константными сущностями, а вот ссылаться они могут как на константу, так и на не-константу. В случае с указателями мы должны различать эти два варианта константности, а вот в случае ссылок можно проявить некоторую терминологическую небрежность. Об этом пишет, например, Стефан Дьюхэрст [Dewhurst].



1.2.2. Rvalue-ссылки


Rvalue-ссылки — это разновидность ссылок, которая появилась в C++11. Они отличаются правилами инициализации (см. раздел 2.4) и правилами перегрузок функций с такими параметрами (см. раздел 3.1.3). Если T некоторый неконстантный и нессылочный тип или псевдоним, то rvalue-ссылка определяется так:


T &&rv = ini_expression;

То есть для их определения используется спецификатор &&, а не &.


Rvalue-ссылки представляют отдельный ссылочный тип, для него можно объявить псевдоним.


using RVT = T&&;

Компилятор различает также rvalue-ссылки на константу:


const T &&rvc = ini_expression;

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


Требования к ini_expression и другие подробности об rvalue-ссылках в последующих разделах.



1.2.3. Ссылки на массив


Можно определить ссылку на массив.


int a[4];
int(&ra)[4] = a;

Тип ссылки на массив включает размер массива, поэтому инициализировать нужно массивом того же размера.


int a[6];
int(&ra)[4] = a; // ошибка, размеры отличаются

Можно определить ссылку на массив констант.


const int сa[] = {1, 2, 3, 4};
const int(&rсa)[4] = ca;

Формально существуют rvalue-ссылки на массив, но они практически не используются.


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


using I4 = int[4];
I4 a;
I4 &ra = a;

Можно объявить псевдоним ссылки на массив.


using RI4 = int(&)[4];

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


int a[4];
int(&ra)[4] = a;
ra[0] = 42;
std::cout << ra[0];

В C++ к массивом применяется правило, называемое сведением (decay, array-to-pointer decay). (Для перевода термина «decay» еще используется слово «низведение», также можно встретить «разложение».) Суть сведения заключается в том, что почти в любом контексте идентификатор массива преобразуется к указателю на первый элемент и информация о размере теряется. Сведение происходит и при использовании массивов в качестве параметров функций. Функции


void Foo(int a[4]);
void Foo(int a[]);
void Foo(int *a);

не перегруженные функции, это одно и то же.


Ссылки на массив как раз и являются теми средствами, с помощью которых можно обойти сведение. Функция


void Foo(int(&a)[4]);

принимает аргументы типа int[4], массивы другого размера и указатели для нее не подходят.


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


int(&Foo(int x))[4];

Это функция, принимающая int и возвращающая ссылку на массив int[4].


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


template<typename T, std::size_t N>
void Foo(T(&a)[N]);

При конкретизации такого шаблона компилятор выводит тип элементов T и размер массива N (который гарантировано больше нуля). В качестве аргументов можно использовать только массивы, указатели будут отвергнуты. Именно этот прием используется при реализации перегруженных версий std::begin(), std::end(), std::size() и других, которые позволяют трактовать обычные массивы как стандартные контейнеры.



1.2.4. Ссылки на функцию


Ссылка на функцию определяется следующим образом:


void Foo(int);
void(&rf)(int) = Foo;

Для вызова функции через ссылку используется привычный синтаксис.


void Foo(int);
void(&rf)(int) = Foo;
rf(42); // тоже самое, что и Foo(42);

Константного варианта ссылки на функцию не существует, так как тип функции не может быть константным. Формально существуют rvalue-ссылки на функцию, но они практически не используются.



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


using FI = void(int);
void Foo(int);
FI &rf = Foo;

Можно объявить псевдоним ссылки на функцию.


using RFI = void(&)(int);

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


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


Нельзя определить ссылку на функцию-член класса.



1.3. Ссылки и указатели



1.3.1. Взаимозаменяемость


Ссылки были добавлены в C++ в качестве более удобной альтернативы указателям, но указатели и ссылки не являются полностью взаимозаменяемыми.(Конечно, при подобной замене надо корректировать код, синтаксис доступа через ссылку и указатель разный.)


Указатели часто можно заменить ссылкой, но не всегда, так как указатель может иметь значение nullptr и это может оказаться существенным моментом в логике работы программы, когда как ссылки не могут быть нулевыми. Также нельзя создавать массивы ссылок и нет ссылочного аналога нетипизированного указателя void*. Указатели могут оказаться незаменимыми в низкоуровневых решениях, где используется арифметика указателей.


Ссылки также не всегда можно заменить указателями. В C++ классы имеют так называемые специальные функции-члены — копирующий конструктор, копирующий оператор присваивания и их перемещающие аналоги. Эти функции-члены имеют единственный параметр, который обычно имеет ссылочный тип. При перегрузке операторов также часто нельзя обойтись без параметров ссылочного типа, см. раздел 3.1.1. Эти параметры нельза заменить указателями. Rvalue-ссылки также нельзя заменить указателем.


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



1.3.2. Внутреннее устройство ссылок


Как и многие другие языки программирования, C++ скрывает внутренне устройство ссылок. Получить какую либо информацию об объекте ссылки непросто — любая операция над ссылкой означает операцию над объектом, на который она ссылается.



Достаточно традиционный взгляд — это считать ссылку «замаскированным» константным указателем. Но Страуструп и другие авторы, например Стефан Дьюхэрст [Dewhurst], считают такую точку зрения неверной и настаивают, что ссылка — это просто псевдоним переменой, на которую она ссылается. Компилятор в процессе оптимизации может вообще удалить объекты ссылок. Понятно, что в простых случаях это сделать можно (см. примеры в разделе 1.1), но как обойтись без объекта ссылки при использовании ссылок в качестве параметров и возвращаемых значений функций, членов классов и реализации полиморфизма не вполне понятно. Вот пример, который косвенно подтверждает материальность ссылок.


class X
{
    int &m_R;
public:
    X(int& r) : m_R(r){}
};

По идее sizeof(X) должен давать размер объекта ссылки. Эксперименты дают ожидаемый результат — этот размер равен размеру указателя.


Впрочем, вопрос внутреннего устройства ссылок не очень принципиальный, C++ спроектирован таким образом, что для программиста от этого практически ничего не зависит.



1.4. Разное



1.4.1. Полиморфизм


Ссылки поддерживают полиморфизм. Ссылку на базовый класс можно инициализировать экземпляром или ссылкой на производный класс. Таким образом, ссылки имеют статический тип и динамический, определяемый фактическим типом инициализатора. При вызове виртуальной функции выбирается вариант соответствующий динамическому типу.


class Base
{
public:
    virtual void Foo();
// ...
};
class Derv : public Base
{
public:
    void Foo() override;
// ...
};

Derv d;
Base &r1 = d;
r1.Foo(); // Derv::Foo()
Derv &rd = d;
Base &r2 = rd;
r2.Foo(); // Derv::Foo()

Операторы static_cast<>() и dynamic_cast<>() можно использовать со ссылками, единственное отличие состоит в том, что если невозможно выполнить приведение dynamic_cast<>(), то при работе с указателями возвращается nullptr, а при работе со ссылками выбрасывается исключение типа std::bad_cast.



1.4.2. Внешнее связывание


Для ссылок можно реализовать внешнее связывание.


// file1.cpp
extern int &ExternIntRef;

// file2.cpp
int ExternInt = 125;
int &ExternIntRef = ExternInt;

Скорее всего, особой пользы в этом нет, но формальная возможность есть.



1.4.3. Неполные объявления


В C++ в ряде случаев компилятору для компиляции правильного кода достаточно знать, что то или иное имя является именем какого-то пользовательского типа (класса, структуры, объединения, перечисления), а полное объявление типа не нужно. В этом случае можно использовать неполное объявление (incomplete declaration), называемое еще упреждающим или предваряющим (forward declaration). Типы с неполным объявлением называются неполными.


Использование неполных типов позволяет решить ряд проблем, традиционно свойственных коду, написанному на С++. Можно уменьшить зависимость проекта по заголовочным файлам, что сокращает время компиляции и предупреждает потенциальные конфликты имен. Неполные объявления позволяют разрывать циклические зависимости, реализовывать решения, полностью разделяющие интерфейс и реализацию (непрозрачные указатели).


Что касается ссылок, то мы можем объявлять параметры функций, возвращаемое значение функции, члены класса, extern переменные ссылочного типа, когда тип, на который ссылается ссылка неполный. Мы можем определить ссылку на неполный тип, если она инициализируется ссылкой такого же типа, то есть допускается копирование ссылок на неполный тип.


class X; // неполное объявление
class Y
{
    X &m_X;
public:
    Y(X& x) : m_X(x){ /* ... */ }
// ...
};

Но другие операции над ссылками невозможны без полного определения типа.



2. Правила инициализации ссылок


Ссылки должны быть обязательно инициализированы. Если ссылка объявлена глобально или в области видимости пространства имен или локально, то она должна быть инициализирована при объявлении (за исключением extern переменных). Для членов класса предназначены специальные правила инициализации, см. далее раздел 2.2.


Ссылки могут быть инициализированы не только переменной или другой ссылкой, в общем случае это выражение, требования к которому зависят от разновидности ссылки. Эти вопросы рассматриваются в разделах 2.4 и 2.5.



2.1. Синтаксис инициализации


В C++ для инициализации переменной, в том числе и ссылки, можно использовать разные синтаксические конструкции. В данной статье мы в основном используем традиционный вариант с помощью символа =.


int x = 6;
int &rx = x;

Единственный контекст, в котором такой синтаксис невозможен — это инициализация нестатического члена класса в списке инициализации конструктора, см. раздел 2.2. Обратим внимание на то, что символ = в данном случае не является оператором присваивания.


Другой вариант — это универсальная инициализация (uniform initialization), которая появилась в C++11. В этом случае используются фигурные скобки.


int x = 6;
int &rx{x};

Этот вариант инициализации самый универсальный, он допустим в любом контексте.


Есть еще вариант универсальной инициализации с символом =.


int x = 6;
int &rx = {x};

Но для инициализации ссылок он синтаксически избыточен. Кроме того, если определять ссылку с использованием ключевого слова auto (см. раздел 2.5), то выводимый тип будет конкретизацией шаблона std::initializer_list<>, что, скорее всего, не будет соответствовать ожиданиям программиста.


Еще один вариант — это использование круглых скобок.


int x = 6;
int &rx(x);

Этот вариант в ряде случаев может привести к инструкции, которая компилятором будет трактоваться как объявление функции. Это старая, достаточно известная проблема неоднозначности некоторых синтаксических конструкций в C++. Когда-то, очень давно, решили, что если инструкция может трактоваться как определение и объявление, то надо выбирать объявление. Вот пример:


class X
{
public:
    X();
// ...
};

const X &rx(X());

На первый взгляд rx — это определение переменной типа const X&, инициализированной неименованным экземпляром типа X, это полностью соответствует синтаксису C++. Но эту инструкцию также можно трактовать как объявление функции, которая возвращает const X& и имеет параметр типа указатель на функцию, которая возвращает X и не имеет параметров. В соответствии с вышеупомянутым правилом, компилятор выбирает второй вариант. Конечно, тяжелых последствий это не вызовет, так как сразу же возникнут ошибки компиляции, но потратить время на осмысление ситуации, возможно, придется. Для исправления ситуации можно, например, взять X() в дополнительные скобки.



2.2. Члены класса ссылочного типа


В классе можно объявить члены ссылочного типа. Нестатический член обычно инициализируется в списке инициализации конструктора с использованием параметров конструктора. В C++11 нестатический член можно инициализировать непосредственно при объявлении, но предложить какой-нибудь содержательный пример в данном случае сложно.


class X
{
    int &m_R;
public:
    X(int& r) : m_R(r){ /* ... */ }
// ...
};

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


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


Можно объявить статический член ссылочного типа. Он должен быть инициализирован при определении. В C++17 появилась возможность инициализировать такой член при объявлении, для этого он должен быть объявлен с ключевым словом inline.


сlass X
{
public:
    static const int &H;
    static inline const int &G = 32;
// ...
};

const int &X::H = 4;


2.3. Категория значения


В C++ каждое выражение наряду с типом имеет категорию значения (value category). (И тип и категория значения выражения известны во время компиляции.) Категория значения необходима для описания правил использования ссылок. Первоначально (в C) было только две категории значения — lvalue и rvalue. Lvalue — это именованная переменная (то, что могло находится в левой части присваивания), а rvalue — это временные, неименованные сущности (могут находится в правой части присваивания). Но в процессе развития языка определение категорий значения становится более сложным. Сейчас в C++17 имеется 5 категорий значения, подробнее см. [VJG], есть статья на Хабре, написанная igorsemenov. Для изложения представленного материала нам достаточно использовать упрощенный вариант, включающий lvalue и rvalue.


Lvalue:


  1. Именованная переменная (в том числе и rvalue-ссылка).
  2. Результат применения оператора разыменования (*).
  3. Результат применения к именованным переменным операторов доступа к членам (., ->) и индексатора.
  4. Строковый литерал.
  5. Вызов функции, которая возвращает ссылку или ссылку на константу.

Rvalue:


  1. Результат применения оператора получение адреса (&).
  2. Результат применения других операторов (за исключением lvalue п.2 и п.3).
  3. Простой литерал (42, ’X’, etc.), член перечисления.
  4. Вызов функции, которая возвращает не-ссылку.
  5. Вызов функции, которая возвращает rvalue-ссылку.

Lvalue можно еще разделить на изменяемые и неизменяемые (константные). Rvalue также можно разделить на изменяемые и неизменяемые, но неизменяемые rvalue практически не используются и мы не будем их рассматривать. Обратим внимание на пункты, начинающиеся с «Вызов функции, которая возвращает ...». Под это попадают также приведения типа, в том числе и неявные.





2.4. Требования к инициализирующему выражению


Пусть T некоторый неконстантный и нессылочный тип или псевдоним.


T &r = ini_expression;

Это простая ссылка. Требования к ini_expression: lvalue типа T, T&, T&& или lvalue/rvalue любого типа, имеющего неявное преобразование к T&.


const T &r = ini_expression;

Это ссылка на константу. Требования к ini_expression: lvalue/rvalue типа T, T&, T&&, const T, const T& или любого типа, имеющего неявное преобразование к одному из этих типов.


T &&r = ini_expression;

Это rvalue-ссылка. Требования к ini_expression: rvalue типа T, T&& или lvalue/rvalue любого типа, имеющего неявное преобразования к T, T&&. Обратим внимание, что ini_expression не может быть именованной переменной ссылочного типа (в том числе и T&&), то есть прямо rvalue-ссылку скопировать нельзя. Как правильно копировать rvalue-ссылку показано далее в разделе 3.1.4



2.5. Инициализация ссылок с использованием автоопределения типа


Многие современные языки программирования со статической типизацией (то есть определяющие тип переменных на этапе компиляции) имеют возможность не указывать явно тип переменных, а предоставить вывод типа компилятору, который решает эту задачу исходя из типа инициализирующего выражения. В C++11 также появилась такая возможность, для этого используется ключевое слово auto. Но в этом случае правила вывода типа переменной не столь просты, как может показаться с первого взгляда. Ключевое слово auto может быть дополнено спецификатором ссылки и квалификатором const, что усложняет правила вывода и иногда приводит к неприятным неожиданностям. Еще следует обратить внимание на то, что в этом случае при выводе типа переменных не используются неявные преобразования типа, в том числе основанные на правилах полиморфизма. Также с использованием auto нельзя объявлять члены класса. В приводимых примерах T некоторый неконстантный и нессылочный тип или псевдоним.


auto x = ini_expression;

Тип переменной x никогда не будет выведен ссылочным или константным. Тип x выводится как T, если ini_expression имеет тип T, T&, T&&, const T, const T&, категория значения ini_expression может быть любая. В процессе инициализации вызывается копирующий или перемещающий конструктор для типа T. Если ini_expression lvalue, то будет вызван копирующий конструктор, если ini_expression rvalue, то при поддержке типом T семантики перемещения будет вызван перемещающий конструктор, иначе копирующий. В случае rvalue вызов конструктора может быть удален при оптимизации.


auto &x = ini_expression;

Тип переменной x выводится как T&, если ini_expression имеет тип T, T&, T&&. Тип x выводится как const T&, если ini_expression имеет тип const T, const T&. Если выводимый тип T&, то ini_expression должен быть lvalue.


const auto &x = ini_expression;

Тип переменной x выводится как const T&, если ini_expression имеет тип T, T&, T&&, const T, const T&, категория значения ini_expression может быть любая.


auto &&x = ini_expression;

Этот тип ссылки называется универсальной ссылкой (universal reference), и имеет довольно специфические правила вывода, выводимый тип зависит от категории значения ini_expression. Тип переменной x выводится как T&, если ini_expression является lvalue и имеет тип T, T&, T&&. Тип переменной x выводится как const T&, если ini_expression является lvalue и имеет тип const T, const T&. Тип переменной x выводится как T&&, если ini_expression является rvalue и имеет тип T, T&, T&&. В C++17 этот тип ссылки стали называть передаваемой ссылкой (forwarding reference), о причинах рассказано далее в разделе 3.2.4.


Особо следует отметить случай, когда ini_expression является массивом или функцией. В этом случае в определении


auto x = ini_expression;

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



3. Ссылки в качестве параметров и возвращаемого значения функций


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



3.1. Параметры функций


В этом случае ссылки обеспечивают ряд преимуществ.


  1. Затраты на передачу параметра постоянны и не зависят от типа, на который ссылается ссылка (они эквиваленты затратам на передачу указателя).
  2. Позволяют модифицировать объект, на который ссылается параметр, то есть превращать параметр в выходной.
  3. Позволяют запретить модифицировать объект, на который ссылается параметр.
  4. Обеспечивают реализацию семантики перемещения.
  5. Передача ссылки по стеку вызовов не приводит к появлению висячих ссылок.
  6. Поддерживают полиморфизм.


3.1.1. Специальные функции-члены и перегруженные операторы


В C++ классы имеют так называемые специальные функции-члены — копирующий конструктор, копирующий оператор присваивания и их перемещающие аналоги. Эти функции-члены имеют единственный параметр, который обычно имеет ссылочный тип. При перегрузке операторов также часто нельзя обойтись без параметров ссылочного типа.


class X
{
public:
    X(const X& src); // копирующий конструктор
    X& operator=(const X& src); // оператор копирующего
                                // присваивания
    X(X&& src) noexcept; // перемещающий конструктор
    X& operator=(X&& src) noexcept;// оператор перемещающего
                                   // присваивания
// ...
};

X operator+(const X& lh, const X& rh); // перегруженный оператор +

Для копирующего конструктора и перемещающих операций тип параметра изменить нельзя. При перегрузке операторов (в том числе и оператора копирующего присваивания) передачу параметра по ссылке иногда можно заменить передачей по значению, см. раздел 3.3.



3.1.2. Требования к аргументам


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


void Foo(T x);

Это передачу параметра по значению. Подробнее см. раздел 3.3. В ряде случаев мы должны сравнивать передачу параметра по значению и передачу параметра по ссылке.


void Foo(T& x);

Параметр — простая ссылка. Требования к аргументу: lvalue типа T, T&, T&& или lvalue/rvalue любого типа, который имеет неявное преобразование к T&. В этом случае мы имеем возможность модифицировать аргумент, то есть x может быть выходным параметром.


void Foo(const T& x);

Параметр — ссылка на константу. Требования к аргументу: lvalue/rvalue типа T, T&, T&&, const T, const T& или любого типа, имеющего неявное преобразование к одному из этих типов. В этом случае мы не имеем возможность модифицировать аргумент.


void Foo(T&& x);

Параметр — rvalue-ссылка. Требования к аргументу: rvalue типа T, T&& или lvalue/ rvalue любого типа, который имеет неявное преобразование к T, T&&. Этот вариант используется для реализации семантики перемещения. В классе, поддерживающем перемещение, должен быть определен перемещающий конструктор с параметром типа rvalue-ссылка и оператор перемещающего присваивания с таким же параметром.


class X
{
public:
    X(X&& src) noexcept;
    X& operator=(X&& src) noexcept;
// ...
};

Эти функции-члены и выполняют в конечном итоге перемещение. В ряде случаев компилятор сам генерирует перемещающий конструктор и оператор перемещающего присваивания, подробности см. [Meyers]. Использование noexcept не является строго обязательным, но крайне желательным, иначе в стандартной библиотеке в некоторых случаях перемещение будет заменено на копирование, подробности см. [Meyers].


Ключевой момент концепции семантики перемещения заключается в том, что источником перемещения является rvalue и, таким образом, после выполнения перемещения этот объект будет недоступен и не надо беспокоиться о случайном доступе к «опустошенному» объекту. (Возможно принудительное приведение lvalue к rvalue (см. раздел 3.1.4 ), но в этом случае программист уже сам отвечает за недопущение некорректных операций.)



3.1.3. Перегрузка функций


Перегрузка (overloading) — это возможность одновременно использовать несколько функций или шаблонов функций с одним именем. Компилятор различает их благодаря тому, что они имеют разный набор параметров. В точки вызова компилятор анализирует типы аргументов и определяет, какая конкретно функция должна быть вызвана. Эта процедура называется разрешением перегрузки. Разрешение перегрузки может завершиться неудачей, то есть компилятор может не отдать предпочтение ни одной из функций, в этом случае говорят, что вызов неоднозначный (ambigious). Более подробно перегрузка обсуждается в одной из предыдущих статей серии.


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


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


void Foo(T& x);
void Foo(const T& x);

В этом случае для неконстантных lvalue-аргументов будет выбрана первая функция (хотя вторая также допустима), для константных lvalue-аргументов и rvalue-аргументов вторая (первая недопустима).


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


void Foo(T& x);
void Foo(T x);

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


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


void Foo(const T& x);
void Foo(T x);

Для любых аргументов разрешение перегрузки завершится неудачей (хотя обе функции допустимы).


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


void Foo(const T& x);
void Foo(T&& x);

В этом случае первая функция будет выбрана для lvalue-аргументов (вторая недопустима), а вторая для rvalue-аргументов (хотя первая допустима). Это правило является ключевым при реализации семантики перемещения, именно оно используется при выборе между копирующим и перемещающим конструктором (а также соответствующими операторами присваивания).


class X
{
public:
    X(const X& src);     // копирующий конструктор
    X(X&& src) noexcept; // перемещающий конструктор
// ...
};

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


void Foo(T& x);
void Foo(T&& x);

В этом случае первая функция будет выбрана для неконстантных lvalue-аргументов (вторая недопустима), вторая для rvalue-аргументов (первая недопустима), а для константных lvalue-аргументов обе функции недопустимы и, соответственно, разрешение перегрузки завершится неудачей.


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


void Foo(T x);
void Foo(T&& x);

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


Отметим также появившееся в С++11 ссылочные квалификаторы для нестатических функций-членов. Они позволяют перегружать по категории значения скрытого параметра this.


class X
{
public:
    X();
    void Foo() &;  // this указывает на lvalue
    void Foo() &&; // this указывает на rvalue
// ...
};

X x;
x.Foo();   // X::Foo() &
X().Foo(); // X::Foo() &&


3.1.4. Функции с параметром типа rvalue-ссылка


Рассмотрим случай, когда у нас есть функция с параметром типа rvalue-ссылка. Такая функция принимает только rvalue-аргументы. Пусть теперь этот параметр мы просто должны передать другой функции с параметром типа rvalue-ссылка. В этом случае необходимо учитывать, что сам параметр будет lvalue и поэтому для корректной передачи такой параметр необходимо пропустить через преобразование типа static_cast<T&&>() или вызов стандартной функции std::move(), которые превращают lvalue в rvalue.


class X;

void FooInt(X&& x);
void Foo(X&& x)
{
// x это lvalue, а std::move(x) это rvalue
    FooInt(std::move(x));
// ...
}

Если этого не сделать, то будет либо ошибка, либо, если есть перегруженная функция c параметром типа X, X&, const X&, то будет выбрана она (в частности перемещение может быть заменено на копирование, см. раздел 3.1.3). Таким образом, без этого преобразования семантика перемещения в дальнейшем не будет работать. Такие ошибки опасны тем, что их можно долго не замечать.


Обратим внимание на немного сбивающее с толка название std::move(). Реально эта функция ничего не перемещает, это приведение типа, которое превращает lvalue в rvalue и использовать ее надо только так, как показано в примере — ее вызов должен быть аргументом функции, с параметром rvalue-ссылка. Реальное перемещение делает перемещающий конструктор.



3.2. Параметры шаблонов функций



3.2.1. Автоматический вывод аргументов шаблонов функций


Аргументы шаблона функции могут выводиться компилятором автоматически, основываясь на типе аргумента вызова. Это наиболее распространенный вариант использования шаблонов функций. Если аргумент шаблона выводится автоматически, то правила вывода практически полностью совпадают с правилами вывода для объявлений с помощью ключевого слова auto. При описании аргумента будем считать T неконстантным и нессылочным типом или псевдонимом.


template<typename T>
void Foo(T x);

Тип аргумента шаблона выводится как T, если аргумент имеет тип T, T&, T&&, const T, const T&, тип параметра x будет T, категория значения аргумента может быть любая. Таким образом, тип T никогда не будет выведен ссылочным или константным. Здесь мы имеем передачу параметра по значению.


template<typename T>
void Foo(T& x);

Тип аргумента шаблона выводится как T, если аргумент имеет тип T, T&, T&&, тип параметра x будет T&, аргумент должен быть lvalue. Тип аргумента шаблона выводится как const T, если аргумент имеет тип const T, const T&, тип параметра x будет const T&, категория значения аргумента может быть любая.


template<typename T>
void Foo(const T& x);

Тип аргумента шаблона выводится как T, если аргумент имеет тип T, T&, T&&, const T, const T&, тип параметра x будет const T&, категория значения аргумента может быть любая.


template<typename T>
void Foo(T&& x);

Это универсальная ссылка. Аргумента шаблона выводится как T&, если аргумент lvalue и имеет тип T, T&, T&&, тип параметра x будет также T&. Аргумента шаблона выводится как const T&, если аргумент lvalue и имеет тип const T, const T&, тип параметра x будет также const T&. Тип аргумента шаблона выводится как T, если аргумент rvalue и имеет тип T, T&, T&&, тип параметра x будет T&&.


Особо следует отметить случай, когда аргумент вызова является массивом или функцией. В этом случае в шаблоне функции


template<typename T>
void Foo(T x);

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



3.2.2. Явное задание аргумента шаблона функции


Аргумент шаблона функции может быть указан явно. Иногда это вынужденное решение, когда автоматический вывод невозможен (например для типа возвращаемого значения) или не дает нужный результат (например для ссылочных типов). В этом случае механизм вывода аргументов шаблона не используется и мы фактически имеем дело с нешаблонной функцией. В частности, явное задание аргумента используется в рассматриваемом далее шаблоне функции std::forward<>().


Пусть у нас параметр функции имеет тип ссылки на параметр шаблона. В этом случае, если явно заданный аргумент шаблона будет иметь ссылочный тип, то мы получим, что параметр функции будет иметь тип ссылка на ссылку. В C++ такие типы запрещены, поэтому в этой ситуации выполняется операция под названием свертывание ссылок (reference collapsing), в результате чего тип параметра функции будет ссылка или rvalue ссылка. Подробнее свертывание ссылок рассмотрено в раздел 5.2.2, простые примеры будут в следующем разделе.



3.2.3. Универсальные ссылки и rvalue-ссылки


Универсальная ссылка и rvalue-ссылка объявляются одинаково, с помощью спецификатора &&, поэтому важно четко понимать, с каким вариантом мы имеем дело в том или ином случае.


Универсальная ссылка — это не особая разновидность ссылок, а некоторый специальный механизм автоматического вывода аргумента шаблона и для того чтобы он был использован, необходимо выполнение трех условий.


  1. Наличие шаблона функции с типовым параметром (обозначим его через T).
  2. Параметр функции объявлен как T&&.
  3. Аргумент шаблона выводится автоматически, исходя из типа аргумента вызова функции.

Если аргумент шаблона задается явно и параметр шаблона функции объявлен как T&&, то в случае аргумента шаблона ссылочного типа применяется свертывание ссылок (см. раздел 5.2.2) и параметр конкретизированной функции превратится в обычную ссылку или rvalue-ссылку. Если аргумент шаблона нессылочного типа, то параметр будет rvalue-ссылка.


Рассмотрим примеры.


class X
{
public:
    X();
// ...
};

X x; // x это lvalue

Рассмотрим несколько вариантов использования x в качестве аргумента при вызове функции.


void F(X&& x);

F(x); // ошибка

В данном случае у нас обычная функция (нарушено условие 1), параметр имеет тип rvalue-ссылка, lvalue-аргумент не подходит.


template<typename T>
void Foo(T&& x)

Foo(x); // OK

В данном случае все условия выполнены, параметр является универсальной ссылкой, можно использовать lvalue-аргумент.


Foo<X>(x); // ошибка

Аргумент шаблона задается явно, параметр в данном случае имеет тип rvalue-ссылка, lvalue-аргумент не подходит.


Foo<X&>(x); // OK

Аргумент шаблона задается явно и имеет ссылочный тип, следовательно выполняется свертывание ссылок (X& && -> X&). Параметр будет обычной ссылкой, поэтому можно использовать lvalue-аргумент.


template<typename T>
class W
{
public:
    W();
    void Foo1(T&& x);
    template<typename U>
    void Foo2(U&& x);
// ...
};

W<X> wx;
wx.Foo1(x);  // ошибка

Тип параметра функции-члена Foo1() определяется явно, при конкретизации шаблона класса W, параметр имеет тип rvalue-ссылка, lvalue-аргумент не подходит.


W<X&> wrx;
wrx.Foo1(x); // OK

Тип параметра функции-члена Foo1() определяется явно, при конкретизации шаблона класса W, аргумент шаблона класса имеет ссылочный тип, следовательно выполняется свертывание ссылок (X& && -> X&). Параметр будет обычной ссылкой, поэтому можно использовать lvalue-аргумент.


W<X> wx;
wx.Foo2(x);

Здесь мы имеем шаблон функции-члена. В этом вызове аргумент шаблона функции-члена выводится автоматически, параметр будет универсальной ссылкой, поэтому можно использовать lvalue-аргумент.


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


Универсальные ссылки также используются при выводе типа переменных, объявленных с помощью auto &&. Это происходит при объявлении переменных (см. раздел 2.5) и параметров лямбда-выражений (см. раздел 3.4.1).



3.2.4. Прямая передача


Теперь рассмотрим ситуацию, когда параметр функции, являющейся универсальной ссылкой, надо передать в другую функцию. Параметр функции всегда будет lvalue и для того, чтобы его корректно передать другой функции, мы должны его преобразовать в rvalue, но только тогда, когда аргумент является rvalue, то есть мы должны сохранить категорию значения аргумента. Если этого не сделать, то могут возникнуть проблемы, описанные в разделе 3.1.4, то есть по существу будет отключена семантика перемещения. В силу того, что аргумент шаблона имеет разный тип в зависимости от категории значения аргумента вызова, задача эта решаема и для этого служит стандартный шаблон функции std::forward<>(). Его надо конкретизировать параметром шаблона и параметр пропустить через вызов этой функции.


class X
{
public:
    X();
// ...
};
void FooInt(const X& x); // для lvalue
void FooInt(X&& x);      // для rvalue

template<typename T>
void Foo(T&& x)
{
// x это lvalue, а std::forward<T>(x) это
// lvalue, если аргумент lvalue и rvalue, если аргумент rvalue
    FooInt(std::forward<T>(x));
// ...
}
X x;
Foo(x);   // FooInt(const X& x), lvalue аргумент
Foo(X()); // FooInt(X&& x), rvalue аргумент

Эта схема передачи параметра называется прямой (иногда идеальной) передачей (perfect forwarding). Теперь понятно, почему универсальную ссылку стали называть передаваемой (forwarding reference). Универсальные ссылки и прямая передача являются довольно сложной темой со своими «подводными камнями». У Скотта Мейерса [Meyers] можно найти много важных и интересных подробностей на этот счет.


Опять же обратим внимание на то, что шаблон std::forward<>() — это преобразование типа, ничего больше он не делает. Его задача — обеспечить корректную работу правил вызова и перегрузки функций, с учетом категории значения аргумента.



3.2.5. Перегрузка шаблонов функций


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


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


template<typename T>
void Foo(T&& x);
template<typename T>
void Foo(T& x);

Для lvalue-аргументов конкретизации обоих шаблонов одинаковы, но будет выбран второй шаблон как более специализированный. Для rvalue-аргументов конкретизации разные и по правилам раздела 3.1.3 будет выбран первый шаблон.


template<typename T>
void Foo(T&& x);
class X;
void Foo(X&& x);

Для rvalue-аргументов типа X, X&& конкретизации обоих шаблонов одинаковы и будет выбрана вторая функция как нешаблонная, для остальных аргументов первый шаблон.


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



3.3. Передача параметра по ссылке на константу vs передачи по значению


Передача параметра по ссылке на константу и по значению в ряде случаев выступают как конкуренты.


void Foo(const T& x); // передача по ссылке на константу
void Foo(T x);        // передача по значению

Рассмотрим особенности каждого варианта.


Эти варианты не могут быть перегружены (см. раздел 3.1.3), то есть программист заранее должен выбрать один из них. В любом из них аргумент может быть типа T, T&, T&&, const T, const T& или любого типа, имеющего неявное преобразование к какому-то из этих типов, категория значения аргумента может быть любая. Оба варианта гарантируют неизменяемость аргумента.


Рассмотрим теперь требования к типу T и накладные расходы при передаче параметра. При передаче по ссылке на константу особых требований к типу T нет, копируется ссылка, затраты постоянны и совпадают с затратами по копированию указателя. При передаче по значению для lvalue-аргументов вызывается копирующий конструктор, а для rvalue-аргументов вызывается перемещающий конструктор, если тип T поддерживает семантику перемещение, и копирующий конструктор в противном случае. В C++17 для rvalue-аргументов при передаче по значению в ряде случаев не требуется наличия копирующего или перемещающего конструктора, так как оптимизации, удаляющие вызов конструктора, внесены в стандарт и наличие соответствующего конструктора уже не требуется. В предыдущих версиях C++ требовалось наличие конструктора, даже когда он удалялся при оптимизации.


Напомним, что вызов копирующего конструктора может оказаться весьма дорогостоящей операцией, например, стандартные контейнеры используют так называемое глубокое копирование (deep copying), когда копируются все элементы контейнера. Стоимость вызова перемещающего конструктора также больше, чем копирование ссылки.


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


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


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


class X
{
public:
    X& operator=(X src);
// ...
};

Но вот как раз с таким оператором присваивания есть одна потенциальная проблема: вместе с ним нельзя будет использовать оператор перемещающего присваивания, так как для них разрешение перегрузки окончится неудачей для rvalue-аргументов, см. правила перегрузки в разделе 3.1.3.


Традиционные рекомендации для выбора из этих двух вариантов следующие: для простых неполиморфных типов с тривиальным копированием и размером до 8 байт использовать передачу по значению, для остальных типов передачу по ссылке на константу.


Интересные и не совсем традиционные размышления по поводу этих вариантов можно найти у Скотта Мейерса [Meyers].



3.4. Лямбда-выражения



3.4.1. Автоопределение типа параметра


Лямбда-выражения до C++20 не могли быть шаблонами, но в С++14 появилась возможность для типа параметров использовать ключевое слово auto, при этом можно использовать спецификатор ссылки и квалификатор const. Это частично компенсировало отсутствие шаблонов (вывод типа auto и вывод типа аргумента шаблона это практически одно и то же). Например, параметр типа универсальная ссылка в лямбда-выражении можно объявить следующим образом:


[](auto&& x){ /* ... */ }

Но тут возникает вопрос — что делать, если надо реализовать прямую передачу? Для прямой передачи мы должны шаблон функции std::forward<>() конкретизировать аргументом шаблона, а его в данном случае нет. Оказывается, вместо аргумента шаблона можно использовать decltype(x). Для rvalue-аргументов это будет другой тип по сравнению с типом, выводимым для шаблонов с универсальной ссылкой, но, несмотря на это, std::forward<>() будет работать так, как надо для прямой передачи. Таким образом, в качестве передаваемого дальше аргумента надо использовать выражение std::forward<decltype(x)>(x). Детали можно найти у Скотта Мейерса [Meyers].



3.4.2. Захват переменной по ссылке


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


int callCntr = 0;
auto g = [&callCntr](){ ++callCntr; };
g();

В этом примере переменная callCntr используется в качестве счетчика вызовов.


Захват по ссылке потенциально может привести к появлению висячей ссылки (см. раздел 4), так как замыкания (в нашем примере это g) можно копировать и потенциально копия может иметь время жизни больше, чем захваченная переменная.



3.5. Возвращаемое значение функции



3.5.1. Варианты использования


Использование ссылок в качестве возвращаемых значений функций таит в себе определенную опасность, могут появиться висячие ссылки, см. раздел 4. Но, не смотря на это, такой прием используется достаточно широко, в том числе и в стандартной библиотеке.
В качестве первого примера рассмотрим итераторы. В стандартном интерфейсе итератора перегруженный оператор * (разыменование) обычно возвращает ссылку на объект, хранимый в контейнере. У некоторых стандартных контейнеров есть еще специальные функции-члены, например, индексатор, front(), back(), которые возвращают ссылку на объект, хранимый в контейнере.
Вызов функции, которая возвращает ссылку, может находиться в левой части оператора присваивания. Это делает код более компактным и читабельным и позволяет использовать перегруженные операторы для пользовательских типов таким же образом, как и для встроенных типов.


std::vector<int> v(2);
v.front() = 31;
v[1] = 41;

При перегрузке оператора присваивания (и составных операторов присваивания: +=, etc.) возвращаемое значение должно быть ссылкой на результат операции, это позволяет строить цепочку присваиваний.


x = y = z;

Еще один пример использования ссылок в качестве возвращаемого значения — это потоки ввода/вывода, где перегруженные операторы >> и << должны возвращать ссылку на поток, что дает возможность строить цепочку операций.


int x, y;
std::cout << "x=" << x << ", y=" << y << '\n';

Накладные расходы, связанные возвращаемым значением ссылочного типа, невелики, они совпадают с соответствующими расходами для указателя. Они могут быть еще меньше в результате применения оптимизаций (RVO, etc).



3.5.2. Автоопределение типа возвращаемого значения


В C++14 появилась возможность не указывать явно, а выводить тип возвращаемого значения функции. Для этого в качестве типа возвращаемого значения указывается auto, при этом можно использовать спецификатор ссылки и квалификатор const. Правила вывода типа те же, что и при инициализации переменных, объявленных с помощью auto, см. раздел 2.5.


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


Автоопределение типа возвращаемого значения используется в основном в шаблонах функций.



4. Висячие ссылки


Для любой ссылки в широком смысле существует проблема висячей ссылки (dangling reference). Она возникает, когда объект, на который ссылается ссылка, удаляется или перемещается, а ссылка про это «ничего не знает». В этом случае использование ссылки приводит к так называемому неопределенному поведению, то есть может произойти все, что угодно — аварийное завершение программы, неверный, но правдоподобный результат и другие неприятные вещи.


В C++ в ряде случаев компилятор гарантирует отсутствие висячих ссылок, но в общем случае программисту самому приходиться следить, чтобы висячие ссылки не появлялись.


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


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



4.1. Ссылка на rvalue



Как мы видели выше, ссылка на константу и rvalue-ссылка может быть инициализирована rvalue. Спрашивается, а на что она тогда ссылается? Компилятор в этом случае реализует механизм под названием временная материализация (temporary materialization) — создается скрытая переменная, которая инициализируется этим rvalue, и ссылка будет ссылаться на эту переменную. И самое важное, компилятор обеспечивает время жизни этой переменной не меньше, чем время жизни ссылки, поэтому такая ссылка никогда не станет висячей. Следующий странноватый на первый взгляд код является совершенно корректным.


int &&rr = 7;
rr = 8;

Литерал 7 — это rvalue, значить происходит временная материализация и во второй инструкции просто меняется значение соответствующей скрытой переменной.


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



4.2. Временные объекты


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



4.3. Примеры


Можно, конечно, висячую ссылку создать как-нибудь так:


int &dx = *new int(32);
delete &dx;
// dx — висячая ссылка

Но подобный код встречается, наверное, очень редко. Чаще всего проблемы возникают, когда объект удаляется неявно. Рассмотрим насколько типичных сценариев.


Одна из самых грубых ошибок — это возврат из функции ссылки на локальный объект.


class X
{
public:
    X();
// ... 
};

X& Foo()
{
    X x;
// ...
    return x;
}

const X& Foo2()
{
    const X &ret = X();
// ...
    return ret;
}

Подобный код гарантирует висячую ссылку. (Правда иногда может спасти inline подстановка.) Компилятор выдает предупреждение, но не ошибку.


Рассмотрим теперь функцию:


const X& Foo(const X& x)
{
// ...
    return x;
}

Если при вызове этой функции используется lvalue-аргумент, то гарантируемых проблем не возникает, время жизни x будет определяться контекстом вызова, но в случае rvalue-аргумента время жизни x будет тело функции и после вызова этой функции возвращаемая ссылка будет ссылаться на удаленный объект. Спасти ситуацию может inline подстановка или, если вызов этой функции будет инициализировать значение X, а не ссылку, тогда деструктор X будет вызван после копирования.


Интересно, что подобным образом реализованы некоторые стандартные функции, например:


// https://en.cppreference.com/w/cpp/algorithm/max
// header <algorithm>
// namespace std

template<class T>
const T& max(const T& a, const T& b);

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


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


class X;
class Y
{
    const X &m_X;
// ...
public:
    Y(const X& x) : m_X(x){ /* ... */}
// ...
};

Здесь та же проблема, что и в предыдущем примере. Если в конструкторе используется lvalue-аргумент, то все может работать, но если аргумент является rvalue, то m_X гарантируемо будет висячей ссылкой. В этом случае можно подстраховаться и запретить инициализацию экземпляра Y с помощью rvalue, для этого надо объявить конструктор с параметром типа rvalue-ссылка удаленным.


Y(X&&) = delete;

Рассмотрим еще один пример.


class X
{
    int m_Value;
public:
    X(int x) :  m_Value(x) {}
    const int& Value() const { return m_Value; }
};

Рассмотрим первый вариант использования этого класса.


const int &rxv = X(32).Value();

Ссылка на константу rxv инициализируется вызовом функциии, которая возвращает ссылку на константу, а это lvalue (см. раздел 2.3), поэтому временной материализации не будет. Но эта функция является функцией-членом, которая возвращает ссылку на подобъект временного объекта типа X. В соответствии с разделом 4.2 этот временный объект будет удален сразу после того, как ссылка rxv будет инициализирована. Получаем висячую ссылку.


Этот пример показывает потенциально опасную ситуацию — «превращение» rvalue в lvalue с помощью вызова функции, которая возвращает ссылку, и как следствие появления висячей ссылки. Ситуация будет еще более опасной, когда класс имеет неявное преобразование к ссылке, применение неявного преобразования — это по существу вызов функции, но происходит это неявно.


Перепишем предыдущий код следующим образом:


const X &rx = X(32);
const int &rxv = rx.Value();

Ссылка на константу rx инициализируется временным объектом типа X, а это rvalue, поэтому происходит временная материализация и rxv будет ссылаться на подобъект «живого» объекта и, таким образом, rxv не будет висячей ссылкой во время жизни rx.


А теперь будем использовать выражение из первого варианта в качестве аргумента функции.


void Foo(const int& rr);
Foo(X(32).Value());

В этом примере временной материализации не будет по той же причине, что и в первом варианте, но временный экземпляр X будет удален, только после того, как Foo() вернет управление (см. раздел 4.2) и, таким образом, в теле функции Foo() ссылка rr не будет висячей.


В C++11 можно запретить вызов нестатической функции-члена для rvalue:


const int& Value() const & { return m_Value; }
const int& Value() const && = delete;

Здесь мы использовали так называемые ссылочные квалификаторы для нестатических функций-членов. Они позволяют перегружать по категории значения скрытого параметра this (см. раздел 3.1.3).



4.4. Стандартные контейнеры


Стандартный доступ к элементам контейнера осуществляется через итератор. В интерфейсе итератора есть перегруженный оператор * (разыменование), который обычно возвращает ссылку на объект, хранимый в контейнере. Если после получения такой ссылки происходит какая-то операция с контейнером, то эта ссылка может оказаться висячей. Понятно, что для любого контейнера вызов clear() гарантирует, что все ранее полученные ссылки становятся висячими. Вот менее очевидный пример — при добавлении элемента в экземпляр std::vector<> может произойти выделение нового буфера и копировании или перемещение всех старых данных в новый буфер, после чего все ранее полученные ссылки становятся висячими. В документации по стандартной библиотеке можно найти информацию о том, при каких операциях с контейнером гарантируется, что ранее полученные итераторы не станут недействительными.


В стандартных контейнерах также есть другие функции-члены (индексатор, front(), back(), etc.), возвращающие ссылки на элементы, хранимые в контейнере, эти ссылки также могут стать висячими.



4.5. Другие языки


Многие языки программирования озабочены проблемой висячих ссылок. В языках, использующих сборку мусора (C#, Java и многие другие), эта проблема решается так: объект, контролируемый сборщиком мусора, может быть удален только после того, как на него не останется ссылок, а при перемещении объекта ссылки автоматически корректируются.


Другой пример — это Rust. Одна из рекламируемых особенностей этого языка является более сложная система отслеживания жизненного цикла ссылок и, возможно, некоторые из описанных выше проблем были бы обнаружены на стадии компиляции.



5. Ссылочные типы и шаблоны



5.1. Ссылочные типы


Если T некоторый нессылочный тип или псевдоним, то T& будет соответствующим ссылочным типом. Можно объявить псевдоним ссылочного типа


using RT = T&;

или с использованием традиционного typedef


typedef T& RT;

Если не использовать псевдонимы, то ссылочные типы для массивов и функций надо объявлять несколько по другому, см. разделы 1.2.3, 1.2.4.


Ссылки на константу представляют отдельный ссылочный тип:


using RCT = const T&;

Если T константный тип, то в этом объявлении const игнорируется. (Нельзя быть дважды константным.)


Rvalue-ссылки также представляют отдельные ссылочные типы:


using RVT = T&&;

Ссылочные типы являются практически полностью скрытыми, в том смысле, что любой запрос по поводу этого типа (например sizeof или typeid) будет переадресован к типу, на который этот ссылочный тип ссылается. Размер самой ссылки можно узнать только косвенно, см. раздел 1.3.2. Из-за этих особенностей у ссылочных типов имеются ряд ограничений.


Нельзя объявить указатель на ссылку.


T x;
T &rx = x;
using RT = T&;
RT *prx = rx;    // ошибка
using PRT = RT*; // ошибка

Если бы и был тип указателя на ссылку, то мы не могли бы инициализировать экземпляр такого типа, так как оператор & (получение адреса), примененный к ссылке, возвращает указатель на объект, на который ссылка ссылается.


Но можно объявить ссылку на указатель.


T x;
T *px = &x;
using PT = T*;
PT &rpx = px; 
using RPT = PT&;

Нельзя определить ссылку на ссылку.


T x;
T &rx = x;
using RT = T&;
RT &rrx = rx; // ошибка

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


using RT = T&;
using RRT = RT&; // OK

На самом деле типом RRT будет T&, почему это так будет объяснено далее в разделе 5.2.2.


Нельзя объявить массив ссылок. Если мы попробуем как-нибудь так


int x = 1, y = 2;
int &ra[] = {x, y}; // ошибка

то компилятор выдаст ошибку. Это вытекает из предыдущих ограничений — тип, используемый для определения массива, должен иметь соответствующий тип указателя. Кроме того, оператор sizeof, примененный к элементу массива, должен давать размер элемента, а в случае массива ссылок он будет давать не размер самой ссылки, а размер объекта, на который эта ссылка ссылается.


Нельзя объявить ссылку на void.


using RVOID = void&; // ошибка


5.2. Аргументы шаблона ссылочного типа


В общем случае запрета на использование ссылочного типа в качестве аргумента шаблона нет, но в силу его особенностей тот или иной шаблон может не конкретизироваться с такими аргументами или, что еще опаснее, конкретизироваться, но работать неправильно. Другая проблема — это то, что при автоматическом выводе аргумента шаблона ссылочные типы выводятся только для параметров с универсальными ссылками. Это вынуждает использовать явное задание аргумента шаблона или использовать некоторые специальные «хитрости» (см. раздел 5.3.2).



5.2.1. Свойства типов


При разработке шаблонов широко используются специальные стандартные шаблоны, которые называются свойствами типов (заголовочный файл <type_traits>). Среди них есть несколько, предназначенных работать со ссылочными типами. Прежде всего, это std::is_reference<>, его статический член value будет иметь значение true, в случае аргументов шаблона ссылочного типа. (На самом деле есть еще свойства типа для более тонкой проверки: std::is_lvalue_reference<>, std::is_rvalue_reference<>.) Отметим, что для остальных свойств типов из этой группы (std::is_const<>, std::is_integral<>, etc.) это значение будет false и не будет зависеть от типа, на который ссылочный тип ссылается. Также можно использовать шаблон std::remove_reference<>, который превращает ссылочный тип в соответствующий нессылочный (типовой член шаблона type). Шаблон std::decay<> также снимает ссылочность, но выполняет еще и другие операции над типом.



5.2.2. Свертывание ссылок


Как уже упоминалось выше, ссылки на ссылку не существует, но при использовании аргументов шаблона ссылочного типа в ряде контекстов могут появляться конструкции, которые по правилам C++ интерпретируются как ссылки на ссылку. В этом случае применяется особое правило, которое называется свертывание ссылок (reference collapsing). В результате такая конструкция интерпретируется как ссылка или rvalue-ссылка на нессылочный тип. Правило простое — если обе ссылки являются rvalue-ссылками, то результирующая ссылка также будет rvalue-ссылка, в противном случае результирующая ссылка будет обычная ссылка. (На самом деле правило немного сложнее, нужно еще учитывать константность, см. [VJG].)


Первый пример — это вывод типа параметров функций при явном задании аргумента шаблона (см. также раздел 3.2.3).


template<typename T>
class W
{
public:
    W() = default;
    void Foo(T&& x);
// ...
};

class X { /* ... */ };

W<X> wx;     // void Foo(X&&); // нет свертывания
W<X&> wrx;   // void Foo(X&);  // X& && -> X&
W<X&&> wrvx; // void Foo(X&&); // X&& && -> X&&

Другой пример — это объявление псевдонимов.


using RI = int&;
using RRI = RI&;  // int& & -> int&

using RI = int&;
using RRI = RI&&; // int& && -> int&

using RI = int&&;
using RRI = RI&&; // int&& && -> int&&

Аналогичным образом работают правила объявления псевдонимов с помощью typedef.


Свертывание ссылок появилось в C++03 и было доработано в C++11, подробнее см. [VJG], [Meyers].



5.2.3. Запрет на использование ссылочных типов


Программист при разработке шаблона должен заранее решить вопрос о допустимости аргументов ссылочного типа. Если допустимы, то, возможно, придется использовать специальною обработку для таких аргументов. Если недопустимы, то надо запретить их использование. Наиболее современный вариант — это использование концептов, появившихся в C++20.


template<typename T> requires (!std::is_reference_v<T>)
class X { /* ... */ };

В более старых версиях можно использовать static_assert().


В стандартной библиотеке аргументы шаблона ссылочного типа для некоторых шаблонов запрещены, например, для контейнеров. В качестве примера шаблонов, где аргументы шаблона ссылочного типа допустимы, можно привести std::pair<>, std::tuple<>.



5.3. Стандартный эмулятор ссылок


В этом разделе описывается шаблон класса std::reference_wrapper<>. Этот шаблон позволяет создать «нормальный» тип, у которого нет ограничений ссылочного типа, но интерфейс максимально к нему близок. Такой шаблон можно назвать эмулятором ссылки.



5.3.1. Как устроен


Пусть T аргумент шаблона, то есть тип, которым конкретизируют шаблон. Традиционная реализация — это обертка над указателем на T. Понятно, что T не может быть ссылочным типом, указатели на ссылку запрещены. А вот константным типом может. Аргумент конструктора — это lvalue типа T, T&. Понятно, почему не rvalue, в этом случае мы бы сразу получили висячую ссылку. Висячую ссылку можно получить и для lvalue-аргумента, это зависит от времени жизни экземпляра класса по сравнению со временем жизни аргумента конструктора. Если не использовать автоматический вывод аргумента шаблона (C++17), то в качестве аргумента конструктора также можно использовать lvalue /rvalue любого типа, имеющего неявное преобразование к T&. Конструктора по умолчанию и, соответственно, возможности создать нулевую ссылку нет. Класс не поддерживает управление жизненным циклом объекта, на который указывает указатель, — деструктор ничего не делает. Семантика копирования — по умолчанию, просто копируется указатель. Перемещающее копирование не поддерживается, так как нет нулевых ссылок. Семантика присваивания — по умолчанию, происходит присваивание указателей. Обратим внимание, что эта семантика отличается от семантики присваивания для ссылок — присваивание ссылок реализовано, как присваивание объектов, на которые они ссылаются. Класс имеет неявное преобразование к T&. Это позволяет использовать экземпляры класса для инициализации ссылок на T и в качестве аргумента в функциях, в которых принимаются ссылки на T.


void Foo(int& rx);
int x = 6;
std::reference_wrapper<int> rwx = x;
int &rx = rwx; // OK
Foo(rwx);      // OK

А вот изменить значение, на которое ссылается экземпляр, с помощью присваивания или вызвать функцию-член класса T нельзя. Для решения этой задачи надо сначала вызвать функцию-член get(), которая возвращает T&.


int x = 6;
std::reference_wrapper<int> rwx = x;
rwx = 32;       // ошибка
rwx.get() = 32; // OK

Тип T может быть типом функции. На этот случай в классе перегружен оператор ().


void Foo(int x);
std::reference_wrapper<void(int)> rwf = Foo;
rwf(32);

Для создания экземпляра класса можно использовать шаблон функции std::ref<>(), который может выводить аргумент шаблона класса.


int x = 6;
auto rwx = std::ref(x); // то же, что и
 // std::reference_wrapper<int> rwx = x;

Также можно использовать шаблон функции std::сref<>(). В этом случае аргумент шаблона класса выводится как константный тип.


const int x = 6;
auto сrwx = std::сref(x); // то же, что и
 // std::reference_wrapper<const int> crwx = x;


5.3.2. Использование


Конкретизации шаблона std::reference_wrapper<> являются «нормальными» типами, их можно использовать для создания массивов, в качестве аргументов для стандартных контейнеров и других шаблонов. Если у нас есть шаблон функции


template<typename T>
void Foo(T param);

то при использовании аргумента, являющегося конкретизацией шаблона std::reference_wrapper<>, мы по существу заменяем передачу параметра по значению на передачу параметра по ссылке. Гарантии того, что с таким аргументом шаблон будет успешно конкретизирован, нет, но определенные доработки шаблона могут решить эту проблему, см. примеры ниже.


В стандартной библиотеке иногда применяется следующий прием: если аргумент шаблона функции имеет тип std::reference_wrapper<T>, то он преобразуется в T&, в противном случае остается неизменным. Приведем примеры.


int x = 1, y = 2;
auto rp1 = std::make_pair(std::ref(x), std::ref(y));

Тип rp1 будет выведен, как std::pair<int&, int&>.


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


int x = 1, y = 2;
auto rp2 = std::pair<int&, int&>(x, y);

Тип rp2 также будет выведен, как std::pair<int&, int&>. Получилось даже еще и короче, но мы вынуждены явно задавать аргументы шаблона класса, автоматический вывод здесь работать не будет.


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


// header <utility>
// namespace std
//
// ALIAS TEMPLATE _Unrefwrap_t
template <class _Ty>
struct _Unrefwrap_helper { // leave unchanged if not a reference_wrapper
    using type = _Ty;
};
template <class _Ty>
struct _Unrefwrap_helper<reference_wrapper<_Ty>> { // make a reference from a reference_wrapper
    using type = _Ty&;
};
// decay, then unwrap a reference_wrapper
template <class _Ty>
using _Unrefwrap_t = typename _Unrefwrap_helper<decay_t<_Ty>>::type;

По такому же принципу реализован шаблон функции std::make_tuple().


Шаблон std::reference_wrapper<> может оказаться полезным при разработке других шаблонов, но возможность его использования должна быть предусмотрена заранее.



6. Список статей серии «C++, копаем вглубь»


1. Перегрузка в C++. Часть I. Перегрузка функций и шаблонов.
2. Перегрузка в C++. Часть II. Перегрузка операторов.
3. Перегрузка в C++. Часть III. Перегрузка операторов new/delete.
4. Массивы в C++.
Семантика перемещения подробно обсуждается в статье «Семантика копирования и управление ресурсами в C++».



7. Итоги


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


2. Категория значения выражения — важнейшее понятие, необходимое для описания правил использования ссылок. Имеются две основные категории значения — lvalue и rvalue.


3. Rvalue-ссылки — это разновидность ссылок, которая появилась в C++11. С помощью них реализуется семантика перемещения — одно из самых значительных нововведений C++11.


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


5. Ссылочные типы широко применяются в качестве типов параметров и возвращаемого значения функций. Это позволяет увеличить эффективность, функциональность и читаемость кода, поддержать полиморфизм и семантику перемещения. Ключевую роль играют правила перегрузки функций, которые имеют параметры ссылочного типа.


6. В C++ нет универсального механизма предотвращения появления висячих ссылок. Эта задача ложится на плечи программиста. Потенциальный источник висячих ссылок — функции, возвращающие ссылку, и члены класса ссылочного типа, с ними надо быть особенно аккуратным.


7. Ссылочные типы имеют ряд ограничений, их использование в качестве аргументов шаблона в общем случае не запрещено, но в отдельных случаях может вызвать проблемы. По этой причине некоторые шаблоны вынуждены ввести запрет на использование ссылочных типов в качестве аргументов шаблона. Для решения проблем, связанных с ограничениями ссылочных типов, может оказаться полезным шаблон класса std::reference_wrapper<>.



Список литературы


[VJG]
Вандевурд, Дэвид, Джосаттис, Николаи М., Грегор, Дуглас. Шаблоны C++. Справочник разработчика, 2-е изд.: Пер. с англ. — СПб.: ООО «Диалектика», 2020.


[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.


[Meyers]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендаций по использованию C++11 и C ++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.






Комментарии (21):

  1. Ryppka
    /#23950675 / +1

    Только мне кажется, что называть C++ ссылки во всех их ипостасях "ссылочными типами" -- по меньшей мере, преувеличение?

    Ссылочный тип -- это тип доступный исключительно по ссылке, а не ограничение на использование обычного типа. Что-то похожее на ссылочные типы в плюсах -- это а)известные со времен C указатели на непрозрачные стуркутры и б)непрозрачные структуры/классы, завернутые в умные указатели и в)классы с PIMPL. Стрелочка вместо точки -- синтаксическая мелочь, которая ничего не меняет. В случае в) стрелочка и неособо нужна...

    • dm_frox
      /#23950813 / +1

      Не соглашусь с Вами. Ссылочный тип - это отдельный тип, но вторичный или производный, то есть образованный от другого типа (как и тип массива). Это хорошо видно, если использовать ссылочный тип в качестве аргумента шаблона, например std::pair<int&, int&>. Ссылочный тип в некотором смысле ущербный, об этом я много писал. А тип переменной, на которую ссылка ссылается - это совсем другое. Здесь мы имеем проблему скорее относящуюся к русскому языку в котором подобные вещи не очень удобно различать. Это примерно тоже, что и разница между константным указателем и указателем на константу.

      • Ryppka
        /#23950893

        Не соглашусь с Вами

        Сколько угодно, терминологические битвы неинтересны. Ссылочный тип -- устоявшийся термин, появившийся, если не ошибаюсь, в языках со сборкой мусора (вроде Java) для типов, экземпляры которых создаются исключительно в куче и доступных только через handle-ссылку.

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

        //...
        std::pair<int&, int&> p;
        {
         int a = 15;
         int b = 4;
         p = std::pair<int&, int&>{a, b};
         // а тут копируются инты? Думаю, адреса интов...
         ...
         }
         // что?!!!

        Кстати, я, лично, полагаю, что в C++ нужны ссылочные типы: синтаксический сахар для создания "запимпленных" классов, экземпляр такого класса и будет служить handl'ом для неявного и доступного только в одной единице компиляции "внутреннего" типа.

        • dm_frox
          /#23951033 / +1

          Согласен, что в C#, Jave термин "ссылочный тип" имеет совсем другой смысл. Но в C++ шаблонах нужно как то называть тип самой ссылки, а не тип переменой, на которую она ссылается, без этого описание шаблонов невозможно. Посмотрите Вандевурд, Дэвид, Джосаттис, Николаи М., Грегор, Дуглас. Шаблоны C++. Справочник разработчика, 2-е изд. В этом справочнике как раз для этого и используется термин "ссылочный тип", так что я ничего не придумывал. Вот цитата

          std::is_reference<T>::value

          Дает true, если T представляет собой ссылочный тип.

          • Ryppka
            /#23951093

            Пустой какой-то спор, оба правы. Строго говоря, и ссылки и указатели в C++ называют ссылочными типами, если верить Wikipedia. :)

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

            Подытоживая: думайте, что хотите, но и в C, и в C++ адрес памяти -- совершенно законный целочисленный тип данных. У указателя может быть свой адрес, не связанный с адресуемым объектом. Не все то, что можно вытворить с указателем, можно делать с ссылкой, хотя они могут быть равны побитово. А ссылка -- указатель с дополнительными ограничениями и сахаром. Можно называть ссылки ссылочными типами, можно иначе, но это не добавляет ничего нового и не несет никакой пользы. Поэтому остаюсь при своем мнении.

  2. NeoCode
    /#23951251 / +3

    Крутая статья, спасибо!

    Мне как интересующемуся дизайном языков программирования в целом, интересно мнение практикующих С++ (и не только) программистов: насколько на ваш взгляд удобен и совершенен дизайн ссылок? Если бы вы разрабатывали язык с нуля без оглядки на обратную совмесимость, что бы вы изменили в ссылках?

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

    • явный (с разыменованием) или неявный (без разыменования) доступ к тому, на что указывает ссылка; в первом случае ссылки превращаются в "ненуллабельные указатели". Явный доступ более многословен, но и более очевиден.

    • наличие или отсутствие возможности изменения самой ссылки (перенаправления на другой объект в памяти); как вариант - наличие специального синтаксиса перенаправления

    • при присваивании ссылок друг другу, изменяются сами ссылки (C#) или значения, на которые они ссылаются (C++)

    • при передаче в функцию: явное указание передачи именно по ссылке (ref - C#, D) или отсутствие такого указания (C++)

    • наличие, кроме обычных ссылок, также RValue-ссылок, обладающих специальной семантикой (С++)

    • привязка способа передачи к типу. Объекты всех типов могут передаваться как по ссылке, так и по значению (С++), или некоторые типы всегда "ссылочные" а некоторые всегда "значения" (Java, C#, D), например принятие по умолчанию, что все классы "ссылочные" а все структуры "значения".

    • dm_frox
      /#23951283

      Спасибо! Вопросы интересные, но не простые, с ходу ответить не могу. Подробнее напишу немного позднее.

    • dm_frox
      /#23953271 / +2

      Одна из идей, которой руководствуются при проектировании ссылок – это сделать ссылку максимально неотличимой от объекта, на который она ссылается. Этим можно объяснить, почему при инициализации C++ ссылки не используется специальный оператор (типа & в случае указателей) и не используется оператор разыменования (типа * в случае указателей) для доступа к объекту. В результате возникают некоторые коллизии, но небольшие, например, нельзя перегрузить функции, у которых параметр передается по значению и по ссылке на константу.
      Теперь про неизменяемость C++ ссылок, то есть отсутствие нулевых ссылок (обязательная инициализация) и невозможность перенаправить на другую переменную. Посмотрим, какие изменения в языке нужны, если бы ссылки были изменяемые, то есть допускали нулевое значение и перенаправление на другую переменную. Первая проблема – это как быть с присваиванием. Нужно отличать присваивание самих ссылок и присваивание объектов, на которые она ссылается. Для изменяемых ссылок логично реализовывать присваивание как присваивание самих ссылок (а как иначе организовать перенаправление?), но присваивание объектов тоже очень важно в C++. Вторая проблема – это использование самих ссылок в качестве выходного параметра (это еще один вариант реализовать перенаправление). Для этого надо было бы разрешить указатели на ссылку и ссылку на ссылку. Решение всех этих проблем усложнило бы и без того непростой C++, то есть неизменяемость C++ ссылок позволило не переусложнить язык.
      Теперь посмотрим, что происходит в языках со сборкой мусора, например C#. Объекты, управляемые сборщиком мусора, доступны только через ссылку, таким образом, никаких коллизий между объектом и ссылкой на него быть не может, не нужен оператор разыменования. Ссылки на объекты, управляемые сборщиком мусора, являются изменяемыми, то есть могут быть нулевыми и при присваивании происходит присваивание самих ссылок. Но при этом сами объекты не поддерживают присваивание, для них нельзя перегрузить оператор =, поэтому коллизий не возникает. Если для объекта требуется операция, аналогичная копированию или присваиванию, то в C# надо реализовать специальные методы, типа IClonable.Clone(). Но потребность в таких операциях возникает крайне редко. Ссылки на объекты, управляемые сборщиком мусора, сами могут быть выходными параметрами функции, для таких параметров при объявлении и вызове надо использовать ключевое слово ref.

      • NN1
        /#23955321

        Поэтому и нет std::optional<T&>.

        Не смогли решить относиться к этому как к обёртке или сделать неотличимым от объекта.

        • NeoCode
          /#23960891

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

          Для неотличимости от объекта нужна другая языковая конструкция - "alias", в С/С++ ближе всего #define.

  3. NN1
    /#23951627

    Кстати, о применимости const T&&. Если мы требуем именно временный объект.

    https://www.walletfox.com/course/cheatsheets_cpp.php

  4. dm_frox
    /#23951769

    Да, const T&& не может быть универсальной ссылкой. Но я решил не обсуждать rvalue ссылки на константу, статья и так чрезмерно большая. Согласен, что можно придумать для них применение, но чем-то надо жертвовать.

    • NN1
      /#23951933

      Я этот момент не критикую. Статья действительно лбъёмная. ????

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

      Кстати, вроде как отказались от термина "универсальная ссылка" и плавильней "forwarding reference", наверное перевести стоит как "передающая ссылка".

      • dm_frox
        /#23952477

        В [VJG] forwarding reference переводится как передаваемая ссылка, и мне кажется по смыслу это больше подходит, английские отглагольные определения не всегда можно так просто перевести. А к универсальной ссылке все (включая меня) привыкли благодаря Скотту Мейерсу.

  5. HiTawr
    /#23951853

    Все пошло криво, когда pointer перевели как ссылку (кстати, довольно двусмысленно, почему не ккакалка). А это просто адресс.

    • dm_frox
      /#23951877 / +2

      Проблема в том что термин "ссылка" применяется очень широко и, соответственно, имеет разный смысл в разных контекстах. Указатель - это тоже ссылка, если трактовать его в широком смысле. В C++ ссылка (reference) имеет вполне конкретное и достаточно узкое значение.

  6. desertkun
    /#23952173 / +1

    T &tx

    Я T &tx не люблю. T& tx и делайте со мной что хотите!

    • dm_frox
      /#23952491

      И я тоже. И [VJG] тоже (с развернутой аргументацией). Здесь мы имеем дело с старыми сишными правилами, которые действительно многих раздражают. Я сам долго думал, как с этим быть, и решил все-таки остановится на каноническом синтаксисе, хотя в своих проектах я его не использую.

  7. fk0
    /#23953553 / +2

    Процитирую:

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

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

    Про std::min и std::max неплохо бы сказать, у вас как-то вскользь, что попытка им подсунуть временный объект и сохранить ссылку тоже -- баг:

    const auto& min = std::min(f1(arg), f2(arg2)) -- как пример.

    И про "ссылку" получаемую из итератора std::vector<bool> тоже следовало бы упомянуть, попытка присвоить её обычной ссылке наталкивается на срок жизни временного объекта.

    И про то, что offsetof() для ссылок строго формально -- не применим, не сказано.

    Не сказано так же о том, что взятие ссылки на временный объект продлевает его срок жизни до конца жизни самой ссылки. Толку от этого не много, такая ссылка работает не лучше и не хуже, чем переменная нужного типа. Но смысл появляется в случае, когда ссылка имеет тип базового класса, а тип самого временного объекта -- производный и явно не задан, не виден. Тогда ссылка хоть и представляется базовым классом, но при выходе из области видимости компилятор вызовет деструктор производного типа. Такой трюк использовался в C++03 обычно совместно с шаблоном (параметр которого и менял тип самого временного объекта). В современности ключевое слово auto делает данный трюк бессмысленным.

    • dm_frox
      /#23954683

      Спасибо за конструктивную критику.

      А вот использовать ссылку в качестве параметра функции безопасно

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

      И про "ссылку" получаемую из итератора std::vector<bool> ...

      Согласен, что итераторы, возвращающие прокси-объекты, не обсуждал. Эта тема подробно обсуждается у Скотта Мейерса.

      Не сказано так же о том, что взятие ссылки на временный объект продлевает его срок жизни до конца жизни самой ссылки.

      Не согласен, этому посвящен раздел 4.1.