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
2
3
4
5
6
7
int main()
{
auto v1 { 12 / 4 }; // int / int => int
auto v2 { 12.0 / 4 }; // double / int => double

return 0;
}

编译器可以使用表达式的类型来确定表达式在给定上下文中是否有效。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

void print(int x)
{
std::cout << x << '\n';
}

int main()
{
print("foo"); // error: print() was expecting an int argument, we tried to pass in a string literal

return 0;
}

在上面的程序中,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
2
3
4
5
6
7
int main()
{
int x { 5 };
int y { x }; // x is an lvalue expression

return 0;
}

在上面的程序中,表达式 x 是一个左值表达式,因为它的计算结果为变量 x(具有标识符)。

自从语言中引入常量以来,左值有两种子类型:可修改的左值是其值可以修改的左值。不可修改的左值是其值无法修改的左值(因为左值是 const 或 constexpr)。

1
2
3
4
5
6
7
8
9
10
int main()
{
int x{};
const double d{};

int y { x }; // x is a modifiable lvalue expression
const double e { d }; // d is a non-modifiable lvalue expression

return 0;
}

右值(发音为“arr-value”,是“right value”的缩写,有时写为 r-value)是不是左值的表达式。右值表达式的计算结果为一个值。常见的右值包括文本(C 样式字符串文本除外,它们是左值)以及按值返回的函数和运算符的返回值。右值是不可识别的(意味着它们必须立即使用),并且仅存在于使用它们的表达式的范围内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int return5()
{
return 5;
}

int main()
{
int x{ 5 }; // 5 is an rvalue expression
const double d{ 1.2 }; // 1.2 is an rvalue expression

int y { x }; // x is a modifiable lvalue expression
const double e { d }; // d is a non-modifiable lvalue expression
int z { return5() }; // return5() is an rvalue expression (since the result is returned by value)

int w { x + 1 }; // x + 1 is an rvalue expression
int q { static_cast<int>(d) }; // the result of static casting d to an int is an rvalue expression

return 0;
}

左值表达式的计算结果为可识别对象。右值表达式的计算结果为一个值。

除非另有指定,否则运算符希望其操作数为右值。例如,二元operator+ 期望其操作数为右值:

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
std::cout << 1 + 2; // 1 and 2 are rvalues, operator+ returns an rvalue

return 0;
}

文本 1 和 2 都是右值表达式。operator+ 将很乐意使用它们来返回右值表达式 3

为什么 x = 5 有效但 5 = x 无效:赋值运算要求其左操作数是可修改的左值表达式。后一个赋值 (5 = x) 失败,因为左操作数表达式 5 是右值,而不是可修改的左值。

1
2
3
4
5
6
7
8
9
10
int main()
{
int x{};

// Assignment requires the left operand to be a modifiable lvalue expression and the right operand to be an rvalue expression
x = 5; // valid: x is a modifiable lvalue expression and 5 is an rvalue expression
5 = x; // error: 5 is an rvalue expression and x is a modifiable lvalue expression

return 0;
}

由于赋值操作期望正确的操作数是右值表达式,因此你可能想知道为什么以下操作会起作用:

1
2
3
4
5
6
7
8
9
int main()
{
int x{ 1 };
int y{ 2 };

x = y; // y is not an rvalue, but this is legal

return 0;
}

在需要右值但提供了左值的情况下,左值将进行左值到右值的转换,以便可以在此类上下文中使用。这基本上意味着对左值进行计算以生成右值。

左值将隐式转换为右值。这意味着左值可以在需要右值的任何位置使用。另一方面,右值不会隐式转换为左值。

标识左值和右值表达式的经验法则:

  • 左值表达式是计算结果为函数或可识别对象(包括变量)的表达式,这些表达式在表达式末尾之后仍然存在。

  • 右值表达式是计算结果为值的表达式,包括文本和临时对象,这些表达式不会在表达式末尾之后保留。

最后,我们可以编写一个程序,让编译器告诉我们某物是什么类型的表达式。下面的代码演示了一种确定表达式是左值还是右值的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <string>

// T& is an lvalue reference, so this overload will be preferred for lvalues
template <typename T>
constexpr bool is_lvalue(T&)
{
return true;
}

// T&& is an rvalue reference, so this overload will be preferred for rvalues
template <typename T>
constexpr bool is_lvalue(T&&)
{
return false;
}

// A helper macro (#expr prints whatever is passed in for expr as text)
#define PRINTVCAT(expr) { std::cout << #expr << " is an " << (is_lvalue(expr) ? "lvalue\n" : "rvalue\n"); }

int getint() { return 5; }

int main()
{
PRINTVCAT(5); // rvalue
PRINTVCAT(getint()); // rvalue
int x { 5 };
PRINTVCAT(x); // lvalue
PRINTVCAT(std::string {"Hello"}); // rvalue
PRINTVCAT("Hello"); // lvalue
PRINTVCAT(++x); // lvalue
PRINTVCAT(x++); // rvalue
}

输出:

1
2
3
4
5
6
7
5 is an rvalue
getint() is an rvalue
x is an lvalue
std::string {"Hello"} is an rvalue
"Hello" is an lvalue
++x is an lvalue
x++ is an rvalue

3. 左值引用

在 C++ 中,引用是现有对象的别名。定义引用后,对该引用的任何操作都将应用于被引用的对象。这意味着我们可以使用引用来读取或修改被引用的对象。引用本质上与被引用的对象相同。现代 C++ 包含两种类型的引用:左值引用和右值引用

左值引用(通常简称为“引用”,因为在 C++11 之前只有一种类型的引用)充当现有左值(例如变量)的别名。就像对象的类型决定了它可以保存的值类型一样,引用的类型决定了它可以引用的对象类型。左值引用类型可以通过在类型说明符中使用单个 & 符号来标识:

1
2
3
4
5
// regular types
int // a normal int type (not an reference)
int& // an lvalue reference to an int object
double& // an lvalue reference to a double object
const int& // an lvalue reference to a const int object

例如,int& 是对 int 对象的左值引用的类型,const int& 是对 const int 对象的左值引用的类型。指定引用的类型(例如 int&)称为引用类型。可以被引用的类型(例如 int)称为被引用类型

左值引用有两种:

  • 对非常量的左值引用通常简称为“左值引用”,但也可以称为对非常量的左值引用非常量左值引用(因为它不是使用 const 关键字定义的)。

  • 对常量的左值引用通常称为对常量的左值引用或 常量左值引用

我们可以对左值引用类型执行的操作之一是创建一个左值引用变量。左值引用变量是充当对左值(通常是另一个变量)的引用的变量。要创建左值引用变量,我们只需定义一个具有左值引用类型的变量:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main()
{
int x { 5 }; // x is a normal integer variable
int& ref { x }; // ref is an lvalue reference variable that can now be used as an alias for variable x

std::cout << x << '\n'; // print the value of x (5)
std::cout << ref << '\n'; // print the value of x via ref (5)

return 0;
}

输出:

1
2
5
5

从编译器的角度来看,无论与符号是“附加”到类型名称(int& ref)还是变量名称(int & ref)并不重要,你选择哪个是风格问题。现代 C++ 程序员往往更喜欢将 & 符号附加到类型,因为它可以更清楚地表明引用是类型信息的一部分,而不是标识符的一部分。

在上面的例子中,我们展示了我们可以使用引用来读取被引用的对象的值。我们还可以使用非常量引用来修改被引用的对象的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

int main()
{
int x { 5 }; // normal integer variable
int& ref { x }; // ref is now an alias for variable x

std::cout << x << ref << '\n'; // print 55

x = 6; // x now has value 6

std::cout << x << ref << '\n'; // prints 66

ref = 7; // the object being referenced (x) now has value 7

std::cout << x << ref << '\n'; // prints 77

return 0;
}

该程序打印:

1
2
3
55
66
77

与常量类似,所有引用都必须被初始化。引用的初始化使用一种称为**引用初始化(reference initialization)**的初始化形式。

