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);
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))
{
// ...
}
2
3
4
5
6
7
8
我们如何改进这个函数呢?有以下几个方面:
- 看看调用者的代码:必须创建所有用于存储输出结果的变量。如果在多个地方调用该函数,这无疑会产生代码重复。
- 输出参数:《C++核心指南》建议不要使用输出参数。(F.20:对于 “输出” 输出值,优先使用返回值而非输出参数 )
- 如果使用原始指针,就必须检查它们是否有效。如果使用引用作为输出参数,可以省去这些检查。
- 要是扩展这个函数呢?如果需要添加另一个输出参数该怎么办?
还有其他问题吗?你会如何重构这段代码呢?
受《C++核心指南》和C++17新特性的启发,我们可以按以下步骤改进代码:
- 将输出参数重构为一个元组(
tuple
)并返回。 - 将元组重构为一个单独的结构体,并将元组简化为一个
pair
。 - 使用
std::optional
来表示某个值是否被计算出来。 - 使用
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 };
}
2
3
4
5
6
7
8
9
10
11
12
13
是不是好多了?元组版本有以下优点:
- 无需检查原始指针。
- 代码更具表现力,所有返回值都封装在一个对象中。
在调用点,可以使用结构化绑定(Structured Bindings)来解包返回的元组:
auto [ok, anyCivil, anyCombat, numAnim] = CheckSelectionVer2(sel);
if (ok) {
// ...
}
2
3
4
遗憾的是,这个版本可能不是最佳方案。例如,存在忘记元组中输出值顺序的风险。
函数扩展的问题仍然存在。所以当需要添加另一个输出值时,必须同时扩展元组和调用点的代码。
我们可以再进一步改进:使用结构体代替元组(这也是《C++核心指南》建议的)。
# 单独的结构体
这些输出似乎代表了相关的数据。因此,将它们封装到一个名为SelectionData
的结构体中可能是个不错的主意。
struct SelectionData {
bool anyCivilUnits { false };
bool anyCombatUnits { false };
int numAnimating { 0 };
};
2
3
4
5
然后可以将函数改写为:
std::pair<bool, SelectionData> CheckSelectionVer3(const ObjSelection &objList) {
SelectionData out;
if (!objList.IsValid())
return {false, out};
// 扫描...
return {true, out};
}
2
3
4
5
6
7
8
调用点代码如下:
if (auto [ok, selData] = CheckSelectionVer3(sel); ok)
{
// ...
}
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;
}
2
3
4
5
6
7
8
9
调用点代码如下:
if (auto ret = CheckSelectionVer4(sel); ret.has_value()) {
// 通过 *ret 或 ret-> 访问
// ret->numAnimating
}
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;
}
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;
}
}
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
。这种类型让你有机会返回完整的错误代码。