C++17 之 std::optional 与 std::variant

系列文章: C++ 新特性系列 · 第3篇
阅读时间: 约 15 分钟
适用标准: C++17 及以上


引言:传统 C++ 的两大痛点

在 C++17 之前,我们经常面临两个让人头疼的问题:

问题一:如何表示"没有值"?

查找一个键值对,可能找到,也可能没找到。传统做法是返回指针或使用特殊标记值:

// 方式1:返回指针 —— 悬空指针的风险
int* find(const std::map<std::string, int>& m, const std::string& key);

// 方式2:使用特殊值 —— -1 本身就是合法值怎么办?
int find(const std::map<std::string, int>& m, const std::string& key);

// 方式3:输出参数 —— 代码啰嗦,不够优雅
bool find(const std::map<std::string, int>& m, const std::string& key, int& out);

问题二:如何安全地存储不同类型?

union 可以存储不同类型,但不记录当前存的是哪个,手动管理类型标签既繁琐又容易出错:

union Value {
    int i;
    double d;
    const char* s;
};
// 你永远不知道当前是 int 还是 double,访问错了就是未定义行为

C++17 用两个优雅的工具解决了这两个问题:std::optionalstd::variant


std::optional:优雅地表达"可能没有值"

为什么需要 optional?

std::optional<T> 本质上是一个包装了 T 值的容器,它要么包含一个值,要么为空。相比传统方式:

方式 优点 缺点
返回指针 简单 需管理内存,可能悬空
特殊标记值 无额外开销 占用合法值,语义不清
输出参数 + bool 兼容老代码 写法啰嗦,不直观
std::optional<T> 语义明确,类型安全 需 C++17

核心 API 速览

#include <optional>
#include <string>

std::optional<int> opt1;              // 空 optional
std::optional<int> opt2 = 42;         // 包含值 42
std::optional<std::string> opt3 = "hello"; // 包含字符串

// 判断是否有值
if (opt2.has_value()) { ... }        // 方式1
if (opt2) { ... }                    // 方式2,更简洁

// 获取值
int val1 = opt2.value();             // 无值时抛 std::bad_optional_access
int val2 = opt2.value_or(0);         // 无值时返回默认值 0
int val3 = *opt2;                    // 语法糖,无值时行为未定义(别乱用)

示例1:optional 作为函数返回值表示可能失败

这是 std::optional 最常见的使用场景——函数可能返回有效结果,也可能"什么都返回不了"

#include <iostream>
#include <optional>
#include <string>
#include <map>

// 在 map 中查找值,找不到返回空 optional
std::optional<int> findValue(const std::map<std::string, int>& data,
                             const std::string& key) {
    auto it = data.find(key);
    if (it != data.end()) {
        return it->second;   // 隐式构造 optional<int>
    }
    return std::nullopt;     // 表示"没有值"
}

int main() {
    std::map<std::string, int> scores = {
        {"Alice", 95}, {"Bob", 87}, {"Charlie", 72}
    };

    auto result = findValue(scores, "Bob");
    if (result) {
        std::cout << "Bob's score: " << *result << std::endl;
    }

    auto missing = findValue(scores, "David");
    // 用 value_or 提供默认值,安全又简洁
    std::cout << "David's score: " << missing.value_or(-1) << std::endl;

    return 0;
}

输出:

Bob's score: 87
David's score: -1

关键点: return it->second 利用 optional 的隐式构造,无需写 std::optional<int>(it->second)。返回 std::nullopt 比返回 -1nullptr 语义清晰得多。

示例2:optional 的链式操作

C++23 之前,std::optional 的链式操作需要手动处理,但配合辅助函数也能写出优雅的代码:

#include <iostream>
#include <optional>
#include <string>
#include <sstream>

struct User {
    std::string name;
    std::optional<std::string> email;
    std::optional<std::string> phone;
};

// 辅助函数:如果 optional 有值,对其进行变换
template<typename T, typename F>
auto optionalMap(const std::optional<T>& opt, F&& func)
    -> std::optional<decltype(func(*opt))> {
    if (opt) {
        return func(*opt);
    }
    return std::nullopt;
}

// 模拟从数据库获取用户(可能失败)
std::optional<User> findUser(int id) {
    if (id == 1) {
        return User{"Alice", "alice@example.com", "13800138000"};
    }
    if (id == 2) {
        return User{"Bob", std::nullopt, std::nullopt};  // Bob 没有邮箱和电话
    }
    return std::nullopt;
}

int main() {
    auto user = findUser(1);

    // 链式获取:用户 -> 邮箱 -> 邮箱用户名部分
    auto emailUsername = optionalMap(user, [](const User& u) {
        return optionalMap(u.email, [](const std::string& email) {
            return email.substr(0, email.find('@'));
        });
    });

    if (emailUsername) {
        std::cout << "Email username: " << *emailUsername << std::endl;
    }

    // 用 value_or 提供默认联系信息
    auto contact = user
        ? (user->email.value_or(user->phone.value_or("无联系方式")))
        : "用户不存在";
    std::cout << "Contact: " << contact << std::endl;

    return 0;
}

