本周小贴士#147:负责地使用穷举witch语句

Posted -飞鹤-

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了本周小贴士#147:负责地使用穷举witch语句相关的知识,希望对你有一定的参考价值。

作为TotW#147最初发表于2018年4月19日

由Jim Newsome创作

介绍

使用 -Werror 编译器标志,如果枚举的任何枚举数没有相应的大小写,则没有默认标签的枚举类型值的 switch 语句将无法编译。 这有时被称为穷举或无默认 switch 语句。

穷举switch 语句是确保在编译时显式处理给定枚举的每个枚举值的良好构造。 但是,当变量(合法!)具有非枚举值时,我们必须确保处理失败情况,并且是以下情况之一:

  1. 枚举的所有者保证不会添加新的枚举数,
  2. 当添加新的枚举值时,枚举的所有者愿意并且能够修复我们的代码(例如,枚举定义是同一项目的一部分),
  3. 枚举的所有者不会因破坏我们的构建而被阻止(例如,他们的代码位于单独的源代码控制存储库中),并且我们愿意在更新到枚举所有者的最新版本时强迫更新我们的 switch 语句代码。

初步尝试

假设我们正在编写一个将枚举的每个枚举数映射到 std::string 的函数。 我们决定使用穷尽 switch 语句来确保我们不会忘记处理任何枚举数:

std::string AnEnumToString(AnEnum an_enum) 
  switch (an_enum) 
    case kFoo:
      return "kFoo";
    case kBar:
      return "kBar";
    case kBaz:
      return "kBaz";
  

假设 AnEnum 确实只有这三个枚举值,则此代码将编译,并且似乎具有预期的效果。但是,有两个重要的问题必须考虑。

具有非枚举值的枚举

在 C++ 中,允许枚举具有显式枚举数以外的值。 所有枚举都可以合法地采用至少由整数类型表示的所有值,只需足够的位来表示每个枚举值,并且具有固定基础类型的枚举(例如使用 enum 类声明的枚举)可以采用该类型可表示的任何值 . 这有时会被有意利用来将枚举用作位域或表示编译代码时不存在的枚举值(如在 proto 3 中)。

那么如果 an_enum 不是已处理的枚举类型之一,我们的代码会发生什么?

通常,当 switch 语句没有匹配 switch 条件的 case 并且没有默认 case 时,执行会跳过整个 switch 语句。 这可能会导致令人惊讶的行为; 在我们的示例中,它会导致未定义的行为。 在执行通过 switch 语句后,它到达函数的末尾而不返回值,这对于具有非 void 返回类型的函数来说是未定义的行为。

我们可以通过显式处理执行通过 switch 语句的情况来解决这个问题。 这确保我们始终在运行时获得定义和可预测的行为,同时继续受益于所有枚举数都被显式处理的编译时检查。

在我们的示例中,我们将记录一个警告并返回一个标记值。 另一个合理的替代方案,特别是如果我们确信函数(当前)不能接收非枚举值,将立即崩溃并显示可调试的错误消息和堆栈跟踪,例如 使用LOG(FATAL)。

std::string AnEnumToString(AnEnum an_enum) 
  switch (an_enum) 
    case kFoo:
      return "kFoo";
    case kBar:
      return "kBar";
    case kBaz:
      return "kBaz";
  
  std::cerr << "Unexpected value for AnEnum: " << an_enum;
  return kUnknownAnEnumString;

我们现在已经确保 an_enum 的任何可能值都会合理地发生,但仍然存在潜在的问题。

添加新枚举值时会发生什么?

假设后来有人想向 AnEnum 添加一个新的枚举值。 这样做会导致 AnEnumToString 不再编译。 这是错误还是功能取决于谁拥有 AnEnum 以及他们提供的保证。

如果 AnEnum 与 AnEnumToString 属于同一项目,则添加新枚举值的工程师可能会因编译错误而无法在修复 AnEnumToString 之前提交更改。 他们也很可能愿意并且能够这样做。 在这种情况下,我们使用穷举witch语句是一个胜利:它成功地确保了 switch 语句得到适当的更新,并且每个人都很高兴。

同样,如果 AnEnum 是不同存储库中不同项目的一部分,那么在我们项目的工程师尝试更新到该代码的较新版本之前,损坏不会出现。如果我们期望那些工程师愿意并且能够修复 switch 语句,那么一切都很好。

