作为TotW#175最初发表于2020年3月12日
由Etienne Dechamps创作
问题
考虑下面的代码:
// 从提供的doodad中提取foo spec和bar spec。
// 如果输入无效,则返回false。
bool ExtractSpecs(Doodad doodad, FooSpec* foo_spec, BarSpec* bar_spec);
正确使用(或实现)此函数需要开发人员问自己惊人数量的问题:
- foo_spec和bar_spec是输出参数还是输入/输出参数?
- foo_spec或bar_spec中的现有数据会发生什么?它会被附加到吗?它会被覆盖吗?它会使函数检查失败吗?它会使它返回false吗?它是未定义的行为吗?
- foo_spec可以为null吗?bar_spec可以吗?如果它们不能为null,那么空指针会使函数检查失败吗?它会使它返回false吗?它是未定义的行为吗?
- foo_spec和bar_spec的生命周期要求是什么?换句话说,它们需要超过函数调用的生命周期吗?
- 如果返回false,foo_spec和bar_spec会发生什么?它们保证不变吗?它们以某种方式“重置”吗?它是未指定的吗?
仅从函数签名无法回答任何这些问题,也不能依赖C++编译器执行这些契约。函数注释可以帮助,但通常不会。例如,此函数的文档在大多数问题上保持沉默,并且对“输入”是什么意思也存在歧义。它仅指doodad,还是也指其他参数?
此外,这种方法对每个调用点都造成样板文件:调用方必须预先分配FooSpec和BarSpec对象才能调用函数。
在这种情况下,有一种简单的方法可以消除样板文件,并以编译器可以强制执行的方式编码契约。
解决方案
以下是使所有这些问题无关紧要的方法:
struct ExtractSpecsResult {
FooSpec foo_spec;
BarSpec bar_spec;
};
// 从提供的doodad中提取foo spec和bar spec。
// 如果输入无效,则返回nullopt。
std::optional<ExtractSpecsResult> ExtractSpecs(Doodad doodad);
这种新的API在语义上是相同的,但现在很难被误用:
输入和输出更清晰。 由于函数从头开始创建它们,因此不会有关于foo_spec和bar_spec中现有数据的问题。 由于没有指针,所以没有关于空指针的问题。 由于所有内容都是通过值传递和返回的,所以没有关于寿命的问题。 如果返回nullopt,则甚至无法访问foo_spec和bar_spec,因此不会出现关于失败时它们会发生什么的问题。 这反过来降低了错误发生的可能性,并减少了开发人员的认知负担。
还有其他好处。例如,该函数更容易组合;也就是说,它可以很容易地作为更广泛表达式的一部分使用,例如SomeFunction(ExtractSpecs(…))。
注意事项
这种方法对于输入/输出参数不起作用。 在某些情况下,可以使用这种方法的变体,其中参数按值传递,然后通过值返回。这是否是一个好主意取决于函数的使用方式以及该值是否可以高效地移动(Tip#117)。 这种方法不允许调用方轻松自定义返回对象的创建。 例如,如果FooSpec和BarSpec是protos,则使用输出参数方法,如果调用方希望,可以在特定的arena上分配这些protos。在返回值方法中,需要将arena指定为附加参数,或者调用方可能已经知道它。 性能可能因您选择的方法和具体情况而异。 在某些情况下,返回值可能不太高效,例如如果它导致在循环中重复分配。 在其他情况下,返回值可能比您想象的更有效,感谢(N)RVO(Tip#11,Tip#166)。甚至可能比输出参数更高效,因为优化器不必担心别名问题。 像往常一样,避免过早优化,先编写可读性和可维护性较好的代码。
建议
- 尽可能使用返回值而不是输出参数。这与样式指南一致。
- 使用通用包装器,例如std::optional来表示缺少的返回值。如果需要更灵活的表示形式并具有多个备选项,则考虑返回absl::variant。
- 使用结构体从函数返回多个值。
- 如果有意义,可以自由编写一个新的结构体来表示函数的返回值。
- 不要滥用std::pair或std::tuple来实现此目的。