输出:

Email username: alice
Contact: alice@example.com

std::variant:类型安全的"万能容器"

为什么需要 variant?

union 的致命问题是类型不安全——你必须自己记住当前存的是什么类型,访问错了就是未定义行为。std::variant<Types...> 完美解决了这个问题:

特性 union std::variant
类型安全 ❌ 可能 UB ✅ 编译期检查
自动类型标签 ❌ 手动管理 ✅ 内置 index()
析构函数调用 ❌ 需手动 ✅ 自动调用
访问方式 直接强转 get<>() / visit()
空状态 ✅ 可以为空 ❌ 必须存一个值

核心 API 速览

#include <variant>
#include <string>

std::variant<int, double, std::string> v;  // 默认存第一个类型(int, 值为0)

// 赋值不同类型
v = 3.14;                   // 现在存 double
v = "hello";                // 现在存 std::string(C++17 用 const char* 隐式转换)

// 查询当前类型
size_t idx = v.index();     // 当前存的是第几个类型(从0开始)
bool ok = std::holds_alternative<std::string>(v);  // 是否是 string

// 获取值
auto& s = std::get<std::string>(v);   // 引用获取,类型不对抛 std::bad_variant_access
auto* p = std::get_if<std::string>(&v);  // 指针获取,类型不对返回 nullptr

示例3:variant 存储不同类型

std::variant 最典型的场景是需要存储多种可能类型的值,比如 JSON 值、AST 节点等:

#include <iostream>
#include <variant>
#include <string>
#include <vector>

// 模拟一个简单的 JSON 值类型
using JsonValue = std::variant<
    std::nullptr_t,   // null
    bool,             // boolean
    int,              // integer
    double,           // number
    std::string,      // string
    std::vector<int>  // array(简化为 int 数组)
>;

void printJson(const JsonValue& val) {
    // std::visit + 重载模式,优雅地处理所有类型
    std::visit([](const auto& arg) {
        using T = std::decay_t<decltype(arg)>;
        if constexpr (std::is_same_v<T, std::nullptr_t>) {
            std::cout << "null";
        } else if constexpr (std::is_same_v<T, bool>) {
            std::cout << (arg ? "true" : "false");
        } else if constexpr (std::is_same_v<T, std::string>) {
            std::cout << "\"" << arg << "\"";
        } else if constexpr (std::is_same_v<T, std::vector<int>>) {
            std::cout << "[";
            for (size_t i = 0; i < arg.size(); ++i) {
                if (i > 0) std::cout << ", ";
                std::cout << arg[i];
            }
            std::cout << "]";
        } else {
            std::cout << arg;  // int 和 double 走这里
        }
    }, val);
    std::cout << std::endl;
}

int main() {
    std::vector<JsonValue> jsonArray = {
        nullptr,
        true,
        42,
        3.14,
        std::string("Hello, C++17!"),
        std::vector<int>{1, 2, 3, 4, 5}
    };

    for (const auto& val : jsonArray) {
        std::cout << "Type: " << val.index()
                  << ", Value: ";
        printJson(val);
    }

    return 0;
}

输出:

Type: 0, Value: null
Type: 1, Value: true
Type: 2, Value: 42
Type: 3, Value: 3.14
Type: 4, Value: "Hello, C++17!"
Type: 5, Value: [1, 2, 3, 4, 5]

示例4:visit 访问 variant

std::visit 是访问 variant推荐方式,配合重载模式(overloaded pattern)可以写出非常优雅的代码:

#include <iostream>
#include <variant>
#include <string>
#include <vector>

// 定义一些不同的"形状"
struct Circle    { double radius; };
struct Rectangle { double width, height; };
struct Triangle  { double base, height; };

using Shape = std::variant<Circle, Rectangle, Triangle>;

// 计算面积
double area(const Shape& shape) {
    return std::visit([](const auto& s) -> double {
        using T = std::decay_t<decltype(s)>;
        if constexpr (std::is_same_v<T, Circle>) {
            return 3.14159265 * s.radius * s.radius;
        } else if constexpr (std::is_same_v<T, Rectangle>) {
            return s.width * s.height;
        } else if constexpr (std::is_same_v<T, Triangle>) {
            return 0.5 * s.base * s.height;
        }
    }, shape);
}

// 重载模式:把多个 lambda 合并成一个 visitor
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

// 用重载模式获取形状描述
std::string describe(const Shape& shape) {
    return std::visit(overloaded{
        [](const Circle& c) {
            return "Circle(r=" + std::to_string(c.radius) + ")";
        },
        [](const Rectangle& r) {
            return "Rect(" + std::to_string(r.width) + "x"
                 + std::to_string(r.height) + ")";
        },
        [](const Triangle& t) {
            return "Tri(b=" + std::to_string(t.base)
                 + ",h=" + std::to_string(t.height) + ")";
        }
    }, shape);
}

