from Pixabayfrom Pixabay

想必你知道 Python 中有一個內建的特殊類別叫 Enum(來自 enum 模組),專門用來處理「列舉」態型的資料集合。

如同 collection 模組中的各種容器(比如:dequeCounter),Enum 已經定義好很多「內建特性(屬性、方法、行為)」供你使用。這些特性會讓你在處理特定情境時非常順手。

但是,這些特性也使得 Enum 類別與一般類別有著很大的差異,增加了學習門檻

如你所見,Enum 的特性頗多,這讓人在學習、使用之前,難免有點望之卻步——至少我是這樣!

本文主旨與架構

本文分享我最近才開始把 Enum 應用在 API 開發中的經驗——從它的三大特性入手,並輔以一個實際問題情境。

Enum 的特性不少,但只要知曉這三件事,就可以在遇到「列舉」欄位時,善用 Enum 來提升開發效率,同時增進程式碼的簡潔與穩健。

不過話說回來,即使不是開發 API,也不影響你對本文的理解。只是我的經驗是從後端開發而來。

本文架構

為了讓你感受 Enum 的強大與美妙,本文的架構經過精心設計。共分為三個部分

  1. 問題情境:我會先提出一個問題情境,突顯舊方法的不足。
  2. 然後再介紹 Enum 的三大特性,帶你進入 Enum 的世界。
  3. 最後看 Enum 特性在問題情境中如何有效發揮,讓程式碼變得更加優雅——也就是它解決的痛點

藉由這個流程,相信你對 Enum 會有更進一步的理解。


話不多說,先從問題情境開始。

第一部分:問題情境

為了有效講解,舉個實例是必要的,但我會盡可能簡化,只著眼於能夠彰顯 Enum 價值的部分。

作為一個 Django API 開發者,不管是什麼專案,幾乎都會出現「列舉型」資料。此時 Django ORM 中對應的就是「choices」欄位。

假設我們現在要開發一個 Docker 相關的服務(這是我實際遇到的情境),其中一個 API 是「新增容器」。而容器有一個需要使用者輸入的設定是:restart policy。

從官方文件可知,Docker 容器的 restart policy 總共就只有 4 種而已——沒錯,它屬於列舉型資料

Django Model 欄位設計

此時,我們的 Django model 欄位會長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
from django.db import models

class Container(models.Model):
...
RESTART_POLICIES = (
(0, 'no'),
(1, 'always'),
(2, 'unless-stopped'),
(3, 'on-failure'),
)
restart_policy = models.PositiveSmallIntegerField(
choices=RESTART_POLICIES, default=0)

為了方便講解與避免混淆,這裡我採用了「數字(0-3)」作為列舉成員。但實際工作中,我使用的是「字串」,也就是'no''always'等:

1
2
3
4
5
6
7
8
class Container(models.Model):
...
RESTART_POLICIES = (
('no', 'no'),
('always', 'always'),
('unless-stopped', 'unless-stopped'),
('on-failure', 'on-failure'),
)

這是一個重要的細節,以後會另篇討論:什麼時候列舉成員應該用數字,而什麼時候用字串更好。

為了專注於對 Enum 的學習,這裡採最常見的「數字」版本。


以往的做法

以前用 DRF(Django REST framework)開發 API 列舉資料時,除了在 model 增加了上述的 choices 限制,我並沒有使用 Enum。

這裡有一個問題:那前端人員怎麼知道列舉的「成員」有幾個、有哪些?當然只能靠 API 文件!

以往我們都是用 API Blueprint 寫 API 文件。說真的,我不喜歡(我的後端同事們也都不喜歡XD),太多手刻細節了——難怪它的普及率不高🙂‍↔️

雖然在 codebase 中看不到 Enum 的身影,但是,為了增加程式碼可讀性,我們還是會額外設計這樣的類別(這是為了後端開發者們在維護上的考慮):

1
2
3
4
5
6
7
8
class RestartPolicy():
"""
所有容器重啟策略
"""
NO = 0
ALWAYS = 1
UNLESS_STOPPED = 2
ON_FAILURE = 3

如何使用這個類別?

一個典型的情境是,在 view 函式中將變數值與列舉成員進行比較