1
2
3
4
5
6
7
8
9
int main()
{
int& invalidRef; // error: references must be initialized

int x { 5 };
int& ref { x }; // okay: reference to int is bound to int variable

return 0;
}

当一个引用被一个对象(或函数)初始化时,我们说它被绑定到那个对象(或函数)。绑定此类引用的过程称为引用绑定。被引用的对象(或函数)有时称为引用对象。非常量左值引用只能绑定到可修改的左值。

1
2
3
4
5
6
7
8
9
10
11
int main()
{
int x { 5 };
int& ref { x }; // okay: non-const lvalue reference bound to a modifiable lvalue

const int y { 5 };
int& invalidRef { y }; // invalid: non-const lvalue reference can't bind to a non-modifiable lvalue
int& invalidRef2 { 0 }; // invalid: non-const lvalue reference can't bind to an rvalue

return 0;
}

不允许对 void 的左值引用。在大多数情况下,引用只会绑定到其类型与引用类型匹配的对象(此规则有一些例外情况,我们将在讨论继承时讨论)。如果尝试将引用绑定到与其引用类型不匹配的对象,编译器将尝试将对象隐式转换为引用类型,然后将引用绑定到该类型。由于转换的结果是右值,并且非常量左值引用无法绑定到右值,因此尝试将非常量左值引用绑定到与其引用类型不匹配的对象将导致编译错误。

1
2
3
4
5
6
7
8
9
10
11
int main()
{
int x { 5 };
int& ref { x }; // okay: referenced type (int) matches type of initializer

double d { 6.0 };
int& invalidRef { d }; // invalid: conversion of double to int is narrowing conversion, disallowed by list initialization
double& invalidRef2 { x }; // invalid: non-const lvalue reference can't bind to rvalue (result of converting x to double)

return 0;
}

一旦初始化,在 C++ 中的引用就不能重新绑定(reseated),这意味着它不能更改为引用另一个对象。请考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

int main()
{
int x { 5 };
int y { 6 };

int& ref { x }; // ref is now an alias for x

ref = y; // assigns 6 (the value of y) to x (the object being referenced by ref)
// The above line does NOT change ref into a reference to variable y!

std::cout << x << '\n'; // user is expecting this to print 5

return 0;
}

这打印了:

1
6

在表达式中计算引用时,它将解析为它所引用的对象。所以 ref = y 不会将 ref 更改为现在引用 y。相反,由于 ref 是 x 的别名,因此表达式的计算结果就像它被编写为 x = y 一样 – 并且由于 y 的计算结果为值 6,因此 x 被分配值 6

引用变量遵循与普通变量相同的范围和持续时间规则:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
int x { 5 }; // normal integer
int& ref { x }; // reference to variable value

return 0;
} // x and ref die here

除一个例外,引用的生命周期与其所引用对象的生命周期是独立的。换句话说,以下两种情况都成立:

  • 引用可以在它所引用的对象之前被销毁。

  • 被引用的对象可以在引用之前被销毁。

当引用在被引用的对象之前销毁时,被引用的对象不受影响。以下程序演示了这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main()
{
int x { 5 };

{
int& ref { x }; // ref is a reference to x
std::cout << ref << '\n'; // prints value of ref (5)
} // ref is destroyed here -- x is unaware of this

std::cout << x << '\n'; // prints value of x (5)

return 0;
} // x destroyed here

以上打印:

1
2
5
5

当 ref 死亡时,变量 x 照常进行。

当被引用的对象在引用之前被销毁时,该引用将指向一个不再存在的对象。这样的引用称为悬空引用(dangling reference)。访问悬空引用会导致未定义行为(undefined behavior)

4. 对常量的左值引用

通过在声明左值引用时使用 const 关键字,我们告诉左值引用将其引用的对象视为常量。此类引用称为对常量的左值引用。对常量的左值引用可以绑定到不可修改的左值:

1
2
3
4
5
6
7
int main()
{
const int x { 5 }; // x is a non-modifiable lvalue
const int& ref { x }; // okay: ref is a an lvalue reference to a const value

return 0;
}

由于对常量的左值引用将其引用的对象视为常量,因此它们可用于访问但不能修改所引用的值:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main()
{
const int x { 5 }; // x is a non-modifiable lvalue
const int& ref { x }; // okay: ref is a an lvalue reference to a const value

std::cout << ref << '\n'; // okay: we can access the const object
ref = 6; // error: we can not modify an object through a const reference

return 0;
}

对常量的左值引用也可以绑定到可修改的左值。在这种情况下,当通过引用访问时,被引用的对象将被视为常量(即使底层对象是非常量的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main()
{
int x { 5 }; // x is a modifiable lvalue
const int& ref { x }; // okay: we can bind a const reference to a modifiable lvalue

std::cout << ref << '\n'; // okay: we can access the object through our const reference
ref = 7; // error: we can not modify an object through a const reference

x = 6; // okay: x is a modifiable lvalue, we can still modify it through the original identifier

return 0;
}

对常量的左值引用也可以绑定到右值:

1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main()
{
const int& ref { 5 }; // okay: 5 is an rvalue

std::cout << ref << '\n'; // prints 5

return 0;
}

发生这种情况时,将创建一个临时对象并使用右值进行初始化,并将对常量的引用绑定到该临时对象。对常量的左值引用甚至可以绑定到不同类型的值,只要这些值可以隐式转换为引用类型即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

int main()
{
// case 1
const double& r1 { 5 }; // temporary double initialized with value 5, r1 binds to temporary

std::cout << r1 << '\n'; // prints 5

// case 2
char c { 'a' };
const int& r2 { c }; // temporary int initialized with value 'a', r2 binds to temporary

std::cout << r2 << '\n'; // prints 97 (since r2 is a reference to int)

return 0;
}

在情况 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
2
3
4
5
6
7
8
9
10
#include <iostream>

int main()
{
const int& ref { 5 }; // The temporary object holding value 5 has its lifetime extended to match ref

std::cout << ref << '\n'; // Therefore, we can safely use it here

return 0;
} // Both ref and the temporary object die here

5. 按左值引用传递

我们之前看到的许多函数都是按值传递的,其中传递给函数的参数被复制到函数的参数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

void printValue(int y)
{
std::cout << y << '\n';
} // y is destroyed here

int main()
{
int x { 2 };

printValue(x); // x is passed by value (copied) into parameter y (inexpensive)

return 0;
}

在上面的程序中,当调用 printValue(x) 时,x2) 的值被复制到参数 y 中。然后,在函数结束时,对象 y 被销毁。

某些对象的赋值成本很高,我们通常希望避免对复制成本高昂的对象进行不必要的复制。避免在调用函数时对参数进行昂贵的复制的一种方法是使用引用传递,而不是值传递。在使用引用传递时,我们将函数参数声明为引用类型(或常量引用类型),而不是普通类型。当函数被调用时,每个引用参数会绑定到相应的实参。由于引用充当了实参的别名,因此不会对实参进行复制。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <string>

void printValue(std::string& y) // type changed to std::string&
{
std::cout << y << '\n';
} // y is destroyed here

int main()
{
std::string x { "Hello, world!" };

printValue(x); // x is now passed by reference into reference parameter y (inexpensive)

return 0;
}

printValue() 使用引用 y 时,它将访问实际的参数 x(而不是 x 的副本)。

以下程序演示了值参数与实参是独立的对象,而引用参数则被视为与实参相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

void printAddresses(int val, int& ref)
{
std::cout << "The address of the value parameter is: " << &val << '\n';
std::cout << "The address of the reference parameter is: " << &ref << '\n';
}

int main()
{
int x { 5 };
std::cout << "The address of x is: " << &x << '\n';
printAddresses(x, x);

return 0;
}

输出:

1
2
3
The address of x is: 0x7ffd16574de0
The address of the value parameter is: 0x7ffd16574de4
The address of the reference parameter is: 0x7ffd16574de0

