Lecture 15 (Week 8 - Tuesday): std::optional 与类型安全 (std::optional & Type Safety)

目录 · ← l14 · l16 →

Lecture 15 (Week 8 - Tuesday): std::optional 与类型安全 (std::optional & Type Safety)

概述

本讲从类型安全(Type Safety)的定义出发,探讨”函数签名应如何诚实地表达可能失败的结果”。经典反例是 vector::back():当容器为空时返回”最后一个元素”是未定义行为,因为函数签名 valueType& 向调用者做出了虚假的承诺。std::pair<bool, T> 方案有种种缺陷(要求 T 可默认构造、需要构造一个无意义的值、语义仍不可靠),而 std::optional<T> 优雅地解决了问题:它要么包含一个 T,要么什么都不包含(std::nullopt)。本讲还介绍 has_value()/value()/value_or() 接口与 C++23 的 monadic 操作and_then/transform/or_else),并对比 Rust/Swift 中 Option 类型的应用。

核心特性与语法详解

1. 类型安全(Type Safety)

  • 定义与目的:类型安全是”语言在多大程度上防止类型错误”(幻灯片第一版定义:The extent to which a language prevents typing errors;后修正为:The extent to which a function signature guarantees the behavior of a function)。核心思想:让函数签名自己”说清楚”行为,把错误挡在编译期。Python 中 div_3("hello") 要到运行时才崩溃;C++ 中 int div_3(int x) 传字符串是编译错误——代码永远不会运行。
  • 核心语法
    int div_3(int x) { return x / 3; }   // 签名承诺:参数是 int
    // div_3("hello");                    // 编译错误:类型不匹配
    
  • 设计意图与最佳实践:类型系统是”编译期强制安全”的体现(呼应 C++ 设计哲学:Enforce safety at compile time whenever possible)。判断一个签名是否类型安全,就看它是否诚实:如果函数可能没有返回值,签名就不该承诺”必然返回一个 T”。

2. vector::back() 的问题:虚假承诺与未定义行为

  • 定义与目的valueType& vector<valueType>::back() 返回末元素的引用;当容器为空时,它返回 *(begin() + size() - 1),即解引用一个指向”过去末尾”的指针——未定义行为(UB):可能崩溃、可能返回垃圾值、甚至可能”碰巧”给出正确值,无法做出任何保证。
  • 核心语法
    // 有 UB 的版本
    valueType& vector<valueType>::back() {
        return *(begin() + size() - 1);      // 空容器时解引用野指针
    }
    // 可靠报错的版本
    valueType& vector<valueType>::back() {
        if (empty()) throw std::out_of_range("back on empty");
        return *(begin() + size() - 1);
    }
    
  • 设计意图与最佳实践:幻灯片强调”保证 precondition 是程序员的责任”,但更好的做法是让函数签名自己传达”可能没有值”。抛异常是”至少可靠地出错”,而 std::optional 是”把’可能没有’写进类型里”。

3. std::pair<bool, T> 方案的缺陷

  • 定义与目的:一个直观的”第一版方案”是用 std::pair<bool, valueType> 同时返回”是否存在”和”值”,但幻灯片指出三大缺陷。
  • 核心语法
    std::pair<bool, valueType> vector<valueType>::back() {
        if (empty()) return {false, valueType()};   // 需要一个"假值"
        return {true, *(begin() + size() - 1)};
    }
    
  • 设计意图与最佳实践:缺陷一:valueType 可能没有默认构造函数,无法构造”假值”;缺陷二:即便能构造,白白调用构造器也是浪费;缺陷三(最要命):语义仍然不可靠——while (vec.back().second % 2 == 1) 在容器为空时会用”假值”继续运算,如果这个假值恰好是奇数,程序行为依旧错误。pair 把”是否存在”和”值”分离成两个字段,调用者必须记得检查 bool,而检查可以漏掉。

4. std::optional<T>:值或空

  • 定义与目的std::optional<T> 是模板类,要么包含一个 T 类型的值,要么什么都不包含(用 std::nullopt 表示)。C++17 引入。
  • 核心语法
    #include <optional>
    std::optional<int> o = std::nullopt;  // 空 optional
    o = 42;                               // 有值
    o = std::nullopt;                     // 又变空
    std::optional<int> num1 = {};         // 空 optional
    
  • 设计意图与最佳实践nullopt 不是 nullptrnullptr 可转换为任何指针类型,nullopt 可转换为任何 optional 类型——它是一个独立的”空标记”。与 pair 方案不同,optional 把”有没有值”内建为类型的一部分,不需要单独构造一个假值,也强制调用者在取值前处理”空”的情况。

