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

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

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

本章将通过一个示例,展示std::optionalstd::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

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

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

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,它有两种可能的类型:SelectionDataErrorCode。它和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

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>