对非常量值的引用只能绑定到可修改的左值(本质上是非常量),因此这意味着按引用传递仅适用于可修改左值的参数。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void printValue(int& y) // y only accepts modifiable lvalues
{
std::cout << y << '\n';
}

int main()
{
int x { 5 };
printValue(x); // ok: x is a modifiable lvalue

const int z { 5 };
printValue(z); // error: z is a non-modifiable lvalue

printValue(5); // error: 5 is an rvalue

return 0;
}

6. 按常量左值引用传递

与对非常量的引用(只能绑定到可修改的左值)不同,对常量的引用可以绑定到可修改的左值、不可修改的左值和右值。因此,如果我们创建一个引用参数常量,那么它将能够绑定到任何类型的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void printRef(const int& y) // y is a const reference
{
std::cout << y << '\n';
}

int main()
{
int x { 5 };
printRef(x); // ok: x is a modifiable lvalue, y binds to x

const int z { 5 };
printRef(z); // ok: z is a non-modifiable lvalue, y binds to z

printRef(5); // ok: 5 is rvalue literal, y binds to temporary int object

return 0;
}

通过常量引用传递提供与通过非常量引用传递相同的主要好处(避免复制参数),同时还保证函数无法更改被引用的值。在大多数情况下,我们不希望我们的函数修改参数的值。因此优先使用常量引用传递。

使用按引用传递时,请确保参数的类型与引用的类型匹配,否则将导致意外的(并且可能代价高昂的)转换。因为如果类型不匹配,还是会创建副本进行类型转换。

具有多个参数的函数可以确定每个参数是按值传递还是按引用单独传递。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <string>

void foo(int a, int& b, const std::string& c)
{
}

int main()
{
int x { 5 };
const std::string s { "Hello, world!" };

foo(5, x, s);

return 0;
}

以下内容通常按值传递(因为它效率更高):

  • 基本类型和枚举类型的复制成本较低,因此它们通常按值传递。

  • 枚举类型(未限定作用域和限定作用域的枚举)。

  • 视图和切片(例如 std::string_viewstd::span)。

  • 模拟引用或(非拥有)指针的类型(例如迭代器、std::reference_wrapper)。

  • 具有值语义的、复制成本低的类类型(例如,元素为基本类型的 std::pairstd::optionalstd::expected)。

按引用传递应用于以下情况:

  • 类类型的复制成本可能很高(有时非常昂贵),因此它们通常由 const 引用传递。

  • 需要在函数中修改的参数。

  • 不能复制的类型(例如 std::ostream)。

  • 复制具有所有权含义且我们希望避免的类型(例如 std::unique_ptrstd::shared_ptr)。

  • 具有虚函数或可能被继承的类型。

对于函数参数,在大多数情况下,首选 std::string_view 而不是 const std::string&

7. 指针简介

虽然变量使用的内存地址默认情况下不会显示给我们,但我们可以访问这些信息。取地址运算符(&)返回其操作数的内存地址。示例:

1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main()
{
int x{ 5 };
std::cout << x << '\n'; // print the value of variable x
std::cout << &x << '\n'; // print the memory address of variable x

return 0;
}

输出:

1
2
5
0027FEA0

&符号往往会引起混淆,因为它根据上下文具有不同的含义:

  • 当跟随类型名称时,&表示左值引用:int& ref

  • 在表达式中以一元运算符形式使用时,& 是取地址运算符(address-of operator):std::cout << &x

  • 在表达式中以二元运算符形式使用时,& 是按位与运算符(Bitwise AND operator):std::cout << x & y

我们可以对地址做的最有用的事情是访问存储在该地址的值。取消引用运算符 (*)(有时也称为间接运算符)将给定内存地址处的值作为左值返回:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main()
{
int x{ 5 };
std::cout << x << '\n'; // print the value of variable x
std::cout << &x << '\n'; // print the memory address of variable x

std::cout << *(&x) << '\n'; // print the value at the memory address of variable x (parentheses not required, but make it easier to read)

return 0;
}

输出:

1
2
3
5
0027FEA0
5

指针是一个对象,它保存一个内存地址(通常是另一个变量的地址)作为其值。这允许我们存储其他对象的地址以供以后使用。指定指针的类型(例如 int*)称为指针类型。与使用与 & 字符声明引用类型非常相似,指针类型使用星号 (*) 声明:

1
2
3
4
int;  // a normal int
int&; // an lvalue reference to an int value

int*; // a pointer to an int value (holds the address of an integer value)

要创建指针变量,我们只需定义一个具有指针类型的变量:

1
2
3
4
5
6
7
8
9
int main()
{
int x { 5 }; // normal variable
int& ref { x }; // a reference to an integer (bound to x)

int* ptr; // a pointer to an integer

return 0;
}

请注意,此星号是指针声明语法的一部分,而不是取消引用运算符的使用。

尽管通常不应在一行中声明多个变量,但如果这样做,则必须在每个变量中包含星号。示例:

1
2
int* ptr1, ptr2;   // incorrect: ptr1 is a pointer to an int, but ptr2 is just a plain int!
int* ptr3, * ptr4; // correct: ptr3 and ptr4 are both pointers to an int

与普通变量一样,指针默认情况下也不会被初始化。未初始化的指针有时被称为野指针 (wild pointer)。野指针包含一个垃圾地址,解引用 (dereferencing) 野指针将导致未定义行为 (undefined behavior)。因此,您应始终将指针初始化为一个已知的值。示例:

1
2
3
4
5
6
7
8
9
10
int main()
{
int x{ 5 };

int* ptr; // an uninitialized pointer (holds a garbage address)
int* ptr2{}; // a null pointer (we'll discuss these in the next lesson)
int* ptr3{ &x }; // a pointer initialized with the address of variable x

return 0;
}

由于指针包含地址,因此当我们初始化或为指针赋值时,该值必须是一个地址。通常,指针用于保存另一个变量的地址(我们可以使用取地址运算符 (&) 获得)。一旦我们有一个包含另一个对象地址的指针,我们就可以使用解引用运算符 (*) 来访问该地址的值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main()
{
int x{ 5 };
std::cout << x << '\n'; // print the value of variable x

int* ptr{ &x }; // ptr holds the address of x
std::cout << *ptr << '\n'; // use dereference operator to print the value at the address that ptr is holding (which is x's address)

return 0;
}

输出:

1
2
5
5

就像引用的类型必须与所引用的对象类型匹配一样,指针的类型也必须与所指向的对象的类型匹配:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
int i{ 5 };
double d{ 7.0 };

int* iPtr{ &i }; // ok: a pointer to an int can point to an int object
int* iPtr2 { &d }; // not okay: a pointer to an int can't point to a double object
double* dPtr{ &d }; // ok: a pointer to a double can point to a double object
double* dPtr2{ &i }; // not okay: a pointer to a double can't point to an int object

return 0;
}

不允许使用文本值初始化指针(后面会讨论一个例外):

1
2
int* ptr{ 5 }; // not okay
int* ptr{ 0x0012FF7C }; // not okay, 0x0012FF7C is treated as an integer literal

我们可以通过两种不同的方式使用带指针的赋值:

  • 更改指针所指向的内容(通过为指针分配新地址),示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <iostream>

    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
    2
    5
    6
  • 更改所指向的值(通过为取消引用的指针分配新值),示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <iostream>

    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
    4
    5
    5
    6
    6

指针和左值引用的行为类似。请考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

int main()
{
int x{ 5 };
int& ref { x }; // get a reference to x
int* ptr { &x }; // get a pointer to x

std::cout << x;
std::cout << ref; // use the reference to print x's value (5)
std::cout << *ptr << '\n'; // use the pointer to print x's value (5)

ref = 6; // use the reference to change the value of x
std::cout << x;
std::cout << ref; // use the reference to print x's value (6)
std::cout << *ptr << '\n'; // use the pointer to print x's value (6)

*ptr = 7; // use the pointer to change the value of x
std::cout << x;
std::cout << ref; // use the reference to print x's value (7)
std::cout << *ptr << '\n'; // use the pointer to print x's value (7)

return 0;
}

