每周技巧 #147:负责任地使用穷尽式 `switch` 语句

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #147: Use Exhaustive switch Statements Responsibly

原文最初作为 TotW #147 发布于 2018 年 4 月 25 日。

作者:Jim Newsome

更新于 2020 年 4 月 6 日。

快捷链接:abseil.io/tips/147

引言

使用 -Werror 编译器标志时,如果对 enum 类型的值使用不带 default 标签的 switch 语句,而该 enum 的某个枚举器没有对应 case,编译就会失败。这有时称为穷尽式无 defaultswitch 语句。

穷尽式 switch 语句是一个很好的构造,可以在编译期确保给定 enum 的每个枚举器都被显式处理。不过,我们必须确保处理变量(合法地!)拥有非枚举器值时的穿透情况,并且满足下面条件之一:

  1. enum 的所有者保证不会添加新的枚举器;
  2. enum 的所有者愿意且能够在添加新枚举器时修复我们的代码(例如该 enum 定义是同一项目的一部分);
  3. enum 的所有者不会因为破坏我们的构建而被阻塞(例如他们的代码位于独立源码仓库),并且我们愿意在更新到 enum 所有者代码的最新版本时,被迫更新我们的 switch 语句。

初次尝试

假设我们正在写一个函数,把 enum 的每个枚举器映射到 std::string。我们决定使用穷尽式 switch 语句,以确保没有忘记处理任何枚举器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
std::string AnEnumToString(AnEnum an_enum) {
  switch (an_enum) {
    case AnEnum::kFoo:
      return "kFoo";
    case AnEnum::kBar:
      return "kBar";
    case AnEnum::kBaz:
      return "kBaz";
  }
}

假设 AnEnum 确实只有这三个枚举器,这段代码会编译,并且看起来达到了预期效果。然而,有两个重要问题必须考虑。

带有非枚举器值的 enum

在 C++ 中,enum 允许拥有显式枚举器之外的值。所有 enum 都至少可以合法取到某个整数类型能表示的所有值,该整数类型只需有足够位数表示每个枚举器;而具有固定底层类型的 enum(例如用 enum class 声明的那些)可以取到该类型能表示的任何值。有时这会被有意利用,用 enum 充当位字段,或者表示我们编译代码时还不存在的枚举器(如 proto 3)。

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

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

我们可以通过显式处理执行穿过 switch 语句的情况来解决这个问题。这确保我们对 an_enum 的任何可能值都能在运行时得到定义良好且可预测的行为,同时继续受益于“所有枚举器都被显式处理”的编译期检查。

在我们的例子中,我们会记录一个警告并返回哨兵值。另一个合理替代方案,尤其是在我们确信这个函数(目前)不可能接收非枚举器值时,是立即以可调试的错误消息和栈跟踪崩溃。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
std::string AnEnumToString(AnEnum an_enum) {
  switch (an_enum) {
    case AnEnum::kFoo:
      return "kFoo";
    case AnEnum::kBar:
      return "kBar";
    case AnEnum::kBaz:
      return "kBaz";
  }
  LOG(ERROR) << "Unexpected value for AnEnum: " << static_cast<int>(an_enum);
  return kUnknownAnEnumString;
}

现在我们已经确保对 an_enum任何可能值都会发生某种合理事情,但仍然可能有问题。

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

假设后来有人想给 AnEnum 添加一个新枚举器。这样会导致 AnEnumToString 不再编译。这是 bug 还是特性,取决于谁拥有 AnEnum 以及他们提供什么保证。

如果 AnEnumAnEnumToString 属于同一项目,那么添加新枚举器的工程师很可能会因为编译错误而在提交变更前被阻止,直到修复 AnEnumToString。他们也相当可能愿意且能够这么做。在这种情况下,使用穷尽式 switch 语句是胜利:它成功确保 switch 语句被适当更新,大家都满意。

