CppGuide社区 CppGuide社区
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
GitHub (opens new window)
  • C++17 详解 说明
  • 第一部分——语言特性
  • 1. 快速入门
  • 2. 移除或修正的语言特性
  • 3. 语言澄清(Language Clarification)
  • 4. 通用语言特性
  • 5. 模板(Templates)
  • 6. 代码标注
  • 第二部分 - 标准库的变化
  • 7. std::optional
  • 8. std::variant
  • 9. std::any
  • 10. std::string_view
  • 11. 字符串转换
  • 12. 搜索器与字符串匹配
  • 13. 文件系统
  • 14. 并行STL算法
  • 15. 标准库中的其他变化
  • 16. 移除和弃用的库特性
  • 第三部分 - 更多示例和用例
  • 17. 使用std::optional和std::variant进行重构
    • 17. 使用std::optional和std::variant进行重构
      • 用例
      • 元组版本
      • 单独的结构体
      • 使用std::optional
      • 使用std::variant
      • 总结
  • 18. 使用[[nodiscard]]强制执行代码契约
  • 19. 用if constexpr替换enable_if——带可变参数的工厂函数
  • 20. 如何实现CSV读取器的并行化
目录

17. 使用std::optional和std::variant进行重构

# 17. 使用std::optional和std::variant进行重构

std::variant和std::optional被称为 “描述性” 类型,因为利用它们可以传达更多的设计信息,使代码更加简洁且富有表现力。

本章将通过一个示例,展示std::optional和std::variant如何帮助重构一个函数。我们将从一些旧代码入手,逐步改进,最终得到一个更好的解决方案。为了让你更好地理解,每一步的优缺点都会进行说明。

# 用例

假设有一个函数,用于处理游戏中当前鼠标选择的内容。该函数会扫描选定范围,并计算出以下几个输出结果:

  • 正在动画的对象数量
  • 选择中是否有民用单位
  • 选择中是否有战斗单位

现有代码如下:

class ObjSelection {
public:
    bool IsValid() const { return true; }
    // 更多代码...
};

bool CheckSelectionVer1(const ObjSelection &objList,
                        bool *pOutAnyCivilUnits,
                        bool *pOutAnyCombatUnits,
                        int *pOutNumAnimating);
1
2
3
4
5
6
7
8
9
10

如你所见,这个函数使用了大量的输出参数(以原始指针的形式),并且通过返回true或false来表示操作是否成功(例如,输入的选择可能无效)。

目前函数的实现并不重要,下面是调用该函数的示例代码:

ObjSelection sel;
bool anyCivilUnits { false }; 
bool anyCombatUnits { false }; 
int numAnimating { 0 };
if (CheckSelectionVer1(sel, &anyCivilUnits, &anyCombatUnits, &numAnimating))
{
    // ...
}
1
2
3
4
5
6
7
8

我们如何改进这个函数呢?有以下几个方面:

  • 看看调用者的代码:必须创建所有用于存储输出结果的变量。如果在多个地方调用该函数,这无疑会产生代码重复。
  • 输出参数:《C++核心指南》建议不要使用输出参数。(F.20:对于 “输出” 输出值,优先使用返回值而非输出参数 )
  • 如果使用原始指针,就必须检查它们是否有效。如果使用引用作为输出参数,可以省去这些检查。
  • 要是扩展这个函数呢?如果需要添加另一个输出参数该怎么办?

还有其他问题吗?你会如何重构这段代码呢?

受《C++核心指南》和C++17新特性的启发,我们可以按以下步骤改进代码:

  1. 将输出参数重构为一个元组(tuple)并返回。
  2. 将元组重构为一个单独的结构体,并将元组简化为一个pair。
  3. 使用std::optional来表示某个值是否被计算出来。
  4. 使用std::variant不仅可以传达可选的结果,还能传递完整的错误信息。

# 元组版本

第一步是将输出参数转换为一个元组,并从函数中返回。根据《C++核心指南》F.21:要返回多个 “输出” 值,优先返回一个元组或结构体 :

返回值作为 “仅输出” 值具有自解释性。注意,C++ 可以通过使用元组(包括pair)的约定来返回多个值,在调用点使用tie可能会更方便。

修改后的代码如下:

std::tuple<bool, bool, bool, int>
CheckSelectionVer2(const ObjSelection &objList) {
    if (!objList.IsValid())
        return {false, false, false, 0};

    // 局部变量:
    int numCivilUnits = 0; 
    int numCombat = 0;
    int numAnimating = 0;

    // 扫描...
    return {true, numCivilUnits > 0, numCombat > 0, numAnimating };
}
1
2
3
4
5
6
7
8
9
10
11
12
13

是不是好多了?元组版本有以下优点:

  • 无需检查原始指针。
  • 代码更具表现力,所有返回值都封装在一个对象中。

在调用点,可以使用结构化绑定(Structured Bindings)来解包返回的元组:

auto [ok, anyCivil, anyCombat, numAnim] = CheckSelectionVer2(sel);
if (ok) {
    // ...
}
1
2
3
4