输出:

1
2
3
555
666
777

在上面的程序中,我们创建一个值为 5 的普通变量 x,然后创建一个左值引用和一个指向 x 的指针。接下来,我们使用左值引用将值从 5 更改为 6,并表明我们可以通过所有三种方法访问更新后的值。最后,我们使用解引用指针将值从 6 更改为 7,并再次表明我们可以通过所有三种方法访问更新后的值。

因此,指针和引用都提供了一种间接访问另一个对象的方法。主要区别在于,对于指针,我们需要显式地获取要指向的地址,并且我们必须显式地取消引用指针才能获取值。对于引用,取地址和取消引用地址是隐式发生的。

指针和引用之间还有一些值得注意的区别:

  • 引用必须初始化,而指针不需要初始化(但应该初始化)。

  • 引用不是对象,而指针是对象。

  • 引用不能重新绑定(不能更改为引用其他对象),而指针可以更改它指向的内容。

  • 引用必须始终绑定到一个对象,而指针可以指向空 (null)。

  • 引用是“安全”的(除了悬空引用),而指针本质上是危险的。

值得注意的是,取地址运算符 (&) 不会将其操作数的地址作为文本返回(因为 C++ 不支持地址文本)。相反,它返回指向作数的指针(其值是作数的地址)。换句话说,给定变量 int x&x 返回一个包含 x 地址的 int*。我们可以在下面的示例中看到这一点:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <typeinfo>

int main()
{
int x{ 4 };
std::cout << typeid(x).name() << '\n'; // print the type of x
std::cout << typeid(&x).name() << '\n'; // print the type of &x

return 0;
}

输出:

1
2
int
int *

指针的大小取决于编译可执行文件的体系结构 – 32 位可执行文件使用 32 位内存地址 – 因此,32 位计算机上的指针为 32 位 (4 字节)。对于 64 位可执行文件,指针将为 64 位(8 字节)。请注意,无论所指向的对象的大小如何,都是如此:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main() // assume a 32-bit application
{
char* chPtr{}; // chars are 1 byte
int* iPtr{}; // ints are usually 4 bytes
long double* ldPtr{}; // long doubles are usually 8 or 12 bytes

std::cout << sizeof(chPtr) << '\n'; // prints 4
std::cout << sizeof(iPtr) << '\n'; // prints 4
std::cout << sizeof(ldPtr) << '\n'; // prints 4

return 0;
}

与悬空引用非常相似,悬空指针是保存不再有效的对象地址的指针。取消引用悬空指针将导致未定义的行为,因为你正在尝试访问不再有效的对象。下面是一个创建悬空指针的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

int main()
{
int x{ 5 };
int* ptr{ &x };

std::cout << *ptr << '\n'; // valid

{
int y{ 6 };
ptr = &y;

std::cout << *ptr << '\n'; // valid
} // y goes out of scope, and ptr is now dangling

std::cout << *ptr << '\n'; // undefined behavior from dereferencing a dangling pointer

return 0;
}

上面的程序可能会打印:

1
2
3
5
6
6

但事实并非如此,因为 ptr 所指向的对象超出了范围,并在内部块的末尾被销毁,使 ptr 悬空。

8. 空指针

除了内存地址之外,指针还可以保存一个额外的值:空值 (null value)空值 (null) 是一种特殊的值,表示某个东西没有值。当指针保存空值时,意味着该指针没有指向任何东西。这样的指针被称为空指针 (null pointer)。创建 null 指针的最简单方法是使用值初始化:

1
2
3
4
5
6
int main()
{
int* ptr {}; // ptr is now a null pointer, and is not holding an address

return 0;
}

由于我们可以通过赋值来更改指针的指向,因此一个最初设置为 null 的指针可以在之后更改为指向一个有效的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

int main()
{
int* ptr {}; // ptr is a null pointer, and is not holding an address

int x { 5 };
ptr = &x; // ptr now pointing at object x (no longer a null pointer)

std::cout << *ptr << '\n'; // print value of x through dereferenced ptr

return 0;
}

与关键字 truefalse 表示布尔文本值非常相似,nullptr 关键字表示 null 指针文本。我们可以使用 nullptr 显式初始化指针或为指针分配 null 值。示例:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
int* ptr { nullptr }; // can use nullptr to initialize a pointer to be a null pointer

int value { 5 };
int* ptr2 { &value }; // ptr2 is a valid pointer
ptr2 = nullptr; // Can assign nullptr to make the pointer a null pointer

someFunction(nullptr); // we can also pass nullptr to a function that has a pointer parameter

return 0;
}

与取消引用悬空指针导致未定义的行为非常相似,取消引用 null 指针也会导致未定义行为。在大多数情况下,它会使你的应用程序崩溃。示例:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
int* ptr {}; // Create a null pointer
std::cout << *ptr << '\n'; // Dereference the null pointer

return 0;
}

就像我们可以使用条件来测试布尔值是 true 还是 false 一样,我们也可以使用条件来测试指针是否具有值 nullptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

int main()
{
int x { 5 };
int* ptr { &x };

if (ptr == nullptr) // explicit test for equivalence
std::cout << "ptr is null\n";
else
std::cout << "ptr is non-null\n";

int* nullPtr {};
std::cout << "nullPtr is " << (nullPtr==nullptr ? "null\n" : "non-null\n"); // explicit test for equivalence

return 0;
}

上述程序打印:

1
2
ptr is non-null
nullPtr is null

指针也会隐式转换为布尔值:null 指针转换为布尔值 false,非 null 指针转换为布尔值 true。这允许我们跳过显式测试 nullptr,只使用到布尔值的隐式转换来测试指针是否为 null 指针。以下程序等效于前一个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

int main()
{
int x { 5 };
int* ptr { &x };

// pointers convert to Boolean false if they are null, and Boolean true if they are non-null
if (ptr) // implicit conversion to Boolean
std::cout << "ptr is non-null\n";
else
std::cout << "ptr is null\n";

int* nullPtr {};
std::cout << "nullPtr is " << (nullPtr ? "non-null\n" : "null\n"); // implicit conversion to Boolean

return 0;
}

条件语句只能用于区分 null 指针和非 null 指针。没有方便的方法可以确定非 null 指针是指向有效对象还是悬空 (指向无效对象)

我们可以通过使用条件来确保指针在尝试取消引用之前为非 null,从而轻松避免取消引用 null 指针:

1
2
3
4
5
// Assume ptr is some pointer that may or may not be a null pointer
if (ptr) // if ptr is not a null pointer
std::cout << *ptr << '\n'; // okay to dereference
else
// do something else that doesn't involve dereferencing ptr (print an error message, do nothing at all, etc...)

因为没有办法检测指针是否悬空,所以我们首先需要避免在我们的程序中出现任何悬空指针。为此,我们确保将任何未指向有效对象的指针都设置为 nullptr

销毁对象时,指向已销毁对象的任何指针都将悬空(它们不会自动设置为 nullptr)。你应该注意这些情况并确保这些指针随后设置为 nullptr

在较旧的代码中,您可能会看到使用了另外两个文本值,而不是 nullptr。第一个是文本 0。在指针的上下文中,文本 0 被专门定义为表示 null 值,并且是唯一一次可以将整型文本分配给指针。示例:

1
2
3
4
5
6
7
8
9
int main()
{
float* ptr { 0 }; // ptr is now a null pointer (for example only, don't do this)

float* ptr2; // ptr2 is uninitialized
ptr2 = 0; // ptr2 is now a null pointer (for example only, don't do this)

return 0;
}

此外,还有一个名为 NULL 的预处理器宏(在 header 中定义)。此宏继承自 C,通常用于指示 null 指针。示例:

1
2
3
4
5
6
7
8
9
10
11
#include <cstddef> // for NULL

