指针,数组和引用
(Pointers, Arrays, and References)
目录
7.3.2.1 原字符串(Raw Character Strings)
7.3.2.2 大字符集(Larger Character Sets)
7.4.2 多维数组(Multidimensional Arrays)
7.6 指针和属主(Pointers and Ownership)
7.1 引言
本章介绍引用内存的基本语言机制。显然,我们可以通过名称引用对象,但在 C++ 中(大多数)对象“具有身份”。也就是说,它们驻留在内存中的特定地址,如果您知道对象的地址和类型,则可以访问该对象。用于保存和使用地址的语言构造是指针和引用。
7.2 指针
对于一个类型T ,T∗ 是“T类型指针”类型。 即,类型 T∗ 是一个可以存储类型T的对象的地址的变量。例如:
char c = 'a';
char∗ p = &c; //p 存储 c 的地址; & 是取地址运算符
或者用图像表示为
指针的基本操作是解引用,即引用指针指向的对象。此操作也称为间接引用。解引用运算符是(前缀)一元 ∗。例如:
char c = 'a';
char∗ p = &c; // p 存储 c 的地址; & 是取地址运算符
char c2 = ∗p; // c2 == ’a’; * 是解引用运算符
p指向的对象为c,c中存储的值为'a',因此赋给c2的∗p的值为'a'。
可以对指向数组元素的指针执行一些算术运算(§7.4)。
指针的实现旨在直接映射到程序运行所在机器的寻址机制(译注:即利用指针直接操作地址)。大多数机器可以寻址一个字节。那些不能寻址的机器往往有硬件从字中提取字节。在另一方面,很少有机器可以直接寻址单个位。因此,可以使用内置指针类型独立分配和指向的最小对象是 char(译注:即1个字节)。请注意,bool 占用的空间至少与 char 一样多(§6.2.8)。为了更紧凑地存储较小的值,可以使用按位逻辑运算(§11.1.1)、结构中的位域(bit-fields)(§8.2.7)或位集(bitset)(§34.2.2)。
∗ 表示“指向”的指针,用作类型名称的后缀。不幸的是,指向数组的指针和指向函数的指针需要更复杂的符号:
int∗ pi; //int指针
char∗∗ ppc; // char指针的指针
int∗ ap[15]; // 15个 int 指针的数组
int (∗fp)(char∗); // 取一个char* 参数的函数指针(译注:()优于任何类型名); 返回 int
int∗ f(char∗); //取一个char* 参数的函数; 返回int指针
查看 §6.3.1 了解声明语法的解释,并查看 §iso.A 了解完整语法。
函数指针很有用;在§12.5 中进行了讨论。指向类成员的指针在§20.6中进行介绍。
7.2.1 void* 指针
在底层代码中,我们偶尔需要存储或传递内存位置的地址,而实际上并不知道存储在那里的对象类型,这时void∗ 可用于此目的。您可以将void∗ 理解为“指向未知类型对象的指针”。
指向任意类型对象的指针都可以赋值给 void∗ 类型的变量,但指向函数的指针(§12.5)或指向成员的指针(§20.6)则不能。此外,void∗ 可以赋值给另一个void∗,void∗ 可以比较相等和不相等,并且 void∗ 可以显式转换为其他类型。其他操作是不安全的,因为编译器无法知道实际指向的对象类型。因此,其他操作会导致编译时错误。要使用 void∗,我们必须将其显式转换为指向特定类型的指针。例如:
void f(int∗ pi)
{
void∗ pv = pi; // ok: int* 到 void* 的隐式转换
∗pv; //错 : 不能解引用void*
++pv; //错: 不能递加 void* (指向对象的大小未知)
int∗ pi2 = static_cast<int∗>(pv); //显式转换回 int*
double∗ pd1 = pv; //错
double∗ pd2 = pi; //错
double∗ pd3 = static_cast<double∗>(pv); // 不安全(§11.5.2)
}
一般来说,使用已转换(“强制转换”)为不同于所指向对象类型的指针是不安全的。例如,机器可能假设每个 double 都分配在 8 字节边界上。如果是这样,如果 pi 指向的 int 不是以这种方式分配的,则可能会出现奇怪的行为。这种显式类型转换形式本质上是不安全且丑陋的。因此,所使用的表示法 static_cast(§11.5.2)被设计为丑陋状且易于在代码中找到。
void∗ 的主要用途是将指针传递给不允许对对象类型作出假设的函数,以及从函数返回无类型的对象。要使用这样的对象,我们必须使用显式类型转换。
使用 void∗ 指针的函数通常存在于系统的最低层,在那里操纵真实的硬件资源。例如:
void∗ my_alloc(siz e_t n); // 从我的专用堆分配n字节的空间
系统较上层出现的 void∗ 应引起高度警惕,因为它们很可能是设计错误的征兆。在用于优化时,void∗ 可以隐藏在类型安全接口后面(§27.3.1)。
指向函数的指针(§12.5)和指向成员的指针(§20.6)不能分配给 void∗ 。
7.2.2 nullptr 指针
文字量 nullptr 表示空指针(null pointer),即不指向对象的指针(译注:事实上,其值为0,表示不存储任意地址,也可以称其为0指针)。它可以赋值给任何指针类型,但不能分配给其他内置类型:
int∗ pi = nullptr; //译注:pi == 0
double∗ pd = nullptr;
int i = nullptr; // 错 : i 不是指针
只有一个 nullptr,它可以用于每个指针类型,而不是每个指针类型都有一个空指针。
(译注:实际上,系统在实现的时候,nullptr 就是用 0 替代:
int* pi = nullptr;
00007FF649EC284D mov qword ptr [pi],0
double* pd = nullptr;
00007FF649EC2856 mov qword ptr [pd],0
)
(译注:因为 nullptr 本质上就是一个 0 值,因此在不考虑意义的情况下,强制转换是可以的,可以看到下式的 i == 0:
int i = (int)nullptr;
)
在引入 nullptr之前,零 (0) 被用作空指针的表示法。例如:
int∗ x = 0; // x 获得 nullptr 代表的值
没有对象被分配地址 0,并且 0(全零位模式)是 nullptr的最常见表示。零(0)是 int。但是,标准转换(§10.5.2.3)允许将 0 用作指针或指向成员类型的常量。
定义一个宏 NULL 来表示空指针是一种很流行的做法。例如:
int∗ p = NULL; //使用宏 NULL
但是,不同实现中 NULL 的定义存在差异;例如,NULL 可能是 0 或 0L。在 C 中,NULL 通常是 (void∗)0,这使其在 C++ 中不合法(§7.2.1):
int∗ p = NULL; // 错:不能将void*赋给int* (译注:有的编译是可以的,如vs2022)
与其他替代方案相比,使用 nullptr 可使代码更具可读性,并且避免了当函数重载以接受指针或整数时可能产生的混淆(§12.3.1)。
7.3 数组
对于一个类型T ,T[size] 是“T类型的size个元素的数组”类型。元素按从 0 到 size – 1 的下标进行检索。例如:
float v[3]; // 3个float元素数组: v[0], v[1], v[2]
char∗ a[32]; // 32个指向char的指针数组: a[0] .. a[31]
您可以使用下标运算符 [] 或通过指针(使用运算符 ∗ 或运算符 [];§7.4)访问数组。例如:
void f()
{
int aa[10];
aa[6] = 9; // 对aa的第7个元素赋值
int x = aa[99]; // 未定义行业
}
超出数组范围的访问是未定义的,通常会导致灾难性的后果。特别是,运行时范围检查既没有保证,也不常见。
数组元素的数量(即数组边界)必须是常量表达式(§10.4)。如果需要可变边界,请使用vector(§4.4.1、§31.4)。例如:
void f(int n)
{
int v1[n]; // 错 : array size not a constant expression
vector<int> v2(n); // OK: 具有n个int元素的vector
}
多维数组表示为数组的数组(§7.4.2)。
数组是 C++ 表示内存中对象序列的基本方式。如果您想要的是内存中给定类型的简单固定长度对象序列,则数组是理想的解决方案。对于其他所有需求,数组都存在严重问题。
数组可以静态分配在栈上以及在自由存储空间上(§6.4.2)。例如:
int a1[10]; // 10 ints in static storage
void f()
{
int a2 [20]; // 20 ints on the stack
int∗p = new int[40]; // 40 ints on the free store
// ...
}
C++ 内置数组本质上是一种底层工具,主要应用于更高级、性能更好的数据结构(如标准库向量或数组)的实现中。不存在数组赋值,数组名称会在最轻微的触发下隐式转换为指向其第一个元素的指针(§7.4)。特别是,避免在接口中使用数组(例如,作为函数参数;§7.4.3、§12.2.2),因为隐式转换为指针是 C 代码和 C风格C++ 代码中许多常见错误的根源。如果在自由存储区中分配数组,请确保仅在最后一次使用后 delete[] 其指针一次(§11.2.2)。最简单、最可靠的方法是让资源句柄控制自由存储数组的生命周期(例如,string(§19.3、§36.3)、vector(§13.6、§34.2)或unique_ptr(§34.3.1))(译注:类为它们是类,由它们的析构函数去负责释放内存空间)。如果您静态地或在栈上分配数组,请确保永远不要删除它。显然,C 程序员无法遵循这些建议,因为 C 缺乏封装数组的能力,但这并不意味着这些建议在 C++ 环境中是坏的。
最广泛使用的数组类型之一是以零结尾的 char 数组。这是 C 存储字符串的方式,因此以零结尾的 char 数组通常称为 C 风格字符串。C++ 字符串文字量遵循该约定(§7.3.2),并且一些标准库函数(例如 strcpy() 和 strcmp();§43.4)依赖于它。通常,char∗ 或 const char∗ 被假定指向以零结尾的字符序列。
7.3.1 数组初始化
数组可以通过值列表进行初始化。例如:
int v1[] = { 1, 2, 3, 4 };
char v2[] = { 'a', 'b', 'c', 0 };
当声明数组时没有指定大小,但带有初始化列表,则通过计算初始化列表的元素来计算大小。因此,v1 和 v2 分别为 int[4] 和 char[4] 类型。如果明确指定了大小,则在初始化列表中提供多余的元素是错误的。例如:
char v3[2] = { 'a', 'b', 0 }; // 错 :太多初始化项
char v4[3] = { 'a', 'b', 0 }; // OK
如果初始化器为数组提供的元素太少,则其余元素将使用 0。例如:
int v5[8] = { 1, 2, 3, 4 };
相当于
int v5[] = { 1, 2, 3, 4 , 0, 0, 0, 0 };
数组没有内置的复制操作。您不能用一个数组初始化另一个数组(即使是完全相同类型的数组),并且没有数组赋值:
int v6[8] = v5; // 错: 不能复制数组 (不能将 int*赋值给数组 )
v6 = v5; //错: 不存在数组赋值
类似地,你不能通过值传递数组。另请参阅 §7.4。
当你需要对对象集合进行赋值时,请改用vector(§4.4.1、§13.6、§34.2)、数组(§8.2.4)或 valarray(§40.5)。
可以方便地通过字符串文字量(§7.3.2)初始化字符数组。
7.3.2 字符串文字量
字符串文字量是一个用双引号(“”)括起来的字符串。
"this is a string"
字符串文字量包含的字符比它实际包含的字符多一个;它以空字符 '\0' 结尾,值为 0(译注:只不过在调试显示时隐藏了这个字符串尾的0,但在内存中是可以看到这个0值的)。例如:
sizeof("Bohr")==5
字符串文字的类型是“适当数量的 const 字符数组”,因此“Bohr”属于const char[5] 类型。
在 C 和较旧的 C++ 代码中,可以将字符串文字量分配给非常量 char∗:
void f()
{
char∗ p = "Plato"; // 错,但在C++11以下标准中是可接受的
p[4] = 'e'; //错:赋值给常量
}
接受这种赋值显然是不安全的。它曾经(现在也是)是隐晦错误的来源,所以如果某些旧代码由于这个原因无法编译,请不要抱怨太多。让字符串文字量不可变不仅显而易见,而且还允许实现对字符串文字量的存储和访问方式进行重大优化。
如果我们想要一个可以保证能够修改的字符串,我们必须将字符串放在一个非常量数组中:
void f()
{
char p[] = "Zeno"; // p 5个字符的数组
p[0] = 'R'; // OK
}
字符串文字量是静态分配的,因此可以安全地从函数返回一个字符串文字量。例如:
const char∗ error_message(int i)
{
// ...
return "range error";
}
调用 error_message() 之后,保存“range error”的内存不会消失。
(译注:也就是说字符串文字量是系统静态分配并维护的,不需要我们去释放空间,当我们return 的时候,系统首先取得字符串 "range error" 在静态存储区中的地址,存入eax寄存器,然后返回。在vs 2022 中编译后类似下面的样子:
00007FF72D542864 lea rax,[__xt_z+7D8h (07FF72D6A3C10h)]
00007FF72D54286B ret
这时,我们看 rax 的地址内容是多少:0x00007ff72d6a3c10 。然后我们跳到这个内存地址去看看其存储的内容,正好是字符串对应的ASCII码的十六进表示:
两个相同的字符串文字量是否分配为一个数组或两个数组由实现定义(§6.1)。例如:
const char∗ p = "Heraclitus";
const char∗ q = "Heraclitus";
void g()
{
if (p == q) cout << "one!\n"; // 结果由实现定义
// ...
}
请注意,当应用于指针时,== 会比较地址(指针值),而不是指向的值。
空字符串写为一对相邻的双引号“”,类型为 const char[1]。空字符串的一个字符是终止符 '\0'。
表示非图形字符的反斜杠约定(§6.2.3.2)也可以在字符串中使用。这样就可以在字符串中表示双引号(“)和转义字符反斜杠(\)。到目前为止,最常见的此类字符是换行符“\n”。例如:
cout<<"beep at end of message\a\n";
转义字符 '\a' 是 ASCII 字符 BEL(也称为 alert),它会发出声音。
在(非原始)字符串文字量中不可能有“真正的”换行符:
"this is not a string
but a syntax error"
长字符串可以用空格分隔,以使程序文本更整洁。例如:
char alpha[] = "abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
编译器将连接相邻的字符串,因此数组 alpha 可以等效地由单个字符串
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
初始化。
字符串中可以包含空字符,但大多数程序不会怀疑其后有字符。例如,字符串“Jens\000Munk”将被标准库函数(如 strcpy() 和 strlen())视为“Jens”;请参阅 §43.4。
7.3.2.1 原字符串(Raw Character Strings)
要在字符串文字量中表示反斜杠 (\) 或双引号 ("),我们必须在其前面加上反斜杠。这是合乎逻辑的,而且在大多数情况下都很简单。但是,如果我们需要在字符串文字量中使用大量反斜杠和大量引号,这种简单的技术就变得难以管理。特别是,在正则表达式中,反斜杠既用作转义字符,也用于引入表示字符类的字符(§37.1.1)。这是许多编程语言共享的惯例,所以我们不能改变它。因此,当您编写用于标准正则表达式库(第 37 章)的正则表达式时,反斜杠是转义字符这一事实会成为明显的错误来源。考虑如何编写表示由反斜杠 (\) 分隔的两个单词的模式:
string s = "\\w\\\\w"; // 我希望它如预期
原字符串文字量使用 R"(ccc)" 符号表示字符序列 ccc。开头的 R 用来区分原字符串文字和普通字符串文字。括号用来允许使用 (‘‘未转义’’) 双引号(译注:原字符串指其内容中所有符号都表示字符,即使含有转义字符也当成普通字符对待)。例如:
R"("quoted string")" // 字符串是 "quoted string"
那么,我们如何将字符序列 )" 转换为原字符串文字量呢?幸运的是,这是一个罕见的问题,但“( 和 )”只是默认的分隔符对(译注:我们可以添加其这符号对作为分隔符)。我们可以在“(...)”中的 ( 之前和 ) 之后添加分隔符。例如:
R"∗∗∗ ("quoted string containing the usual terminator ("))")∗∗∗"
// "quoted string containing the usual terminator ("))"
(译注,我们添加一对∗∗∗----∗∗∗作为分隔符。)
) 之后的字符序列必须与 ( 之前的字符序列相同。这样我们就可以应对(几乎)任意复杂的模式。
除非您使用正则表达式,否则原始字符串文字量对你来说可能只是一种好奇心(还有一件事需要学习),但正则表达式很有用并且被广泛使用。考虑一个现实世界的例子:
"('(?:[ˆ\\\\']|\\\\.)∗'|\"(?:[ˆ\\\\\"]|\\\\.)∗\")|" // 这5个反斜线正确与否?
有了这样的例子,即使是专家也很容易感到困惑,而原字符串文字量提供了重要的服务。
与非原字符串文字量相比,原字符串文字量可以包含换行符。例如:
string counts {R"(1
22
333)"};
相当于
string x {"1\n22\n333"};
7.3.2.2 大字符集(Larger Character Sets)
带有前缀 L 的字符串(例如 L"angst")是宽字符字符串(§6.2.3)。其类型为 const wchar_t[]。类似地,带有前缀 LR 的字符串(例如 LR"(angst)")是类型为 const wchar_t[] 的宽字符原字符串(§7.3.2.1)。此类字符串以 L'\0' 字符结尾(译注:本质上是两个00)。
支持 Unicode 的字符文字量有六种(Unicode 文字)。这听起来有点多余,但 Unicode 有三种主要编码:UTF-8、UTF-16 和 UTF-32。对于这三种替代方案中的每一种,都支持原字符串和“普通”字符串。所有三种 UTF 编码都支持所有 Unicode 字符,因此使用哪种编码取决于您需要适应的系统。基本上所有互联网应用程序(例如浏览器和电子邮件)都依赖于其中一种或多种编码。
UTF-8 是一种可变宽度编码:常用字符占用 1 个字节,不常用的字符(根据使用情况估计)占用 2 个字节,而罕见字符占用 3 或 4 个字节。具体而言,ASCII 字符占用 1 个字节,UTF-8 中的编码(整数值)与 ASCII 中的相同。各种拉丁字母、希腊字母、西里尔字母、希伯来字母、阿拉伯字母等占用 2 个字节。
UTF-8 字符串以 '\0' 结尾,UTF-16 字符串以 u'\0' 结尾,UTF-32 字符串以 U'\0' 结尾。我们可以用多种方式表示普通英文字符串。考虑使用反斜杠作为分隔符的文件名:
"folder\\file" //实现字符集字符串
R"(folder\file)" //实现字符原始集字符串
u8"folder\\file" // UTF-8 string
u8R"(folder\file)" // UTF-8 raw string
u"folder\\file" // UTF-16 string
uR"(folder\file)" //UTF-16 raw string
U"folder\\file" // UTF-32 string
UR"(folder\file)" //UTF-32 raw string
如果打印出来,这些字符串看起来都一样,但除了“纯文本”和 UTF-8 字符串之外,它们的内部表示可能会有所不同。
显然,Unicode 字符串的真正目的是能够将 Unicode 字符放入其中。例如:
u8"The official vowels in Danish are: a, e, i, o, u, \u00E6, \u00F8, \u00E5 and y."
正确打印该字符串可给出
The official vowels in Danish are: a, e, i, o, u, æ, ø, å and y.
\u 后面的十六进制数是 Unicode 编码点(code point) (§iso.2.14.3) [Unicode,1996]。此类编码点与所用的编码无关,实际上在不同的编码中会有不同的表示形式(以字节中的位表示)。例如,u'0430'(西里尔小写字母“a”)在 UTF-8 中是 2 字节十六进制值 D0B0,在 UTF-16 中是 2 字节十六进制值 0403,在 UTF-32 中是 4 字节十六进制值 00000403。这些十六进制值称为通用字符名称。
u 和 R 的顺序及其大小写非常重要:RU 和 Ur 不是有效的字符串前缀。
7.4 数组中的指针
在 C++ 中,指针和数组密切相关。数组的名称可以视为指向其起始元素的指针(译注:即数组起始地址)。例如:
int v[] = { 1, 2, 3, 4 };
int∗ p1 = v; // 指针指向起始元素(隐式转换)
int∗ p2 = &v[0]; // 指针指向起始元素(同上一行相同)
int∗ p3 = v+4; // 指向最后一个元素尾(译注:指针“+”的意义取决于数据类型的大小)
或用图形表示为:
取数组末尾元素之后的地址有效(译注:指取地址是允许的)。这对于许多算法来说很重要(§4.5、§33.1)。但是,由于这样的指针实际上并不指向数组的元素,因此不能用于读取或写入。获取初始元素之前或最后一个元素之后的元素地址的结果是未定义的,应避免。例如:
int∗ p4 = v−1; // 数组起始地址之前一个元素大小的位置, 未定义:勿做
int∗ p5 = v+7; // 超过元素末尾,未定义:勿做
在 C 风格代码的函数调用中,数组名称被广泛地用作指向数组起始元素的指针的隐式转换。例如:
extern "C" int strlen(const char∗); // from <string.h>
void f()
{
char v[] = "Annemarie";
char∗ p = v; // char[] 到 char* 的隐式转换
strlen(p);
strlen(v); // char[] 到 char*的隐式转换
v = p; // 错 : 不能给数组名赋值
}
在两次调用中,相同的值都会传递给标准库函数 strlen()。问题是无法避免隐式转换。换句话说,没有办法声明一个以便在调用它时复制数组 v 的函数。幸运的是,没有从指针到数组的隐式或显式转换。
将数组参数隐式转换为指针意味着被调用函数无法获知数组的大小。但是,被调用函数必须以某种方式确定大小才能执行有意义的操作。与其他使用指向字符的指针的 C 标准库函数一样,strlen() 依赖零来指示字符串结尾;strlen(p) 返回直到(不包括)终止 0 的字符数。这都是相当底层的。标准库向量类vector(§4.4.1,§13.6,§31.4)、数组类array(§8.2.4,§34.2.1)和字符串类string(§4.2)都不会遇到此问题。这些库类型将其元素数量作为其 size(),而无需每次都计算元素数量。
7.4.1 数组操作导引(Navigating Arrays)
高效而优雅地访问数组(和类似的数据结构)是许多算法的关键(参见第32章第4.5节)。可以通过指向数组的指针(译注:数组首地址)加上索引或通过指向元素的指针来实现对数组的访问。例如:
void fi(char v[])
{
for (int i = 0; v[i]!=0; ++i)
use(v[i]);
}
void fp(char v[])
{
for (char∗ p = v; ∗p!=0; ++p)
use(∗p);
}
前缀 ∗ 运算符角引用指针,使得 ∗p 是 p 指向的字符,而 ++ 增加指针,使其指向数组的下一个元素。
没有内在原因说明为什么一个版本应该比另一个版本更快。使用现代编译器,两个示例应该(通常也是)生成相同的代码。程序员可以根据逻辑和审美选择版本。
内置数组的下标是根据指针操作 + 和 ∗ 定义的。对于每个内置数组 a 和 a 范围内的整数 j,我们有:
a[j] == ∗(&a[0]+j) == ∗(a+j) == ∗(j+a) == j[a]
人们通常会惊讶地发现 a[j]==j[a]。例如,3["Texas"]=="Texas"[3]=='a'。这种“聪明”的做法在生产代码中是行不通的。这些等价关系非常底层,不适用于标准库容器,例如array和vector 。
将算术运算符 +、−、++ 或 −− 应用于指针的结果取决于指向的对象的类型。当将算术运算符应用于类型 T∗ 的指针 p 时,假定 p 指向类型 T 的对象数组的一个元素;p+1 指向该数组的下一个元素,而 p−1 指向前一个元素。这意味着 p+1 的整数值将比 p 的整数值 sizeof(T) 大。例如:
template<typename T>
int byte_diff(T∗ p, T∗ q)
{
return reinterpret_cast<char∗>(q)−reinterpret_cast<char∗>(p);
}
void diff_test()
{
int vi[10];
short vs[10];
cout << vi << ' ' << &vi[1] << ' ' << &vi[1]−&vi[0] << ' ' << byte_diff(&vi[0],&vi[1]) << '\n';
cout << vs << ' ' << &vs[1] << ' ' << &vs[1]−&vs[0] << ' ' << byte_diff(&vs[0],&vs[1]) << '\n';
}
产生:
0x7fffaef0 0x7fffaef4 1 4
0x7fffaedc 0x7fffaede 1 2
指针值使用默认的十六进制表示法打印。这表明在我的实现中,sizeof(short) 是 2,sizeof(int) 是 4。
仅当两个指针都指向同一数组的元素时,指针减法才有定义(尽管语言没有快速确保这种情况的方法)。当从另一个指针 q(q - p)中减去指针 p 时,结果是序列 [p:q) 中的数组元素数(一个整数)。可以将整数添加到指针或从指针中减去整数;在这两种情况下,结果都是指针值。如果该值不指向与原始指针相同或更远的数组的元素,则使用该值的结果是未定义的。例如:
void f()
{
int v1[10];
int v2[10];
int i1 = &v1[5]−&v1[3]; // i1 = 2
int i2 = &v1[5]−&v2[3]; // result undefined
int∗ p1 = v2+2; // p1 = &v2[2]
int∗ p2 = v2−2; // *p2 undefined
}
复杂的指针运算通常是不必要的,最好避免。指针之间相加是没有意义的,也是不允许的。
数组不是自描述的(self-describing),因为不能保证数组元素的数量与数组一起存储。这意味着,要像 C 风格字符串那样遍历不包含终止符的数组,我们必须以某种方式提供元素的数量。例如:
void fp(char v[], int size)
{
for (int i=0; i!=size; ++i)
use(v[i]); // 希望v至少有size个元素
for (int x : v)
use(x); //错: range-for 不适用于指针
const int N = 7;
char v2[N];
for (int i=0; i!=N; ++i)
use(v2[i]);
for (int x : v2)
use(x); //range-for 对已知大小的数组有效
}
此数组概念本质上是底层的。通过使用标准库容器array(§8.2.4、§34.2.1),可以获得内置数组的大多数优点和少数缺点。一些 C++ 实现为数组提供可选的范围检查。但是,这种检查可能非常昂贵,因此它通常仅用作开发辅助(而不是包含在生产代码中)。如果您没有对单个访问使用范围检查,请尝试保持一致的策略,即仅在明确定义的范围内访问元素。当通过更高级容器类型(例如vector)的接口操作数组时,最好这样做,因为在这种情况下,很难混淆有效元素的范围。
7.4.2 多维数组(Multidimensional Arrays)
多维数组表示为数组的数组;3 × 5 数组声明如下:
int ma[3][5]; // 3个一维数组,且每个一维数组具有 5 个int 元素
我们可以像这样初始化 ma:
void init_ma()
{
for (int i = 0; i!=3; i++)
for (int j = 0; j!=5; j++)
ma[i][j] = 10∗i+j;
}
或用图形表示为:
数组 ma 只是 15 个 int,我们访问它就像访问 3 个 5 int 数组一样。具体来说,内存中没有单个对象是矩阵 ma —— 只存储元素。维数 3 和 5 仅存在于编译器源代码中。当我们编写代码时,我们的工作是以某种方式记住它们并在需要时提供维数。例如,我们可以像这样打印 ma:
void print_ma()
{
for (int i = 0; i!=3; i++) {
for (int j = 0; j!=5; j++)
cout << ma[i][j] << '\t';
cout << '\n';
}
}
某些语言中用于数组边界的逗号表示法不能在 C++ 中使用,因为逗号 (,) 是排序运算符 (§10.3.2)。幸运的是,大多数错误都会被编译器捕获。例如:
int bad[3,5]; // 错: 逗号不允许用在常量表达式中
int good[3][5]; // 3个一维数组,且每个一维数组具有 5 个int 元素
int ouch = good[1,4]; //错:用 int*初始化I nt (good[1,4] 指 good[4], 这是int*
int nice = good[1][4];
7.4.3 传递数组(Passing Arrays)
数组不能直接按值传递。相反,数组作为指向其第一个元素的指针传递。例如:
void comp(double arg[10]) // arg is a double*
{
for (int i=0; i!=10; ++i)
arg[i]+=99;
}
void f()
{
double a1[10];
double a2[5];
double a3[100];
comp(a1);
comp(a2); // disaster!
comp(a3); // uses only the first 10 elements
};
这段代码看起来合理,但实际上并非如此。代码可以编译,但调用 comp(a2) 会超出 a2 的边界。此外,任何猜测数组是通过值传递的人都会感到失望:对 arg[i] 的写入直接写入 comp() 参数的元素,而不是副本。该函数可以等价地写为
void comp(double∗ arg)
{
for (int i=0; i!=10; ++i)
arg[i]+=99;
}
现在,这种疯狂(希望)显而易见了。当用作函数参数时,数组的第一个维度仅被视为指针。任何指定的数组绑定都将被忽略。这意味着如果您想传递元素序列而不丢失大小信息,则不应传递内置数组。相反,您可以将数组作为成员放在类中(就像对 std::array 所做的那样)或定义一个充当句柄的类(就像对 std::string 和 std::vector 所做的那样)。
如果你坚持直接使用数组,你将不得不处理错误和混乱,而得不到明显的优势。考虑定义一个函数来操作二维矩阵。如果在编译时知道维数,那就没有问题:
void print_m35(int m[3][5])
{
for (int i = 0; i!=3; i++) {
for (int j = 0; j!=5; j++)
cout << m[i][j] << '\t';
cout << '\n';
}
}
表示为多维数组的矩阵作为指针传递(而不是复制;§7.4)。数组的第一维与查找元素的位置无关;它只是说明有多少个元素(这里是 3)具有适当的类型(这里是 int[5])。例如,查看上面的 ma 的布局,并注意,只要知道第二维是 5,我们就可以求得任何 i 的 ma[i][5]。因此,第一维可以作为参数传递:
void print_mi5(int m[][5], int dim1)
{
for (int i = 0; i!=dim1; i++) {
for (int j = 0; j!=5; j++)
cout << m[i][j] << '\t';
cout << '\n';
}
}
当需要传递两个维度时,“显而易见的解决方案”不起作用:
void print_mij(int m[][], int dim1, int dim2) // 并不会表现得像大多数人认为的那样
{
for (int i = 0; i!=dim1; i++) {
for (int j = 0; j!=dim2; j++)
cout << m[i][j] << '\t'; // 意外!
cout << '\n';
}
}
幸运的是,参数声明 m[][] 是非法的,因为必须知道多维数组的第二个维数才能求得元素的位置。但是,表达式 m[i][j] 被(正确地)解释为 ∗(∗(m+i)+j),尽管这不太可能是程序员的意图。正确的解决方案是:
void print_mij(int∗ m, int dim1, int dim2)
{
for (int i = 0; i!=dim1; i++) {
for (int j = 0; j!=dim2; j++)
cout << m[i∗dim2+j] << '\t'; // 模糊
cout << '\n';
}
}
用于访问 print_mij () 中成员的表达式相当于编译器在知道最后一个维度时生成的表达式。
为了调用此函数,我们将矩阵作为普通指针传递:
int test()
{
int v[3][5] = {
{0,1,2,3,4}, {10,11,12,13,14}, {20,21,22,23,24}
};
print_m35(v);
print_mi5(v,3);
print_mij(&v[0][0],3,5);
}
请注意最后一次调用使用了 &v[0][0];v[0] 可以,因为它是等效的,但 v 会出现类型错误。这种微妙而混乱的代码最好隐藏起来。如果您必须直接处理多维数组,请考虑封装依赖它的代码。这样,您可能会减轻下一个接触代码的程序员的任务。为多维数组类型提供适当的下标运算符可以使大多数用户不必担心数组中数据的布局(§29.2.2、§40.5.2)。
标准vector(§31.4)不会遇到这些问题。
7.5 指针和const
C++ 提供了两种“const”的相关含义:
(1) constexpr: 在编译时计算(§2.2.3, §10.4)。
(2) const: 在其作用域内不可修改(§2.2.3)。
基本上,constexpr的作用是使能和确保编译时计算,而const的主要作用是指定接口中的不变性。本节主要关注第二个作用:接口规范。
许多对象的值在初始化之后不会改变:
(1) 与直接在代码中使用文字量相比,符号常量可使代码更易于维护。
(2) 许多指针通常被读取但从未被写入。
(3) 大多数函数参数都被读取但从未被写入。
为了表达初始化后的不变性概念,我们可以将 const 添加到对象的定义中。例如:
const int model = 90; // model is a const
const int v[] = { 1, 2, 3, 4 }; // v[i] is a const
const int x; // error : no initializer
由于声明为 const 的对象不能赋值,因此必须对其进行初始化。
将某个东西声明为 const 可以确保它的值在其作用域内不会改变:
void f()
{
model = 200; // error
v[2] = 3; // error
}
请注意,const 修改类型;它限制对象的使用方式(译注:使用时按照语法规则不能去改变它的值),而不是指定如何分配常量。例如:
void g(const X∗ p)
{
// can’t modify *p here
}
void h()
{
X val; // val can be modified here
g(&val);
// ...
}
使用指针时,涉及两个对象:指针本身和指向的对象。在指针声明前加上 const 会使对象(而不是指针)成为常量。要将指针本身(而不是指向的对象)声明为常量,我们使用声明符运算符 ∗const 而不是普通的 ∗。例如:
void f1(char∗ p)
{
char s[] = "Gorm";
const char∗ pc = s; // 指向常量的指针
pc[3] = 'g'; // error : pc 指向常量
pc = p; // OK
char ∗const cp = s; // 常量指针
cp[3] = 'a'; // OK
cp = p; // error : cp 是常量
const char ∗const cpc = s; // 指向常量的常量指针
cpc[3] = 'a'; // error : cpc 指向常量
cpc = p; // error : cpc 是常量
}
使指针成为常量的声明符运算符是 ∗const。没有 const∗ 声明符运算符,因此出现在 ∗ 之前的 const 被视为基类型的一部分。例如:
char ∗const cp; // const pointer to char
char const∗pc; //pointer to const char
const char∗ pc2; //pointer to const char
有些人发现从右到左阅读此类声明很有帮助,例如,“cp 是指向 char 的 const 指针”和“pc2 是指向 char const 的指针”。
通过一个指针访问时为常量的对象,在以其他方式访问时可能为变量。这对于函数参数特别有用。通过将指针参数声明为 const,函数将无法修改指向的对象。例如:
const char∗ strchr(const char∗ p, char c); // 求c在p中第一次出现位置
char∗ strchr(char∗ p, char c); //求c在p中第一次出现位置
第一个版本用于元素不能被修改的字符串,并返回一个不允许修改的 const 指针。第二个版本用于可变字符串。
您可以将非const变量的地址赋给指向常量的指针,因为这样做不会造成任何损害。但是,不能将常量的地址赋给不受限制的指针,因为这将允许更改对象的值。例如:
void f4()
{
int a = 1;
const int c = 2;
const int∗ p1 = &c; // OK
const int∗ p2 = &a; // OK
int∗ p3 = &c; // 错 :用const int*初始化int*
∗p3 = 7; // 试图改变c的值
}
通过显式类型转换(§16.2.9、§11.5)明确删除对指向 const 的指针的限制是可能的,但通常是不明智的(译注:实际上const只是编译时限制,如果显式取得const指针的地址,则可绕开const限制进行任何操作)。
7.6 指针和属主(Pointers and Ownership)
资源是在需要是必须获取并随后释放的东西(§5.2)。通过 new 获取并通过 delete 释放的内存(§11.2)以及通过 fopen() 打开并通过 fclose() 关闭的文件(§43.2)都是资源的例子,其中资源的最直接句柄是指针。这可能最令人困惑,因为指针很容易在程序中传递,并且类型系统中没有任何东西可以区分拥有资源的指针和不拥有资源的指针。考虑:
void confused(int∗ p)
{
// delete p?
}
int global {7};
void f()
{
X∗ pn = new int{7};
int i {7};
int q = &i;
confused(pn);
confused(q);
confused(&global);
}
如果 confused() 删除了 p,程序在后两次调用时将出现严重错误,因为我们可能无法删除未由 new 分配的对象(§11.2)。如果 confused() 不删除 p,程序内存就会泄漏(§11.2.1)。在这种情况下,显然 f() 必须管理它在自由存储中创建的对象的生命周期,但一般来说,在大型程序中跟踪需要删除的内容需要一种简单而一致的策略。
通常,最好立即将表示属主的指针放置在资源句柄类中,例如 vector,string 和 unique_ptr。这样,我们可以假设不在资源句柄内的每个指针都不是属主,并且不可删除。第 13 章更详细地讨论了资源管理。
7.7 引用(References)
指针使我们能够以低成本传递大量数据:我们无需复制数据,只需将其地址作为指针值传递即可。指针的类型决定了可以通过指针对数据执行哪些操作。使用指针与使用对象名称有以下几点不同:
(1) 我们使用不同的语法,例如,使用 ∗p 而不是 obj,使用 p−>m 而不是 obj.m。
(2) 我们可以让指针在不同时间指向不同的对象。
(3) 使用指针时必须比直接使用对象时更加小心:指针可能为 nullptr,或者指向的不是我们预期的对象。
这些差异可能令人烦恼;例如,一些程序员发现 f(&x) 与f(x)相比很丑陋。更糟糕的是,管理具有不同值的指针变量并保护代码免受 nullptr 的可能性的影响可能是一项沉重的负担。最后,当我们想要重载运算符(例如 + )时,我们希望编写 x+y 而不是 &x+&y。解决这些问题的语言机制称为引用。与指针一样,引用是对象的别名,通常用于保存对象的机器地址,并且与指针相比不会带来性能开销,但它与指针的不同之处在于:
(1) 访问引用的语法与访问对象名称的语法完全相同。
(2) 引用始终指向初始化时所指向的对象。
(3) 不存在“空引用”,我们可以假设引用指向对象(§7.7.4)。
引用是对象的替代名称,即别名。引用的主要用途是为函数指定参数和返回值,特别是为重载运算符(第 18 章)指定参数和返回值。例如:
template<class T>
class vector {
T∗ elem;
// ...
public:
T& operator[](int i) { return elem[i]; } // 返回指向元素的引用
const T& operator[](int i) const { return elem[i]; } //返回指向const元素的引用
void push_back(const T& a); // 用引用传递被加元素
// ...
};
void f(const vector<double>& v)
{
double d1 = v[1]; //将 v.operator[](1) 引用的 double 值复制到 d1 中
v[2] = 7; //将 7 放置在 v.operator[](2) 的结果所引用的双精度数中v.push_back(d1); //给 push_back() 一个对 d1 的引用,以便使用
}
通过引用传递函数参数的想法与高级编程语言一样古老(Fortran 的第一个版本就使用了它)。
为了体现左值/右值和const/非常量的区别,有三种类型的引用:
(1) 左值(lvalue)引用:引用我们想要更改其值的对象。
(2) const 引用:引用我们不想更改其值的对象(例如,常量)。
(3) 右值(rvalue)引用:引用我们在使用后不需要保留其值的对象(例如,临时值)。
它们统称为引用。前两个都称为左值引用。
7.7.1 左值引用
在类型名称中,符号 X& 表示“对 X 的引用”。它用于对左值的引用,因此通常称为左值引用。例如:
void f()
{
int var = 1;
int& r {var}; // r 和 var 现在指向同一个int
int x = r; // x 成 1
r = 2; // var 成 2
}
为了确保引用是某个事物的名称(即它绑定到某个对象),我们必须初始化该引用。例如:
int var = 1;
int& r1 {var}; // OK: r1已初始化
int& r2; // 错: 无初始化
extern int& r3; // OK: r3 在别处初始化
引用的初始化与赋值完全不同。尽管表面上如此,但没有任何运算符对引用进行操作。例如:
void g()
{
int var = 0;
int& rr {var};
++rr; //var 递增1
int∗ pp = &rr; // pp 指向 var
}
这里,++rr 不会增加引用 rr;相反,++ 应用于 rr 所引用的 int,即 var。因此,引用的值在初始化后不能更改;它始终指向它被初始化为表示的对象。要获取指向引用 rr 所表示的对象的指针,我们可以写 &rr。因此,我们不能拥有指向引用的指针。此外,我们不能定义引用数组。从这个意义上讲,引用不是对象。
引用的明显实现是作为(常量)指针,每次使用时都会解引用。这样考虑引用不会造成太大的伤害,只需记住引用不是可以像指针那样操纵的对象:
在某些情况下,编译器可以优化掉某个引用,这样运行时就不会有对象代表该引用。
当初始化器是左值(可以获取其地址的对象;参见 §6.4)时,引用的初始化很简单。“普通”T& 的初始化器必须是类型为 T 的左值。
const T& 的初始化器不需要是左值,甚至不需要是 T 类型。在这种情况下:
[1] 首先,如有必要,将隐式类型转换为 T(参见 §10.5)。
[2] 然后,将结果值放在类型 T 的临时变量中。
[3] 最后,将此临时变量用作初始化器的值。
考虑:
double& dr = 1; // 错 :需要左值
const double& cdr {1}; // OK
最后一个初始化的解释可以是:
double temp = double{1}; // 首先创建具有右值的临时对象
const double& cdr {temp}; // 然后用临时对象 y 初努化 cdr
为保存引用初始化器而创建的临时变量会一直存在,直到其引用范围结束。
对变量的引用和对常量的引用是有区别的,因为为变量引入临时变量很容易出错;对变量的赋值将变成对即将消失的临时变量的赋值。对常量的引用不存在这样的问题,对常量的引用通常作为函数参数很重要(§18.2.4)。
引用可用于指定函数参数,以便函数可以更改传递给它的对象的值。例如:
void increment(int& aa)
{
++aa;
}
void f()
{
int x = 1;
increment(x); // x = 2
}
参数传递的语义被定义为初始化的语义,因此在调用时,increment 的参数 aa 成为 x 的另一个名称。为了保持程序的可读性,通常最好避免使用修改其参数的函数。相反,您可以明确地从函数返回一个值:
int next(int p) { return p+1; }
void g()
{
int x = 1;
increment(x); // x = 2
x = next(x); // x = 3
}
递增 (x) 符号不会像 x=next(x) 那样向读者提供 x 的值正在被修改的线索。因此,只有当函数名称强烈暗示引用参数已被修改时,才应使用“普通”引用参数。
引用也可以用作返回类型。这主要用于定义可以在赋值左侧和右侧使用的函数。Map 就是一个很好的例子。例如:
template<class K, class V>
class Map { // a simple map class
public:
V& operator[](const K& v); // 返回键 v 对应的值
pair<K,V>∗ begin() { return &elem[0]; }
pair<K,V>∗ end() { return &elem[0]+elem.size(); }
private:
vector<pair<K,V>> elem; // {key,value} pairs
};
标准库Map(§4.4.3、§31.4.3)通常实现为红黑树,但为了避免分散实现细节,我仅展示基于线性搜索的实现以查找键匹配:
template<class K, class V>
V& Map<K,V>::operator[](const K& k)
{
for (auto& x : elem)
if (k == x.first)
return x.second;
elem.push_back({k,V{}}); // add pair at end (§4.4.2)
return elem.back().second; // 返回新元素的(默认)值
}
我通过引用传递键参数 k,因为它可能属于复制成本高昂的类型。同样,我通过引用返回值,因为它也可能属于复制成本高昂的类型。我对 k 使用 const 引用,因为我不想修改它,而且我可能想使用文字或临时对象作为参数。我通过非const引用返回结果,因为 Map的用户可能非常想修改求得值。例如:
int main() // 基于输入统计每一个单词出现的次数
{
Map<string,int> buf;
for (string s; cin>>s;) ++buf[s];
for (const auto& x : buf)
cout << x.first << ": " << x.second << '\n';
}
每次,输入循环都会从标准输入流 cin 中读出一个单词到字符串 s (§4.3.2) 中,然后更新与之关联的计数器。最后,打印出输入中不同单词的结果表,每个单词都有其出现的次数。例如,给定输入
aa bb bb aa aa bb aa aa
该程序将产生
aa: 5
bb: 3
范围 for 循环适用于此,因为 Map 定义了 begin() 和 end(),就像标准库 map 所做的那样。
7.7.2 右值引用
拥有多种引用的基本思想是为了支持对象的不同用途:
(1) 非 const 左值引用指的是引用的用户可以写入的对象。
(2) const 左值引用指的是常量,从引用的用户的角度来看,它是不可变的。
(3) 右值引用指的是临时对象,引用的用户可以(并且通常会)修改它,假设该对象永远不会再使用。
我们想知道引用是否指向临时对象,因为如果确实如此,我们有时可以将昂贵的复制操作转变为廉价的移动操作(§3.3.2,§17.1,§17.5.2)。如果我们知道源不会再次使用,那么由指向潜在大量信息的小描述符表示的对象(例如字符串或列表)可以简单而廉价地移动。经典示例是返回值,其中编译器知道返回的局部变量永远不会再使用(§3.3.2)。
右值引用可以绑定到右值,但不能绑定到左值。在这方面,右值引用与左值引用完全相反。例如:
string var {"Cambridge"};
string f();
string& r1 {var}; //左值引用,将r1绑定到var (一个左值)
string& r2 {f()}; //左值引用,错 : f() 是一个右值
string& r3 {"Princeton"}; //左值引用, 错 : 不能绑定到临时 y
string&& rr1 {f()}; // 右值引用, 善: 绑定rr1 到右值 (一个临时 y)
string&& rr2 {var}; //右值引用, 错 : var是左值
string&& rr3 {"Oxford"}; // rr3 指向存储"Oxford"的临时y
const string cr1& {"Harvard"}; // OK: 创建临时 y 并绑定到 cr1
&& 声明符表示“右值引用”。我们不使用 const 右值引用;使用右值引用的大多数好处都涉及写入它所引用的对象。const 左值引用和右值引用都可以绑定到右值。但是,目的将根本不同:
(1) 我们使用右值引用来实现“破坏性读取”,以优化原本需要复制的内容。
(2) 我们使用 const 左值引用来防止修改参数。
右值引用所引用的对象与左值引用或普通变量名所引用的对象完全一样。例如:
string f(string&& s)
{
if (s.size())
s[0] = toupper(s[0]);
return s;
}
有时,程序员知道某个对象不会再被使用,但编译器却不知道。考虑一下:
template<class T>
void swap(T& a, T& b) // "旧风格的swap"
{
T tmp {a}; // 现在我们有两个a的副本
a = b; //现在我们有两个b的副本
b = tmp; // 现在我们有两个tmp (aka a) 的副本
}
如果 T 是一种复制元素成本很高的类型,例如字符串和向量,则 swap() 将成为一项成本高昂的操作。请注意一件奇怪的事情:我们根本不想要任何副本;我们只想移动 a,b 和 tmp 的值。我们可以告诉编译器:
template<class T>
void swap(T& a, T& b) // "完美swap" (几乎是)
{
T tmp {static_cast<T&&>(a)}; // 初始化可以写入a
//译注:(tmp的值==a)
a = static_cast<T&&>(b); // 赋值可以写入b
b = static_cast<T&&>(tmp); // 赋值可以写入 tmp
}
static_cast<T&&>(x) 的结果值是 x 的 T&& 类型的右值。针对右值优化的操作现在可以将其优化用于 x。特别是,如果类型 T 具有移动构造函数(§3.3.2、§17.5.2)或移动赋值,则将使用它。考虑向量:
template<class T> class vector {
// ...
vector(const vector& r); // 复制构造函数 (复制 r 的表示)
vector(vector&& r); //移动构造函数(从r"偷走"表示)
};
vector<string> s;
vector<string> s2 {s}; // s是左值, 使用复制构造函数
vector<string> s3 {s+"tail"); // s+"tail"右值因此选移动构造函数
swap() 中使用 static_cast 有点冗长,而且容易出现错误,因此标准库提供了一个 move() 函数:move(x) 表示 static_cast<X&&>(x),其中 X 是 x 的类型。鉴于此,我们可以稍微整理一下 swap() 的定义:
template<class T>
void swap(T& a, T& b) // "perfect swap" (almost)
{
T tmp {move(a)}; // move from a
a = move(b); // move from b
b = move(tmp); // move from tmp
}
与原始 swap() 相比,最新版本不需要进行任何复制;它将尽可能使用移动操作。
由于 move(x) 不会移动 x(它只是生成一个对 x 的右值引用),所以如果 move() 被称为 rval() 会更好,但现在 move() 已经使用了很多年。
我认为这个swap() 是“近乎完美的”,因为它只会交换左值。考虑:
void f(vector<int>& v)
{
swap(v,vector<int>{1,2,3}); //用1,2,3替撒v的元素
// ...
}
用某种默认值替换容器的内容并不罕见,但这个特定的 swap() 无法做到这一点。一种解决方案是通过两个重载来增强它:
template<class T> void swap(T&& a, T& b);
template<class T> void swap(T& a, T&& b)
我们的示例将由 swap() 的最后一个版本处理。标准库采用了不同的方法,通过为向量、字符串等定义 shrink_to_fit() 和 clear()(§31.3.3)来处理 swap() 的右值参数的最常见情况:
void f(string& s, vector<int>& v)
{
s.shrink_to_fit(); // 使 s.capacity()==s.size()
swap(s,string{s}); //使s.capacity()==s.size()
v.clear(); //置空 v
swap(v.vector<int>{}); //置空 v
v ={}; //置空 v
}
右值引用也可用于提供完美转发(§23.5.2.1、§35.5.1)。
所有标准库容器都提供移动构造函数和移动赋值(§31.3.2)。此外,其插入新元素的操作(例如 insert() 和 push_back())都有采用右值引用的版本。
7.7.3 指向引用的引用
如果您获取对某个类型的引用的引用,您将获得对该类型的引用,而不是某种对引用类型的特殊引用。但哪种引用?左值引用还是右值引用?考虑:
using rr_i = int&&;
using lr_i = int&;
using rr_rr_i = rr_i&&; // ‘‘int && &&’’ is an int&&
using lr_rr_i = rr_i&; // ‘‘int && &’’ is an int&
using rr_lr_i = lr_i&&; // ‘‘int & &&’’ is an int&
using lr_lr_i = lr_i&; // ‘‘int & &’’ is an int&
换句话说,左值引用总是获胜。这是有道理的:我们对类型所做的任何事情都无法改变涉及左值的引的事实。这有时被称为引用崩溃。
下面的这种语法是不允许的:
int && & r = i;
对引用的引用只能作为别名(§3.4.5、§6.5)或模板类型参数(§23.5.2.1)的结果而发生。
7.7.4 指针和引用
指针和引用是两种在程序中引用不同位置的对象而无需复制的机制。我们可以用图形展示这种相似性:
各有其长处和弱点。
如果需要更改要引用的对象,请使用指针。可以使用 =、+=、−=、++ 和 −− 来更改指针变量的值(§11.1.4)。例如:
void fp(char∗ p)
{
while (∗p)
cout << ++∗p;
}
void fr(char& r)
{
while (r)
cout << ++r; // oops: 增加引用的字符,而不是引用
// 几乎无限循环!
}
void fr2(char& r)
{
char∗ p = &r; //获得一个引用所指对象的指针
while (∗p)
cout << ++∗p;
}
反之,如果您想确保名称始终指向同一个对象,请使用引用。例如:
template<class T> class Proxy { // Proxy 指的是用来初始化T& m的对象 ;
public:
Proxy(T& mm) :m{mm} {}
// ...
};
template<class T> class Handle { // Handle 指的是其当前对象 T∗ m;;
public:
Proxy(T∗ mm) :m{mm} {}
void rebind(T∗ mm) { m = mm; }
// ...
};
如果要在引用某个对象的东西上使用用户定义(重载)运算符(§18.1),请使用引用:
Matrix operator+(const Matrix&, const Matrix&); // OK
Matrix operator−(const Matrix∗, const Matrix∗); // 错:非用户定义类型参数
Matrix y, z;
// ...
Matrix x = y+z; // OK
Matrix x2 = &y−&z; // 错误且丑陋
无法为诸如指针之类的一对内置类型(重新)定义运算符(§18.2.3)。
如果你想要一个指向某个对象的集合,那么你必须使用指针:
int x, y;
string& a1[] = {x, y}; // 错: 引用数组
string∗ a2[] = {&x, &y}; // OK
vector<string&> s1 = {x , y}; //错: 引用向量
vector<string∗> s2 = {&x, &y}; // OK
一旦我们摆脱了 C++ 不给程序员选择权的情况,我们就进入了美学领域。在理想情况下,我们会做出选择,以尽量减少出错的可能性,特别是最大限度地提高代码的可读性。
如果您需要“无值”的概念,指针会提供 nullptr。没有等价的“空引用”,因此如果您需要“无值”,使用指针可能是最合适的。例如:
void fp(X∗ p)
{
if (p == nullptr) {
// 无值
}
else {
// use *p
}
}
void fr(X& r) // 常见风格
{
// 假设r有效并用之
}
如果您确实想要,您可以构造并检查特定类型的“空引用”:
void fr2(X& r)
{
if (&r == &nullX) { // 或可以是 r== nullX
// 无值
}
else {
// use r
}
}
显然,您需要适当定义 nullX。这种风格不符合惯用语,我不推荐它。程序员可以假设引用是有效的。可以创建一个无效的引用,但您必须特意这样做。例如:
char∗ ident(char ∗ p) { return p; }
char& r {∗ident(nullptr)}; // 无效代码
此代码不是有效的 C++ 代码。即使您当前的实现(译注:编译器)无法捕获此问题,也不要编写此类代码。
7.8 建议
[1] 保持指针的使用简单明了;§7.4.1。
[2] 避免非平凡的指针算法;§7.4。
[3] 注意不要超出数组的界限;§7.4.1。
[4] 避免使用多维数组;而是定义合适的容器;§7.4.2。
[5] 使用 nullptr 而不是 0 或 NULL;§7.2.2。
[6] 使用容器(例如,vector,array 和 valarray)而不是内置(C 风格)数组;§7.4.1。
[7] 使用字符串而不是以零结尾的 char 数组;§7.4。
[8] 使用原字符串作为反斜杠复杂用法的字符串文字量;§7.3.2.1。
[9] 优先使用 const 引用参数而不是普通引用参数;§7.7.3。
[10] 仅使用右值引用进行转发和移动语义;§7.7.2。
[11] 将表示所有权的指针保留在句柄类中;§7.6。
[12] 除底层代码外,避免使用 void∗;§7.2.1。
[13] 使用 const 指针和 const 引用来表达接口中的不变性;§7.5。
[14] 优先使用引用而非指针作为参数,除非“无对象”是合理的选择;
§7.7.4。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup