(原文連結:https://abseil.io/tips/147 譯者:[email protected])
每周貼士 #147: 負責任地使用窮舉 switch
switch
- 最初釋出于:2018-04-25
- 作者:Jim Newsome
- 更新于:2020-04-06
- 短連結:abseil.io/tips/147
介紹
指定了編譯選項
-Werror
以後,在
switch
一個
enum
類型數值的語句裡,如果某個
enum
的枚舉值沒有對應的
case
,且沒有
default
标簽,那麼編譯就會失敗。這通常被稱作 窮舉 或者 無預設值(defaultless) 的
switch
語句。
窮舉
switch
語句提供了一個絕好的概念,以在編譯期確定枚舉類型的每個枚舉值都被顯式地處理了。然而,我們必須確定處理了落空(fall-through)的情況:變量(合法地)含有一個非枚舉值,且要保證以下情況滿足其一:
-
的所有者保證沒有新的枚舉值會被添加,enum
- 在新的枚舉值被添加的時候,
的所有者有意願且有能力修好我們的代碼(例如,enum
的定義是同一個項目的一部分),enum
-
的所有者不會被我們的建構破壞所影響(例如,其代碼在另一個代碼控制倉庫中),而且我們願意在更新到enum
所有者最新版本代碼的時候強制更新我們的enum
語句。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 class
)的枚舉類型,可以接受該類型可以表達的所有值。有的時候這一點被有意地用來以
enum
表達位域,或者用來表達編譯代碼時尚不存在的枚舉值(如proto 3)。
那麼,當
an_enum
不在我們處理的枚舉值之中時會發生什麼?
一般當
switch
語句沒有
case
比對
switch
的條件并且沒有
default
分支的時候,代碼會直接越過整個
switch
語句。這可能會導緻意外的行為;在我們的例子中,它導緻了未定義行為。在代碼越過
switch
語句之後,它走到了函數結尾卻沒有傳回一個值,這對于一個傳回非空(non-void)類型的函數來說是未定義行為。
我們可以顯式地處理代碼越過
switch
語句的情況,以解決這個問題。這確定了我們在運作時總是得到定義好的、可預測的行為,并且繼續受益于編譯期檢查,確定所有的枚舉值都被顯式地處理了。
在我們的例子中,我們将列印警告日志,然後傳回一個哨兵值。另一個合理的選項,尤其是當我們确信該函數(現在)不能 接受一個非枚舉值的時候,就是立即讓程式崩潰(crash),并列印調試資訊和堆棧資訊,例如,用
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
的編譯錯誤之前很可能沒法送出代碼。他很可能有意願也有能力這麼做。這種情況下使用窮舉
switch
語句是好事:它成功地確定了
switch
語句被恰當地更新了,每個人都開心。
相似地,如果
AnEnum
是 另一個代碼庫 中的另一個項目的一部分,那這個破壞直到我們項目的工程師試圖更新到新版本代碼之前都不會浮現出來。如果我們期待那些工程師有意願且有能力修好
switch
語句,那也還好。
然而,如果
AnEnum
屬于 同一個代碼庫 中的另一個項目,那情況就更危險了。一個對
AnEnum
的修改可能導緻我們的代碼在最新版本被破壞,而且做出該修改的工程師也許沒有意願或沒有能力幫我們修好。确實,如果有很多怼着
AnEnum
的窮舉
switch
語句,那麼把它們全修好可是個相當大的挑戰。
因為這些原因,最好把窮舉
switch
語句的使用場景限制在:要麼我們擁有該
enum
類型,要麼該類型的所有者顯式地保證不會添加新的枚舉值。
在我們的例子中,讓我們假設
AnEnum
屬于另一個項目,但是文檔保證了不會有新的枚舉值被添加。讓我們添加一條注釋,以便未來的讀者了解我們的考量。
std::string AnEnumToString(AnEnum an_enum) {
switch (an_enum) {
case kFoo:
return "kFoo";
case kBar:
return "kBar";
case kBaz:
return "kBaz";
// 沒有default。AnEnum的API保證了沒有新的枚舉值會被添加。
}
std::cerr << "Unexpected value for AnEnum: " << an_enum;
return kUnknownAnEnumString;
}
結論
窮舉
switch
語句可以是一個優秀的工具,確定所有的枚舉值都被顯式地處理了。為此要求我們:
- 顯式地處理
含有非枚舉值,是以跳出整個enum
語句的情況。具體來說,如果其所在的函數有傳回值,我們必須確定該函數要麼仍然傳回一個值,要麼以良好定義的且可調試的方式崩潰。switch
- 確定以下滿足其一:
-
的所有者保證沒有新的枚舉值會被添加,enum
-
的所有者在添加新的枚舉值的時候,有意願且有能力修好我們的代碼,enum
- 如果我們的代碼使用了窮舉
語句,并且被新添加的枚舉值破壞了,switch
的所有者不會是以而被阻礙其開發。owner
-
當把
enum
類型暴露給其他項目時,我們應該做到如下之一:
- 顯式地保證沒有新的枚舉值會被添加,是以使用者可以受益于窮舉
語句。switch
- 顯式地保留未經通知而添加新的枚舉值的權利,以阻止使用者寫窮舉
語句。一個慣用的方式是添加一個哨兵枚舉值,且清楚地表明它不該被用于窮舉switch
語句;例如,switch
。kNotForUseWithExhaustiveSwitchStatements
常見問題
- 為什麼編譯器允許在窮舉
之後省略switch
return
語句?
如果有額外的步驟確定
變量隻能是其枚舉值之一,那麼省略最後的傳回 可以 是安全的。在這種情況下,最好還是加一層保險,添加一個最終的enum
或return
,但是有足夠多的祖傳代碼沒這麼寫,是以google3的預設編譯選項允許沒有最終傳回的代碼編譯。LOG(FATAL)
- 我要
的枚舉類型,已經到處都有窮舉switch
語句使用它了。既然其所有者已經事實上沒法給它加新的枚舉值了,我再多寫一個窮舉switch
switch
語句有什麼關系?
一般比起進一步增加維護者的負擔,從所有者那兒拿到一個明确的政策會更好。
-
那protobuf裡的枚舉呢?
權威指導請參見protobuf文檔。
在proto3的
類型之上的窮舉enum
語句是不推薦的。解析器 不 保證switch
字段會有枚舉值。另外,在不引用特殊的(應該被視為protobuf工具内部實作細節的)哨兵枚舉值的情況下,不可能寫出針對proto3的enum
類型的窮舉enum
switch
語句。
如果是你擁有的(或者其擁有者保證不會遷移到proto3,且不會添加新的枚舉值的)proto2的
類型,對其使用窮舉enum
語句是安全的,也是被protobuf團隊推薦的。protobuf解析器保證switch
字段會被賦予一個編譯期的枚舉值。不過還是要當心enum
值不保證來自解析器的情況(例如,如果它是函數參數傳進來的enum
對象的一部分)。proto
- 那限定枚舉(
enum class
)呢?
本貼士裡所有的東西适用于截稿之時的C++的所有枚舉類型(也就是說,至少到C++20)。
參考資料
- Enum handling in protobuf generated code
- C++ enum specification