5. std::optional 的接口:has_value / value / value_or

  • 定义与目的:读取 optional 内容的三个基本方法。
  • 核心语法
    std::optional<T> opt;
    opt.has_value();        // bool:是否有值
    opt.value();            // 返回 T&;空时抛出 std::bad_optional_access
    opt.value_or(default);  // 有值返回值,空返回 default
    // 另外:operator bool / 直接 if(opt) 判断是否有值;operator* 无检查地取值(同 value() 但不抛异常,UB!)
    
  • 设计意图与最佳实践value() 在空时抛 std::bad_optional_access(比 UB 好——行为确定),value_or(default) 适合”有就用,没有用兜底”,if (opt) 是惯用的存在性检查(nullopt 是 falsy)。

6. Monadic 操作:and_then / transform / or_else(C++23)

  • 定义与目的:monadic(单子式)是一种把”可选值上的函数应用”串成链的设计模式——每一步要么继续计算,要么短路为 nullopt,避免层层手写 if 判空。
  • 核心语法
    // 有值时对内部值调用 f 并返回 f 的结果(f 必须返回 optional)
    opt.and_then(f);        // f: T → std::optional<U>
    // 有值时对内部值调用 f,把结果包进 optional(f 返回普通值)
    opt.transform(f);       // f: T → U
    // 有值就返回自身,空时调用 f 产生兜底值(f 返回 optional)
    opt.or_else(f);         // f: () → std::optional<U>
    
  • 设计意图与最佳实践:三者都短路:一旦链上某一步是 nullopt,后续不再调用函数、整条链保持 nulloptand_then 用于”后续步骤可能失败”(函数返回 optional),transform 用于”纯映射不会失败”(函数返回普通值),or_else 用于”空时兜底”。这让你写”如果 A 存在则做 B 否则给默认”时完全不需要 if 语句。注意:std::optional<T&> 不存在(引用必须绑定有效对象,而 optional 不保证这一点),需要引用语义时只能返回指针或用 .at() 抛异常。

代码示例与逐步解说(核心)

示例 1:UB 版本 vs 修复版本(C++11)

// C++11(示例来源:Jonathan Müller / foonathan.net,幻灯片引用)
#include <iostream>
#include <vector>

// 有 UB 的版本:空容器时 vec.back() 是未定义行为
void removeOddsFromEndBad(std::vector<int>& vec) {
    while (vec.back() % 2 == 1) {   // 空容器时:解引用野指针 → UB
        vec.pop_back();
    }
}

// 修复:先检查 empty(),保证 precondition
void removeOddsFromEnd(std::vector<int>& vec) {
    while (!vec.empty() && vec.back() % 2 == 1) {
        vec.pop_back();
    }
}

int main() {
    std::vector<int> v = {2, 3, 5, 8};
    removeOddsFromEnd(v);           // 去掉末尾的奇数?不——back=8 是偶数,直接结束
    for (int x : v) std::cout << x << ' ';
    std::cout << '\n';              // 2 3 5 8

    std::vector<int> w = {2, 3, 5};
    removeOddsFromEnd(w);           // 5→pop, 3→pop, 2 停
    for (int x : w) std::cout << x << ' ';
    std::cout << '\n';              // 2

    // removeOddsFromEndBad(std::vector<int>{});  // 空容器 → 未定义行为,切勿运行
}
  • 代码做什么removeOddsFromEnd 从末尾删除连续的奇数。修复版先 !vec.empty() 短路,空容器时 back() 根本不会执行。
  • 特性机制解说vec.back() 内部是 *(begin() + size() - 1)。容器为空时 size() - 1 下溢,begin() + 巨大值 指向野内存,解引用就是 UB。UB 意味着程序行为无任何保证(崩溃/垃圾值/碰巧正确都有可能),这正是类型安全要消灭的东西。修复方案把”vec 非空”这个 precondition 的检查责任留在调用处——可行,但幻灯片追问:能不能让函数签名自己告诉调用者”可能没有末元素”?这引出 optional。

示例 2:std::optional 基础——除法防除零(改写自课堂代码)(C++17)

// C++17(课堂 main.cpp 的 divide 示例)
#include <iostream>
#include <optional>

std::optional<int> divide(int numerator, int denominator) {
    if (denominator != 0) {
        return numerator / denominator;   // 有值
    } else {
        return std::nullopt;              // 空!
    }
}