1
2
if restart_policy == RestartPolicy.UNLESS_STOPPED
...

有了RestartPolicy,程式碼變得更加直觀易讀

換句話說,我們不會直接這樣寫:

1
2
if restart_policy == 2  # 咦,2 是什麼東西?
...

驗證列舉成員

如果列舉資料只限於後端自行使用,那上述的寫法其實就足夠了。

但是,很多時候列舉資料是從前端(使用者輸入)來的。

此時你首先要驗證前端給的資料,是不是屬於這個列舉集合的成員!套用上面的寫法就會顯得囉嗦:

1
2
3
4
5
6
if payload.restart_policy not in (
RestartPolicy.NO,
RestartPolicy.ALWAYS,
RestartPolicy.UNLESS_STOPPED,
RestartPolicy.ON_FAILURE):
...

為了不要讓程式這麼冗長,我們通常會幫這個類別加上一個簡單的ALL屬性:

1
2
3
4
5
6
7
8
9
class RestartPolicy():
"""
所有容器重啟策略
"""
NO = 0
ALWAYS = 1
UNLESS_STOPPED = 2
ON_FAILURE = 3
ALL = [0, 1, 2, 3]

於是程式碼變成這樣:

1
2
if payload.restart_policy not in RestartPolicy.ALL:
...

好像還可以,不是嗎?——但這顯然不是最佳實踐

雖然在這個例子中,restart policy 主要就這 4 種,而且也不太會變動。

但如果在別的列舉場合,偶爾甚至不時會增加成員時,我們的 ALL 屬性的值也要跟著修正,這會產生潛在的「同步」問題——忘記一併更新 ALL 屬性。

使用序列化器驗證

話說回來,DRF 有序列化器模組,作為驗證輸入資料正確性的手段。

只要你有寫序列化器,通常也不需要像上述程式碼一般手工驗證。

但畢竟寫序列化器需要一定的成本與維護,一些相對簡單的 POST API,你可能未必有相關的序列化器。

退萬步言,你就是要在某些自定義的邏輯進行手動驗證,上面的程式碼還是會出現。

小結:目前寫法的問題

假設沒有序列化器輔助,要在程式碼中進行手動驗證,則上述寫法有兩個不妥之處:

  1. 要驗證 input 資料是否為合法列舉成員,太囉嗦、冗長了。
  2. 如果加上ALL這類自定義屬性,雖然可以減緩程式碼冗長問題,又產生了潛在的同步問題。

事實上,即使沒有ALL屬性,第一種寫法一樣存在同步問題。因為你要手動維護這個驗證條件。

ALL屬性只是把這個「新增、刪除列舉成員」的同步議題從 view 函式中(或任何其它用到的地方)抽離出來,統一移到類別中控管。

這或許可以算是一個改進:當情況變動時,需要跟著修改的地方只剩下一個——ALL本身。但依舊不是最佳解法。


第二部分:介紹 Python Enum 的三大特性

自從寫 Django Ninja 後,為了渲染出嚴謹的 API 文件,我不得不用 Enum 了(可參考文章最後的補充部分),同時也感受到了它的強大!

我們來看看,採用 Enum 之後會有什麼改變。

在此之前,我們要先介紹 Enum 中,我認為最重要的三大特性。至少在我的例子中,知道這三個特性會非常有用

我們看一下加入了 Enum 後的類別模樣:

1
2
3
4
5
6
7
8
9
10
from enum import Enum

class RestartPolicy(Enum):
"""
所有容器重啟策略
"""
NO = 0
ALWAYS = 1
UNLESS_STOPPED = 2
ON_FAILURE = 3

我把ALL屬性移除了,因為它不再需要。

我們就以這個新類別為例,來看看 Enum 的三大特性。


一、Enum 類別可以用 for 迴圈迭代

一般的類別並不能以 for 迴圈迭代,因為沒有實作__iter__方法,但 Enum 有!

對於「列舉」這種有限成員的情況,能迭代它非常重要。

還記得原來的ALL屬性嗎? ALL = [0, 1, 2, 3]

這個ALL屬性的值,其實就是為了取得類別中的每一個屬性值。但有了 Enum,我們不用這麼麻煩了!

