K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 15 讲:std::optional 与类型安全

用类型表达“值可能不存在”,系统掌握 std::optional 与安全接口设计。

CS106L 第 15 讲:std::optional 与类型安全 的封面
C++ · CLASS-C

本课程根据 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() 如何组织连续计算。

学习本节课所需的前置知识

阅读本课程前,只需要熟悉:

  • 基本变量与函数;
  • ifwhilefor
  • 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::stringstd::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::vectorstd::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 转换成 boolfalse,程序会错误地输出:

not found

当目标不存在时,函数返回:

-1

非零整数转换成 booltrue,程序反而会输出:

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::nulloptnullptr 不是同一个东西

这两个名字很像,但用途不同。

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)

转换为真正用于 whilebool

状态变化:

容器非空,末尾为奇数
→ 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'

综合练习:安全查询课程平均分

我们要设计一个小程序。

每门课程包含:

课程名
若干次考试成绩

程序需要:

  1. 根据课程名查找课程;
  2. 课程可能不存在;
  3. 课程即使存在,也可能没有成绩;
  4. 只有存在成绩时才能计算平均分。

请补全两个函数:

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';
    }
}

请判断:

  1. 它是否会产生未定义行为?
  2. 空容器时 result 是有值还是无值?
  3. if (result) 会不会进入?
  4. return 0; 是否准确表达了没有最后一个元素?
  5. 应当如何修改?

综合练习答案与解释

程序不会因为访问空容器产生未定义行为,因为:

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 所做的,正是把原本藏在注释中的一句话:

这个值也许不存在

提升为编译器能够看见的类型事实。