int main() {
    std::optional<int> result = divide(10, 2);
    if (result) {                                  // optional 可隐式转 bool
        std::cout << "Result: " << result.value() << '\n';   // Result: 5
    } else {
        std::cout << "Division by zero occurred.\n";
    }

    result = divide(10, 0);
    if (result) {
        std::cout << "Result: " << result.value() << '\n';
    } else {
        std::cout << "Division by zero occurred.\n";   // 走这里
    }
}
  • 代码做什么divide 返回 std::optional<int>:除数为 0 时返回 std::nullopt,否则返回商。main 里用 if (result) 判断,有值用 .value() 取。
  • 特性机制解说:函数签名 std::optional<int> 在类型层面宣告“可能没有结果”——调用者无法假装一定有值(想用值必须先过存在性检查)。result 的 truthiness:operator bool 等价于 has_value()std::nullopt 是 falsy。若空 optional 上调 .value(),会抛 std::bad_optional_access(确定的行为,可被 catch),而 *resultoperator*)不做检查,空时是 UB——课程提醒:* 需自己保证有值

示例 3:查找函数——has_value / value / value_or(C++17)

// C++17
#include <iostream>
#include <optional>
#include <vector>

// 返回 value 在 vec 中的下标;找不到返回 nullopt("可能不存在"写进签名)
std::optional<size_t> findIndex(const std::vector<int>& vec, int value) {
    for (size_t i = 0; i < vec.size(); ++i) {
        if (vec[i] == value) return i;
    }
    return std::nullopt;
}

int main() {
    std::vector<int> v = {1, 3, 7};

    auto idx = findIndex(v, 3);
    std::cout << idx.value_or(999) << '\n';    // 1(有值 → 返回值)
    std::cout << idx.has_value() << '\n';      // 1(true)

    auto missing = findIndex(v, 42);
    std::cout << missing.value_or(999) << '\n'; // 999(空 → 返回兜底)
    std::cout << missing.has_value() << '\n';   // 0(false)

    try {
        missing.value();                        // 空 optional 调 value() → 抛异常
    } catch (const std::bad_optional_access& e) {
        std::cout << "Caught bad_optional_access\n";
    }
}
  • 代码做什么findIndex 用 optional 表达”下标可能不存在”。value_or(999) 一句代码同时处理”有值”和”空”两种情况;.value() 在空时抛 std::bad_optional_access
  • 特性机制解说:对比 pair<bool, T> 方案:findIndex 的 optional 版本不需要为”找不到”构造任何假下标(pair 方案要 valueType() 默认构造,且可能没默认构造);返回 std::nullopt 就是”空”。value_or 按值返回兜底,适合”取默认值”;value() 抛异常,适合”逻辑上必须有值、没有就是 bug”的场景。这套接口让”可能失败”从程序员的自律变成类型的强制。

示例 4:模拟 optionalback() 与 monadic 链(C++17 / C++23)

// C++23(back 返回 optional 是教学假设;真实 std::vector::back() 并不返回 optional)
#include <iostream>
#include <optional>
#include <vector>

// 教学用的"安全 back":空容器返回 nullopt
std::optional<int> safeBack(const std::vector<int>& vec) {
    if (vec.empty()) return std::nullopt;
    return vec.back();
}

int main() {
    std::vector<int> w = {2, 3, 5};

    // transform:把内部值 int 映射成 bool(奇数?),空时短路为 nullopt
    // value_or(false):空 → false,循环结束
    while (safeBack(w)
               .transform([](int x) { return x % 2 == 1; })
               .value_or(false)) {
        w.pop_back();
    }
    for (int x : w) std::cout << x << ' ';   // 2
    std::cout << '\n';

    // and_then:f 接收内部值、返回 optional,可继续接链
    std::optional<int> o = 10;
    auto chained = o
        .and_then([](int n) -> std::optional<int> {   // 10 → 100
            return n * 10;
        })
        .transform([](int n) { return n + 1; });      // 100 → 101
    std::cout << chained.value_or(-1) << '\n';        // 101

    // or_else:空时调用兜底函数
    std::optional<int> empty = std::nullopt;
    auto rescued = empty.or_else([]() -> std::optional<int> {
        return std::optional<int>(7);
    });
    std::cout << rescued.value() << '\n';             // 7
}
  • 代码做什么:第一段把 removeOddsFromEnd 改写成 monadic 风格——transform 把”末元素是否奇数”算出来,value_or(false) 在空容器时给出 false,循环自然终止,无需手写判空。后两段演示 and_thenor_else 的链式语义。
  • 特性机制解说transform(f):有值 → 返回 std::optional<f(值)>;空 → 返回 nullopt(f 不会被调用)。and_then(f):有值 → 返回 f(值)(f 必须返回 optional,因此可用于”下一步可能失败”);空 → nulloptor_else(f):有值 → 返回自身;空 → 调用 f 得到兜底 optional。三者都短路:链上任一步得到 nullopt,后面全部跳过。monadic(单子)是一种软件设计模式——把函数组合包装进带额外计算(这里是”可能为空”)的类型里;课程提到我们早就在用 monadic 思想:链式 view 管道(views::filter \| views::transform)同样是一步步组合函数并短路。幻灯片还提醒:真实的 std::vector::back() 并不返回 optional(也不大可能改),optional 主要用于你自己的接口设计。