那我要怎麼取得 Enum 類別中的所有屬性值?這就要看下面第二個特性。

二、了解 Enum 實例

這段特別長,因為它包含了三個子命題,都和 Enum 實例有關。

這裡要先問:如何取得 Enum 實例?

Enum 實例的建構與條件

沒錯,和一般的類別相同,都是從建構與初始化開始,依舊使用上面的例子,我們可以像這樣獲得 Enum 實例:

1
p = RestartPolicy(2)

和一般類別不同的是,這裡的建構引數只能是「任一 Enum 成員的屬性值」。也就是範例類別中的0123

這是 Enum 類別的限制,也是它的重要特性

換句話說,所有 Enum 類別的實例,都一定是由 Enum 成員值(比如上述的 2)建構而來的

此外,沒有引數也不行!

1
2
3
4
5
# 沒有引數會報錯
>>> RestartPolicy()
...
...
TypeError: EnumMeta.__call__() missing 1 required positional argument: 'value'

而建構出的實例,就是「代表該成員」的 Enum 實例

1
2
>>> RestartPolicy(2)
<RestartPolicy.UNLESS_STOPPED: 2>

Enum 類別屬性「就是」Enum 實例

這是 Enum 最重要的特性之一,也是最容易讓人困惑的地方。

除了透過建構,其實直接呼叫類別屬性,也能夠得到同一個 Enum 成員實例!

這是一般類別所難以想像的,因為一般類別呼叫類別屬性後,只會得到單純的,比如本文一開始的例子:

1
2
3
# 這裡是非 Enum 版的 RestartPolicy
>>> RestartPolicy.UNLESS_STOPPED
2

回到 Enum。換言之,下面兩種寫法,獲得的結果完全相同

1
2
>>> RestartPolicy(2)
<RestartPolicy.UNLESS_STOPPED: 2>
1
2
>>> RestartPolicy.UNLESS_STOPPED
<RestartPolicy.UNLESS_STOPPED: 2>

不了解這個特性,會讓你對 Enum 的使用產生很大困惑。

單例模式

更進一步說,Enum 類別的每一個成員實例,實際上都是單例

這意味著對於每個成員,無論你在程式碼中呼叫多少次,都是指向「同一個」物件。

1
2
3
4
5
6
7
>>> p1 = RestartPolicy(2)
... p2 = RestartPolicy(2)
... p1 is p2
True

>>> RestartPolicy(2) is RestartPolicy.UNLESS_STOPPED
True

了解這一點,就能夠更好地利用 Enum 來處理列舉資料。


Enum 實例的兩個屬性

所有 Enum 成員實例都有兩個內建的屬性:namevalue

name成員的名稱,也就是類別屬性名稱value則是成員的值

我們分別使用兩種不同的實例取得方式來呼叫這兩個屬性!

1
2
3
4
5
6
>>> RestartPolicy(2).name
'UNLESS_STOPPED'

# 等同於
>>> RestartPolicy.UNLESS_STOPPED.name
'UNLESS_STOPPED'
1
2
3
4
5
6
>>> RestartPolicy.UNLESS_STOPPED.value
2

# 等同於
>>> RestartPolicy(2).value
2

綜上所述,如果用 for 迴圈迭代RestartPolicy,你將得到(這裡使用repr突顯):

1
2
3
4
5
6
7
>>> for i in RestartPolicy:
... print(repr(i))

<RestartPolicy.NO: 0>
<RestartPolicy.ALWAYS: 1>
<RestartPolicy.UNLESS_STOPPED: 2>
<RestartPolicy.ON_FAILURE: 3>

得到RestartPolicy的每一個成員實例。


三、Enum 實例之間可以進行比較

Enum 成員之間,支援身分比較(is)和等值(==)比較。

只不過,如前所述,每一個 Enum 成員都是「單例」。所以身分比較和等值比較對於 Enum 成員是等價的,也就是結果都相同。因為每個成員都是唯一的。

1
2
3
4
5
>>> RestartPolicy(2) == RestartPolicy.UNLESS_STOPPED
True

>>> RestartPolicy(2) is RestartPolicy.UNLESS_STOPPED
True