遗憾的是,这个版本可能不是最佳方案。例如,存在忘记元组中输出值顺序的风险。

函数扩展的问题仍然存在。所以当需要添加另一个输出值时,必须同时扩展元组和调用点的代码。

我们可以再进一步改进:使用结构体代替元组(这也是《C++核心指南》建议的)。

# 单独的结构体

这些输出似乎代表了相关的数据。因此,将它们封装到一个名为SelectionData的结构体中可能是个不错的主意。

struct SelectionData {
    bool anyCivilUnits { false };
    bool anyCombatUnits { false };
    int numAnimating { 0 };
};
1
2
3
4
5

然后可以将函数改写为:

std::pair<bool, SelectionData> CheckSelectionVer3(const ObjSelection &objList) { 
    SelectionData out;
    if (!objList.IsValid())
        return {false, out};

    // 扫描...
    return {true, out};
}
1
2
3
4
5
6
7
8

调用点代码如下:

if (auto [ok, selData] = CheckSelectionVer3(sel); ok)
{
    // ...
}
1
2
3
4

代码使用了std::pair,这样仍然保留了表示操作是否成功的标志,它不属于新结构体的一部分。

这样做的主要优点是改进了代码结构,提高了可扩展性。如果要添加一个新参数,只需扩展结构体即可。而在之前的函数声明中使用输出参数列表时,需要更新更多的代码。

但是std::pair<bool, MyType>是不是和std::optional的概念很相似呢?

# 使用std::optional

在关于std::optional的章节中提到:

std::optional是一种包装类型,用于表示 “可空” 类型。它要么包含一个值,要么为空,并且不使用任何额外的内存分配。

这似乎很适合我们的代码。可以去掉ok变量,利用optional的语义来处理。

新的代码版本如下:

std::optional<SelectionData> CheckSelectionVer4(const ObjSelection &objList) {
    if (!objList.IsValid())
        return std::nullopt;

    SelectionData out;
    // 扫描...

    return out; 
}
1
2
3
4
5
6
7
8
9

调用点代码如下:

if (auto ret = CheckSelectionVer4(sel); ret.has_value()) {
    // 通过 *ret 或 ret-> 访问
    // ret->numAnimating
}
1
2
3
4

optional版本有哪些优点呢?列举如下:

  • 形式简洁且富有表现力,optional明确表达了值可能不存在的情况。
  • 高效性,optional的实现不允许使用额外的存储,比如动态内存。其包含的值应在optional的存储区域内分配,且存储区域的对齐方式适合类型T。

# 使用std::variant

最后一个使用std::optional的实现忽略了一个关键方面:错误处理。无法得知某个值未被计算出来的原因。例如,在使用std::pair的版本中,我们能够返回一个错误代码来表明原因。那么针对这个问题该怎么做呢?

如果需要函数中可能出现的完整错误信息,可以考虑使用std::variant的另一种方法。

enum class [[nodiscard]] ErrorCode {
    InvalidSelection,
    Undefined
};

variant<SelectionData, ErrorCode> CheckSelectionVer5(const ObjSelection &objList) { 
    if (!objList.IsValid())
        return ErrorCode::InvalidSelection;

    SelectionData out;
    // 扫描...

    return out; 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

如你所见,代码使用了std::variant,它有两种可能的类型:SelectionData或ErrorCode。它和pair很像,但variant同一时间只会有一个活跃值。

可以这样使用上述实现:

if (auto retV5 = CheckSelectionVer5(sel);
    std::holds_alternative<SelectionData>(retV5)) {
    std::cout << "ok..."
              << std::get<SelectionData>(retV5).numAnimating << '\n ';
}
else {
    switch (std::get<ErrorCode>(retV5))
    {
    case ErrorCode::InvalidSelection:
        std::cerr << "无效选择!\n ";
        break;
    case ErrorCode::Undefined:
        std::cerr << "未定义错误!\n "; 
        break;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

可以看到,使用std::variant比使用std::optional能获取更多信息。它可以返回错误代码,并对可能出现的错误做出响应。

std::variant<ValueType, ErrorCode>可能是std::expected(一种可能会被纳入未来标准库版本的新描述性类型)的一种可行实现方式。

# 总结

你可以在Chapter Refactoring With Optional And Variant/refactoring_optional_variant.cpp中运行这段代码。

在本章中,你学习了如何将大量难看的输出参数重构为更美观的std::optional版本。optional包装器清晰地表明计算值可能不存在。你还学习了如何将多个函数参数封装到一个单独的结构体中。使用单独的类型可以在不改变逻辑结构的情况下轻松扩展代码。

最后,如果你需要函数内部完整的错误信息,还可以考虑使用std::variant。这种类型让你有机会返回完整的错误代码。

上次更新: 2025/04/01, 13:21:34
第三部分 - 更多示例和用例
18. 使用[[nodiscard]]强制执行代码契约

← 第三部分 - 更多示例和用例 18. 使用[[nodiscard]]强制执行代码契约→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
第四章 Lambda函数
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式