大家好!我是大聪明-PLUS!
我们继续推出"C++ 深度解析"系列文章。本系列旨在尽可能详细地介绍各种语言特性,其中一些特性相当专业。本文是该系列的第五篇文章;之前的文章列表位于第六节末尾。本系列面向具有一定 C++ 编程经验的程序员。本文重点介绍 C++ 中的引用和引用类型。
"引用"一词在日常生活、计算机科学及其他领域被广泛使用,其含义很大程度上取决于上下文。在编程语言中,引用是一个小型对象,其主要目的是提供对位于其他位置、大小不同的另一个对象的访问。引用对象通常位于栈上,便于复制,从而允许从代码的不同位置访问被引用的对象。所有编程语言都以某种形式支持引用。在许多编程语言中,例如 C#、Java、Python 等,引用本质上是其概念的核心。
在 C 语言中,指针扮演着引用的角色,但使用起来并不方便,因此 C++ 引入了一个独立的实体------引用。在 C++11 中,引用得到了进一步发展,引入了右值引用和通用(可转移)引用,它们在实现移动语义方面发挥着关键作用------移动语义是 C++11 最重要的创新之一。
那么,让我们尽可能详细地讨论一下 C++ 中的引用。
1. 基础知识
1.1 链接的定义
最简单的情况下,引用定义如下:如果T某个类型是类型为的变量,那么带有说明符的T类型为的变量,如果初始化为该变量,则将是对该变量的引用。T``&
T` `x`;
`T` `&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 等)中的含义不同。在后者中,它指的是实例由垃圾回收器管理且只能通过引用访问的类型。关于引用类型的更详细讨论,请参见 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`;
`int` `&rx` `=` `х`;
`const` `int` `&rcx2` `=` `rx`; `
让我们回顾一下使用限定符的一些规则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`;`
有些作者认为后一种选择更为正确,并提出了充分的论据;本文将采用传统选择。
不能同时使用两个常量;编译器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`); `
现在我们来注意一个术语。对常量的引用通常被称为常量引用。这种说法并不完全准确;引用本身是常量实体,但它们可以指向常量或非常量。对于指针而言,我们必须区分这两种常量,但对于引用而言,我们在术语上可以稍微宽松一些。
1.2.2. R值参考
右值引用是 C++11 中引入的一种引用类型。它们在初始化规则(参见第 2.4 节)和使用此类参数重载函数的规则(参见第 3.1.3 节)方面有所不同。如果T定义一个非常量且非引用的类型或别名如下:
T` `&&rv` `=` `ini_expression`;`
也就是说,该说明符用于定义它们&&,而不是&。
右值引用代表一种独特的引用类型,可以为其声明别名。
using` `RVT` `=` `T&&`;`
编译器还会区分对常量的右值引用:
const` `T` `&&rvc` `=` `ini_expression`;`
但这种类型的链接实际上并不使用,我们也不会考虑它。
ini_expression后续章节将给出右值引用的要求和其他细节。
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`;`
形式上,数组的右值引用是存在的,但实际上从未被使用过。
通过使用数组类型别名,您可以实现更熟悉的数组引用定义语法。
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++ 中,数组遵循一种称为衰减(数组到指针的衰减)的规则。("衰减"有时也被翻译为"缩减"或"分解"。)衰减的定义是,在几乎所有情况下,数组标识符都会被转换为指向第一个元素的指针,并且数组大小信息会丢失。当数组用作函数参数时,也会发生衰减。
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(保证大于零)。只有数组才能用作参数;指针会被拒绝。这种技术用于实现 `ArrayList`、`ArrayList` 等函数的重载版本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`); `
函数引用不存在常量版本,因为函数类型不可能是常量。形式上,存在右值函数引用,但它们很少使用。
通过使用函数类型别名,您可以实现更熟悉的数组引用定义语法。
using` `FI` `=` `void`(`int`);
`void` `Foo`(`int`);
`FI` `&rf` `=` `Foo`;`
你可以为函数引用声明别名。
using` `RFI` `=` `void`(`&`)(`int`);`
函数也会被扁平化------在很多情况下,函数标识符会被转换为函数指针。然而,与会丢失大小信息的数组不同,扁平化不会丢失参数或返回值信息,因此扁平化对函数的影响要小得多。
函数引用很少使用;它们相比指针没有任何优势------无需解引用即可通过指针调用函数,而且无需使用 `\n` 运算符即可用函数名初始化函数指针&。然而,引用固有的所有局限性依然存在。函数引用最常见的应用场景是将函数类型用作模板参数。
无法定义对类成员函数的引用。
1.3. 链接和指针
1.3.1. 互换性
C++ 引入引用是为了提供比指针更便捷的替代方案,但指针和引用并不能完全互换。(当然,这种替换需要对代码进行一些调整;通过引用和指针访问数据的语法有所不同。)
指针通常可以用引用代替,但并非总是如此,因为指针可以有值nullptr,这在程序逻辑中可能至关重要,而引用不能为空。此外,引用数组无法创建,也没有与无类型指针等效的引用void*。在需要使用指针运算的底层解决方案中,指针可能必不可少。
引用并非总是能被指针替代。在 C++ 中,类拥有所谓的特殊成员函数------复制构造函数、复制赋值运算符及其对应的移动函数。这些成员函数只有一个参数,通常是引用类型。运算符重载也常常需要引用类型参数(参见 3.1.1 节)。这些参数不能被指针替代。右值引用也不能被指针替代。
一般来说,最好尽可能使用链接而不是指针,因为指针在很大程度上是 C 语言的遗留产物。
1.3.2. 链接的内部结构
与其他许多编程语言一样,C++ 隐藏了引用的内部运作机制。获取被引用对象的任何信息都非常困难------对引用的任何操作都必然涉及对被引用对象的操作。
一种较为传统的观点是将引用视为"掩码"常量指针。编译器甚至可能在优化过程中消除引用对象。显然,在简单的情况下可以做到这一点(参见 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<>()`and`运算符dynamic_cast<>()可以与引用一起使用,唯一的区别是,如果无法执行强制转换dynamic_cast<>(),则在使用指针时返回 `null` nullptr,而在使用引用时,会抛出 `Exception` 类型的异常std::bad_cast。
1.4.2 外部链接
外部链接可以应用于链接。
// file1.cpp`
`extern` `int` `&ExternIntRef`;
`// file2.cpp`
`int` `ExternInt` `=` `125`;
`int` `&ExternIntRef` `=` `ExternInt`;`
这样做很可能没有任何实际好处,但理论上是可行的。
1.4.3. 公告不完整
在 C++ 中,某些情况下,编译器只需要知道给定的名称是用户自定义类型(类、结构体、联合体、枚举)的名称即可编译正确的代码,而无需完整的类型声明。在这种情况下,可以使用不完整声明,也称为前向声明。使用不完整声明的类型称为不完整类型。
使用不完整类型可以解决许多传统上与 C++ 代码相关的难题。它减少了项目对头文件的依赖,从而缩短了编译时间并避免了潜在的名称冲突。不完整声明允许消除循环依赖,并实现完全分离接口和实现(不透明指针)的解决方案。
关于引用,当引用的类型不完整时,我们可以声明函数参数、函数返回值、类成员和外部变量。如果引用已用相同类型的引用初始化,则可以定义对不完整类型的引用;也就是说,允许复制对不完整类型的引用。
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 节。请注意,=在这种情况下,该符号不是赋值运算符。
另一种选择是统一初始化,它是在 C++11 中引入的。在这种情况下,需要使用花括号。
int` `x` `=` `6`;
`int` `&rx`{`x`};`
这种初始化选项是最通用的,在任何情况下都可以接受。
还有一种使用符号的通用初始化变体=。
int` `x` `=` `6`;
`int` `&rx` `=` {`x`};`
然而,对于引用初始化而言,这种写法在语法上是多余的。此外,如果使用关键字(参见 2.5 节)定义引用auto,则推断出的类型将是模板实例化std::initializer_list<>,这很可能与程序员的预期不符。
另一种方法是使用括号。
int` `x` `=` `6`;
`int` `&rx`(`x`);`
在某些情况下,这种做法会导致编译器将语句解释为函数声明。这是 C++ 中某些语法结构歧义性导致的一个由来已久的常见问题。很久以前,人们就决定,如果一条语句既可以被解释为定义也可以被解释为声明,那么就应该选择声明。例如:
class` `X`
{
`public`:
`X`();
`// ...`
};
`const` `X` `&rx`(`X`());`
乍一看rx,这似乎是定义了一个类型为 `T` 的变量const X&,并用一个未命名的类型 `T` 实例进行初始化X,这完全符合 C++ 语法。然而,这条语句也可以被解释为声明一个返回 `T`const X&且带有一个指向另一个返回值且没有参数的函数的指针类型的参数的函数X。根据前面提到的规则,编译器会选择第二种解释。当然,这不会造成严重后果,因为编译错误会立即出现,但理解这种情况可能需要一些时间。要纠正这种情况,例如,你可以使用X()额外的括号。
2.2. 参考类型类成员
类可以声明引用类型成员。非静态成员通常在构造函数的初始化列表中使用构造函数的参数进行初始化。在 C++11 中,非静态成员可以直接在声明时初始化,但很难提供一个有意义的示例。
class` `X`
{
`int` `&m_R`;
`public`:
`X`(`int&` `r`) : `m_R`(`r`){ `/* ... */` }
`// ...`
};`
这是唯一不能使用符号初始化=,但允许使用花括号进行通用初始化的情况。
具有非静态引用类型成员的类有一个特殊之处:编译器不会为这类类生成赋值运算符。程序员可以自行定义赋值运算符,但这可能会导致赋值语句语义上的不合理问题。
你可以声明引用类型的静态成员。静态成员在定义时必须初始化。在 C++17 中,你可以在声明静态成员时对其进行初始化;为此,必须使用关键字 `static` 声明它inline。
сlass` `X`
{
`public`:
`static` `const` `int` `&H`;
`static` `inline` `const` `int` `&G` `=` `32`;
`// ...`
};
`const` `int` `&X::H` `=` `4`;`
2.3. 意义类别
在 C++ 中,每个表达式除了类型之外,还有一个值类别。(表达式的类型和值类别在编译时就已经确定。)值类别对于描述引用的使用规则至关重要。最初(在 C 语言中),只有两种值类别------左值和右值。左值是命名变量(可以出现在赋值语句的左侧),而右值是临时的、未命名的实体(可以出现在赋值语句的右侧)。但随着语言的发展,值类别的定义变得越来越复杂。目前,C++17 有 5 种值类别;为了便于讲解,我们只需使用包含左值和右值的简化版本即可。
L值:
- 命名变量(包括右值引用)。
- 应用解引用运算符()的结果
*。 .将成员访问运算符( ,->)和索引器应用于命名变量的结果。- 字符串字面量。
- 调用返回引用或常量引用的函数。
R值:
- 应用地址检索运算符()的结果
&。 - 应用其他运算符的结果(左值 p.2 和 p.3 除外)。
- 一个简单的字面量(
42,,'X'等等),一个枚举成员。 - 调用返回非引用类型的函数。
- 调用一个返回右值引用的函数。
左值也可以分为可变左值和不可变左值(常量)。右值也可以分为可变右值和不可变右值,但不可变右值很少使用,因此本文不作讨论。请注意以"调用返回值的函数......"开头的语句。这其中也包括类型转换,包括隐式类型转换。
2.4 初始化表达式的要求
设 为T某种非常量且非引用类型或别名。
T` `&r` `=` `ini_expression`;`
这是一个简单的参考。要求ini_expression:类型为T、T&或T&&任何类型的左值/右值,只要该类型可以隐式转换为T&。
const` `T` `&r` `=` `ini_expression`;`
这是一个常量引用。要求:类型为 `int` 、`int`、`int` 、`int`ini_expression的左值/右值,或者任何可以隐式转换为这些类型之一的类型。T``T&``T&&``const T``const T&
T` `&&r` `=` `ini_expression`;`
这是一个右值引用。其要求ini_expression:类型为 `<T>` 的右值T,T&&或任何可以隐式转换为T`<T>`的左值/右值类型T&&。请注意,ini_expression它不能是引用类型(包括 `<T>`)的命名变量T&&,这意味着右值引用不能直接复制。如何正确复制右值引用将在 3.1.4 节中进一步介绍。
2.5 使用类型自动检测初始化引用
许多现代静态类型语言(即在编译时确定变量类型的语言)允许不显式指定变量类型,而是让编译器根据初始化表达式的类型推断变量类型。C++11 也引入了这种功能,使用关键字 `@type` auto。然而,在这种情况下,变量类型推断的规则并不像乍看起来那么简单。`@type` 关键字auto可以与引用说明符和限定符一起使用const,这会使推断规则变得复杂,有时甚至会导致意想不到的结果。还应该注意的是,在这种情况下,隐式类型转换(包括基于多态规则的转换)不会用于变量类型推断。此外,auto不能使用 `@type` 关键字声明类成员。在给出的示例中,T使用了非常量、非引用类型或别名。
auto` `x` `=` `ini_expression`;`
变量的类型x永远不会被推断为引用或常量。如果变量具有类型 `[]`、`[]`、`[]`、`[]` 或 `[]`,则其x类型T被ini_expression推断T为T&` T&&[ ] const T` const T&;值类别ini_expression可以是任何类型。初始化期间,会调用该类型的复制构造函数或移动构造函数T。ini_expression如果类型ini_expression是左值,则调用复制构造函数;如果类型是右值,且支持T移动语义,则调用移动构造函数;否则,调用复制构造函数。对于右值,构造函数调用可以在优化期间被移除。
auto` `&x` `=` `ini_expression`;`
如果变量的类型为 `T` 、 `T` 或 `L` x,则推断其类型为`T`。如果变量的类型为 `T`、`L` 或 `L`,则推断其类型为 `L`。如果推断的类型为` L`,则它一定是左值。T&``ini_expression``T``T&``T&&``x``const T&``ini_expression``const T``const T&``T&``ini_expression
const` `auto` `&x` `=` `ini_expression`;`
如果变量类型为, , , , ,x则推断其类型为 const ,值类别可以是任意值。T&``ini_expression``T``T&``T&&``const T``const T&``ini_expression
auto` `&&x` `=` `ini_expression`;`
这种类型的引用称为通用引用,它具有相当具体的类型推断规则;推断出的类型取决于值类别。如果ini_expression变量是左值且类型为 `int`、`int` 或 `int`,x则推断其类型为 `int`。如果变量是左值且类型为 `int`、`int` 或 `int`,则推断其类型为`int` 。如果变量是右值且类型为 `int`、`int` 或` int` ,则推断其类型为`int` 。在 C++17 中,这种类型的引用被称为转发引用;其原因将在 3.2.4 节中进一步讨论。T&``ini_expression``T``T&``T&&``x``const T&``ini_expression``const T``const T&``x``T&&``ini_expression``T``T&``T&&
特别值得注意的是当 ` ini_expressionis` 是一个数组或函数时的情况。在这种情况下,在定义中
auto` `x` `=` `ini_expression`;`
归约操作将会执行,变量的类型x将被推断为指向数组元素的指针或指向函数的指针。在其他情况下,根据上述规则,推断的类型将是引用类型。(但有一个例外:对于函数,常量性将被忽略,因为函数的类型不可能是常量。)
3. 函数的参数和返回值中的引用
事实上,引用类型主要用作函数的参数和返回类型,而不是用于创建变量。
3.1 函数参数
在这种情况下,链接可以带来诸多好处。
- 传递参数的成本是恒定的,与引用的类型无关(它等价于传递指针的成本)。
- 允许您修改参数引用的对象,即将参数转换为输出参数。
- 允许您禁止修改参数引用的对象。
- 提供移动语义的实现。
- 将引用向上传递到调用栈不会导致悬空引用。
- 支持多态性。
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`);`
该参数是一个简单的引用。参数要求:类型为 `<T>` T、 `<T>` 的左值T&,T&&或任何可以隐式转换为 `<T>` 类型的左值/右值T&。在这种情况下,我们可以修改参数,这意味着x它可以是输出参数。
void` `Foo`(`const` `T&` `x`);`
该参数是一个常量引用。参数要求:类型为 `<int>`、`<string>`、`<string>`、`<string>` 或任何可以隐式转换为这些类型的左值/右值T。T&在T&&这种const T情况const T&下,我们不能修改参数。
void` `Foo`(`T&&` `x`);`
该参数是一个右值引用。参数要求:类型为 `<T>` 的右值T,T&&或者任何可以隐式转换为 `<T>` 的左值/右值T。T&&此变体用于实现移动语义。支持移动的类必须定义一个带有右值引用参数的移动构造函数和一个带有相同参数的移动赋值运算符。
class` `X`
{
`public`:
`X`(`X&&` `src`) `noexcept`;
`X&` `operator=`(`X&&` `src`) `noexcept`;
`// ...`
};`
这些成员函数最终执行移动操作。
移动语义的关键概念在于,移动的源对象是一个右值,因此移动后该对象将不可用,无需担心意外访问"空"对象。(虽然可以强制将左值转换为右值(参见 3.1.4 节),但这种情况下,程序员有责任防止错误操作。)
3.1.3. 函数重载
重载是指同时使用多个同名函数或函数模板的能力。编译器通过不同的参数集来区分它们。在调用时,编译器会分析参数类型并确定应该调用哪个函数。这个过程称为重载解析。重载解析可能会失败,这意味着编译器可能最终没有选择任何函数;在这种情况下,该调用被称为不明确调用。本系列之前的文章中对重载进行了更详细的讨论。
重载解析规则至关重要:为了实现其预期目的,程序员必须清楚地理解在给定上下文中将调用哪个重载函数。特别是,移动语义基于带有右值引用参数的函数的重载规则,而对这些重载规则的误解可能导致在不知不觉中将移动操作替换为复制操作。带有引用参数的函数的重载规则可以看作是上一节所述规则的扩展,因为它们决定了在几个有效选项中进行选择。
允许函数重载如下:
void` `Foo`(`T&` `x`);
`void` `Foo`(`const` `T&` `x`);`
在这种情况下,对于非常量左值参数,将选择第一个函数(尽管也允许选择第二个函数);对于常量左值参数和右值参数,将选择第二个函数(不允许选择第一个函数)。
允许函数重载如下:
void` `Foo`(`T&` `x`);
`void` `Foo`(`T` `x`);`
对于常量左值参数和右值参数,将选择第二个函数(第一个函数无效),但对于非常量左值参数,重载解析将失败(即使两个函数都有效),也就是说,对于这种重载函数的变体,永远不会选择第一个函数。
允许函数重载如下:
void` `Foo`(`const` `T&` `x`);
`void` `Foo`(`T` `x`);`
对于任何参数,重载解析都会失败(尽管两个函数都是有效的)。
允许函数重载如下:
void` `Foo`(`const` `T&` `x`);
`void` `Foo`(`T&&` `x`);`
在这种情况下,对于左值参数,会选择第一个函数(第二个函数无效);对于右值参数,会选择第二个函数(即使第一个函数有效)。这条规则是实现移动语义的关键;它用于在复制构造函数和移动构造函数(以及相应的赋值运算符)之间进行选择。
class` `X`
{
`public`:
`X`(`const` `X&` `src`);
`X`(`X&&` `src`) `noexcept`;
`// ...`
};`
允许函数重载如下:
void` `Foo`(`T&` `x`);
`void` `Foo`(`T&&` `x`);`
在这种情况下,对于非常量左值参数,将选择第一个函数(第二个函数无效);对于右值参数,将选择第二个函数(第一个函数无效);对于常量左值参数,两个函数都无效,因此重载解析将失败。
允许函数重载如下:
void` `Foo`(`T` `x`);
`void` `Foo`(`T&&` `x`);`
在这种情况下,对于左值参数,将选择第一个函数(第二个函数无效),但对于右值参数,重载解析将失败(即使两个函数都有效),这意味着对于这种重载函数的变体,永远不会选择第二个函数。
值得注意的是,C++11 中引入了非静态成员函数的引用限定符。这允许按隐藏参数值类别进行重载this。
class` `X`
{
`public`:
`X`();
`void` `Foo`() `&`;
`void` `Foo`() `&&`;
`// ...`
};
`X` `x`;
`x`.`Foo`(); `// X::Foo() &`
`X`().`Foo`(); `// X::Foo() &&
3.1.4. 带有右值引用类型参数的函数
我们考虑这样一种情况:有一个函数,它接受一个右值引用参数。这个函数只接受右值参数。现在假设我们需要将这个参数传递给另一个也接受右值引用参数的函数。在这种情况下,我们必须考虑到参数本身会是一个左值,因此,为了正确传递它,我们必须通过类型转换或调用一个将左值转换为右值的static_cast<T&&>()标准函数来传递这个参数。std::move()
class` `X`;
`void` `FooInt`(`X&&` `x`);
`void` `Foo`(`X&&` `x`)
{
`FooInt`(`std::move`(`x`));
`// ...`
}`
如果不这样做,要么会导致错误,要么,如果存在一个带有类型为 `<T>`、`<T>` 或 `<T>` 的参数的重载函数X,X&则会const X&选中该函数(特别是,`move` 可能被 `copy` 替换;参见 3.1.3 节)。因此,如果没有这种转换,移动语义将不再有效。这类错误很危险,因为它们可能长时间不被察觉。
请注意这个略有误导性的名称std::move()。这个函数实际上并不移动任何值;它是一个类型转换函数,将左值转换为右值,并且只能按照示例中的方式使用------它的调用必须是一个函数参数,并且参数必须是右值引用。实际的移动操作是由 `move` 构造函数完成的。
3.2. 函数模板选项
3.2.1. 函数模板参数的自动推导
函数模板的参数可以由编译器根据调用参数的类型自动推断。这是函数模板最常见的用法。如果模板参数是自动推断的,那么推断规则几乎与使用关键字声明的推断规则相同auto。声明参数时,我们会假定它是T一个非常量且非引用的类型或别名。
template<typename` `T>`
`void` `Foo`(`T` `x`);`
模板参数的类型推断如下T:如果参数类型为`T` T、 `T` T&、 ` T`、` T&&T` ,则参数类型为 `T`,参数值类别可以是任意值。因此,类型永远不会被推断为引用或常量。这里我们使用的是按值传递参数的方式。const T``const T&``x``T``T
template<typename` `T>`
`void` `Foo`(`T&` `x`);`
模板参数的类型推断如下T:如果参数类型为 `T` T,T&则T&&参数类型x为T&`T`,且参数必须是左值。模板参数的类型推断如下const T:如果参数类型为 `T` const T,const T&则参数类型x为const T&`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&如果参数是左值且类型为 `a` T、 `b` T&、T&&`c`,x则模板参数类型推导为 `a` T&。const T&如果参数是左值且类型为 `b`、`c`、`c`,const T则模板const T&参数类型推导为 `a`。如果参数是右值且类型为 `b`、`c`、`c` ,则模板参数类型推导为` a`。x``const T&``T``T``T&``T&&``x``T&&
特别值得注意的是,当调用参数是数组或函数时的情况。在这种情况下,在函数模板中
template<typename` `T>`
`void` `Foo`(`T` `x`);`
将执行归约操作,并推断模板参数类型为指向数组元素的指针或指向函数的指针,参数类型与之x相同。在其他情况下,x根据上述规则,推断模板参数类型为数组或函数,参数类型为引用类型。(但有一个例外:对于函数,将忽略常量性,因为函数类型不能是常量。)
3.2.2. 显式指定函数模板参数
函数模板参数可以显式指定。有时,当自动推断无法实现(例如,返回类型)或无法产生预期结果(例如,引用类型)时,显式指定参数是必要的。在这种情况下,模板参数推断机制不会被使用,我们实际上是在处理一个非模板函数。特别是,下文讨论的函数模板就使用了显式参数指定std::forward<>()。
假设一个函数参数的类型是指向模板参数的引用。在这种情况下,如果显式声明的模板参数是引用类型,则该函数参数的类型将是指向引用的引用。在 C++ 中,这种类型是被禁止的,因此在这种情况下,会执行称为引用折叠的操作,从而使函数参数的类型变为引用或右值引用。引用折叠将在 5.2.2 节中详细讨论,下一节将提供一些简单的示例。
3.2.3 通用引用和右值引用
通用引用和右值引用的声明方式相同,都使用 `@` 说明符&&,因此,在任何给定的情况下,清楚地了解我们正在处理的是哪个变体非常重要。
通用引用不是一种特殊的引用,而是一种自动推导模板论证的特殊机制,要使用它,必须满足三个条件。
- 存在一个具有典型参数的函数模板(我们将其表示为
T)。 - 函数参数声明为
T&&。 - 模板参数是根据函数调用参数的类型自动推断的。
如果显式声明了模板参数,并且函数模板参数声明为 `<T>` T&&,则引用折叠(参见 5.2.2 节)将应用于引用类型的模板参数,实例化的函数参数将变为常规引用或右值引用。如果模板参数是非引用类型,则该参数将是右值引用。
我们来看一些例子。
class` `X`
{
`public`:
`X`();
`// ...`
};
`X` `x`; `
让我们来看看x在调用函数时,如何将其用作参数的几种方法。
void` `F`(`X&&` `x`);
`F`(`x`); `
在这种情况下,我们有一个常规函数(违反了条件 1),参数是右值引用类型,左值参数不适用。
template<typename` `T>`
`void` `Foo`(`T&&` `x`)
`Foo`(`x`); `
在这种情况下,所有条件都满足,参数是通用引用,可以使用左值参数。
Foo<X>`(`x`); `
模板参数已明确指定;本例中的参数类型为右值引用;左值参数不适用。
Foo<X&>`(`x`); `
模板参数已显式声明且为引用类型,因此X& && -> X&会执行引用折叠()。参数将是常规引用,因此可以使用左值参数。
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;该参数的类型为右值引用;左值参数不适用。
W<X&>` `wrx`;
`wrx`.`Foo1`(`x`); `
成员函数参数的类型Foo1()在实例化类模板时显式定义W。类模板参数是引用类型,因此X& && -> X&会执行引用折叠。该参数将是常规引用,因此可以使用左值参数。
W<X>` `wx`;
`wx`.`Foo2`(`x`);`
这里我们有一个成员函数模板。在这个调用中,成员函数模板的参数是自动推断的,该参数将是一个通用引用,因此可以使用左值参数。
因此,如果一个函数参数看起来像这样T&&,它在模板实例化期间可能会变成普通引用或右值引用。这种情况在自动模板参数推导和显式指定时都会发生,但自动推导对参数值类别没有限制。
通用引用也用于使用 `.` 声明的变量的类型推断auto &&。这发生在声明变量(参见第 2.5 节)和 lambda 表达式参数(参见第 3.4.1 节)时。
3.2.4. 直接传输
现在考虑这样一种情况:一个通用引用类型的函数参数必须传递给另一个函数。该函数参数始终是左值,为了正确地将其传递给另一个函数,我们必须将其转换为右值,但前提是该参数本身也是右值;也就是说,我们必须保留参数的值类别。否则,可能会导致 3.1.4 节中描述的问题,本质上会禁用移动语义。由于模板参数的类型取决于调用参数的值类别,因此这个问题是可以解决的,标准函数模板正是为此目的而开发的std::forward<>()。它必须使用模板参数进行实例化,并且该参数必须通过函数调用传递。
class` `X`
{
`public`:
`X`();
`// ...`
};
`void` `FooInt`(`const` `X&` `x`);
`void` `FooInt`(`X&&` `x`);
`template<typename` `T>`
`void` `Foo`(`T&&` `x`)
{
`FooInt`(`std::forward<T>`(`x`));
`// ...`
}
`X` `x`;
`Foo`(`x`);
`Foo`(`X`()); `
这种参数传递方案被称为完美转发(有时也简称完美转发)。现在我们就能明白为什么通用引用现在被称为转发引用了。
再次强调,模板的作用std::forward<>()仅仅是类型转换,除此之外它不做任何其他事情。它的目的是确保函数调用和重载规则能够正确生效,并考虑到参数值的类别。
3.2.5. 函数模板重载
函数模板可以与非模板函数一起参与重载。在这种情况下,重载解析规则会变得更加复杂,但我们这里只讨论与本文主题相关的几个规则。
解析规则可以描述如下:考虑模板实例和非模板函数,并应用第 3.1.3 节中的规则。当两个函数相同时,适用以下规则:如果其中一个是非模板函数,则选择该非模板函数;如果两者都是模板实例,则选择更具体的模板实例;如果无法选择更具体的模板,则重载解析失败。具有通用引用的模板被认为比具有引用或常量引用参数的模板更不具体。请注意,如果模板未被实例化,则它将被排除在重载解析之外(SFINAE 原则)。以下是一些示例。
template<typename` `T>`
`void` `Foo`(`T&&` `x`);
`template<typename` `T>`
`void` `Foo`(`T&` `x`);`
对于左值参数,两个模板的实例化方式相同,但第二个模板会被视为更专门的模板。对于右值参数,实例化方式不同,根据 3.1.3 节的规则,会选择第一个模板。
template<typename` `T>`
`void` `Foo`(`T&&` `x`);
`class` `X`;
`void` `Foo`(`X&&` `x`);`
对于类型为 的右值参数X,X&&两个模板的实例化是相同的,第二个函数将被选为非模板函数,其余参数则被选为第一个模板函数。
函数模板和非模板函数的重载会带来一些潜在问题。模板通常具有贪婪性,在重载解析过程中被选中的频率可能比程序员预期的要高,因为它们的实例化几乎总是提供精确的参数匹配。参与重载的函数模板实际上会禁用非模板函数的隐式参数转换。本系列的前一篇文章对此主题进行了更详细的讨论。
3.3 通过引用常量传递参数与通过值传递参数
在某些情况下,通过引用常量和通过值传递参数是相互竞争的。
void` `Foo`(`const` `T&` `x`);
`void` `Foo`(`T` `x`); `
让我们来分析一下每种方案的特点。
这些变体不能重载(参见 3.1.3 节),这意味着程序员必须预先选择其中一种。无论哪种情况,参数类型都可以是 `T` T、 `C` T&、T&&`D`、 ` const TE` 或const T&任何可以隐式转换为这些类型的类型;参数的值类别可以是任意的。两种变体都保证参数的不可变性。
现在我们来考虑T参数传递的类型要求和开销。当按引用传递常量时T,没有特殊的类型要求;引用会被复制,开销与复制指针相同,是常量。当按值传递时,如果类型T支持移动语义,则对左值参数调用复制构造函数;如果类型不支持移动语义,则对右值参数调用移动构造函数;否则调用复制构造函数。在 C++17 中,某些情况下按值传递的右值参数不需要复制或移动构造函数,因为移除构造函数调用的优化已经标准化,相应的构造函数不再是必需的。在之前的 C++ 版本中,即使构造函数已被优化移除,也仍然需要构造函数。
请记住,调用复制构造函数可能是一项非常耗时的操作。例如,标准容器使用所谓的深拷贝,它会复制容器中的所有元素。调用移动构造函数也比复制引用更耗时。
通过引用传递常量支持多态性。参数的类型可以派生自函数参数的类型,但函数参数会像实参一样接收到"正确"的虚函数表指针。通过值传递会导致所谓的"切片",所有关于派生类型的信息都会丢失。
当程序逻辑只需要传递对先前创建对象的引用时,按值传递也是不可接受的。
在重载运算符时,有时可以使用值传递的方式,而不是通过引用常量来传递参数。一个众所周知的例子是赋值运算符,它使用"复制并交换"的惯用法来实现。
class` `X`
{
`public`:
`X&` `operator=`(`X` `src`);
`// ...`
};`
但是,此赋值运算符存在一个潜在问题:您不能将其与移动赋值运算符一起使用,因为对于右值参数,它们的重载解析将失败,请参阅第 3.1.3 节中的重载规则。
对于这两种选项之间的选择,传统的建议如下:对于复制操作简单且大小不超过 8 字节的非多态简单类型,使用按值传递;对于其他类型,按引用传递到常量。
3.4 Lambda 表达式
3.4.1. 参数类型的自动检测
在 C++20 之前,Lambda 表达式不能作为模板,但 C++14 引入了使用关键字来指定参数类型的功能auto,从而允许使用引用说明符和限定符const。这在一定程度上弥补了模板的缺失(类型推断auto和模板参数类型推断本质上是相同的)。例如,Lambda 表达式中的通用引用类型参数可以声明如下:
`[](`auto&&` `x`){ `/* ... */` }`
但这引出了一个问题:如果我们需要实现转发该怎么办?对于转发,我们必须std::forward<>()使用模板参数实例化函数模板,但本例中没有这样的参数。实际上,你可以使用 ` decltype(x).`。对于右值参数,其类型与通用引用模板推断的类型不同,但std::forward<>()它仍然可以按预期用于转发。因此,应该使用表达式 `.` 作为传递给 `.` 的参数std::forward<decltype(x)>(x)。
3.4.2. 通过引用捕获变量
在 lambda 表达式中通过引用捕获变量可以避免复制,并将该参数用作输出参数。
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`;`
当重载赋值运算符(以及复合赋值运算符:+=,等等)时,返回值必须是对操作结果的引用,这样就可以进行链式赋值。
x` `=` `y` `=` `z`;`
使用引用作为返回值的另一个例子是 I/O 流,其中重载运算符>>必须<<返回对流的引用,这使得构建操作链成为可能。
int` `x`, `y`;
`std::cout` `<<` `"x="` `<<` `x` `<<` `", y="` `<<` `y` `<<` `'\n'`;`
返回引用类型值的开销很小,与指针的开销相当。通过应用优化(例如 RVO),还可以进一步降低开销。
3.5.2. 自动检测返回类型
C++14 引入了函数返回类型推断而非显式指定的能力。为此,可以使用引用说明符 `{{ return type }}`auto和限定符 `{{ return type }}` 来指定返回类型const。类型推断的规则与使用 `{{ return type }}` 声明的变量的初始化规则相同auto;参见第 2.5 节。
您还可以将返回类型指定为decltype(auto)。在这种情况下,返回类型会被推断为decltype(return_expression)。也就是说,如果return_expression是引用类型,则返回类型也将是引用类型。
返回类型自动检测主要用于函数模板。
4. 悬空链接
广义上讲,任何引用都会受到悬空引用问题的影响。当引用所指向的对象被删除或移动,而引用本身却毫不知情时,就会发生这种情况。此时,使用该引用会导致所谓的未定义行为,这意味着任何事情都可能发生------程序崩溃、产生错误但看似合理的结果,或其他不良后果。
在 C++ 中,在某些情况下,编译器会保证不会出现悬空引用,但通常情况下,程序员自己必须确保不会出现悬空引用。
当复制一个引用且副本的生命周期比原始引用长时,就会出现潜在问题。这种情况可能发生在使用返回引用的函数时。另一种可能获得生命周期更长的引用副本的情况是,使用引用类型的类成员时。
但是,将引用用作函数参数是安全的;在这种情况下,引用也会被复制,但调用堆栈的逻辑不允许出现悬空引用(除了一些明显的异常情况)。
4.1 R值参考
正如我们前面看到的,对常量的引用和对右值的引用都可以用右值初始化。问题是,它指向的是什么?在这种情况下,编译器实现了一种称为临时物化的机制:创建一个隐藏变量,并用该右值初始化,然后引用将指向这个变量。最重要的是,编译器确保该变量的生命周期不短于引用的生命周期,因此这样的引用永远不会变成悬空引用。下面的代码乍一看似乎很奇怪,但实际上是完全正确的。
int` `&&rr` `=` `7`;
`rr` `=` `8`;`
字面量7是右值,这意味着会发生临时物化,在第二条指令中,相应的隐藏变量的值会被简单地改变。
但如果我们复制这样的链接,那么副本的生命周期就无法得到保证;副本可能会变成悬空链接。
4.2 临时对象
在计算复杂表达式的过程中,经常会创建临时的、未命名的对象。了解这些对象何时被删除至关重要,因为有时会获取到对这些对象的引用,而了解何时可能出现悬空引用非常重要。标准规定,临时对象应在封闭表达式(完整表达式)计算完成后立即删除。例如,如果在函数调用中计算参数时创建了一个临时对象,则其生命周期至少为函数体,如果该函数调用是更大表达式的一部分,则其生命周期可能更长。
4.3 示例
当然,你可以像这样创建一个悬空链接:
int` `&dx` `=` `*new` `int`(`32`);
`delete` `&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`;
}`
如果在调用此函数时使用左值参数,则不会出现问题;其生命周期x将由调用上下文决定。但是,如果使用右值参数,x则生命周期为函数体,调用此函数后,返回的引用将指向已删除的对象。可以通过替换来避免这种情况inline,或者,如果此函数的调用初始化的是值X而不是引用,X则在复制之后会调用析构函数。
有趣的是,一些标准函数的实现方式类似,例如:
`
`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`){ `/* ... */`}
`// ...`
};`
这与之前的例子问题相同。如果构造函数使用左值参数,一切可能正常;但如果参数是右值,则必然会造成悬空引用。在这种情况下,为了安全起见,可以通过声明构造函数并删除右值引用参数来m_X防止使用右值初始化实例。Y
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是通过调用一个返回常量引用的函数来初始化的,该常量引用是左值(参见 2.3 节),因此不会发生临时对象化。然而,这个函数是一个成员函数,它返回的是对一个临时对象子对象的引用X。根据 4.2 节,这个临时对象会在引用rxv初始化后立即被删除。这就产生了一个悬空引用。
这个例子演示了一种潜在的危险情况:通过调用返回引用的函数将右值"转换"为左值,导致出现悬空引用。当类中存在隐式引用转换时,情况会更加危险;应用隐式转换本质上就是一次函数调用,只不过它是隐式发生的。
让我们将之前的代码重写如下:
const` `X` `&rx` `=` `X`(`32`);
`const` `int` `&rxv` `=` `rx`.`Value`();`
常量引用rx被初始化为一个类型为 的临时对象X,这是一个右值,因此它是暂时物化的,并且rxv将引用"活动"对象的子对象,因此rxv在其生命周期内不会成为悬空引用rx。
现在我们将使用第一个选项中的表达式作为函数的参数。
void` `Foo`(`const` `int&` `rr`);
`Foo`(`X`(`32`).`Value`());`
在这个例子中,由于与第一个选项相同的原因,不会有临时实例,但临时实例X只有在返回控制权后才会被删除Foo()(参见第 4.2 节),因此函数体中不会有悬空Foo()引用rr。
在 C++11 中,您可以禁止对右值调用非静态成员函数:
const` `int&` `Value`() `const` `&` { `return` `m_Value`; }
`const` `int&` `Value`() `const` `&&` `=` `delete`;`
这里我们使用了所谓的引用限定符来表示非静态成员函数。它们允许按隐藏的参数值类别进行重载this(参见第 3.1.3 节)。
4.4 标准容器
访问容器元素的标准方式是通过迭代器。迭代器接口有一个重载运算符*(解引用),它通常返回对容器中存储对象的引用。如果在获取此类引用之后对容器执行操作,则该引用可能会变为悬空引用。显然,对于任何容器,调用该运算符都会clear()保证所有先前获取的引用都变为悬空引用。一个不太明显的例子:向实例添加元素std::vector<>可能会分配一个新的缓冲区,并将所有旧数据复制或移动到新缓冲区,之后所有先前获取的引用都会变为悬空引用。标准库文档提供了有关哪些容器操作可以保证先前获取的迭代器不会失效的信息。
标准容器还有其他成员函数(索引器、front()元素back()引用等),这些函数返回对容器中存储的元素的引用;这些引用也可能变成悬空引用。
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忽略。(不能重复声明为常量。)
R值参考文献也代表不同的参考文献类型:
using` `RVT` `=` `T&&`;`
引用类型几乎完全隐藏,这意味着任何关于类型的查询(例如 `type`sizeof或 `type` 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&`; `
事实上,类型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>)的特殊标准模板。其中一些模板专门用于处理引用类型。首先是 `type` 属性std::is_reference<>,当模板参数为引用类型时,其静态成员的value值为 `true` true。(实际上,还有一些类型属性用于更精确的检查:std::is_lvalue_reference<>`type`、` type` 等std::is_rvalue_reference<>。)请注意,对于此组中的其他类型属性(std::is_const<>`type`、std::is_integral<>`type` 等),其值为 `false` false,并且不依赖于引用类型所引用的类型。您还可以使用模板 `type` std::remove_reference<>,它将引用类型转换为相应的非引用类型(模板类型成员type)。该模板std::decay<>不仅会移除引用,还会对类型执行其他操作。
5.2.2. 折叠链接
如上所述,不存在"引用的引用"这种概念,但在某些情况下使用引用类型模板参数时,可能会出现一些结构,根据 C++ 规则,这些结构会被解释为"引用的引用"。此时,会应用一条称为"引用折叠"的特殊规则。因此,这种结构会被解释为指向非引用类型的引用或右值引用。规则很简单:如果两个引用都是右值引用,则结果引用也是右值引用;否则,结果引用是普通引用。
第一个例子是当显式指定模板参数时函数参数的类型推断(另见第 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&); `
`W<X&&>` `wrvx`; `// void Foo(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使用.声明别名的规则
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` 的指针T。显然,它T不能是引用类型;指向引用的指针是被禁止的。但是,它可以是常量类型。构造函数参数是 `T` 类型的左值T。T&很明显,它不能是右值;在这种情况下,我们会立即得到一个悬空引用。左值参数也可能产生悬空引用;这取决于类实例的生命周期与构造函数参数的生命周期。如果没有自动模板参数推导(C++17),任何可以隐式转换为 `T` 的左值/右值都可以用作构造函数参数T&。没有默认构造函数,因此也无法创建空引用。该类不支持对指针指向的对象进行生命周期管理------析构函数不执行任何操作。默认情况下使用复制语义;指针会被直接复制。由于不存在空引用,因此不支持移动复制。赋值语义------默认情况下,指针会被赋值。请注意,此语义与引用赋值语义不同------引用赋值是通过赋值它们所指向的对象来实现的。该类具有到 `Object` 的隐式转换T&。这允许使用该类的实例来初始化对 `Object` 的引用,T并将其作为参数传递给接受 `Object` 引用的函数T。
void` `Foo`(`int&` `rx`);
`int` `x` `=` `6`;
`std::reference_wrapper<int>` `rwx` `=` `x`;
`int` `&rx` `=` `rwx`;
`Foo`(`rwx`); `
但是,你不能通过赋值或调用类成员函数来更改实例引用的值T。要解决这个问题,你必须首先调用一个get()返回值的成员函数T&。
int` `x` `=` `6`;
`std::reference_wrapper<int>` `rwx` `=` `x`;
`rwx` `=` `32`;
`rwx`.`get`() `=` `32`; `
该类型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::сref<>()。在这种情况下,类模板参数会被推断为常量类型。
const` `int` `x` `=` `6`;
`auto` `сrwx` `=` `std::сref`(`x`);
`
5.3.2. 使用
模板实例化std::reference_wrapper<>是"普通"类型;它们可用于创建数组、作为标准容器的参数以及其他模板。如果我们有一个函数模板
template<typename` `T>`
`void` `Foo`(`T` `param`);`
当使用模板实例化作为参数时std::reference_wrapper<>,我们实际上是将按值传递参数替换为按引用传递参数。虽然不能保证使用此类参数一定能成功实例化模板,但对模板进行某些修改可以解决这个问题;请参见以下示例。
标准库有时会使用以下技巧:如果函数模板参数的类型为 `T` std::reference_wrapper<T>,则将其转换为 `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++11 中引入的一种引用类型。它们实现了移动语义,这是 C++11 最重要的新特性之一。
-
通用(可传递)引用和转发是一种在转发过程中保留论元值类别的机制。这对于支持模板中的移动语义是必要的。
-
引用类型被广泛用作函数的参数类型和返回值。这提高了代码效率、功能性和可读性,并支持多态性和移动语义。带有引用类型参数的函数的重载规则起着关键作用。
-
C++ 没有通用的机制来防止悬空引用。这项任务需要程序员自行完成。返回引用的函数和引用类型的类成员都可能产生悬空引用;这些都需要特别注意。
-
引用类型存在诸多限制;虽然通常不禁止将其用作模板参数,但在某些情况下可能会导致问题。因此,一些模板被强制禁止使用引用类型作为模板参数。为了解决引用类型的这些限制,类模板会非常有用
std::reference_wrapper<>。