我認為大部分時候,使用等值(==)比較就已經足夠了。


第三部分:使用 Enum 改進原有程式!

現在你對 Enum 的特性已經有了相當的了解。我們來看看原來的程式碼在使用 Enum 版本後,會發生什麼樣的變化。

回顧一下之前的問題:

  1. 要驗證 input 資料是否為合法列舉成員,太囉嗦、冗長了。
  2. 如果加上ALL這類自定義屬性,雖然可以減緩程式碼冗長問題,又產生了潛在的同步問題。

對問題一的改善

現在,你想要驗證 input 值是否屬於列舉成員,可以這樣寫(我們假設 input 值為 2,代表'unless-stopped'

1
2
if payload.restart_policy in [p.value for p in RestartPolicy]:
...

因為 RestartPolicy 類別現在是可以用 for 迭代了!而每一個元素則是成員實例,需要透過value屬性取值,所以可以像上面那樣寫。

乍看之下,好像也沒有方便到哪去?確實如此😅,沒關係,我們還有後手

Pydantic 預處理

如果你有用 Pydantic 對資料預處理(FastAPI、Django Ninja 等後端框架會自動做這件事),通常payload.restart_policy的值,就已經是一個 Enum 實例了。

當然,如果有用 Pydantic 預處理,這個驗證根本不需要XD,這裡只是為了舉例。

如此一來,驗證成員身分的寫法將異常簡單

1
2
if payload.restart_policy in RestartPolicy:
...

對,就這麼簡單!

因為in運算子的內部,本來就會迭代後面接的物件了——所以in後面必須緊接一個 iterable,而 Enum 類別正是一個 iterable。


透過「建立 Enum 實例」進行驗證

但如果你的payload.restart_policy,就只是一般的值,而不是 Enum 成員。那該怎麼辦?難道真的要像上面那樣,寫落落長的[p.value in p in RestartPolicy]嗎?

當然不!其實做法一樣非常簡單,那就是「直接用這個值來建立實例」。

前面說過,Enum 實例只能由 Enum 成員值建構而來。所以,如果你輸入的值不是合法的 Enum 成員,建構實例時就會拋出ValueError

換句話說,只要能成功建立實例,就是合法的成員!所以你可以這樣寫:

1
2
3
4
try:
RestartPolicy(payload.restart_policy)
except ValueError:
...

如果是不合法的,比如 4,則會拋出ValueError

1
2
3
4
>>> RestartPolicy(4)
...
...
ValueError: 4 is not a valid RestartPolicy

總之,記得處理這個例外。

對問題二的改善

既然 Enum 類別本身就可以用 for 迭代以取得所有成員(甚至直接使用in運算子),那就完全沒必要再寫前面像ALL一樣的屬性,然後還要維護它。

由此可見,Enum 真的棒!


補充:Enum 類別在 Django Ninja 中的應用

前面說到我會開始用 Enum,正是因為 Django Ninja。

Django Ninja 的 Schema 實際上就是 Pydantic 的 BaseModel

Schema 用來描述 API 輸入與輸出的資料結構。主要的功能有二:

  1. 驗證輸入資料是否符合規範。
  2. 自動產生 API 文件。

我們看一下使用了 Enum 型別後的 Schema:

1
2
3
class CreateContainerRequest(Schema):
...
restart_policy: RestartPolicy

直接將restart_policy欄位型別標記為RestartPolicy,如此渲染出的 API 文件,不止能限定輸入資料的型別,連「值域」也會有明確標示

型別:integer;值域:僅限 0、1、2、3型別:integer;值域:僅限 0、1、2、3

甚至還能把RestartPolicy類別中的docstring直接渲染成欄位說明,太貼心了吧!偉哉 Pydantic!

好處不止如此,一旦你變更RestartPolicy的內容(比如增加了成員選項),API 文件也會自動更新

不必再手動修改 API 文件,也不必修改 Schema 的 type hints,完全省去了「同步」的煩惱。


除了上述的「數字列舉」版,我同時再附上「字串列舉」版本,看圖你應該就能理解,為何我一開始說,有時候列舉資料用字串會更好。

直接採用字串選項,對前端人員更加友善——更容易了解每個選項代表的意義。