复合类型:引用和指针
1. 复合数据类型简介
C++ 支持第二组数据类型:复合数据类型是根据其他现有数据类型定义的类型。复合数据类型具有其他属性和行为,使它们可用于解决某些类型的问题。
每种数据类型要么是基本类型,要么是复合类型。C++ 语言标准明确定义了每种类型属于哪个类别。
C++ 支持以下复合类型:
-
函数 (Functions)
-
C 风格的数组 (C-style Arrays)
-
指针类型 (Pointer types):
- 对象指针 (Pointer to object)
- 函数指针 (Pointer to function)
-
成员指针类型 (Pointer to member types):
- 数据成员指针 (Pointer to data member)
- 成员函数指针 (Pointer to member function)
-
引用类型 (Reference types):
- 左值引用 (L-value references)
- 右值引用 (R-value references)
-
枚举类型 (Enumerated types):
- 非作用域枚举 (Unscoped enumerations)
- 作用域枚举 (Scoped enumerations)
-
类类型 (Class types):
- 结构体 (Structs)
- 类 (Classes)
- 联合体 (Unions)
2. 值类别(左值和右值)
为了帮助确定表达式的计算方式以及它们的使用位置,C++ 中的所有表达式都有两个属性:类型和值类别。表达式的类型等效于计算表达式生成的值、对象或函数的类型。例如:
1 | int main() |
编译器可以使用表达式的类型来确定表达式在给定上下文中是否有效。例如:
1 |
|
在上面的程序中,print(int)
函数需要一个 int
参数。但是,我们传入的表达式的类型(字符串文本 “foo”
)不匹配,并且找不到转换。因此,会导致编译错误。
在 C++11 之前,只有两个可能的值类别:左值和右值。在 C++11 中,为了支持一种名为“移动语义 (move semantics)”的新特性,新增了三个值类别 (value categories):
-
glvalue (Generalized L-value):广义左值,表示有明确内存位置的值,包括传统的左值 (L-value) 和可求值的右值 (xvalue)。
-
prvalue (Pure R-value):纯右值,表示临时值或不具备内存地址的值,通常用于初始化对象或作为表达式的操作数。
-
xvalue (Expiring Value):将亡值,表示资源即将被销毁但仍可以移动的值,通常出现在返回右值引用的表达式中,例如
std::move
。
左值(发音为“ell-value”,是“left value”或“locator value”的缩写,有时写为“l-value”)是一种计算结果为可识别对象或函数(或位字段)的表达式。术语“标识”由 C++ 标准使用,但定义不明确。具有标识的实体(如对象或函数)可以与其他类似实体区分开来(通常通过比较实体的地址)。具有标识的实体可以通过标识符、引用或指针进行访问,并且通常比单个表达式或语句的生命周期长。
1 | int main() |
在上面的程序中,表达式 x
是一个左值表达式,因为它的计算结果为变量 x
(具有标识符)。
自从语言中引入常量以来,左值有两种子类型:可修改的左值是其值可以修改的左值。不可修改的左值是其值无法修改的左值(因为左值是 const 或 constexpr)。
1 | int main() |
右值(发音为“arr-value”,是“right value”的缩写,有时写为 r-value
)是不是左值的表达式。右值表达式的计算结果为一个值。常见的右值包括文本(C 样式字符串文本除外,它们是左值)以及按值返回的函数和运算符的返回值。右值是不可识别的(意味着它们必须立即使用),并且仅存在于使用它们的表达式的范围内。
1 | int return5() |
左值表达式的计算结果为可识别对象。右值表达式的计算结果为一个值。
除非另有指定,否则运算符希望其操作数为右值。例如,二元operator+
期望其操作数为右值:
1 |
|
文本 1
和 2
都是右值表达式。operator+
将很乐意使用它们来返回右值表达式 3
。
为什么 x = 5
有效但 5 = x
无效:赋值运算要求其左操作数是可修改的左值表达式。后一个赋值 (5 = x
) 失败,因为左操作数表达式 5
是右值,而不是可修改的左值。
1 | int main() |
由于赋值操作期望正确的操作数是右值表达式,因此你可能想知道为什么以下操作会起作用:
1 | int main() |
在需要右值但提供了左值的情况下,左值将进行左值到右值的转换,以便可以在此类上下文中使用。这基本上意味着对左值进行计算以生成右值。
左值将隐式转换为右值。这意味着左值可以在需要右值的任何位置使用。另一方面,右值不会隐式转换为左值。
标识左值和右值表达式的经验法则:
-
左值表达式是计算结果为函数或可识别对象(包括变量)的表达式,这些表达式在表达式末尾之后仍然存在。
-
右值表达式是计算结果为值的表达式,包括文本和临时对象,这些表达式不会在表达式末尾之后保留。
最后,我们可以编写一个程序,让编译器告诉我们某物是什么类型的表达式。下面的代码演示了一种确定表达式是左值还是右值的方法:
1 |
|
输出:
1 | 5 is an rvalue |
3. 左值引用
在 C++ 中,引用是现有对象的别名。定义引用后,对该引用的任何操作都将应用于被引用的对象。这意味着我们可以使用引用来读取或修改被引用的对象。引用本质上与被引用的对象相同。现代 C++ 包含两种类型的引用:左值引用和右值引用
左值引用(通常简称为“引用”,因为在 C++11 之前只有一种类型的引用)充当现有左值(例如变量)的别名。就像对象的类型决定了它可以保存的值类型一样,引用的类型决定了它可以引用的对象类型。左值引用类型可以通过在类型说明符中使用单个 &
符号来标识:
1 | // regular types |
例如,int&
是对 int
对象的左值引用的类型,const int&
是对 const int
对象的左值引用的类型。指定引用的类型(例如 int&
)称为引用类型。可以被引用的类型(例如 int
)称为被引用类型。
左值引用有两种:
-
对非常量的左值引用通常简称为“左值引用”,但也可以称为对非常量的左值引用或非常量左值引用(因为它不是使用
const
关键字定义的)。 -
对常量的左值引用通常称为对常量的左值引用或 常量左值引用。
我们可以对左值引用类型执行的操作之一是创建一个左值引用变量。左值引用变量是充当对左值(通常是另一个变量)的引用的变量。要创建左值引用变量,我们只需定义一个具有左值引用类型的变量:
1 |
|
输出:
1 | 5 |
从编译器的角度来看,无论与符号是“附加”到类型名称(int& ref
)还是变量名称(int & ref
)并不重要,你选择哪个是风格问题。现代 C++ 程序员往往更喜欢将 &
符号附加到类型,因为它可以更清楚地表明引用是类型信息的一部分,而不是标识符的一部分。
在上面的例子中,我们展示了我们可以使用引用来读取被引用的对象的值。我们还可以使用非常量引用来修改被引用的对象的值:
1 |
|
该程序打印:
1 | 55 |
与常量类似,所有引用都必须被初始化。引用的初始化使用一种称为**引用初始化(reference initialization)**的初始化形式。
1 | int main() |
当一个引用被一个对象(或函数)初始化时,我们说它被绑定到那个对象(或函数)。绑定此类引用的过程称为引用绑定。被引用的对象(或函数)有时称为引用对象。非常量左值引用只能绑定到可修改的左值。
1 | int main() |
不允许对 void
的左值引用。在大多数情况下,引用只会绑定到其类型与引用类型匹配的对象(此规则有一些例外情况,我们将在讨论继承时讨论)。如果尝试将引用绑定到与其引用类型不匹配的对象,编译器将尝试将对象隐式转换为引用类型,然后将引用绑定到该类型。由于转换的结果是右值,并且非常量左值引用无法绑定到右值,因此尝试将非常量左值引用绑定到与其引用类型不匹配的对象将导致编译错误。
1 | int main() |
一旦初始化,在 C++ 中的引用就不能重新绑定(reseated),这意味着它不能更改为引用另一个对象。请考虑以下程序:
1 |
|
这打印了:
1 | 6 |
在表达式中计算引用时,它将解析为它所引用的对象。所以 ref = y
不会将 ref
更改为现在引用 y
。相反,由于 ref
是 x
的别名,因此表达式的计算结果就像它被编写为 x = y
一样 – 并且由于 y
的计算结果为值 6
,因此 x
被分配值 6
。
引用变量遵循与普通变量相同的范围和持续时间规则:
1 |
|
除一个例外,引用的生命周期与其所引用对象的生命周期是独立的。换句话说,以下两种情况都成立:
-
引用可以在它所引用的对象之前被销毁。
-
被引用的对象可以在引用之前被销毁。
当引用在被引用的对象之前销毁时,被引用的对象不受影响。以下程序演示了这一点:
1 |
|
以上打印:
1 | 5 |
当 ref
死亡时,变量 x
照常进行。
当被引用的对象在引用之前被销毁时,该引用将指向一个不再存在的对象。这样的引用称为悬空引用(dangling reference)。访问悬空引用会导致未定义行为(undefined behavior)。
4. 对常量的左值引用
通过在声明左值引用时使用 const
关键字,我们告诉左值引用将其引用的对象视为常量。此类引用称为对常量的左值引用。对常量的左值引用可以绑定到不可修改的左值:
1 | int main() |
由于对常量的左值引用将其引用的对象视为常量,因此它们可用于访问但不能修改所引用的值:
1 |
|
对常量的左值引用也可以绑定到可修改的左值。在这种情况下,当通过引用访问时,被引用的对象将被视为常量(即使底层对象是非常量的):
1 |
|
对常量的左值引用也可以绑定到右值:
1 |
|
发生这种情况时,将创建一个临时对象并使用右值进行初始化,并将对常量的引用绑定到该临时对象。对常量的左值引用甚至可以绑定到不同类型的值,只要这些值可以隐式转换为引用类型即可:
1 |
|
在情况 1 中,将创建一个 double
类型的临时对象,并使用 int
值 5
进行初始化。然后 const double& r1
被绑定到那个临时的 double
对象。在情况 2 中,将创建一个 int
类型的临时对象,并使用 char
值 a
进行初始化。然后 const int& r2
绑定到那个临时的 int
对象。还要注意,当我们打印 r2
时,它打印为 int
而不是 char
。这是因为 r2
是对 int
对象(创建的临时 int
)的引用,而不是对 char c
的引用。
临时对象通常在创建它们的表达式结束时销毁。给定语句 const int& ref { 5 };
,请考虑为保存右值 5
而创建的临时对象在初始化 ref
的表达式末尾销毁时会发生什么情况。引用 ref
将悬空(引用已销毁的对象),并且当我们尝试访问 ref
时,我们会得到未定义行为。为了避免在这种情况下出现悬空引用,C++ 有一个特殊规则:当常量的左值引用直接绑定到临时对象时,临时对象的生存期将延长以匹配引用的生存期。
1 |
|
5. 按左值引用传递
我们之前看到的许多函数都是按值传递的,其中传递给函数的参数被复制到函数的参数中:
1 |
|
在上面的程序中,当调用 printValue(x)
时,x
(2
) 的值被复制到参数 y
中。然后,在函数结束时,对象 y
被销毁。
某些对象的赋值成本很高,我们通常希望避免对复制成本高昂的对象进行不必要的复制。避免在调用函数时对参数进行昂贵的复制的一种方法是使用引用传递,而不是值传递。在使用引用传递时,我们将函数参数声明为引用类型(或常量引用类型),而不是普通类型。当函数被调用时,每个引用参数会绑定到相应的实参。由于引用充当了实参的别名,因此不会对实参进行复制。示例:
1 |
|
当 printValue()
使用引用 y
时,它将访问实际的参数 x
(而不是 x
的副本)。
以下程序演示了值参数与实参是独立的对象,而引用参数则被视为与实参相同:
1 |
|
输出:
1 | The address of x is: 0x7ffd16574de0 |
对非常量值的引用只能绑定到可修改的左值(本质上是非常量),因此这意味着按引用传递仅适用于可修改左值的参数。示例:
1 |
|
6. 按常量左值引用传递
与对非常量的引用(只能绑定到可修改的左值)不同,对常量的引用可以绑定到可修改的左值、不可修改的左值和右值。因此,如果我们创建一个引用参数常量,那么它将能够绑定到任何类型的参数:
1 |
|
通过常量引用传递提供与通过非常量引用传递相同的主要好处(避免复制参数),同时还保证函数无法更改被引用的值。在大多数情况下,我们不希望我们的函数修改参数的值。因此优先使用常量引用传递。
使用按引用传递时,请确保参数的类型与引用的类型匹配,否则将导致意外的(并且可能代价高昂的)转换。因为如果类型不匹配,还是会创建副本进行类型转换。
具有多个参数的函数可以确定每个参数是按值传递还是按引用单独传递。示例:
1 |
|
以下内容通常按值传递(因为它效率更高):
-
基本类型和枚举类型的复制成本较低,因此它们通常按值传递。
-
枚举类型(未限定作用域和限定作用域的枚举)。
-
视图和切片(例如
std::string_view
、std::span
)。 -
模拟引用或(非拥有)指针的类型(例如迭代器、
std::reference_wrapper
)。 -
具有值语义的、复制成本低的类类型(例如,元素为基本类型的
std::pair
,std::optional
,std::expected
)。
按引用传递应用于以下情况:
-
类类型的复制成本可能很高(有时非常昂贵),因此它们通常由 const 引用传递。
-
需要在函数中修改的参数。
-
不能复制的类型(例如
std::ostream
)。 -
复制具有所有权含义且我们希望避免的类型(例如
std::unique_ptr
,std::shared_ptr
)。 -
具有虚函数或可能被继承的类型。
对于函数参数,在大多数情况下,首选 std::string_view
而不是 const std::string&
7. 指针简介
虽然变量使用的内存地址默认情况下不会显示给我们,但我们可以访问这些信息。取地址运算符(&)返回其操作数的内存地址。示例:
1 |
|
输出:
1 | 5 |
&
符号往往会引起混淆,因为它根据上下文具有不同的含义:
-
当跟随类型名称时,
&
表示左值引用:int& ref
-
在表达式中以一元运算符形式使用时,& 是取地址运算符(address-of operator):
std::cout << &x
-
在表达式中以二元运算符形式使用时,& 是按位与运算符(Bitwise AND operator):
std::cout << x & y
我们可以对地址做的最有用的事情是访问存储在该地址的值。取消引用运算符 (*)(有时也称为间接运算符)将给定内存地址处的值作为左值返回:
1 |
|
输出:
1 | 5 |
指针是一个对象,它保存一个内存地址(通常是另一个变量的地址)作为其值。这允许我们存储其他对象的地址以供以后使用。指定指针的类型(例如 int*
)称为指针类型。与使用与 &
字符声明引用类型非常相似,指针类型使用星号 (*) 声明:
1 | int; // a normal int |
要创建指针变量,我们只需定义一个具有指针类型的变量:
1 | int main() |
请注意,此星号是指针声明语法的一部分,而不是取消引用运算符的使用。
尽管通常不应在一行中声明多个变量,但如果这样做,则必须在每个变量中包含星号。示例:
1 | int* ptr1, ptr2; // incorrect: ptr1 is a pointer to an int, but ptr2 is just a plain int! |
与普通变量一样,指针默认情况下也不会被初始化。未初始化的指针有时被称为野指针 (wild pointer)。野指针包含一个垃圾地址,解引用 (dereferencing) 野指针将导致未定义行为 (undefined behavior)。因此,您应始终将指针初始化为一个已知的值。示例:
1 | int main() |
由于指针包含地址,因此当我们初始化或为指针赋值时,该值必须是一个地址。通常,指针用于保存另一个变量的地址(我们可以使用取地址运算符 (&) 获得)。一旦我们有一个包含另一个对象地址的指针,我们就可以使用解引用运算符 (*) 来访问该地址的值。例如:
1 |
|
输出:
1 | 5 |
就像引用的类型必须与所引用的对象类型匹配一样,指针的类型也必须与所指向的对象的类型匹配:
1 | int main() |
不允许使用文本值初始化指针(后面会讨论一个例外):
1 | int* ptr{ 5 }; // not okay |
我们可以通过两种不同的方式使用带指针的赋值:
-
更改指针所指向的内容(通过为指针分配新地址),示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr initialized to point at x
std::cout << *ptr << '\n'; // print the value at the address being pointed to (x's address)
int y{ 6 };
ptr = &y; // // change ptr to point at y
std::cout << *ptr << '\n'; // print the value at the address being pointed to (y's address)
return 0;
}输出:
1
25
6 -
更改所指向的值(通过为取消引用的指针分配新值),示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
int x{ 5 };
int* ptr{ &x }; // initialize ptr with address of variable x
std::cout << x << '\n'; // print x's value
std::cout << *ptr << '\n'; // print the value at the address that ptr is holding (x's address)
*ptr = 6; // The object at the address held by ptr (x) assigned value 6 (note that ptr is dereferenced here)
std::cout << x << '\n';
std::cout << *ptr << '\n'; // print the value at the address that ptr is holding (x's address)
return 0;
}输出:
1
2
3
45
5
6
6
指针和左值引用的行为类似。请考虑以下程序:
1 |
|
输出:
1 | 555 |
在上面的程序中,我们创建一个值为 5
的普通变量 x
,然后创建一个左值引用和一个指向 x
的指针。接下来,我们使用左值引用将值从 5
更改为 6
,并表明我们可以通过所有三种方法访问更新后的值。最后,我们使用解引用指针将值从 6
更改为 7
,并再次表明我们可以通过所有三种方法访问更新后的值。
因此,指针和引用都提供了一种间接访问另一个对象的方法。主要区别在于,对于指针,我们需要显式地获取要指向的地址,并且我们必须显式地取消引用指针才能获取值。对于引用,取地址和取消引用地址是隐式发生的。
指针和引用之间还有一些值得注意的区别:
-
引用必须初始化,而指针不需要初始化(但应该初始化)。
-
引用不是对象,而指针是对象。
-
引用不能重新绑定(不能更改为引用其他对象),而指针可以更改它指向的内容。
-
引用必须始终绑定到一个对象,而指针可以指向空 (null)。
-
引用是“安全”的(除了悬空引用),而指针本质上是危险的。
值得注意的是,取地址运算符 (&) 不会将其操作数的地址作为文本返回(因为 C++ 不支持地址文本)。相反,它返回指向作数的指针(其值是作数的地址)。换句话说,给定变量 int x
, &x
返回一个包含 x
地址的 int*
。我们可以在下面的示例中看到这一点:
1 |
|
输出:
1 | int |
指针的大小取决于编译可执行文件的体系结构 – 32 位可执行文件使用 32 位内存地址 – 因此,32 位计算机上的指针为 32 位 (4 字节)。对于 64 位可执行文件,指针将为 64 位(8 字节)。请注意,无论所指向的对象的大小如何,都是如此:
1 |
|
与悬空引用非常相似,悬空指针是保存不再有效的对象地址的指针。取消引用悬空指针将导致未定义的行为,因为你正在尝试访问不再有效的对象。下面是一个创建悬空指针的示例:
1 |
|
上面的程序可能会打印:
1 | 5 |
但事实并非如此,因为 ptr
所指向的对象超出了范围,并在内部块的末尾被销毁,使 ptr
悬空。
8. 空指针
除了内存地址之外,指针还可以保存一个额外的值:空值 (null value)。空值 (null) 是一种特殊的值,表示某个东西没有值。当指针保存空值时,意味着该指针没有指向任何东西。这样的指针被称为空指针 (null pointer)。创建 null 指针的最简单方法是使用值初始化:
1 | int main() |
由于我们可以通过赋值来更改指针的指向,因此一个最初设置为 null 的指针可以在之后更改为指向一个有效的对象:
1 |
|
与关键字 true
和 false
表示布尔文本值非常相似,nullptr 关键字表示 null 指针文本。我们可以使用 nullptr
显式初始化指针或为指针分配 null 值。示例:
1 | int main() |
与取消引用悬空指针导致未定义的行为非常相似,取消引用 null 指针也会导致未定义行为。在大多数情况下,它会使你的应用程序崩溃。示例:
1 |
|
就像我们可以使用条件来测试布尔值是 true
还是 false
一样,我们也可以使用条件来测试指针是否具有值 nullptr
:
1 |
|
上述程序打印:
1 | ptr is non-null |
指针也会隐式转换为布尔值:null 指针转换为布尔值 false
,非 null 指针转换为布尔值 true
。这允许我们跳过显式测试 nullptr
,只使用到布尔值的隐式转换来测试指针是否为 null 指针。以下程序等效于前一个程序:
1 |
|
条件语句只能用于区分 null 指针和非 null 指针。没有方便的方法可以确定非 null 指针是指向有效对象还是悬空 (指向无效对象)
我们可以通过使用条件来确保指针在尝试取消引用之前为非 null,从而轻松避免取消引用 null 指针:
1 | // Assume ptr is some pointer that may or may not be a null pointer |
因为没有办法检测指针是否悬空,所以我们首先需要避免在我们的程序中出现任何悬空指针。为此,我们确保将任何未指向有效对象的指针都设置为 nullptr
。
销毁对象时,指向已销毁对象的任何指针都将悬空(它们不会自动设置为
nullptr
)。你应该注意这些情况并确保这些指针随后设置为nullptr
。
在较旧的代码中,您可能会看到使用了另外两个文本值,而不是 nullptr
。第一个是文本 0
。在指针的上下文中,文本 0
被专门定义为表示 null 值,并且是唯一一次可以将整型文本分配给指针。示例:
1 | int main() |
此外,还有一个名为 NULL
的预处理器宏(在 header 中定义)。此宏继承自 C,通常用于指示 null 指针。示例:
1 |
|
在现代 C++ 中,应避免使用 0
和 NULL
(请改用 nullptr
)。
指针具有能够更改它们所指向的内容以及指向 null 的额外功能。但是,这些指针功能本身也是危险的:null 指针有被取消引用的风险,并且更改指针指向的内容的能力可以更轻松地创建悬空指针。由于引用不能绑定到 null,因此我们不必担心 null 引用。由于引用必须在创建时绑定到有效对象,然后才能重新放置,因此更难创建悬空引用。因为它们更安全,所以应该优先使用引用而不是指针,除非需要指针提供的其他功能。
9. 指针和常量
指向常量的指针 (pointer to a const value)是一个(非 const 的)指针,它指向一个常量值。要声明指向常量值的指针,请在指针的数据类型之前使用 const
关键字:
1 | int main() |
但是,由于指向常量的指针本身不是常量 (它只指向常量值),因此我们可以通过为指针分配新地址来更改指针指向的内容:
1 | int main() |
就像指向常量的引用 (reference to const) 一样,指向常量的指针 (pointer to const) 也可以指向非常量 (non-const) 变量。指向常量的指针 会将被指向的值视为常量,无论该地址处的对象最初是否被定义为常量 (const) 都不影响:
1 | int main() |
我们也可以使指针本身变为常量。常量指针 (const pointer) 是一种在初始化后其地址无法更改的指针。要声明常量指针,请在指针声明中星号后使用 const
关键字:
1 | int main() |
就像普通的常量一样,常量指针必须在定义时初始化,并且该值不能通过赋值来更改:
1 | int main() |
但是,由于指向的值是非常量,因此可以通过取消引用常量指针来更改指向的值:
1 | int main() |
最后,可以通过在类型之前和星号之后使用 const
关键字来声明指向常量值的常量指针:
1 | int main() |
指向常量值的常量指针不能更改其地址,也不能通过指针更改它指向的值。它只能被取消引用以获得它所指向的值。
10. 按地址传递
下面是一个示例程序,它显示了按值和引用传递的 std::string
对象:
1 |
|
当我们按值传递参数 str
时,函数参数 val
会收到参数的副本。由于参数是参数的副本,因此对 val
的任何更改都是对副本进行的,而不是对原始参数进行的。当我们通过引用传递参数 str
时,引用参数 ref
会绑定到实际的参数。这样可以避免复制参数。因为我们的引用参数是常量,所以不允许更改 ref
。但是如果 ref
是非常量,我们对 ref
所做的任何更改都会改变 str
。在这两种情况下,调用方都提供了要作为参数传递给函数调用的实际对象 (str
)。
C++ 提供了第三种将值传递给函数的方法,称为按地址传递。使用按地址传递时,调用方不是提供对象作为参数,而是提供对象的地址(通过指针)。此指针(保存对象的地址)被复制到被调用函数的指针参数中(该函数现在也保存对象的地址)。然后,该函数可以取消引用该指针以访问传递了地址的对象。这是上述程序的一个版本,它添加了一个按地址传递的变体:
1 |
|
首先,因为我们希望 printByAddress()
函数使用按地址传递,所以我们将函数参数设置为名为 ptr
的指针。由于 printByAddress()
将以只读方式使用 ptr
,因此 ptr
是指向常量值的指针。
1 | void printByAddress(const std::string* ptr) |
在 printByAddress()
函数中,我们取消引用 ptr
参数以访问所指向的对象的值。其次,当函数被调用时,我们不能只传入 str
对象——我们需要传入 str
的地址。最简单的方法是使用取地址运算符 (&) 来获取一个包含 str
地址的指针:
1 | printByAddress(&str); // use address-of operator (&) to get pointer holding address of str |
执行此调用时,&str
将创建一个包含 str
地址的指针。然后,此地址将作为函数调用的一部分复制到函数参数 ptr
中。由于 ptr
现在保存 str
的地址,因此当函数取消引用 ptr
时,它将获取 str
的值,该函数会将该值打印到控制台。
虽然我们在上面的例子中使用了取地址运算符来获取 str
的地址,但如果我们已经有一个保存 str
地址的指针变量,我们可以改用它:
1 | int main() |
当我们通过地址传递对象时,该函数会收到所传递对象的地址,它可以通过解引用来访问该地址。因为这是正在传递的实际参数对象的地址(不是对象的副本),所以如果函数参数是指向非常量的指针,则函数可以通过指针参数修改参数:
1 |
|
输出:
1 | x = 5 |
如你所见,参数被修改了,即使在 changeValue()
完成运行后,此修改仍然存在。如果函数不应该修改传入的对象,则应将函数参数设置为指向常量的指针:
1 | void changeValue(const int* ptr) // note: ptr is now a pointer to a const |
注意:
-
使用
const
关键字将指针函数参数声明为 常量指针 (const pointer) 的价值不大(因为它对调用者没有影响,主要只是作为文档,表明指针不会改变)。 -
使用
const
关键字将 指向常量的指针 (pointer-to-const) 与 指向非常量的指针 (pointer-to-non-const) 区分开来是非常重要的(因为调用者需要知道函数是否可能更改传入参数的值)。
现在考虑这个程序:
1 |
|
当这个程序运行时,它将打印值 5
,然后很可能会崩溃。在对 print(myPtr)
的调用中,myPtr
是一个 null 指针,因此函数参数 ptr
也将是一个 null 指针。在函数体中取消引用此 null 指针时,将导致未定义行为。
按地址传递参数时,在取消引用值之前,应注意确保指针不是 null 指针。一种方法是使用条件语句:
1 |
|
在大多数情况下,相反的做法会更有效:测试函数参数是否为null作为前提条件,并立即处理否定情况:
1 |
|
如果永远不应该将 null 指针传递给函数,则可以使用 assert
:
1 |
|
按地址传递更常见的用途之一是允许函数接受 “可选择” 参数。这比描述更容易通过示例来说明:
1 |
|
此示例打印:
1 | Your ID number is not known. |
在此程序中,printIDNumber()
函数有一个按地址传递的参数,默认为 nullptr
。在 main()
中,我们调用了这个函数两次。第一次调用时,我们不知道用户的 ID,因此我们调用 printIDNumber()
而不带参数。id
参数默认为 nullptr
,函数打印 Your ID number is not known.
。对于第二次调用,我们现在有一个有效的 ID,因此我们调用 printIDNumber(&userid)
。id
参数接收 userid
的地址,因此该函数打印 Your ID number is 34.
。
但是,在许多情况下,函数重载是实现相同结果的更好选择:
1 |
|
这有很多好处:我们不再需要担心 null 取消引用,并且我们可以将文本值或其他右值作为参数传入。
当我们将一个地址传递给函数时,该地址会从实参复制到指针形参中(这没有问题,因为复制一个地址是非常快的)。现在来看以下程序:
1 |
|
该程序打印:
1 | ptr is non-null |
如你所见,更改指针形参中保存的地址对实参中保存的地址没有任何影响(ptr
仍然指向 x
)。当调用函数 nullify()
时,ptr2
接收到传入地址的副本(在本例中,是 ptr
保存的地址,即 x
的地址)。当函数更改 ptr2
的指向时,这仅影响 ptr2
中保存的副本。如果我们想允许一个函数改变指针形参指向的内容可以通过引用。就像我们可以通过引用传递普通变量一样,我们也可以通过引用传递指针。下面是与上述相同的程序,其中 ptr2
更改为对地址的引用:
1 |
|
该程序打印:
1 | ptr is non-null |
由于 refptr
现在是对指针的引用,因此当 ptr
作为参数传递时,refptr
将绑定到 ptr
。这意味着对 refptr
的任何更改都会对 ptr
进行。
为什么不再首选使用 0
或 NULL
:
文本 0
可以解释为整数文本或 null 指针文本。在某些情况下,我们打算使用哪一个可能是模棱两可的 —— 在某些情况下,编译器可能会假设我们指的是一个 —— 这对我们的程序的行为产生了意想不到的后果。预处理器宏 NULL
的定义不是由语言标准定义的。它可以定义为 0
, 0L
, ((void*)0)
或完全不同的内容。使用 0
或 NULL
时,这可能会导致问题:
1 |
|
这会打印:
1 | print(int*): non-null |
将整数值 0
作为参数传递时,编译器将首选 print(int)
而不是 print(int*)
。当我们打算使用 null 指针参数调用 print(int*)
时,这可能会导致意外结果。在 NULL
定义为值 0
的情况下,print(NULL)
还将调用 print(int)
,而不是像你对 null 指针文字所期望的那样调用 print(int*)
。如果 NULL
未定义为 0
,则可能会导致其他行为,例如调用 print(int*)
或编译错误。使用 nullptr
可以消除这种歧义(它将始终调用 print(int*)
),因为 nullptr
将仅匹配指针类型。
由于 nullptr
可以与函数重载中的整数值区分开来,因此它必须具有不同的类型。那么 nullptr
是什么类型呢?答案是 nullptr
的类型为 std::nullptr_t
(在头文件中定义)。std::nullptr_t
只能保存一个值:nullptr
!虽然这看起来有点愚蠢,但它在一种情况下很有用。如果我们想编写一个只接受 nullptr
字面量参数的函数,我们可以将参数设为 std::nullptr_t
。示例:
1 |
|
11. 按引用返回和按地址返回
按引用返回返回一个绑定到所返回对象的引用,从而避免复制返回值。要通过引用返回,我们只需将函数的返回值定义为引用类型:
1 | std::string& returnByReference(); // returns a reference to an existing std::string (cheap) |
示例:
1 |
|
该程序打印:
1 | This program is named Calculator |
因为 getProgramName()
返回一个常量引用,所以当执行 return s_programName
行时,getProgramName()
将返回对 s_programName
的常量引用(从而避免制作副本)。然后,调用方可以使用该常量引用来访问打印的 s_programName
的值。
使用按引用返回有一个主要警告:程序员必须确保被引用的对象比返回引用的函数长寿。否则,返回的引用将悬空(引用已销毁的对象),并且使用该引用将导致未定义行为。在上面的程序中,由于 s_programName
具有静态持续时间,因此 s_programName
将一直存在,直到程序结束。当 main()
访问返回的引用时,它实际上是在访问 s_programName
,这很好,因为s_programName
直到以后才会被销毁。所以要避免返回对非常量静态局部变量的引用。返回对常量全局变量的常量引用有时也是封装对全局变量的访问的一种方式。
如果一个函数返回一个引用,并且该引用用于初始化或赋值给一个非引用变量,那么返回的值将被复制(就像它是按值返回的一样)。示例:
1 |
|
在上述示例中,getNextId()
返回的是一个引用,但 id1
和 id2
是非引用变量。在这种情况下,返回的引用的值会被复制到普通变量中。因此,这个程序会打印:
1 | 12 |
在很多情况下,通过引用返回对象是有意义的,我们现在可以展示一个有用的示例。如果参数通过引用传递到函数中,则通过引用返回该参数是安全的。这是有道理的:为了将参数传递给函数,该参数必须存在于调用者的范围内。当被调用的函数返回时,该对象必须仍存在于调用者的范围内。以下是此类函数的一个简单示例:
1 |
|
该程序打印:
1 | Hello |
当常量引用参数的参数是右值时,通过常量引用返回该参数仍然是可以的。这是因为在创建右值的完整表达式结束之前,右值不会被销毁。示例:
1 |
|
当通过非常量引用将参数传递给函数时,函数可以使用该引用来修改参数的值。同样,当从函数返回非常量引用时,调用方可以使用该引用来修改返回的值。下面是一个说明性示例:
1 |
|
在上面的程序中,max(a, b)
以 a
和 b
作为参数调用 max()
函数。引用参数 x
绑定到参数 a
,引用参数 y
绑定到参数 b
。然后,该函数确定 x
(5
) 和 y
(6
) 中哪个更大。在本例中,即 y
,因此函数将 y
(仍绑定到 b
)返回给调用方。然后,调用方将值 7
分配给此返回的引用。因此,表达式 max(a, b) = 7
有效地解析为 b = 7
。这将打印出:
1 | 57 |
按地址返回的工作方式与按引用返回的工作方式几乎相同,只是返回的是指向对象的指针,而不是对对象的引用。按地址返回与按引用返回具有相同的主要警告 – 由地址返回的对象必须超过返回地址的函数的范围,否则调用者将收到一个悬空指针。与按引用返回相比,按地址返回的主要优点是,如果没有要返回的有效对象,我们可以让函数返回 nullptr
。例如,假设我们有一个要搜索的学生列表。如果我们在列表中找到我们正在寻找的学生,我们可以返回一个指向表示匹配学生的对象的指针。如果我们没有找到任何匹配的 students,我们可以返回 nullptr
以指示未找到匹配的 student 对象。按地址返回的主要缺点是调用者必须记住在取消引用返回值之前进行 nullptr
检查,否则可能会发生 null 指针取消引用,并导致未定义行为。由于这种危险,除非需要返回 “no object” 的能力,否则应该优先于按地址返回返回。
12. 输入和输出参数
在大多数情况下,函数参数仅用于接收来自调用者的输入。仅用于接收调用者输入的参数有时被称为输入参数 (in parameters)。示例:
1 |
|
通过 非常量引用 (non-const reference) 或 指向非常量的指针 (pointer-to-non-const) 传递的函数参数允许函数修改作为参数传入的对象的值。这为函数提供了一种返回数据给调用者的方式,在某些情况下,使用返回值可能不足以满足需求。仅用于将信息返回给调用者的函数参数称为 输出参数 (out parameter)。示例:
1 |
|
输出参数虽然功能强大,但也有一些缺点。首先,调用者必须实例化(并初始化)对象并将其作为参数传递,即使它不打算使用它们。这些对象必须能够被赋值,这意味着它们不能被设为常量。其次,由于调用方必须传入对象,因此这些值不能用作临时值,也不能在单个表达式中轻松使用。以下示例显示了这两个缺点:
1 |
|
同时使用输出参数并不能明显表明参数被修改,因此我们需要尽量避免输出参数。
在少数情况下,函数会在覆盖输出参数的值之前实际使用该值。这种参数被称为 输入输出参数 (in-out parameter)。输入输出参数与输出参数的工作方式相同,并且面临相同的挑战。
13. 使用指针、引用和 const 的类型推导
除了删除 const 之外,类型推导还会删除引用:
1 |
|
在上面的例子中,变量 ref
使用了类型推导。尽管函数 getRef()
返回 std::string&
,但引用限定符被删除,因此 ref
的类型被推导出为 std::string
。如果你想让推导的类型成为一个引用,你可以在定义点重新应用引用:
1 |
|
顶级 const 是适用于对象本身的 const 限定符。例如:
1 | const int x; // this const applies to x, so it is top-level |
相反,低级 const 是 const 限定符,适用于被引用或指向的对象:
1 | const int& ref; // this const applies to the object being referenced, so it is low-level |
对 const 值的引用始终是低级 const。指针可以具有 top-level、low-level 或两种类型的 const:
1 | const int* const ptr; // the left const is low-level, the right const is top-level |
当我们说类型推导会丢弃 const 限定符时,它只会丢弃顶级 const。不会删除低级 const。
如果初始化器是常量引用 (reference to const),首先会丢弃该引用(如果适用,则会重新应用引用),然后会丢弃结果中的任何顶层 const。示例:
1 |
|
在上面的例子中,由于 getConstRef()
返回一个 const std::string&
,所以首先删除引用,留下一个 const std::string
。这个 const 现在是顶级 const,所以它也被删除了,只剩下推导的类型为 std::string
。
我们可以重新应用引用或 const:
1 |
|
注意:constexpr 不是表达式类型的一部分,因此它不会由
auto
推导。
与引用不同,类型推导不会丢弃指针:
1 |
|
我们还可以将星号与指针类型推导 (auto*
) 结合使用,以更清楚地表明推导的类型是一个指针:
1 |
|
auto 和 auto的区别:*
当我们将 auto
与指针类型初始化器一起使用时,为 auto
推导的类型包括指针。因此,对于上面的 ptr1
,替换 auto
的类型是 std::string*
。当我们将 auto*
与指针类型初始化器一起使用时,为 auto 推导的类型不包括指针 – 在推导类型后,指针会重新应用。因此,对于上面的 ptr2
,替换 auto
的类型是 std::string
,然后重新应用指针。在大多数情况下,实际效果是相同的(在上面的例子中,ptr1
和 ptr2
都推导出 st::string*
)。但是,在实践中,auto
和 auto*
之间存在一些差异。首先,auto*
必须解析为指针初始化器,否则将导致编译错误:
1 |
|
类型推导和 const 指针:
由于指针不会被丢弃,因此我们不必担心这一点。但是对于指针,我们需要考虑 const 指针和指向 const 情况的指针,并且我们还有 auto
与 auto*
。就像引用一样,在指针类型推导期间,只丢弃顶级 const。让我们从一个简单的案例开始:
1 |
|
当我们使用 auto const
或 const auto
时,我们是在说,“将推导的指针设为 const 指针”。因此,在 ptr1
和 ptr2
的情况下,推导的类型是 std::string*
,然后应用 const,使最终类型 std::string* const
。这类似于 const int
和 int const
的含义相同。但是,当我们使用 auto*
时,const 限定符的顺序很重要。左侧的 const
表示“使推导的指针成为指向 const 的指针”,而右侧的 const
表示“将推导的指针类型设为 const 指针”。因此,ptr3
最终成为指向 const 的指针,而 ptr4
最终成为 const 指针。现在让我们看一个示例,其中初始化器是指向 const 的 const 指针。
1 |
|
ptr1
和 ptr2
的情况很简单。顶级 const (指针本身上的 const) 被删除。不会删除所指向的对象上的低级 const。因此,在这两种情况下,最终类型都是 const std::string*
。
ptr3
和 ptr4
的情况也很简单。顶级 const 已删除,但我们正在重新应用它。不会删除所指向的对象上的低级 const。所以在这两种情况下,最终类型都是 const std::string* const
。
ptr5
和 ptr6
的情况类似于我们在前面的例子中展示的情况。在这两种情况下,顶级 const 都会被删除。对于 ptr5
,auto* const
重新应用顶级 const,因此最终类型为 const std::string* const
。对于 ptr6
,const auto*
将 const 应用于所指向的类型(在本例中已经是 const),因此最终类型是 const std::string*
。
在 ptr7
的情况下,我们将 const 限定符应用两次,这是不允许的,并且会导致编译错误。
14. std::optional
在前面我们讨论了函数遇到无法合理处理自身的错误的情况,典型的做法是让函数检测错误,然后将错误传回给调用者,以某种适合程序的方式进行处理。前面我们介绍了将错误返回调用者的两种不同方法:
-
让返回 void 的函数改为返回 bool (指示成功或失败)。
-
让一个返回值的函数返回一个哨兵值(即一个特殊值,该值不会出现在函数可能返回的其他值集合中)以指示错误。
第二种方法有许多缺点:
-
程序员必须知道函数使用哪个哨兵值来指示错误(并且对于每个使用此方法返回错误的函数,该值可能有所不同)。
-
同一函数的不同版本可能使用不同的哨兵值。
-
此方法不适用于所有可能的哨兵值都是有效返回值的函数。
有一种好的方法是,我们可以放弃返回单个值,而是返回两个值:一个(bool
类型)指示函数是否成功,另一个(所需返回类型)保存实际返回值(如果函数成功)或不确定值(如果函数失败)。C++17 引入了 std::optional
,这是一个实现可选值的类模板类型。也就是说,std::optional<T>
的值可以是 T
类型,也可以不是。我们可以使用它来实现上面的所说的内容:
1 |
|
输出:
1 | Result 1: 4 |
使用 std::optional
非常简单。我们可以构造一个 std::optional<T>
,也可以带或不带值:
1 | std::optional<int> o1 { 5 }; // initialize with a value |
要查看 std::optional
是否有值,我们可以选择以下选项之一:
1 | if (o1.has_value()) // call has_value() to check if o1 has a value |
要从 std::optional
中获取值,我们可以选择以下选项之一:
1 | std::cout << *o1; // dereference to get value stored in o1 (undefined behavior if o1 does not have a value) |
返回 std::optional
有以下优点:
-
使用
std::optional
可以有效地表明一个函数可能会返回一个值,也可能不会返回值。 -
我们不需要记住哪个值被用作哨兵值来表示错误。
-
std::optional
的使用语法方便且直观。
然而,返回 std::optional
也有一些缺点:
-
我们需要确保在获取值之前,
std::optional
中确实包含一个值。如果解引用一个不包含值的std::optional
,将会导致未定义的行为。 -
std::optional
并不提供有关函数为何失败的额外信息。
前面我们讨论了如何使用按地址传递来允许函数接受“可选”参数:
1 |
|
std::optional
是函数接受可选参数(仅用作参数内)的另一种方式:
1 |
|