int main()
{
double* ptr { NULL }; // ptr is a null pointer

double* ptr2; // ptr2 is uninitialized
ptr2 = NULL; // ptr2 is now a null pointer

return 0;
}

在现代 C++ 中,应避免使用 0NULL(请改用 nullptr)。

指针具有能够更改它们所指向的内容以及指向 null 的额外功能。但是,这些指针功能本身也是危险的:null 指针有被取消引用的风险,并且更改指针指向的内容的能力可以更轻松地创建悬空指针。由于引用不能绑定到 null,因此我们不必担心 null 引用。由于引用必须在创建时绑定到有效对象,然后才能重新放置,因此更难创建悬空引用。因为它们更安全,所以应该优先使用引用而不是指针,除非需要指针提供的其他功能。

9. 指针和常量

指向常量的指针 (pointer to a const value)是一个(非 const 的)指针,它指向一个常量值。要声明指向常量值的指针,请在指针的数据类型之前使用 const 关键字:

1
2
3
4
5
6
7
8
9
int main()
{
const int x{ 5 };
const int* ptr { &x }; // okay: ptr is pointing to a "const int"

*ptr = 6; // not allowed: we can't change a const value

return 0;
}

但是,由于指向常量的指针本身不是常量 (它只指向常量值),因此我们可以通过为指针分配新地址来更改指针指向的内容:

1
2
3
4
5
6
7
8
9
10
int main()
{
const int x{ 5 };
const int* ptr { &x }; // ptr points to const int x

const int y{ 6 };
ptr = &y; // okay: ptr now points at const int y

return 0;
}

就像指向常量的引用 (reference to const) 一样,指向常量的指针 (pointer to const) 也可以指向非常量 (non-const) 变量。指向常量的指针 会将被指向的值视为常量,无论该地址处的对象最初是否被定义为常量 (const) 都不影响:

1
2
3
4
5
6
7
8
9
10
int main()
{
int x{ 5 }; // non-const
const int* ptr { &x }; // ptr points to a "const int"

*ptr = 6; // not allowed: ptr points to a "const int" so we can't change the value through ptr
x = 6; // allowed: the value is still non-const when accessed through non-const identifier x

return 0;
}

我们也可以使指针本身变为常量。常量指针 (const pointer) 是一种在初始化后其地址无法更改的指针。要声明常量指针,请在指针声明中星号后使用 const 关键字:

1
2
3
4
5
6
7
int main()
{
int x{ 5 };
int* const ptr { &x }; // const after the asterisk means this is a const pointer

return 0;
}

就像普通的常量一样,常量指针必须在定义时初始化,并且该值不能通过赋值来更改:

1
2
3
4
5
6
7
8
9
10
int main()
{
int x{ 5 };
int y{ 6 };

int* const ptr { &x }; // okay: the const pointer is initialized to the address of x
ptr = &y; // error: once initialized, a const pointer can not be changed.

return 0;
}

但是,由于指向的值是非常量,因此可以通过取消引用常量指针来更改指向的值:

1
2
3
4
5
6
7
8
9
int main()
{
int x{ 5 };
int* const ptr { &x }; // ptr will always point to x

*ptr = 6; // okay: the value being pointed to is non-const

return 0;
}

最后,可以通过在类型之前和星号之后使用 const 关键字来声明指向常量值的常量指针

1
2
3
4
5
6
7
int main()
{
int value { 5 };
const int* const ptr { &value }; // a const pointer to a const value

return 0;
}

指向常量值的常量指针不能更改其地址,也不能通过指针更改它指向的值。它只能被取消引用以获得它所指向的值。

10. 按地址传递

下面是一个示例程序,它显示了按值和引用传递的 std::string 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>

void printByValue(std::string val) // The function parameter is a copy of str
{
std::cout << val << '\n'; // print the value via the copy
}

void printByReference(const std::string& ref) // The function parameter is a reference that binds to str
{
std::cout << ref << '\n'; // print the value via the reference
}

int main()
{
std::string str{ "Hello, world!" };

printByValue(str); // pass str by value, makes a copy of str
printByReference(str); // pass str by reference, does not make a copy of str

return 0;
}

当我们按值传递参数 str 时,函数参数 val 会收到参数的副本。由于参数是参数的副本,因此对 val 的任何更改都是对副本进行的,而不是对原始参数进行的。当我们通过引用传递参数 str 时,引用参数 ref 会绑定到实际的参数。这样可以避免复制参数。因为我们的引用参数是常量,所以不允许更改 ref。但是如果 ref 是非常量,我们对 ref 所做的任何更改都会改变 str。在这两种情况下,调用方都提供了要作为参数传递给函数调用的实际对象 (str)。

C++ 提供了第三种将值传递给函数的方法,称为按地址传递。使用按地址传递时,调用方不是提供对象作为参数,而是提供对象的地址(通过指针)。此指针(保存对象的地址)被复制到被调用函数的指针参数中(该函数现在也保存对象的地址)。然后,该函数可以取消引用该指针以访问传递了地址的对象。这是上述程序的一个版本,它添加了一个按地址传递的变体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <string>

void printByValue(std::string val) // The function parameter is a copy of str
{
std::cout << val << '\n'; // print the value via the copy
}

void printByReference(const std::string& ref) // The function parameter is a reference that binds to str
{
std::cout << ref << '\n'; // print the value via the reference
}

void printByAddress(const std::string* ptr) // The function parameter is a pointer that holds the address of str
{
std::cout << *ptr << '\n'; // print the value via the dereferenced pointer
}

int main()
{
std::string str{ "Hello, world!" };

printByValue(str); // pass str by value, makes a copy of str
printByReference(str); // pass str by reference, does not make a copy of str
printByAddress(&str); // pass str by address, does not make a copy of str

return 0;
}

首先,因为我们希望 printByAddress() 函数使用按地址传递,所以我们将函数参数设置为名为 ptr 的指针。由于 printByAddress() 将以只读方式使用 ptr,因此 ptr 是指向常量值的指针。

1
2
3
4
void printByAddress(const std::string* ptr)
{
std::cout << *ptr << '\n'; // print the value via the dereferenced pointer
}

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
2
3
4
5
6
7
8
9
10
11
12
13
int main()
{
std::string str{ "Hello, world!" };

printByValue(str); // pass str by value, makes a copy of str
printByReference(str); // pass str by reference, does not make a copy of str
printByAddress(&str); // pass str by address, does not make a copy of str

std::string* ptr { &str }; // define a pointer variable holding the address of str
printByAddress(ptr); // pass str by address, does not make a copy of str

return 0;
}

当我们通过地址传递对象时,该函数会收到所传递对象的地址,它可以通过解引用来访问该地址。因为这是正在传递的实际参数对象的地址(不是对象的副本),所以如果函数参数是指向非常量的指针,则函数可以通过指针参数修改参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void changeValue(int* ptr) // note: ptr is a pointer to non-const in this example
{
*ptr = 6; // change the value to 6
}

int main()
{
int x{ 5 };

std::cout << "x = " << x << '\n';

changeValue(&x); // we're passing the address of x to the function

std::cout << "x = " << x << '\n';

return 0;
}

输出:

1
2
x = 5
x = 6

如你所见,参数被修改了,即使在 changeValue() 完成运行后,此修改仍然存在。如果函数不应该修改传入的对象,则应将函数参数设置为指向常量的指针:

1
2
3
4
void changeValue(const int* ptr) // note: ptr is now a pointer to a const
{
*ptr = 6; // error: can not change const value
}

注意:

  1. 使用 const 关键字将指针函数参数声明为 常量指针 (const pointer) 的价值不大(因为它对调用者没有影响,主要只是作为文档,表明指针不会改变)。

  2. 使用 const 关键字将 指向常量的指针 (pointer-to-const)指向非常量的指针 (pointer-to-non-const) 区分开来是非常重要的(因为调用者需要知道函数是否可能更改传入参数的值)。

现在考虑这个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