类似地,如果 AnEnum 属于另一个项目、且位于不同仓库,那么破坏不会显现,直到我们项目的工程师尝试更新到那段代码的新版本。如果我们预计这些工程师愿意且能够修复 switch 语句,那也没问题。

然而,如果 AnEnum 属于同一仓库中的另一个项目,情况就更微妙。对 AnEnum 的改动可能导致我们的代码在 head 上破坏,而做出改动的工程师可能不愿意或无法替我们修复。事实上,如果有许多类似的穷尽式 switch 语句使用 AnEnum,要修复所有这类用法会极其困难。

因此,最好只在我们拥有的 enum 类型上使用穷尽式 switch 语句,或者在其所有者已明确保证不会添加新枚举器时使用。

在我们的例子中,假设 AnEnum 由另一个项目拥有,但文档承诺不会添加新枚举器。我们加上一条注释,让未来读者理解我们的推理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
std::string AnEnumToString(AnEnum an_enum) {
  switch (an_enum) {
    case AnEnum::kFoo:
      return "kFoo";
    case AnEnum::kBar:
      return "kBar";
    case AnEnum::kBaz:
      return "kBaz";
    // No default. The API of AnEnum guarantees no new enumerators will be
    // added.
  }
  LOG(ERROR) << "Unexpected value for AnEnum: " << static_cast<int>(an_enum);
  return kUnknownAnEnumString;
}

结论

穷尽式 switch 语句可以是确保所有枚举器都被显式处理的优秀工具,前提是我们:

  • 显式处理 enum 具有非枚举器值、从整个 switch 语句穿透出来的情况。特别是,如果外围函数有返回值,必须确保函数仍然返回一个值,或者以定义良好且可调试的方式崩溃。
  • 确保下面条件之一成立:
    • enum 类型的所有者保证不会添加新的枚举器;
    • enum 的所有者愿意且能够在添加新枚举器时修复我们的代码;
    • 如果我们的代码使用穷尽式 switch 语句,并且因添加枚举器而破坏,enum 的所有者不会被这种破坏阻塞。

当把 enum 类型提供给其他项目时,我们应该:

  • 显式保证不会添加新的枚举器,让用户可以利用穷尽式 switch 语句。
  • 或者,显式保留不经通知添加新枚举器的权利,以阻止消费者编写穷尽式 switch 语句。一种惯用做法是添加一个哨兵枚举器,并清楚表明它不应在 API 消费者的穷尽式 switch 语句中使用;例如 kNotForUseWithExhaustiveSwitchStatements

FAQ

  • 为什么编译器允许在穷尽式 switch 之后省略 return 语句?

    如果采取额外步骤确保 enum 变量只能是其枚举器之一,省略最终 return 可能是安全的。在这种情况下,防御性地添加最终 return 通常仍然更好。

  • 我正在 switch 的 enum 到处都有穷尽式 switch 语句。既然所有者实际上已经被阻止添加新枚举器,那么添加我自己的穷尽式 switch 语句不是无害的吗?

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

  • protobuf enum 怎么办?

    权威指导见 protobuf 文档

    不推荐在 proto3 enum 类型上使用穷尽式 switch 语句。解析器不保证 enum 字段会具有枚举器值。此外,如果不引用应视为 protobuf 工具内部实现细节的特殊哨兵枚举器,就无法为 proto3 enum 类型编写穷尽式 switch 语句。

    对你拥有的 proto2 enum 类型(或其所有者保证永远不会迁移到 proto3、也永远不会添加新枚举器的类型)使用穷尽式 switch 语句是安全的,并且 protobuf 团队推荐这样做。protobuf 解析器保证 enum 字段会被赋予一个编译期枚举器,不过如果 enum 值不保证来自解析器,仍需小心(例如它是某个作为函数参数收到的 proto 对象的一部分)。

  • 作用域枚举(enum class)怎么办?

    在本文写作时(也就是至少到 C++20),本技巧中的所有内容都适用于 C++ 中的所有枚举类型。

参考

每周技巧 #146:默认初始化与值初始化

上一节

每周技巧 #148:重载集

下一节