int main() {
    std::vector<Shape> shapes = {
        Circle{5.0},
        Rectangle{4.0, 6.0},
        Triangle{3.0, 8.0}
    };

    for (const auto& shape : shapes) {
        std::cout << describe(shape)
                  << " -> Area: " << area(shape) << std::endl;
    }

    return 0;
}

输出:

Circle(r=5.000000) -> Area: 78.539816
Rect(4.000000x6.000000) -> Area: 24.000000
Tri(b=3.000000,h=8.000000) -> Area: 12.000000

重载模式小技巧: overloaded 继承了所有 lambda 的 operator(),配合 CTAD(C++17 类模板参数推导),无需手动指定模板参数。


注意事项与常见陷阱

std::optional 的陷阱

陷阱 后果 正确做法
对空 optional 调用 value() std::bad_optional_access has_value() 检查,或用 value_or()
对空 optional 使用 operator* 未定义行为 同上
optional<T> 占用额外空间 sizeof(optional<T>) > sizeof(T) 性能敏感场景考虑替代方案
返回局部变量的引用 悬空引用 optional 存值不存引用
// ❌ 危险写法
std::optional<int> foo();
int val = foo().value();  // 如果 foo() 返回空,直接崩溃

// ✅ 安全写法
auto opt = foo();
if (opt) {
    int val = *opt;
    // 使用 val...
}

// ✅ 更简洁
int val = foo().value_or(0);  // 空时用默认值

std::variant 的异常处理

操作 异常
std::get<T>(v) 类型不匹配 std::bad_variant_access
赋值新值时构造失败 抛构造函数异常,variant 保持原值
std::get_if<T>(&v) 类型不匹配 返回 nullptr(安全,不抛异常)
std::variant<int, std::string> v = 42;

// ❌ 类型不对就炸
// auto& s = std::get<std::string>(v);  // bad_variant_access!

// ✅ 用 get_if 安全获取
if (auto* p = std::get_if<std::string>(&v)) {
    std::cout << *p << std::endl;
} else {
    std::cout << "当前不是 string,index = " << v.index() << std::endl;
}

与传统方式对比

optional 替代指针

// 传统方式 —— 容易忘记判空,可能返回悬空指针
User* findUser(int id) {
    auto it = userMap.find(id);
    return (it != userMap.end()) ? &it->second : nullptr;
}

// C++17 方式 —— 语义清晰,不可能有悬空引用
std::optional<User> findUser(int id) {
    auto it = userMap.find(id);
    if (it != userMap.end()) {
        return it->second;  // 拷贝返回,安全
    }
    return std::nullopt;
}

variant 替代 union

// 传统方式 —— 需手动维护类型标签
struct Value {
    enum Type { INT, DOUBLE, STRING } type;
    union {
        int i;
        double d;
        const char* s;
    };
    void setInt(int v)    { type = INT; i = v; }
    void setDouble(double v) { type = DOUBLE; d = v; }
    // 每加一种类型就要改一堆代码...
};

// C++17 方式 —— 类型安全,自动管理
using Value = std::variant<int, double, std::string>;
Value v = 42;             // 自动识别类型
v = 3.14;                 // 安全切换
v = std::string("hello"); // 析构函数自动调用

编译器支持

编译器 最低版本 备注
GCC 7.0+ 完整支持
Clang 4.0+ 完整支持
MSVC 19.12+ (VS 2017 15.3+) 完整支持

编译命令示例:

# GCC / Clang
g++ -std=c++17 -o main main.cpp

# MSVC (Developer Command Prompt)
cl /std:c++17 main.cpp

如果使用较老的编译器,可以考虑 Boost.OptionalBoost.Variant 作为替代方案。


总结

特性 std::optional std::variant
核心用途 表达"可能没有值" 安全存储多种类型
替代方案 指针、特殊标记值 union + 类型标签
关键 API has_value(), value(), value_or() index(), get<>(), visit()
异常风险 空值访问抛异常 类型不匹配抛异常
编译器要求 C++17 C++17

核心理念: 让类型系统帮你管理状态,而不是靠程序员的记忆力和自觉性。optional 让"空"有了明确的类型语义,variant 让多类型存储不再危险。

这两个工具配合使用,能大幅减少运行时错误,让代码更安全、更易读。


下一篇预告

下一篇我们将介绍 std::string_view —— 一个轻量级的字符串视图类型。它不拥有字符串数据,却能替代 const std::string& 作为函数参数,在避免不必要拷贝的同时保持接口简洁。如果你经常被字符串拷贝性能困扰,下一篇不容错过!


💬 觉得有帮助? 点赞 + 收藏,下次找得到!有问题欢迎评论区交流~

更多推荐