void print(int* ptr)
{
std::cout << *ptr << '\n';
}

int main()
{
int x{ 5 };
print(&x);

int* myPtr {};
print(myPtr);

return 0;
}

当这个程序运行时,它将打印值 5,然后很可能会崩溃。在对 print(myPtr) 的调用中,myPtr 是一个 null 指针,因此函数参数 ptr 也将是一个 null 指针。在函数体中取消引用此 null 指针时,将导致未定义行为。

按地址传递参数时,在取消引用值之前,应注意确保指针不是 null 指针。一种方法是使用条件语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void print(int* ptr)
{
if (ptr) // if ptr is not a null pointer
{
std::cout << *ptr << '\n';
}
}

int main()
{
int x{ 5 };

print(&x);
print(nullptr);

return 0;
}

在大多数情况下,相反的做法会更有效:测试函数参数是否为null作为前提条件,并立即处理否定情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

void print(int* ptr)
{
if (!ptr) // if ptr is a null pointer, early return back to the caller
return;

// if we reached this point, we can assume ptr is valid
// so no more testing or nesting required

std::cout << *ptr << '\n';
}

int main()
{
int x{ 5 };

print(&x);
print(nullptr);

return 0;
}

如果永远不应该将 null 指针传递给函数,则可以使用 assert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <cassert>

void print(const int* ptr) // now a pointer to a const int
{
assert(ptr); // fail the program in debug mode if a null pointer is passed (since this should never happen)

// (optionally) handle this as an error case in production mode so we don't crash if it does happen
if (!ptr)
return;

std::cout << *ptr << '\n';
}

int main()
{
int x{ 5 };

print(&x);
print(nullptr);

return 0;
}

按地址传递更常见的用途之一是允许函数接受 “可选择” 参数。这比描述更容易通过示例来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}

int main()
{
printIDNumber(); // we don't know the user's ID yet

int userid { 34 };
printIDNumber(&userid); // we know the user's ID now

return 0;
}

此示例打印:

1
2
Your ID number is not known.
Your ID number is 34.

在此程序中,printIDNumber() 函数有一个按地址传递的参数,默认为 nullptr。在 main() 中,我们调用了这个函数两次。第一次调用时,我们不知道用户的 ID,因此我们调用 printIDNumber() 而不带参数。id 参数默认为 nullptr,函数打印 Your ID number is not known.。对于第二次调用,我们现在有一个有效的 ID,因此我们调用 printIDNumber(&userid)id 参数接收 userid 的地址,因此该函数打印 Your ID number is 34.

但是,在许多情况下,函数重载是实现相同结果的更好选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

void printIDNumber()
{
std::cout << "Your ID is not known\n";
}

void printIDNumber(int id)
{
std::cout << "Your ID is " << id << "\n";
}

int main()
{
printIDNumber(); // we don't know the user's ID yet

int userid { 34 };
printIDNumber(userid); // we know the user is 34

printIDNumber(62); // now also works with rvalue arguments

return 0;
}

这有很多好处:我们不再需要担心 null 取消引用,并且我们可以将文本值或其他右值作为参数传入。

当我们将一个地址传递给函数时,该地址会从实参复制到指针形参中(这没有问题,因为复制一个地址是非常快的)。现在来看以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

// [[maybe_unused]] gets rid of compiler warnings about ptr2 being set but not used
void nullify([[maybe_unused]] int* ptr2)
{
ptr2 = nullptr; // Make the function parameter a null pointer
}

int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr points to x

std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");

nullify(ptr);

std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}

该程序打印:

1
2
ptr is non-null
ptr is non-null

如你所见,更改指针形参中保存的地址对实参中保存的地址没有任何影响(ptr 仍然指向 x)。当调用函数 nullify() 时,ptr2 接收到传入地址的副本(在本例中,是 ptr 保存的地址,即 x 的地址)。当函数更改 ptr2 的指向时,这仅影响 ptr2 中保存的副本。如果我们想允许一个函数改变指针形参指向的内容可以通过引用。就像我们可以通过引用传递普通变量一样,我们也可以通过引用传递指针。下面是与上述相同的程序,其中 ptr2 更改为对地址的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void nullify(int*& refptr) // refptr is now a reference to a pointer
{
refptr = nullptr; // Make the function parameter a null pointer
}

int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr points to x

std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");

nullify(ptr);

std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}

该程序打印:

1
2
ptr is non-null
ptr is null

由于 refptr 现在是对指针的引用,因此当 ptr 作为参数传递时,refptr 将绑定到 ptr。这意味着对 refptr 的任何更改都会对 ptr 进行。

为什么不再首选使用 0 NULL

文本 0 可以解释为整数文本或 null 指针文本。在某些情况下,我们打算使用哪一个可能是模棱两可的 —— 在某些情况下,编译器可能会假设我们指的是一个 —— 这对我们的程序的行为产生了意想不到的后果。预处理器宏 NULL 的定义不是由语言标准定义的。它可以定义为 00L((void*)0) 或完全不同的内容。使用 0NULL 时,这可能会导致问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <cstddef> // for NULL

void print(int x) // this function accepts an integer
{
std::cout << "print(int): " << x << '\n';
}

void print(int* ptr) // this function accepts an integer pointer
{
std::cout << "print(int*): " << (ptr ? "non-null\n" : "null\n");
}

int main()
{
int x{ 5 };
int* ptr{ &x };

print(ptr); // always calls print(int*) because ptr has type int* (good)
print(0); // always calls print(int) because 0 is an integer literal (hopefully this is what we expected)

print(NULL); // this statement could do any of the following:
// call print(int) (Visual Studio does this)
// call print(int*)
// result in an ambiguous function call compilation error (gcc and Clang do this)

print(nullptr); // always calls print(int*)

return 0;
}

这会打印:

1
2
3
4
print(int*): non-null
print(int): 0
print(int): 0
print(int*): 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <cstddef> // for std::nullptr_t

void print(std::nullptr_t)
{
std::cout << "in print(std::nullptr_t)\n";
}

void print(int*)
{
std::cout << "in print(int*)\n";
}

int main()
{
print(nullptr); // calls print(std::nullptr_t)

int x { 5 };
int* ptr { &x };

print(ptr); // calls print(int*)

ptr = nullptr;
print(ptr); // calls print(int*) (since ptr has type int*)

return 0;
}

11. 按引用返回和按地址返回

按引用返回返回一个绑定到所返回对象的引用,从而避免复制返回值。要通过引用返回,我们只需将函数的返回值定义为引用类型:

1
2
std::string&       returnByReference(); // returns a reference to an existing std::string (cheap)
const std::string& returnByReferenceToConst(); // returns a const reference to an existing std::string (cheap)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <string>

const std::string& getProgramName() // returns a const reference
{
static const std::string s_programName { "Calculator" }; // has static duration, destroyed at end of program

return s_programName;
}

int main()
{
std::cout << "This program is named " << getProgramName();

return 0;
}

该程序打印:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <string>

const int& getNextId()
{
static int s_x{ 0 };
++s_x;
return s_x;
}

int main()
{
const int id1 { getNextId() }; // id1 is a normal variable now and receives a copy of the value returned by reference from getNextId()
const int id2 { getNextId() }; // id2 is a normal variable now and receives a copy of the value returned by reference from getNextId()

std::cout << id1 << id2 << '\n';

return 0;
}

在上述示例中,getNextId() 返回的是一个引用,但 id1id2 是非引用变量。在这种情况下,返回的引用的值会被复制到普通变量中。因此,这个程序会打印:

1
12

在很多情况下,通过引用返回对象是有意义的,我们现在可以展示一个有用的示例。如果参数通过引用传递到函数中,则通过引用返回该参数是安全的。这是有道理的:为了将参数传递给函数,该参数必须存在于调用者的范围内。当被调用的函数返回时,该对象必须仍存在于调用者的范围内。以下是此类函数的一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <string>

