C++ / 2026-08-01
CS106L 第 15 讲:std::optional 与类型安全
用类型表达“值可能不存在”,系统掌握 std::optional 与安全接口设计。
本课程根据 CS106L Lecture 15:
std::optional & Type Safety重构,并对课件中的简化表述与部分代码问题进行了修正。
本节课要解决的核心问题
假设我们要写一个函数,在数组中寻找某个数字的位置:
??? findIndex(const std::vector<int>& values, int target);
找到时,我们可以返回下标:
findIndex({1, 3, 7}, 3) → 1
findIndex({67}, 67) → 0
findIndex({10, 20, 30}, 30) → 2
可是,没有找到时应该返回什么?
findIndex({}, 10) → ???
这不是一个无关紧要的小问题。它揭示了本节课真正要讨论的主题:
一个函数的返回值有时存在,有时不存在。
我们怎样让函数的类型准确表达这一事实?
围绕这个问题,我们会逐步学习:
- 类型安全(type safety)到底在保护什么;
- 为什么“能编译”不代表程序一定安全;
- 什么是前置条件(precondition);
- 什么是未定义行为(undefined behavior);
- 为什么
std::vector::back()可能被错误使用; - 用特殊整数、异常和
std::pair表示失败分别有什么问题; std::optional<T>如何表示“一个T,或者什么都没有”;- 如何安全使用
.has_value()、.value()、.value_or()、*和->; - 为什么不存在
std::optional<T&>; - C++ 标准库为什么没有把所有危险操作都改成
optional; - C++23 中的
.and_then()、.transform()和.or_else()如何组织连续计算。
学习本节课所需的前置知识
阅读本课程前,只需要熟悉:
- 基本变量与函数;
if、while和for;std::vector的基础用法;- 引用参数;
- 类和构造函数的基本概念;
- 函数模板和类模板的基本直觉;
- 上一节课介绍的复制与移动。
对于右值、资源所有权、异常、模板细节等内容,本课程会在需要时补充。
从上一节课出发:对象为什么能够被安全地复制和移动
上一节课讨论了移动语义(move semantics)。
当一个对象拥有较大的资源,而原对象之后不再需要这些资源时,我们希望把资源转交给新对象,而不是完整复制一份。
#include <iostream>
#include <utility>
#include <vector>
int main() {
std::vector<int> source{1, 2, 3};
std::vector<int> destination = std::move(source);
std::cout << "destination.size() = "
<< destination.size() << '\n';
}
编译命令:
g++ -std=c++20 main.cpp -o main
输出:
destination.size() = 3
这里需要修正课件中的一句简化表述:
课件把
std::move(source)描述成“把左值变成右值,然后立刻取走资源”。
这个说法可以帮助初学者建立直觉,但更准确地说:
std::move本身不移动任何资源;- 它只是把表达式转换成一种允许移动的值类别;
- 接下来是否真的移动,由被调用的构造函数或赋值运算符决定。
还要注意:
std::vector<int> destination = std::move(source);
执行后,source 仍然是一个可以析构、可以重新赋值的有效对象,但它的具体内容通常不能依赖。
不要编写依赖下面结果的代码:
std::cout << source.size(); // 不应假定一定是 0
移动后的对象处于:
有效但状态未指定(valid but unspecified)
的状态。
Rule of Zero:优先让成员自己管理资源
考虑一个学生类:
#include <string>
#include <utility>
#include <vector>
class Student {
public:
Student(std::string name, std::vector<int> scores)
: name_(std::move(name)),
scores_(std::move(scores)) {
}
private:
std::string name_;
std::vector<int> scores_;
};
std::string 和 std::vector 都能够自己完成:
- 资源释放;
- 复制;
- 移动;
- 异常处理。
因此,我们通常不需要手写:
~Student();
Student(const Student& other);
Student& operator=(const Student& other);
Student(Student&& other);
Student& operator=(Student&& other);
这就是零法则(Rule of Zero):
当成员变量本身能够安全管理资源时,尽量不要手写特殊成员函数,让编译器生成合适的版本。
Rule of Three 与 Rule of Five
如果一个类直接拥有裸指针资源:
class Student {
private:
int* scores_;
int scoreCount_;
};
那么编译器默认生成的复制操作只会复制指针地址:
原对象 scores_ ───────┐
↓
[动态数组]
↑
复制对象 scores_ ─────┘
两个对象都认为自己拥有同一块内存,可能造成:
- 重复释放;
- 悬空指针;
- 修改一个对象时影响另一个对象。
因此,如果类需要自己编写析构函数来释放资源,它通常还需要正确处理:
- 复制构造函数;
- 复制赋值运算符。
这称为三法则(Rule of Three)。
加入移动语义后,还通常需要:
- 移动构造函数;
- 移动赋值运算符。
这称为五法则(Rule of Five)。
不过,现代 C++ 更推荐回到零法则:使用 std::vector、std::string、智能指针等自管理类型,避免直接管理裸资源。
这和今天的主题有一条重要联系:
好的类型不仅存放数据,还应该帮助对象维持有效状态。
接下来,我们把这个思想从“对象资源”扩展到“函数返回值”。
类型安全不是“变量前面写了类型”这么简单
类型安全(type safety)最直接的含义是:
语言通过类型系统阻止不合理的操作。
例如,下面的 C++ 代码不能通过编译:
#include <string>
int divideByThree(int value) {
return value / 3;
}
int main() {
divideByThree("hello");
}
编译器知道:
divideByThree 需要 int
"hello" 的类型是字符串字面量
两者不匹配,因此程序在运行前就被拒绝。
而在动态类型语言中,类似错误可能要等到执行相应语句时才被发现。
这里不是在说“C++ 一定比动态语言好”,而是在观察一个具体优势:
越早发现错误,错误越容易定位,也越不容易进入正式运行环境。
编译错误、运行时错误与逻辑错误
类型系统能够阻止一部分错误,但不是所有错误。
int divideByThree(int value) {
return value / 3;
}
调用:
divideByThree(9);
类型正确,结果也是预期的。
但是:
divideByThree(10);
返回的是整数 3,小数部分被丢弃。代码仍然类型正确,但可能不符合程序需求。
错误可以大致分成:
| 错误类型 | 发生阶段 | 示例 |
|---|---|---|
| 编译错误 | 编译期 | 把字符串传给需要 int 的函数 |
| 运行时错误 | 运行期 | 调用 .value() 读取空的 optional |
| 未定义行为 | 运行期,但结果不受语言约束 | 对空 vector 调用 back() |
| 逻辑错误 | 程序运行,但结果不符合需求 | 单位混用、公式写错 |
| 设计问题 | 接口容易被误用 | 函数承诺返回一个实际上可能不存在的值 |
课件用火星任务作为现实类比:如果两个模块使用不同单位,却都只把数据表示成普通数字,编译器看见的可能只是:
double value;
类型系统无法判断它代表:
- 米;
- 千米;
- 秒;
- 磅力;
- 牛顿。
这说明类型安全不仅是“避免把字符串除以三”,还包括:
类型能否准确表达数据的含义与程序允许的状态。
函数签名是一份合同
函数签名(function signature)告诉调用者:
- 函数需要什么;
- 函数会返回什么。
例如:
int square(int value);
它向调用者承诺:
给我一个 int
我会给你一个 int
再看寻找下标的函数:
int findIndex(const std::vector<int>& values, int target);
这个签名同样承诺:
无论是否找到,我都会返回一个 int
但是,函数真正存在两种结果:
找到 → 有一个合法下标
没找到 → 没有合法下标
返回类型 int 没有表达第二种状态。
这就是一个不够类型安全的接口。
第一次尝试:用特殊数字表示“没有找到”
一种常见写法是返回 -1:
#include <iostream>
#include <vector>
int findIndex(const std::vector<int>& values, int target) {
for (int i = 0; i < static_cast<int>(values.size()); ++i) {
if (values[i] == target) {
return i;
}
}
return -1;
}
int main() {
std::vector<int> values{10, 20, 30};
int index = findIndex(values, 99);
std::cout << index << '\n';
}
输出:
-1
这种特殊值称为哨兵值(sentinel value)。
它的问题不是完全不能使用,而是类型没有区分:
合法下标
失败标记
两者都只是 int。
调用者很容易忘记检查:
int index = findIndex(values, 99);
std::cout << values[index] << '\n';
此时 index == -1,却被当成下标使用。
为什么改成 std::size_t 也没有解决
容器下标通常使用:
std::size_t
它是无符号整数类型,不能自然表示 -1。
即使强行返回:
return static_cast<std::size_t>(-1);
也只会得到一个很大的无符号整数,并没有真正表达“没有值”。
核心矛盾仍然存在:
我们想返回的不是“某个特殊下标”,而是“可能有下标,也可能没有下标”。
练习:哨兵值是否被正确检查
下面的代码有什么问题?
#include <iostream>
#include <vector>
int findIndex(const std::vector<int>& values, int target) {
for (int i = 0; i < static_cast<int>(values.size()); ++i) {
if (values[i] == target) {
return i;
}
}
return -1;
}
int main() {
std::vector<int> values{4, 8, 12};
int index = findIndex(values, 10);
if (index) {
std::cout << "found\n";
} else {
std::cout << "not found\n";
}
}
答案与解释
这段代码存在逻辑错误。
当目标在下标 0 时:
findIndex(values, 4)
返回:
0
而 0 转换成 bool 是 false,程序会错误地输出:
not found
当目标不存在时,函数返回:
-1
非零整数转换成 bool 是 true,程序反而会输出:
found
正确检查应该是:
if (index != -1) {
std::cout << "found\n";
} else {
std::cout << "not found\n";
}
初学者容易误判,是因为日常语言里的“找到”与“真”很接近,但整数的真假只取决于是否为零,并不知道 -1 在这个程序中代表失败。
一个更危险的例子:空容器有“最后一个元素”吗
课件给出了下面的函数:
void removeOddsFromEnd(std::vector<int>& values) {
while (values.back() % 2 == 1) {
values.pop_back();
}
}
它想完成的任务是:
从末尾连续删除奇数,直到遇到偶数。
例如:
调用前:
values = [2, 4, 7, 9, 11]
执行后:
values = [2, 4]
我们逐步执行:
末尾是 11,奇数 → 删除
values = [2, 4, 7, 9]
末尾是 9,奇数 → 删除
values = [2, 4, 7]
末尾是 7,奇数 → 删除
values = [2, 4]
末尾是 4,偶数 → 循环结束
看起来没有问题。
但是,如果容器一开始就是空的呢?
std::vector<int> values;
removeOddsFromEnd(values);
第一次判断条件时会执行:
values.back()
空容器没有最后一个元素。
所有元素都是奇数时也会出错
考虑:
values = [1, 3, 5]
执行过程:
删除 5 → [1, 3]
删除 3 → [1]
删除 1 → []
循环还会再次检查条件:
values.back()
此时容器已经为空。
所以问题不仅发生在“输入一开始为空”时,还发生在:
删除过程中,容器变为空之后。
未定义行为意味着什么
对空的 std::vector 调用 back() 会产生未定义行为(undefined behavior)。
未定义行为不是一种固定的错误结果。
程序可能:
- 崩溃;
- 输出垃圾值;
- 暂时看起来正常;
- 在开启优化后表现改变;
- 在另一台机器上表现不同;
- 破坏其他对象的数据。
不能把它理解成:
一定抛异常
也不能理解成:
一定返回 0
更准确地说:
C++ 标准不再对之后发生的事情提供要求。
为什么 back() 会出问题
可以用一个简化的实现建立直觉:
T& back() {
return *(begin() + size() - 1);
}
这不是要求标准库必须采用的真实源码,只是用于理解。
当:
size() = 3
时:
begin() + size() - 1
= begin() + 2
指向最后一个元素。
begin()
↓
[10][20][30]
↑
begin() + 2
但是,当:
size() = 0
时,表达式变成:
begin() - 1
它并不指向容器中的合法元素。
随后执行:
*
试图访问那个位置,于是产生未定义行为。
第一种修复:在调用前检查前置条件
正确版本是:
#include <vector>
void removeOddsFromEnd(std::vector<int>& values) {
while (!values.empty() && values.back() % 2 != 0) {
values.pop_back();
}
}
这里依赖逻辑与运算符 && 的短路求值(short-circuit evaluation)。
表达式:
!values.empty() && values.back() % 2 != 0
按照从左到右的顺序判断。
如果:
!values.empty()
为 false,右边的:
values.back()
不会执行。
状态变化如下:
values = []
检查 !values.empty()
结果为 false
因为左边已经是 false:
不执行 values.back()
循环结束
什么是前置条件
前置条件(precondition)是:
调用某个操作之前必须成立的条件。
std::vector::back() 的前置条件是:
vector 不能为空
标准库没有在类型中强制表达这个条件,所以检查责任落在程序员身上。
这段代码:
values.back()
从语法上看不出:
调用前必须先确认 values 非空
这就引出了更深一层的问题:
如果错误后果这么严重,为什么函数签名没有提醒我们?
练习:跟踪短路求值
预测下面程序的输出:
#include <iostream>
#include <vector>
int main() {
std::vector<int> values;
if (!values.empty() && values.back() == 10) {
std::cout << "A\n";
} else {
std::cout << "B\n";
}
}
答案与解释
程序输出:
B
执行过程:
values.empty() → true
!values.empty() → false
&& 的左操作数已经为 false,整个表达式必然为 false。
因此:
values.back() == 10
不会执行,也不会触发未定义行为。
程序进入 else 分支,输出 B。
如果把顺序写反:
values.back() == 10 && !values.empty()
就会先调用 back(),检查已经太晚了。
确定地抛出异常是否更好
我们可以想象一个带检查的版本:
T& checkedBack() {
if (empty()) {
throw std::out_of_range("empty container");
}
return *(begin() + size() - 1);
}
这样至少有两种确定结果:
容器非空 → 返回最后一个元素
容器为空 → 抛出异常
它比未定义行为更可预测。
不过,函数的返回类型仍然是:
T&
只看签名:
T& checkedBack();
调用者仍然看不出:
这个操作可能失败
失败信息藏在文档和实现中,而不在类型中。
这使我们需要重新理解类型安全:
类型安全不仅是防止“字符串除以三”,还包括让函数签名尽可能准确地描述函数行为。
第二种尝试:返回 bool 和结果组成的 pair
我们可以返回两个值:
第一个值:是否成功
第二个值:实际结果
例如:
std::pair<bool, int> result;
状态可能是:
{true, 30} → 有结果,结果是 30
{false, 0} → 没有结果,第二项只是占位
一个简单示例:
#include <iostream>
#include <utility>
#include <vector>
std::pair<bool, int> safeBack(const std::vector<int>& values) {
if (values.empty()) {
return {false, 0};
}
return {true, values.back()};
}
int main() {
std::vector<int> values;
auto result = safeBack(values);
if (result.first) {
std::cout << result.second << '\n';
} else {
std::cout << "没有最后一个元素\n";
}
}
这种设计确实把“成功状态”放进了返回类型中,但仍然有几个问题。
问题一:无结果时仍然必须构造一个 T
如果返回类型是:
std::pair<bool, T>
那么即使没有结果,pair 中仍然必须存在一个 T。
我们可能被迫写:
return {false, T{}};
但是,并非所有类型都有默认构造函数:
class User {
public:
explicit User(std::string name)
: name_(std::move(name)) {
}
private:
std::string name_;
};
下面的表达式不合法:
User{}
因为 User 必须接收名字。
问题二:占位对象可能很昂贵
即使 T{} 合法,它也可能需要:
- 分配内存;
- 初始化大量数据;
- 创建文件、锁或其他资源。
我们只是想表示“没有值”,却被迫构造一个根本不会使用的对象。
问题三:调用者仍然可能忽略 bool
下面的代码可以编译:
auto result = safeBack(values);
std::cout << result.second << '\n';
调用者完全没有检查:
result.first
类型没有阻止这种误用。
课件中 pair<bool, T&> 示例的问题
课件展示了类似的思路:
std::pair<bool, T&>
并在失败时尝试返回:
{false, T()}
这段代码本身通常无法成立,因为:
T&必须引用一个已经存在的有效对象;T()创建的是临时对象;- 普通左值引用不能这样绑定到临时对象;
- 临时对象很快就会销毁,也不能作为长期有效的引用返回。
因此,这个例子的教学目的应该理解为:
pair<bool, value>虽然能表达成功与否,但仍然被迫携带一个值,设计很别扭。
不能把课件中的伪代码直接复制为真实实现。
我们真正需要的类型:T 或者什么都没有
现在可以准确描述需求了。
函数结果不是:
一个 T,加上一个真假标志
而是:
有一个 T
或者
没有 T
C++ 标准库为此提供了:
可选值(optional value)
std::optional<T>
它在 C++17 中加入标准库,头文件是:
#include <optional>
std::optional<T> 有两种状态:
有值状态(engaged)
┌──────────────────┐
│ 一个有效的 T 对象 │
└──────────────────┘
或者:
无值状态(disengaged)
┌──────────────────┐
│ 没有 T 对象 │
└──────────────────┘
例如:
std::optional<int>
可以表示:
有值:42
无值:没有任何 int
它不是“里面放了一个特殊整数”,而是对象本身记录当前是否包含一个 int。
std::nullopt 与 nullptr 不是同一个东西
这两个名字很像,但用途不同。
nullptr
nullptr 用来表示空指针:
int* pointer = nullptr;
它表达:
pointer 当前不指向 int 对象
std::nullopt
std::nullopt 用来表示空的 optional:
std::optional<int> number = std::nullopt;
它表达:
number 当前不包含 int
错误写法:
std::optional<int> number = nullptr;
nullptr 不是空 optional 的标志。
同样:
int* pointer = std::nullopt;
也不合法。
对比:
| 表达式 | 表示什么 |
|---|---|
nullptr |
没有指向对象的指针 |
std::nullopt |
不包含值的 std::optional |
0 |
整数零,不等于“没有整数” |
"" |
空字符串,但仍然是一个存在的字符串 |
创建与修改 std::optional
下面是一个可以独立运行的示例:
#include <iostream>
#include <optional>
int main() {
std::optional<int> number;
std::cout << number.has_value() << '\n';
number = 42;
std::cout << number.has_value() << '\n';
std::cout << number.value() << '\n';
number = std::nullopt;
std::cout << number.has_value() << '\n';
}
输出:
0
1
42
0
逐步跟踪状态。
第一步:默认构造
std::optional<int> number;
此时:
number
┌────────────────────┐
│ 状态:无值 │
│ 内部没有 int 对象 │
└────────────────────┘
第二步:赋值
number = 42;
此时,一个 int 被放入 optional:
number
┌────────────────────┐
│ 状态:有值 │
│ int:42 │
└────────────────────┘
第三步:清空
number = std::nullopt;
内部的 int 被销毁,optional 回到无值状态:
number
┌────────────────────┐
│ 状态:无值 │
│ 内部没有 int 对象 │
└────────────────────┘
也可以用:
number.reset();
清除其中的值。
{} 与 std::nullopt
下面两种写法都会创建空的 optional:
std::optional<int> first{};
std::optional<int> second = std::nullopt;
作为返回值时也可以写:
return {};
不过,对于初学阶段,写成:
return std::nullopt;
通常更容易看出代码意图。
用 optional 重写寻找下标的函数
现在,函数签名可以准确表达两种结果:
std::optional<std::size_t>
findIndex(const std::vector<int>& values, int target);
完整程序如下:
#include <cstddef>
#include <iostream>
#include <optional>
#include <vector>
std::optional<std::size_t>
findIndex(const std::vector<int>& values, int target) {
for (std::size_t index = 0;
index < values.size();
++index) {
if (values[index] == target) {
return index;
}
}
return std::nullopt;
}
int main() {
std::vector<int> values{106, 107, 111, 143};
std::optional<std::size_t> result =
findIndex(values, 111);
if (result.has_value()) {
std::cout << "下标是 "
<< result.value()
<< '\n';
} else {
std::cout << "没有找到\n";
}
}
输出:
下标是 2
函数调用过程
调用:
findIndex(values, 111);
参数类型:
values:const std::vector<int>&
target:int
因为 values 通过常量引用传入:
- 不会复制整个
vector; - 函数不能修改原容器。
循环状态:
index = 0
values[0] = 106
106 != 111
index = 1
values[1] = 107
107 != 111
index = 2
values[2] = 111
111 == 111
执行:
return index;
虽然返回类型是:
std::optional<std::size_t>
但 index 是:
std::size_t
编译器会用它构造一个有值的 optional。
最终状态:
result
┌────────────────────────┐
│ 状态:有值 │
│ std::size_t:2 │
└────────────────────────┘
如果没有找到,则执行:
return std::nullopt;
最终状态:
result
┌────────────────────────┐
│ 状态:无值 │
└────────────────────────┘
函数签名现在直接告诉调用者:
你不能假定下标一定存在。
练习:判断返回状态
预测输出:
#include <iostream>
#include <optional>
#include <vector>
std::optional<int>
firstEven(const std::vector<int>& values) {
for (int value : values) {
if (value % 2 == 0) {
return value;
}
}
return std::nullopt;
}
int main() {
auto first = firstEven({1, 3, 8, 10});
auto second = firstEven({1, 3, 5});
std::cout << first.has_value() << '\n';
std::cout << second.has_value() << '\n';
}
答案与解释
输出:
1
0
第一次调用:
1 → 奇数
3 → 奇数
8 → 偶数
函数返回:
return 8;
因此 first 是有值状态。
第二次调用检查完:
1、3、5
没有找到偶数,于是返回:
std::nullopt
因此 second 是无值状态。
has_value() 返回的是真假值,使用 std::cout 默认打印为:
true → 1
false → 0
std::optional 的核心接口
.has_value():检查有没有值
if (result.has_value()) {
// result 中存在一个 T
}
返回类型是:
bool
它只回答:
optional 当前是否包含值
转换成 bool
optional 也可以直接出现在条件中:
if (result) {
// 有值
}
它与下面的判断含义相同:
if (result.has_value()) {
}
因此:
if (!result) {
// 无值
}
也是合法的。
需要准确理解:
这里判断的是“是否包含值”,不是“内部的值是真是假”。
这个区别稍后会产生一个重要陷阱。
.value():经过检查地访问值
result.value()
如果 result 有值,它会返回内部值。
如果 result 没有值,它会抛出:
std::bad_optional_access
完整示例:
#include <iostream>
#include <optional>
int main() {
std::optional<int> number = 5;
if (number.has_value()) {
std::cout << number.value() << '\n';
}
}
输出:
5
如果写成:
std::optional<int> number = std::nullopt;
std::cout << number.value() << '\n';
程序会抛出异常。如果异常没有被处理,程序通常会终止。
它和未定义行为不同:
.value() 访问空 optional
→ 按照标准规定抛出异常
*optional 访问空 optional
→ 违反前置条件,可能产生未定义行为
*optional:像指针一样取值
有值时,可以使用:
*result
代替:
result.value()
例如:
if (result) {
std::cout << *result << '\n';
}
但 * 不会替你抛出 bad_optional_access。
必须先确认有值:
if (result) {
use(*result);
}
不要写:
std::optional<int> result = std::nullopt;
std::cout << *result;
->:访问内部对象的成员
当 optional 中装的是类对象时,可以使用 ->:
#include <iostream>
#include <optional>
#include <string>
struct Student {
std::string name;
int score;
};
int main() {
std::optional<Student> student =
Student{"Mishi", 95};
if (student) {
std::cout << student->name << '\n';
std::cout << student->score << '\n';
}
}
这相当于:
(*student).name
同样,使用前必须确认 student 有值。
.value_or(defaultValue):没有值时使用默认值
result.value_or(defaultValue)
含义是:
有值 → 返回内部值
无值 → 返回给定的默认值
示例:
#include <iostream>
#include <optional>
int main() {
std::optional<int> first = 5;
std::optional<int> second = std::nullopt;
std::cout << first.value_or(999) << '\n';
std::cout << second.value_or(999) << '\n';
}
输出:
5
999
value_or() 适合这种需求:
缺失并不需要特殊处理,可以自然地使用某个备用值。
例如:
int retryCount =
configuredRetryCount.value_or(3);
但如果“没有值”本身是重要情况,就不应该随便用默认值掩盖它。
四种访问方式的对比
| 写法 | 无值时的行为 | 适用场景 |
|---|---|---|
opt.has_value() |
返回 false |
明确检查状态 |
if (opt) |
条件为 false |
简洁地检查状态 |
opt.value() |
抛出 bad_optional_access |
已检查,或希望失败时抛异常 |
*opt |
违反前置条件 | 已经确定有值 |
opt.value_or(x) |
返回 x |
缺失时有自然默认值 |
推荐的初学者写法是:
if (opt) {
use(*opt);
}
它既简洁,也明确展示了检查过程。
练习:.value() 和 .value_or()
预测输出,并判断程序是否会抛出异常:
#include <iostream>
#include <optional>
int main() {
std::optional<int> a = 20;
std::optional<int> b = std::nullopt;
std::cout << a.value_or(7) << '\n';
std::cout << b.value_or(7) << '\n';
if (b) {
std::cout << b.value() << '\n';
}
std::cout << "done\n";
}
答案与解释
输出:
20
7
done
第一行:
a.value_or(7)
a 有值,因此返回内部的 20。
第二行:
b.value_or(7)
b 没有值,因此返回默认值 7。
随后:
if (b)
条件为 false,不会执行:
b.value()
所以不会抛出异常。
最后输出:
done
用 optional 设计“安全的最后一个元素”
课件把 std::vector::back() 改写成返回 optional,这是一个接口设计实验:
std::optional<T> back();
需要明确:
真实的
std::vector::back()并不返回std::optional,目前仍然返回引用。
真实接口大致是:
T& back();
const T& back() const;
并且要求容器非空。
如果我们为自己的程序编写一个安全版本,可以先按值返回:
#include <optional>
#include <vector>
template <typename T>
std::optional<T>
safeBackCopy(const std::vector<T>& values) {
if (values.empty()) {
return std::nullopt;
}
return values.back();
}
使用:
#include <iostream>
#include <optional>
#include <vector>
template <typename T>
std::optional<T>
safeBackCopy(const std::vector<T>& values) {
if (values.empty()) {
return std::nullopt;
}
return values.back();
}
int main() {
std::vector<int> values{10, 20, 30};
auto last = safeBackCopy(values);
if (last) {
std::cout << *last << '\n';
}
}
输出:
30
这个版本发生了复制
调用:
return values.back();
values.back() 返回原容器中元素的引用。
但是返回类型是:
std::optional<T>
因此会把该元素复制到 optional 中。
状态关系:
values
[10][20][30]
↑
原始元素
last
┌─────────────┐
│ 复制得到的 30 │
└─────────────┘
修改:
*last = 100;
不会修改 values.back(),因为 last 中保存的是副本。
对于 int,复制成本很低。
对于大型对象,复制可能昂贵;对于不可复制对象,这种接口甚至不可用。
我们自然会想到:
std::optional<T&>
但标准 C++ 不允许这样写。
为什么不存在 std::optional<T&>
引用必须始终引用一个有效对象。
int value = 10;
int& reference = value;
引用不能处于:
暂时不引用任何对象
这样的状态。
而 optional 的核心能力恰好是:
有一个 T
或者没有 T
把二者直接组合成:
std::optional<T&>
会带来复杂的赋值、重新绑定与生命周期语义,因此标准库没有提供这个特化。
下面的声明不合法:
std::optional<int&> result;
自定义接口可以使用 std::reference_wrapper
如果确实需要“可能存在的引用”,可以使用:
std::optional<std::reference_wrapper<T>>
完整示例:
#include <functional>
#include <iostream>
#include <optional>
#include <vector>
template <typename T>
std::optional<std::reference_wrapper<T>>
safeBack(std::vector<T>& values) {
if (values.empty()) {
return std::nullopt;
}
return std::ref(values.back());
}
int main() {
std::vector<int> values{10, 20, 30};
auto last = safeBack(values);
if (last) {
last->get() = 99;
}
std::cout << values.back() << '\n';
}
输出:
99
执行关系:
values
[10][20][30]
↑
│
last 中的 reference_wrapper
这里没有复制最后一个元素。
last->get() 得到原元素的引用,因此赋值:
last->get() = 99;
直接修改容器中的最后一个元素。
不过,这种设计要求原容器和元素仍然存活,而且不能在引用使用前执行会让引用失效的操作。
因此,引用包装器虽然解决了复制问题,也引入了生命周期责任。
operator[]、.at()、.back() 与自定义安全访问
课件进一步讨论了:
values[0]
如果 values 为空,operator[] 同样会访问不存在的元素。
标准库同时提供了:
values[index]
和:
values.at(index)
它们的差异是:
operator[]
values[index]
- 不做边界检查;
- 下标非法时产生未定义行为;
- 适合已经确定下标合法的代码。
.at()
values.at(index)
- 检查
index < values.size(); - 越界时抛出
std::out_of_range; - 适合需要运行时保护的代码。
.back()
values.back()
- 返回最后一个元素的引用;
- 要求容器非空;
- 空容器调用时产生未定义行为。
自定义 safeBack
safeBack(values)
- 把“可能没有最后一个元素”写进返回类型;
- 调用者必须面对空状态;
- 可能涉及复制,或者
reference_wrapper的生命周期问题。
为什么标准库保留危险版本
课件回顾了 C++ 的一些设计思想:
- 不为所有操作强制同一种风格;
- 允许程序员控制性能成本;
- 尽量不给不需要检查的代码增加检查开销;
- 同时提供不同安全等级的接口;
- 在可能时优先通过编译期类型系统保证安全;
- 但也保留底层、直接、需要程序员承担责任的操作。
因此,C++ 经常同时提供:
快速但要求前置条件的接口
以及
带检查但可能有额外成本的接口
这并不意味着应该永远使用危险版本。
实际选择取决于:
- 下标是否已经被证明合法;
- 性能是否真的关键;
- 错误是否可以恢复;
- API 是否面向不受信任的输入;
- 代码是否容易被其他人误用。
回到删除末尾奇数:哪种写法最好
对于真实的 std::vector,最直接的 C++20 写法仍然是:
#include <vector>
void removeOddsFromEnd(std::vector<int>& values) {
while (!values.empty() &&
values.back() % 2 != 0) {
values.pop_back();
}
}
这里没必要强行引入 optional。
原因是:
empty()与back()都属于同一个容器;- 前置条件检查非常直接;
- 没有复制元素;
- 不需要创建额外对象;
- 代码含义清楚。
optional 并不是“任何检查都应该使用的万能替代品”。
它最适合:
函数本身的正常结果就可能不存在,而且这种缺失应当交给调用者处理。
使用 safeBackCopy 的版本
为了练习 optional,也可以这样写:
#include <optional>
#include <vector>
std::optional<int>
safeBackCopy(const std::vector<int>& values) {
if (values.empty()) {
return std::nullopt;
}
return values.back();
}
void removeOddsFromEnd(std::vector<int>& values) {
while (true) {
std::optional<int> last =
safeBackCopy(values);
if (!last) {
break;
}
if (*last % 2 == 0) {
break;
}
values.pop_back();
}
}
执行过程中,每次循环只调用一次 safeBackCopy。
不要写成:
while (safeBackCopy(values).has_value() &&
safeBackCopy(values).value() % 2 != 0) {
}
因为这样会重复调用函数并创建多个临时 optional。
更重要的是,两次调用之间如果程序状态可能改变,结果甚至可能不一致。
optional<bool> 的特殊陷阱
考虑:
std::optional<bool> result = false;
现在判断:
if (result) {
std::cout << "true\n";
}
会进入 if 分支。
为什么?
因为:
if (result)
检查的是:
result 是否包含一个 bool
而不是:
result 内部的 bool 是否为 true
此时:
result 的状态:有值
内部的值:false
所以:
static_cast<bool>(result) → true
*result → false
状态图:
result
┌────────────────────┐
│ 状态:有值 │ → 条件判断为 true
│ 内部 bool:false │ → 取值后为 false
└────────────────────┘
要判断内部布尔值,应当写:
if (result.value_or(false)) {
}
或者:
if (result && *result) {
}
练习:optional<bool> 判断的是什么
预测输出:
#include <iostream>
#include <optional>
int main() {
std::optional<bool> a = true;
std::optional<bool> b = false;
std::optional<bool> c = std::nullopt;
std::cout << static_cast<bool>(a) << '\n';
std::cout << static_cast<bool>(b) << '\n';
std::cout << static_cast<bool>(c) << '\n';
std::cout << b.value_or(true) << '\n';
std::cout << c.value_or(true) << '\n';
}
答案与解释
输出:
1
1
0
0
1
前三行检查的是是否有值:
a 包含 true → 有值 → 1
b 包含 false → 有值 → 1
c 不包含值 → 无值 → 0
后两行取得内部值或默认值:
b 有值,内部是 false → 0
c 无值,使用默认 true → 1
初学者容易把:
if (optionalBool)
误认为是在检查内部布尔值。实际上它只检查 optional 是否处于有值状态。
std::optional 的优势与代价
优势:接口更加诚实
对比:
int findIndex(...);
与:
std::optional<std::size_t> findIndex(...);
第二个签名明确告诉调用者:
结果可能不存在
这是一份更有信息量的合同。
优势:不需要发明特殊值
不再需要:
-1
0
空字符串
"NOT_FOUND"
这样的哨兵值。
合法数据与缺失状态不会混在一起。
优势:只在有值时构造 T
空的:
std::optional<T>
不需要构造一个占位用的 T。
这比:
std::pair<bool, T>
更符合真实需求。
代价:调用代码变长
原来可能直接写:
int index = findIndex(values, target);
现在需要处理:
auto index = findIndex(values, target);
if (index) {
use(*index);
} else {
handleMissingValue();
}
但这种“麻烦”有一部分正是 optional 的价值:
它迫使程序员意识到结果可能不存在。
代价:仍然可以被错误使用
下面仍然可能出错:
std::optional<int> value = std::nullopt;
value.value(); // 抛异常
*value; // 违反前置条件
optional 提高了接口表达能力,但不能阻止程序员故意跳过检查。
代价:可能产生复制与额外状态
optional<T> 通常需要同时存储:
- 一个能够容纳
T的空间; - 一个记录是否有值的状态。
如果按值返回大型对象,还可能发生复制或移动。
因此,不能机械地把所有返回值都包成 optional。
C++23:把连续的可选计算连接起来
接下来的三个成员函数来自 C++23:
.and_then()
.transform()
.or_else()
它们不能保证在:
g++ -std=c++20
下使用。
下面这一节的示例需要:
g++ -std=c++23 main.cpp -o main
课件没有突出这一点,但它对实际编译非常重要。
.and_then():下一步也可能失败
假设我们有一个函数:
std::optional<int> halfIfEven(int value);
规则是:
偶数 → 返回它的一半
奇数 → 返回 nullopt
完整代码:
#include <iostream>
#include <optional>
std::optional<int> halfIfEven(int value) {
if (value % 2 != 0) {
return std::nullopt;
}
return value / 2;
}
int main() {
std::optional<int> start = 8;
auto result =
start
.and_then(halfIfEven)
.and_then(halfIfEven)
.and_then(halfIfEven);
if (result) {
std::cout << *result << '\n';
}
}
输出:
1
计算过程:
start = optional(8)
8 是偶数
halfIfEven(8) → optional(4)
4 是偶数
halfIfEven(4) → optional(2)
2 是偶数
halfIfEven(2) → optional(1)
如果起点是:
std::optional<int> start = 10;
过程是:
10 → optional(5)
5 是奇数 → nullopt
之后的 .and_then() 不再调用函数,而是继续传播 nullopt。
.and_then() 所接收的函数形状是:
T → optional<U>
也就是:
输入一个普通值,但计算可能失败。
.transform():下一步不会失败
如果函数一定会返回结果:
int square(int value) {
return value * value;
}
可以使用:
.transform(square)
完整代码:
#include <iostream>
#include <optional>
int square(int value) {
return value * value;
}
int main() {
std::optional<int> first = 5;
std::optional<int> second = std::nullopt;
auto firstResult = first.transform(square);
auto secondResult = second.transform(square);
std::cout << firstResult.value_or(-1) << '\n';
std::cout << secondResult.value_or(-1) << '\n';
}
输出:
25
-1
计算关系:
first = optional(5)
square(5) = 25
结果 = optional(25)
而:
second = nullopt
不调用 square
结果继续为 nullopt
.transform() 接收的函数形状是:
T → U
也就是:
输入一个普通值,而且计算本身不会用
optional表示失败。
.and_then() 与 .transform() 的区别
| 操作 | 传入函数返回什么 | 适合什么情况 |
|---|---|---|
.and_then(f) |
std::optional<U> |
下一步也可能失败 |
.transform(f) |
普通的 U |
下一步只做普通转换 |
例如:
std::optional<int> parseNumber(...);
可能失败,适合参与 .and_then()。
而:
int square(int);
总能返回一个整数,适合 .transform()。
.or_else():缺失时采用备用计算
.or_else() 在当前 optional 无值时调用备用函数。
#include <iostream>
#include <optional>
std::optional<int> fallback() {
return 42;
}
int main() {
std::optional<int> good = 10;
std::optional<int> bad = std::nullopt;
auto first = good.or_else(fallback);
auto second = bad.or_else(fallback);
std::cout << first.value() << '\n';
std::cout << second.value() << '\n';
}
输出:
10
42
过程:
good 有值
→ 不调用 fallback
→ 保留 optional(10)
bad 无值
→ 调用 fallback()
→ 得到 optional(42)
传给 .or_else() 的函数通常形如:
() → optional<T>
修正课件中的链式 back() 示例
课件尝试用 .and_then() 判断最后一个数字是否为奇数,但原示例存在几个问题。
问题一:.and_then() 传入的是内部的 T
对于:
std::optional<int>
.and_then() 调用的函数应该接收:
int
而不是:
std::optional<int>
问题二:判断奇偶不会失败
函数:
[](int value) {
return value % 2 != 0;
}
一定能够返回 bool,所以更适合 .transform(),而不是 .and_then()。
问题三:不能直接把 optional<bool> 当内部布尔值使用
std::optional<bool>{false}
仍然是有值状态,转换成条件时为 true。
正确的 C++23 写法是:
#include <optional>
#include <vector>
std::optional<int>
safeBackCopy(const std::vector<int>& values) {
if (values.empty()) {
return std::nullopt;
}
return values.back();
}
void removeOddsFromEnd(std::vector<int>& values) {
while (
safeBackCopy(values)
.transform([](int value) {
return value % 2 != 0;
})
.value_or(false)
) {
values.pop_back();
}
}
这里:
.transform(...)
得到:
std::optional<bool>
再通过:
.value_or(false)
转换为真正用于 while 的 bool。
状态变化:
容器非空,末尾为奇数
→ optional<int>{奇数}
→ transform 后 optional<bool>{true}
→ value_or(false) 得到 true
→ 删除末尾元素
容器非空,末尾为偶数
→ optional<int>{偶数}
→ transform 后 optional<bool>{false}
→ value_or(false) 得到 false
→ 结束循环
容器为空
→ nullopt
→ transform 不调用函数,仍是 nullopt
→ value_or(false) 得到 false
→ 结束循环
什么是课件所说的“单子式链式处理”
课件把这种模式称为单子式(monadic)处理。
这里先建立直觉,不展开抽象数学定义。
关键思想是:
把一个值放在具有额外状态的类型中,然后让一系列操作自动传播这种状态。
对于 optional,额外状态是:
可能没有值
例如:
optional(8)
↓ halfIfEven
optional(4)
↓ halfIfEven
optional(2)
一旦某一步得到:
nullopt
后面的操作会直接传播缺失状态,而不再重复编写:
if (value) {
if (nextValue) {
if (anotherValue) {
}
}
}
课件还把范围视图(ranges views)的链式组合拿来类比:
numbers
| std::views::transform(...)
| std::views::filter(...);
二者共同体现了:
把小操作连接成处理流程
不过,“范围管道一定就是严格意义上的单子”是一个过度简化。初学阶段可以把它理解为组合式编程的相似设计思路。
C++20 中如何完成同样的事情
在 C++20 中,没有 optional::and_then()、transform() 和 or_else()。
可以直接使用条件判断:
#include <iostream>
#include <optional>
std::optional<int> halfIfEven(int value) {
if (value % 2 != 0) {
return std::nullopt;
}
return value / 2;
}
int main() {
std::optional<int> value = 8;
if (value) {
value = halfIfEven(*value);
}
if (value) {
value = halfIfEven(*value);
}
if (value) {
value = halfIfEven(*value);
}
if (value) {
std::cout << *value << '\n';
}
}
这比 C++23 的链式写法重复,但语义完全可以表达。
本节课最重要的内容并不是链式函数,而是:
用类型表达“值可能不存在”,并在使用前处理这种可能性。
其他语言如何处理“可能没有值”
课件提到了一些更积极使用可选类型或缺失值链式处理的语言。
Rust
Rust 使用:
Option<T>
状态是:
Some(value)
None
并通过类型系统严格要求调用者处理两种情况。
Swift
Swift 使用:
T?
表示可选值,并提供可选绑定与可选链式调用。
JavaScript
JavaScript 有:
null;undefined;- 可选链
?.; - 空值合并
??。
不过,它不像 Rust 和 Swift 那样提供静态的 Option<T> 类型保证。课件把它放在一起,主要是为了说明“缺失值传播和链式处理”的相似用法,不能认为它们具有相同的类型安全程度。
什么时候应该使用 std::optional
适合使用的情况通常是:
查找可能失败
std::optional<std::size_t>
findIndex(...);
配置可能不存在
std::optional<int> readPortFromConfig();
对象某个属性确实是可选的
struct UserProfile {
std::string name;
std::optional<std::string> nickname;
};
正常计算可能没有合法结果
std::optional<double>
average(const std::vector<int>& values);
空数组没有自然平均值,因此可以返回 nullopt。
什么时候不应该使用 std::optional
缺失代表严重程序错误
如果无值说明程序内部状态已经损坏,可能更适合:
- 断言;
- 异常;
- 错误类型;
- 修正程序设计。
存在自然且没有歧义的默认值
例如某个配置缺失时本来就规定使用 3,可以直接在配置处理层填入默认值。
只需要循环中的简单前置条件检查
对于:
while (!values.empty() && values.back() % 2 != 0)
直接检查比包装成 optional 更清楚。
需要表达失败原因
optional 只能表达:
有值
无值
它不能说明为什么没有值。
如果需要区分:
文件不存在
权限不足
格式错误
网络超时
就需要能够携带错误信息的类型。现代 C++ 中一个自然的后续方向是 std::expected<T, E>,它在 C++23 中进入标准库。
初学者最容易犯的错误
错误一:把 nullopt 写成 nullptr
错误:
std::optional<int> value = nullptr;
正确:
std::optional<int> value = std::nullopt;
错误二:没有检查就使用 *
错误:
std::optional<int> value;
std::cout << *value;
正确:
if (value) {
std::cout << *value;
}
错误三:以为 .value() 会返回默认值
value.value()
无值时会抛异常,不会返回 0。
需要默认值时使用:
value.value_or(0)
错误四:把 optional<bool> 的状态当成内部值
错误:
std::optional<bool> result = false;
if (result) {
// 这里仍然会进入
}
正确:
if (result.value_or(false)) {
}
错误五:用 optional 隐藏本应处理的错误
int value = result.value_or(0);
如果 0 在业务中是合法值,那么调用者之后无法区分:
真正得到 0
还是
没有值所以使用 0
默认值必须有清晰语义。
错误六:认为真实的 vector::back() 返回 optional
真实代码仍然需要:
if (!values.empty()) {
use(values.back());
}
课件修改 back() 的写法只是接口设计实验。
错误七:在 C++20 中调用 C++23 接口
下面的代码:
opt.transform(...);
opt.and_then(...);
opt.or_else(...);
需要 C++23。
在 C++20 中可能出现类似:
std::optional<int> has no member named 'transform'
综合练习:安全查询课程平均分
我们要设计一个小程序。
每门课程包含:
课程名
若干次考试成绩
程序需要:
- 根据课程名查找课程;
- 课程可能不存在;
- 课程即使存在,也可能没有成绩;
- 只有存在成绩时才能计算平均分。
请补全两个函数:
std::optional<std::reference_wrapper<const Course>>
findCourse(...);
std::optional<double>
averageScore(...);
题目框架:
#include <functional>
#include <iostream>
#include <optional>
#include <string>
#include <vector>
struct Course {
std::string name;
std::vector<int> scores;
};
std::optional<std::reference_wrapper<const Course>>
findCourse(const std::vector<Course>& courses,
const std::string& name) {
// 补全
}
std::optional<double>
averageScore(const Course& course) {
// 补全
}
int main() {
std::vector<Course> courses{
{"CS106L", {90, 95, 100}},
{"CS106B", {}},
{"MATH51", {80, 85}}
};
std::string target = "CS106L";
auto course = findCourse(courses, target);
if (!course) {
std::cout << "课程不存在\n";
return 0;
}
auto average = averageScore(course->get());
if (!average) {
std::cout << "课程还没有成绩\n";
return 0;
}
std::cout << target
<< " 的平均分是 "
<< *average
<< '\n';
}
综合练习参考实现
#include <functional>
#include <iostream>
#include <optional>
#include <string>
#include <vector>
struct Course {
std::string name;
std::vector<int> scores;
};
std::optional<std::reference_wrapper<const Course>>
findCourse(const std::vector<Course>& courses,
const std::string& name) {
for (const Course& course : courses) {
if (course.name == name) {
return std::cref(course);
}
}
return std::nullopt;
}
std::optional<double>
averageScore(const Course& course) {
if (course.scores.empty()) {
return std::nullopt;
}
int sum = 0;
for (int score : course.scores) {
sum += score;
}
return static_cast<double>(sum) /
static_cast<double>(course.scores.size());
}
int main() {
std::vector<Course> courses{
{"CS106L", {90, 95, 100}},
{"CS106B", {}},
{"MATH51", {80, 85}}
};
std::string target = "CS106L";
auto course = findCourse(courses, target);
if (!course) {
std::cout << "课程不存在\n";
return 0;
}
auto average = averageScore(course->get());
if (!average) {
std::cout << "课程还没有成绩\n";
return 0;
}
std::cout << target
<< " 的平均分是 "
<< *average
<< '\n';
}
编译:
g++ -std=c++20 main.cpp -o main
输出:
CS106L 的平均分是 95
第一步:查找课程
调用:
auto course = findCourse(courses, "CS106L");
函数参数:
courses:const std::vector<Course>&
name:const std::string&
都通过常量引用传入,因此不会复制整个容器或字符串。
循环第一次检查:
course.name = "CS106L"
name = "CS106L"
匹配成功,返回:
std::cref(course)
它被包装成:
std::optional<std::reference_wrapper<const Course>>
状态:
course optional
┌───────────────────────────────┐
│ 状态:有值 │
│ 引用 ───────────────┐ │
└─────────────────────│─────────┘
↓
courses 中原来的 CS106L 对象
没有来的 CS106L 对象
没有复制整个 `Course`。
---
## 第二步:取得课程对象
```cpp
course->get()
这里分成两层:
course-> 访问 optional 内的 reference_wrapper
.get() 从 reference_wrapper 取出 Course 引用
得到的类型是:
const Course&
不会复制课程。
第三步:计算平均分
CS106L 的成绩是:
90, 95, 100
累加过程:
sum = 0
sum = 0 + 90 = 90
sum = 90 + 95 = 185
sum = 185 + 100 = 285
成绩数量:
3
计算:
285 / 3 = 95
函数返回:
return 95.0;
它被构造成:
optional<double>{95.0}
如果查询 CS106B
课程存在,但:
scores.empty()
为 true。
averageScore() 返回:
std::nullopt
程序输出:
课程还没有成绩
如果查询不存在的课程
例如:
std::string target = "CS999";
findCourse() 检查完所有课程仍未匹配,返回:
std::nullopt
程序输出:
课程不存在
这两个失败状态发生在不同阶段:
课程不存在
→ findCourse 返回 nullopt
课程存在但没有成绩
→ averageScore 返回 nullopt
虽然二者都使用 optional,但函数签名和调用位置让它们的含义保持清楚。
综合练习:找出代码中的所有问题
下面的程序包含多个错误或设计问题:
#include <iostream>
#include <optional>
#include <vector>
std::optional<int>
safeBack(const std::vector<int>& values) {
if (values.empty()) {
return 0;
}
return values.back();
}
int main() {
std::vector<int> values;
auto result = safeBack(values);
if (result) {
std::cout << "last = "
<< result.value()
<< '\n';
}
}
请判断:
- 它是否会产生未定义行为?
- 空容器时
result是有值还是无值? if (result)会不会进入?return 0;是否准确表达了没有最后一个元素?- 应当如何修改?
综合练习答案与解释
程序不会因为访问空容器产生未定义行为,因为:
if (values.empty())
阻止了 values.back() 的执行。
但是,空容器时执行的是:
return 0;
返回类型是:
std::optional<int>
所以 0 会被包装成:
optional<int>{0}
它是有值状态。
因此:
if (result)
条件为 true,程序输出:
last = 0
这错误地声称空容器的最后一个元素是 0。
正确实现是:
std::optional<int>
safeBack(const std::vector<int>& values) {
if (values.empty()) {
return std::nullopt;
}
return values.back();
}
或者:
if (values.empty()) {
return {};
}
这里再次体现:
0 → 一个真实存在的整数
std::nullopt → 没有整数
二者不能混为一谈。
本节课的完整知识串联
我们从一个很普通的问题开始:
查找失败时返回什么?
最初尝试返回 -1,但这种设计把:
合法值
失败状态
混在同一种整数类型中。
接着观察 std::vector::back():
T& back();
这个签名承诺返回一个元素引用,但空容器根本没有最后一个元素。
调用者必须知道并维护前置条件:
调用 back() 前,容器必须非空
忘记检查会导致未定义行为。
我们尝试过:
特殊值
异常
pair<bool, T>
它们分别存在问题:
特殊值
→ 可能与合法值冲突,容易忘记检查
异常
→ 行为确定,但可能失败的信息没有直接出现在返回类型中
pair<bool, T>
→ 无结果时仍然需要构造一个 T,调用者也可能忽略 bool
于是引入:
std::optional<T>
它直接表达:
一个 T
或者
没有 T
核心操作是:
.has_value()
if (opt)
.value()
.value_or(defaultValue)
*opt
opt->
其中:
.value()
→ 空状态时抛 bad_optional_access
*opt
→ 使用前必须自己确保有值
if (opt)
→ 判断是否有值,不是判断内部值本身
对于可能存在的引用,不能使用:
std::optional<T&>
但自定义接口可以考虑:
std::optional<std::reference_wrapper<T>>
最后,我们看到了 C++23 的组合操作:
.and_then()
.transform()
.or_else()
它们让“可能失败的连续步骤”可以链式传播缺失状态。
整节课的核心不是记住几个成员函数,而是形成一种 API 设计习惯:
如果一个状态在程序中真实存在,就应该尽量让类型系统知道它,而不是只把它写在注释里。
本节知识如何连接到下一步
std::optional<T> 只能回答:
有没有结果?
它不能回答:
为什么没有结果?
例如读取文件失败时,调用者可能需要知道:
文件不存在
权限不足
内容格式错误
磁盘读取失败
这会自然引出更完整的结果类型:
成功时携带 T
失败时携带错误 E
在现代 C++ 中,这对应:
std::expected<T, E>
另一个自然方向是亲自实现一个简化版 Optional<T>。在实现过程中,需要解决:
- 如何存放一个可能尚未构造的
T; - 什么时候调用构造函数;
- 什么时候调用析构函数;
- 复制和移动一个空
optional时发生什么; - 如何实现
operator*和operator bool; - 如何保证对象始终处于“有值”或“无值”两种合法状态。
这会把本节课与前面学习的:
类模板
特殊成员函数
移动语义
运算符重载
对象生命周期
重新连接在一起。
“Well-typed programs cannot go wrong.”
这句话不表示“只要类型正确,程序就绝对没有 bug”。更准确的理解是:
一个足够严格的类型系统,可以让某些类别的错误根本无法被写成合法程序。
std::optional 所做的,正是把原本藏在注释中的一句话:
这个值也许不存在
提升为编译器能够看见的类型事实。