但是,如果 AnEnum 由同一存储库中的不同项目拥有,则情况会更加不稳定。对 AnEnum 的更改可能会导致我们的代码中断,并且进行更改的工程师可能不愿意或无法为我们修复它。事实上,如果 AnEnum 上有许多类似的穷举 switch 语句,那么修复所有这些用法对他们来说将是极具挑战性的。

由于这些原因,最好只对我们拥有的枚举类型使用穷举switch 语句,或者其所有者明确保证不会添加新的枚举数。

在我们的示例中,假设 AnEnum 归另一个项目所有,但文档承诺不会添加新的枚举值。让我们添加一条评论,以便未来的读者理解我们的推理。

std::string AnEnumToString(AnEnum an_enum) 
  switch (an_enum) 
    case kFoo:
      return "kFoo";
    case kBar:
      return "kBar";
    case kBaz:
      return "kBaz";
    // 无默认值, AnEnum的接口保证不会添加新的枚举值
  
  std::cerr << "Unexpected value for AnEnum: " << an_enum;
  return kUnknownAnEnumString;

结论

穷举switch语句是非常好的工具,用以保证所有的枚举值被显式地处理,并由我们提供:

  • 显式处理枚举具有非枚举值的情况,贯穿整个 switch 语句。 特别是如果封闭函数有一个返回值,我们必须确保该函数仍然返回一个值或以明确定义和可调试的方式崩溃。
  • 确保以下情况:
    – 枚举类型的所有者要么保证不会添加新的枚举数,
    – 当添加新的枚举值时,枚举的所有者愿意并且能够修复我们的代码,
    – 如果我们的代码使用了穷举witch 语句并且由于添加了枚举值而被破坏,则枚举的所有者不会被这种破坏所阻塞。
    当枚举类型用于其他项目时:我们应该:
  • 明确保证不会添加新的枚举值,以便用户可以利用穷举switch 语句。
  • 明确保留添加新枚举值的权利,恕不另行通知,以阻止使用者编写穷举switch 语句。 这样做的一种惯用方式是添加一个明显不打算在 API 使用者穷举switch 语句中使用的标记枚举值; 例如 kNotForUseWithExhaustiveSwitch 语句。

常见问题

  • 为什么编译器允许在穷举切换后省略 return 语句?
    如果采取额外步骤确保枚举变量只能是其枚举值之一,则省略最终返回可能是安全的。 在这种情况下,仍然防御性地添加最终返回或 LOG(FATAL) 通常会更好,但是如果没有 google3 的默认编译器标志允许代码在没有它们的情况下进行编译,那么存在足够多的遗留代码。
  • 我打开的枚举已经到处都有穷举switch 语句。 既然已经有效地阻止了所有者添加新的枚举器,那么添加我自己的穷举switch 语句不是无害的吗?

在进一步增加维护负担之前,通常最好从所有者那里获得明确的政策。

  • protobuf 枚举呢?

权威指导见 protobuf 文档。

不建议在 proto3 枚举类型上使用穷举switch 语句。 解析器不保证枚举字段会有枚举值。 此外,如果不引用应该被视为 protobuf 工具的内部实现细节的特殊标记枚举值,就不可能在 proto3 枚举类型上编写穷举switch 语句。

您拥有的 proto2 枚举类型的穷举switch 语句(或者其所有者保证永远不会移动到 proto3 并且永远不会添加新的枚举值)是安全的,并且被 protobuf 团队推荐。 protobuf 解析器保证枚举字段将被分配一个编译时枚举值,但如果不能保证枚举值来自解析器(例如,如果它是作为函数参数接收的 proto 对象的一部分,则仍应注意 )。

  • 范围枚举(枚举类)呢?

本技巧中的所有内容都适用于撰写本文时 C++ 中的所有枚举类型(即至少通过 C++20)。

以上是关于本周小贴士#147:负责地使用穷举witch语句的主要内容,如果未能解决你的问题,请参考以下文章

本周小贴士#76:使用absl::status

本周小贴士#130:命名空间命名

本周小贴士#140:常量:安全习语

本周小贴士#140:常量:安全习语

本周小贴士#107:引用生命周期的扩展

本周小贴士#90:退役标志