// Takes two std::string objects, returns the one that comes first alphabetically
const std::string& firstAlphabetical(const std::string& a, const std::string& b)
{
return (a < b) ? a : b; // We can use operator< on std::string to determine which comes first alphabetically
}

int main()
{
std::string hello { "Hello" };
std::string world { "World" };

std::cout << firstAlphabetical(hello, world) << '\n';

return 0;
}

该程序打印:

1
Hello

当常量引用参数的参数是右值时,通过常量引用返回该参数仍然是可以的。这是因为在创建右值的完整表达式结束之前,右值不会被销毁。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>

const std::string& foo(const std::string& s)
{
return s;
}

std::string getHello()
{
return "Hello"; // implicit conversion to std::string
}

int main()
{
const std::string s{ foo(getHello()) };

std::cout << s;

return 0;
}

当通过非常量引用将参数传递给函数时,函数可以使用该引用来修改参数的值。同样,当从函数返回非常量引用时,调用方可以使用该引用来修改返回的值。下面是一个说明性示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

// takes two integers by non-const reference, and returns the greater by reference
int& max(int& x, int& y)
{
return (x > y) ? x : y;
}

int main()
{
int a{ 5 };
int b{ 6 };

max(a, b) = 7; // sets the greater of a or b to 7

std::cout << a << b << '\n';

return 0;
}

在上面的程序中,max(a, b)ab 作为参数调用 max() 函数。引用参数 x 绑定到参数 a,引用参数 y 绑定到参数 b。然后,该函数确定 x5) 和 y6) 中哪个更大。在本例中,即 y,因此函数将 y(仍绑定到 b)返回给调用方。然后,调用方将值 7 分配给此返回的引用。因此,表达式 max(a, b) = 7 有效地解析为 b = 7。这将打印出:

1
57

按地址返回的工作方式与按引用返回的工作方式几乎相同,只是返回的是指向对象的指针,而不是对对象的引用。按地址返回与按引用返回具有相同的主要警告 – 由地址返回的对象必须超过返回地址的函数的范围,否则调用者将收到一个悬空指针。与按引用返回相比,按地址返回的主要优点是,如果没有要返回的有效对象,我们可以让函数返回 nullptr。例如,假设我们有一个要搜索的学生列表。如果我们在列表中找到我们正在寻找的学生,我们可以返回一个指向表示匹配学生的对象的指针。如果我们没有找到任何匹配的 students,我们可以返回 nullptr 以指示未找到匹配的 student 对象。按地址返回的主要缺点是调用者必须记住在取消引用返回值之前进行 nullptr 检查,否则可能会发生 null 指针取消引用,并导致未定义行为。由于这种危险,除非需要返回 “no object” 的能力,否则应该优先于按地址返回返回。

12. 输入和输出参数

在大多数情况下,函数参数仅用于接收来自调用者的输入。仅用于接收调用者输入的参数有时被称为输入参数 (in parameters)。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

void print(int x) // x is an in parameter
{
std::cout << x << '\n';
}

void print(const std::string& s) // s is an in parameter
{
std::cout << s << '\n';
}

int main()
{
print(5);
std::string s { "Hello, world!" };
print(s);

return 0;
}

通过 非常量引用 (non-const reference)指向非常量的指针 (pointer-to-non-const) 传递的函数参数允许函数修改作为参数传入的对象的值。这为函数提供了一种返回数据给调用者的方式,在某些情况下,使用返回值可能不足以满足需求。仅用于将信息返回给调用者的函数参数称为 输出参数 (out parameter)。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <cmath>    // for std::sin() and std::cos()
#include <iostream>

// sinOut and cosOut are out parameters
void getSinCos(double degrees, double& sinOut, double& cosOut)
{
// sin() and cos() take radians, not degrees, so we need to convert
constexpr double pi { 3.14159265358979323846 }; // the value of pi
double radians = degrees * pi / 180.0;
sinOut = std::sin(radians);
cosOut = std::cos(radians);
}

int main()
{
double sin { 0.0 };
double cos { 0.0 };

double degrees{};
std::cout << "Enter the number of degrees: ";
std::cin >> degrees;

// getSinCos will return the sin and cos in variables sin and cos
getSinCos(degrees, sin, cos);

std::cout << "The sin is " << sin << '\n';
std::cout << "The cos is " << cos << '\n';

return 0;
}

输出参数虽然功能强大,但也有一些缺点。首先,调用者必须实例化(并初始化)对象并将其作为参数传递,即使它不打算使用它们。这些对象必须能够被赋值,这意味着它们不能被设为常量。其次,由于调用方必须传入对象,因此这些值不能用作临时值,也不能在单个表达式中轻松使用。以下示例显示了这两个缺点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

int getByValue()
{
return 5;
}

void getByReference(int& x)
{
x = 5;
}

int main()
{
// return by value
[[maybe_unused]] int x{ getByValue() }; // can use to initialize object
std::cout << getByValue() << '\n'; // can use temporary return value in expression

// return by out parameter
int y{}; // must first allocate an assignable object
getByReference(y); // then pass to function to assign the desired value
std::cout << y << '\n'; // and only then can we use that value

return 0;
}

同时使用输出参数并不能明显表明参数被修改,因此我们需要尽量避免输出参数。

在少数情况下,函数会在覆盖输出参数的值之前实际使用该值。这种参数被称为 输入输出参数 (in-out parameter)。输入输出参数与输出参数的工作方式相同,并且面临相同的挑战。

13. 使用指针、引用和 const 的类型推导

除了删除 const 之外,类型推导还会删除引用:

1
2
3
4
5
6
7
8
9
10
#include <string>

std::string& getRef(); // some function that returns a reference

int main()
{
auto ref { getRef() }; // type deduced as std::string (not std::string&)

return 0;
}

在上面的例子中,变量 ref 使用了类型推导。尽管函数 getRef() 返回 std::string&,但引用限定符被删除,因此 ref 的类型被推导出为 std::string。如果你想让推导的类型成为一个引用,你可以在定义点重新应用引用:

1
2
3
4
5
6
7
8
9
10
11
#include <string>

std::string& getRef(); // some function that returns a reference

int main()
{
auto ref1 { getRef() }; // std::string (reference dropped)
auto& ref2 { getRef() }; // std::string& (reference dropped, reference reapplied)

return 0;
}

顶级 const 是适用于对象本身的 const 限定符。例如:

1
2
3
const int x;    // this const applies to x, so it is top-level
int* const ptr; // this const applies to ptr, so it is top-level
// references don't have a top-level const syntax, as they are implicitly top-level const

相反,低级 const 是 const 限定符,适用于被引用或指向的对象:

1
2
const int& ref; // this const applies to the object being referenced, so it is low-level
const int* ptr; // this const applies to the object being pointed to, 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
2
3
4
5
6
7
8
9
10
#include <string>

const std::string& getConstRef(); // some function that returns a reference to const

int main()
{
auto ref1{ getConstRef() }; // std::string (reference dropped, then top-level const dropped from result)

return 0;
}

在上面的例子中,由于 getConstRef() 返回一个 const std::string&,所以首先删除引用,留下一个 const std::string。这个 const 现在是顶级 const,所以它也被删除了,只剩下推导的类型为 std::string

我们可以重新应用引用或 const:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <string>

const std::string& getConstRef(); // some function that returns a const reference

int main()
{
auto ref1{ getConstRef() }; // std::string (reference and top-level const dropped)
const auto ref2{ getConstRef() }; // const std::string (reference dropped, const dropped, const reapplied)

auto& ref3{ getConstRef() }; // const std::string& (reference dropped and reapplied, low-level const not dropped)
const auto& ref4{ getConstRef() }; // const std::string& (reference dropped and reapplied, low-level const not dropped)

return 0;
}

注意:constexpr 不是表达式类型的一部分,因此它不会由 auto 推导。

与引用不同,类型推导不会丢弃指针:

1
2
3
4
5
6
7
8
9
10
#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
auto ptr1{ getPtr() }; // std::string*