与旧标准(如C++98)的对比

  • std::optional 是 C++17 才加入标准库<optional>);monadic 操作 and_then/transform/or_else 更是 C++23 才加入。C++98 中表达”可能没有值”只能靠:
    • 哨兵值(sentinel):如返回 -1nullptr"" 表示”无”。缺点:必须与合法值域不冲突、调用者要记得比对、错误容易漏检(例如 -1 恰好是合法下标)。幻灯片中 pair<bool,T> 方案的”假值”问题正是哨兵值缺陷的体现。
    • std::pair<bool, T>:需要 T 可默认构造、浪费构造开销、检查可被忽略(语义不可靠)。
    • 指针:返回 T*,用 nullptr 表示无。缺点:T 必须可寻址(值语义对象被迫堆分配)、所有权语义模糊、还要判空。
  • 现代优势:optional 把”空”作为类型系统的一部分——编译器强制你在取值前处理空;value_or、monadic 操作把判空逻辑压缩成一行;nullopt 是独立的空标记,不会与指针的 nullptr 混淆。课程结论:C++ 的标准容器出于性能(optional 有额外状态开销、调用链繁琐)大多不使用 optional,但在你自己的接口设计中强烈鼓励使用

关键要点

  • 类型安全 = 函数签名诚实地保证行为;”可能没有结果”就不该承诺”必然返回 T”。vector::back() 在空容器上是未定义行为,因为签名 valueType& 是虚假承诺。
  • std::optional<T>(C++17):要么含一个 T,要么为空(std::nullopt);nullopt 不是 nullptr,它可转换为任何 optional 类型。
  • 三件套接口has_value()(或 if (opt))判断、value()(空时抛 std::bad_optional_access)、value_or(default) 兜底取值。
  • Monadic 操作(C++23)and_then(f 返回 optional,下一步可能失败)、transform(f 返回普通值,纯映射)、or_else(空时兜底),全部短路,可无限链式组合、免写 if。
  • “Well typed programs cannot go wrong”:把”可能失败”写进类型,把错误从运行时搬到编译期——这正是 C++ “在编译期尽可能强制安全”设计哲学的体现。

常见陷阱与注意事项

  • 对空 optional 调 .value():抛 std::bad_optional_access(比 UB 好,但仍是运行时错误);更危险的是用 *optoperator*)——它不做任何检查,空时是未定义行为。
  • 误把 nulloptnullptrstd::optional<int*> o = nullptr; 是”有值的 optional,值是空指针”;o = std::nullopt; 才是”optional 本身为空”。两者语义完全不同。
  • 忘记判空就取值:optional 的接口设计是”强迫”你判空,但如果用 opt.value() 直接取值且不 catch,程序照样崩——monadic 链和 value_or 才是优雅解。
  • 想要 std::optional<T&>:C++ 标准库没有 optional<T&>(引用必须绑定有效对象,optional 无法保证)。需要”可选引用”时改用指针或 std::reference_wrapper;下标越界这类场景用 .at()std::out_of_range
  • 误以为 monadic 操作会吞掉异常transform/and_then 里 f 自己抛的异常照常传播,短路只针对 nullopt,不针对异常。
  • 对”检查一下更稳妥”的代码偷懒:例如 removeOddsFromEnd 的 UB 版——即使”看起来能跑”,未定义行为也随时可能翻车,别赌。

关联作业提示

本讲与 A6: ExploreCoursesassignment6/)直接相关:作业要求用 std::optional 表达”课程可能不存在”。

  1. Part 1 写 find_course:把返回类型从占位的 FillMeIn 改为 std::optional<Course>——在 CourseDatabase 的私有 courses 里按 course_title 查找,找到返回该 Course,找不到返回 std::nullopt。这正是本讲”签名要诚实表达可能没有值”的核心用法。
  2. Part 2 用 monadic 操作替换 if 语句mainauto course = db.find_course(argv[1]); 之后,要用恰好两个 monadic 操作把 course 变成 std::string output,且不能使用任何 if
    • transform:把 Course 映射成格式化字符串(如 "Found course: " + title + "," + units + "," + quarter)——f 返回普通 std::string,正适合 transform。
    • or_else:当 course 为空时提供兜底字符串 "Course not found."——空时调 f 返回 std::optional<std::string>
    • 最后 .value()(或 .value_or(...))取出字符串。注意链上类型流:optional<Course>transformoptional<string>or_elseoptional<string>.value()string。作业提示”从 output 的类型倒推”正是这个思路。
  3. 编译命令 g++ -std=c++23 main.cpp -o main 说明 monadic 操作需要 C++23 标准(std::optional 本身 C++17 即可)。