return 0;
}

我们还可以将星号与指针类型推导 (auto*) 结合使用,以更清楚地表明推导的类型是一个指针:

1
2
3
4
5
6
7
8
9
10
11
#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
auto ptr1{ getPtr() }; // std::string*
auto* ptr2{ getPtr() }; // std::string*

return 0;
}

auto 和 auto的区别:*

当我们将 auto 与指针类型初始化器一起使用时,为 auto 推导的类型包括指针。因此,对于上面的 ptr1,替换 auto 的类型是 std::string*。当我们将 auto* 与指针类型初始化器一起使用时,为 auto 推导的类型不包括指针 – 在推导类型后,指针会重新应用。因此,对于上面的 ptr2,替换 auto 的类型是 std::string,然后重新应用指针。在大多数情况下,实际效果是相同的(在上面的例子中,ptr1ptr2 都推导出 st::string*)。但是,在实践中,autoauto* 之间存在一些差异。首先,auto* 必须解析为指针初始化器,否则将导致编译错误:

1
2
3
4
5
6
7
8
9
10
11
#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
auto ptr3{ *getPtr() }; // std::string (because we dereferenced getPtr())
auto* ptr4{ *getPtr() }; // does not compile (initializer not a pointer)

return 0;
}

类型推导和 const 指针:

由于指针不会被丢弃,因此我们不必担心这一点。但是对于指针,我们需要考虑 const 指针和指向 const 情况的指针,并且我们还有 autoauto*。就像引用一样,在指针类型推导期间,只丢弃顶级 const。让我们从一个简单的案例开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <string>

std::string* getPtr(); // some function that returns a pointer

int main()
{
const auto ptr1{ getPtr() }; // std::string* const
auto const ptr2 { getPtr() }; // std::string* const

const auto* ptr3{ getPtr() }; // const std::string*
auto* const ptr4{ getPtr() }; // std::string* const

return 0;
}

当我们使用 auto constconst auto 时,我们是在说,“将推导的指针设为 const 指针”。因此,在 ptr1ptr2 的情况下,推导的类型是 std::string*,然后应用 const,使最终类型 std::string* const。这类似于 const intint const 的含义相同。但是,当我们使用 auto* 时,const 限定符的顺序很重要。左侧的 const 表示“使推导的指针成为指向 const 的指针”,而右侧的 const 表示“将推导的指针类型设为 const 指针”。因此,ptr3 最终成为指向 const 的指针,而 ptr4 最终成为 const 指针。现在让我们看一个示例,其中初始化器是指向 const 的 const 指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <string>

int main()
{
std::string s{};
const std::string* const ptr { &s };

auto ptr1{ ptr }; // const std::string*
auto* ptr2{ ptr }; // const std::string*

auto const ptr3{ ptr }; // const std::string* const
const auto ptr4{ ptr }; // const std::string* const

auto* const ptr5{ ptr }; // const std::string* const
const auto* ptr6{ ptr }; // const std::string*

const auto const ptr7{ ptr }; // error: const qualifer can not be applied twice
const auto* const ptr8{ ptr }; // const std::string* const

return 0;
}

ptr1ptr2 的情况很简单。顶级 const (指针本身上的 const) 被删除。不会删除所指向的对象上的低级 const。因此,在这两种情况下,最终类型都是 const std::string*

ptr3ptr4 的情况也很简单。顶级 const 已删除,但我们正在重新应用它。不会删除所指向的对象上的低级 const。所以在这两种情况下,最终类型都是 const std::string* const

ptr5ptr6 的情况类似于我们在前面的例子中展示的情况。在这两种情况下,顶级 const 都会被删除。对于 ptr5auto* const 重新应用顶级 const,因此最终类型为 const std::string* const。对于 ptr6const auto* 将 const 应用于所指向的类型(在本例中已经是 const),因此最终类型是 const std::string*

ptr7 的情况下,我们将 const 限定符应用两次,这是不允许的,并且会导致编译错误。

14. std::optional

在前面我们讨论了函数遇到无法合理处理自身的错误的情况,典型的做法是让函数检测错误,然后将错误传回给调用者,以某种适合程序的方式进行处理。前面我们介绍了将错误返回调用者的两种不同方法:

  • 让返回 void 的函数改为返回 bool (指示成功或失败)。

  • 让一个返回值的函数返回一个哨兵值(即一个特殊值,该值不会出现在函数可能返回的其他值集合中)以指示错误。

第二种方法有许多缺点:

  • 程序员必须知道函数使用哪个哨兵值来指示错误(并且对于每个使用此方法返回错误的函数,该值可能有所不同)。

  • 同一函数的不同版本可能使用不同的哨兵值。

  • 此方法不适用于所有可能的哨兵值都是有效返回值的函数。

有一种好的方法是,我们可以放弃返回单个值,而是返回两个值:一个(bool 类型)指示函数是否成功,另一个(所需返回类型)保存实际返回值(如果函数成功)或不确定值(如果函数失败)。C++17 引入了 std::optional,这是一个实现可选值的类模板类型。也就是说,std::optional<T> 的值可以是 T 类型,也可以不是。我们可以使用它来实现上面的所说的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <optional> // for std::optional (C++17)

// Our function now optionally returns an int value
std::optional<int> doIntDivision(int x, int y)
{
if (y == 0)
return {}; // or return std::nullopt
return x / y;
}

int main()
{
std::optional<int> result1 { doIntDivision(20, 5) };
if (result1) // if the function returned a value
std::cout << "Result 1: " << *result1 << '\n'; // get the value
else
std::cout << "Result 1: failed\n";

std::optional<int> result2 { doIntDivision(5, 0) };

if (result2)
std::cout << "Result 2: " << *result2 << '\n';
else
std::cout << "Result 2: failed\n";

return 0;
}

输出:

1
2
Result 1: 4
Result 2: failed

使用 std::optional 非常简单。我们可以构造一个 std::optional<T>,也可以带或不带值:

1
2
3
std::optional<int> o1 { 5 };            // initialize with a value
std::optional<int> o2 {}; // initialize with no value
std::optional<int> o3 { std::nullopt }; // initialize with no value

要查看 std::optional 是否有值,我们可以选择以下选项之一:

1
2
if (o1.has_value()) // call has_value() to check if o1 has a value
if (o2) // use implicit conversion to bool to check if o2 has a value

要从 std::optional 中获取值,我们可以选择以下选项之一:

1
2
3
std::cout << *o1;             // dereference to get value stored in o1 (undefined behavior if o1 does not have a value)
std::cout << o2.value(); // call value() to get value stored in o2 (throws std::bad_optional_access exception if o2 does not have a value)
std::cout << o3.value_or(42); // call value_or() to get value stored in o3 (or value `42` if o3 doesn't have a value)

返回 std::optional 有以下优点:

  • 使用 std::optional 可以有效地表明一个函数可能会返回一个值,也可能不会返回值。

  • 我们不需要记住哪个值被用作哨兵值来表示错误。

  • std::optional 的使用语法方便且直观。

然而,返回 std::optional 也有一些缺点:

  • 我们需要确保在获取值之前,std::optional 中确实包含一个值。如果解引用一个不包含值的 std::optional,将会导致未定义的行为。

  • std::optional 并不提供有关函数为何失败的额外信息。

前面我们讨论了如何使用按地址传递来允许函数接受“可选”参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}

int main()
{
printIDNumber(); // we don't know the user's ID yet

int userid { 34 };
printIDNumber(&userid); // we know the user's ID now

return 0;
}

std::optional 是函数接受可选参数(仅用作参数内)的另一种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <optional>

void printIDNumber(std::optional<const int> id = std::nullopt)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}

int main()
{
printIDNumber(); // we don't know the user's ID yet

int userid { 34 };
printIDNumber(userid); // we know the user's ID now

printIDNumber(62); // we can also pass an rvalue

return 0;
}

参考资料

Learn C++ – Skill up with our free tutorials