Python 的 type 不是 type?

Intro

近期因為一些原因,想自己寫一個 Python 用的 DI library。寫是寫完了,不含 test 基本上不到 50 行, 也 release 到 luckydep · PyPI 了。 不過在寫的過程中發現了一些問題。

與 Golang 不同。 在 Python 中,DI container 拿取特定 instance 的介面 (invoke/bind/get 等,下稱 invoke) 需要明確傳遞想拿取的 instance 的 type。

1
2
3
4
5
6
7
func Invoke[T any](c *Container) T {
var _ T // can construct T event if T is a interface
}

// although we need to specify the type parameter, the type parameter
// is not passed during runtime
var instance = Invoke[SomeType](c)
1
2
3
4
5
class Container:
def invoke(t): # search and build a instance of type t

c = Container()
instance = c.invoke(SomeType) # need to pass type as a parameter

其中的根本差異是,Golang 類型的 static type language,generic function 會真的根據不同型別,產生對應的 function 出來,這些 function 的 byte code/machine code 自然知道當下在處理的型別。 而 Python 這類語言,靠 static type checker 建立 generic function,實際上到 runtime 時還是只有一個 function,自然會需要傳遞 type 給 invoke 介面。

自從 Python 3.6 開始我們有 type hint,所以我們可以 annotate function/method 來幫助 IDE/type checker 來推論正確的型別。

1
2
3
4
5
class Container:
def invoke(t: type[T]) -> T: # search and build a instance of type t

c = Container()
instance: SomeType = c.invoke(SomeType) # ok, we can infer instance is SomeType

這邊 type[T] (or typing.Type[T], the old way) 用來表示我們正在用 t 來傳遞傳遞某個 type T, 而非 type 為 T 的某個 instance。

From typing document:

A variable annotated with C may accept a value of type C. In contrast, a variable annotated with type[C] (or typing.Type[C]) may accept values that are classes themselves

The Problem

OK,我們有 type[T] 可以用。 DI library 開發者可以用型別為 type[T]t 來做 indexing, library 使用者可以享受到 static type checker 帶來的 type safety。

於是我們拿這個 library 來用在真實情境.. 沒想到一下子就碰上問題了。 當我們定義 interface type,並透過 DI container 對該 interface 拿取對應的 implementation instance 時。 因為 interface 通常是個 abstract class (or protocol),mypy type checker 會報錯 (mypy: type-abstract)。

1
2
3
4
5
6
7
class SomeInterface(Protocol):
def hello(self): ...

class SomeImplementation:
def hello(self): ...

c.invoke(SomeInterface) # trigger mypy error [type-abstract]

不會吧… 這不是我們需要 DI 的最重要原因嗎? 我們定義 interface 並另外提供 implementation,來達到隔離不同 class 職責的效果。 結果當 user 要用這個 library 的時候卻卡在型別檢查…

The History

翻閱文件,第一時間以為這是 mypy 的設計問題。

Mypy always allows instantiating (calling) type objects typed as Type[t]

沒想到翻了 mypy issue #4717 · python/mypy 後,發現這是已經寫在 PEP 544 內的規格。

Variables and parameters annotated with Type[Proto] accept only concrete (non-protocol) subtypes of Proto. The main reason for this is to allow instantiation of parameters with such type. For example:

1
2
def fun(cls: Type[Proto]) -> int:
return cls().meth() # OK

mypy 允許 construct 一個不知道 constructor 長什麼樣子的 interface type, 所以該標示 Type[Proto] 的 parameter 只能傳遞 concrete type… 嗯?

繼續往下追,想不到一開始會有這個檢查,是因為 Guido 本人 在 2016 年開的 #1843 · python/mypy, 認為應該允許這種使用方法。

於是 mypy 加入了這個檢查,後來 2017 年的 PEP 544 也明確定義了這個使用規則。

The Controversy

這個 t: type[T] 的設計引起很多爭議,從 #4717 · python/mypy 來看,不少人認為: 為了允許 construct t() 而限制只能傳遞 concrete class 會大幅限制這個 type[T] 的使用情境。

也有人認為這個檢查根本就不合理,因為沒有人能保證這個 protocol type 底下的 concrete class 的 constructor 到底要吃什麼東西。 即使 static type check 檢查過了,t() 在 runtime 噴掉一點也不奇怪。 更何況根本沒看過有人在 protocol type 上面定義 __init__ method,這個 t() 一開始到底要怎麼檢查也不知道。

如果看相其他語言的開發經驗… Golang 生態系 constructor 是 plain function,定義 interface type 時自然不會包含 constructor。 寫 C++ 的人應該也沒聽過什麼 abstract constructor,只有 destructor 會掛 abstract keyword。 回到 Python 自身,mypypyright 兩大工具也都允許 __init__ 的 signature 在繼承鍊中被修改。 (see: python/typing · Discussion #1305)

至於 typing.Type 的文件,寫得很模糊,我想有一定程度的人看到反而更容易誤會。

type[C] … may accept values that are classes themselves …

就算捨棄掉 protocol,限制都只能用 concrete class 來定義 interface。 這個只能允許 concrete class 的規則還造成了另一個問題: 使用者該如何傳遞 function type?

1
c.register(Callable[[int, int], int], lambda a, b: a + b) # ????

說好的 function as first-class citizen 呢? 怎麼到了要傳遞型別時就不行了?

在翻閱 issue 的過程中,發現其他 DI framework 的 repo 也遇上同樣的問題 #143 · python-injector/injector, 頓時覺得自己不孤單。

The Future

由於 PEP 544 自從 2017 年就已經完成,mypy 預設執行這個檢查也行之有年, 現在再來改這個行為或許已經來不及了。

於是為了解決這個問題,2020 有人在開了新 issue 9773 · python/mypy 想要定義新的 annotation TypeForm[T]/TypeExpr[T] 來達成要表達任意 type 的 type 的需求。 到目前 (2024-06),對應的 PEP 747 draft 也已經被提出了。

若一切順利,以後我們就會用 TypeExpr[T] 來表達這類 generic function

1
2
3
4
5
6
7
8
class Container:
def invoke(t: TypeExpr[T]) -> T: # search and build a instance of type t

class SomeType(Protocol): ...

c = Container()
instance = c.invoke(SomeType) # ok, we find a object for type SomeType for you!
operator = c.invoke(Callable[[int], bool]) # you need a (int -> bool)? no problem!

至於目前嘛.. library user 在使用到這類 library 的檔案加入下面這行即可。 我想要修改的範圍和造成的影響應該都還可以接受。

1
# mypy: disable-error-code="type-abstract"

期許 Python typing system 完好的那天到來。

Timeline

Golang 1.18 Generics 終於來臨

今天 Golang 1.18 終於正式釋出啦! 我們終於有辦法在 Golang 裡做 generic programming 啦!

Golang 是一個近 10 年快速竄紅的程式語言,但在很多面向其實還是非常土炮。 得靠後續社群不斷的討論與貢獻才達到一個比較完善的水準。 像是..

  • context package in Golang 1.7: 解決 long job cancelling 的問題
  • errors package in Golang 1.13: 滿足其他語言常見的 error 嵌套需求和提供統一的判斷方式
  • Generic support in Golang 1.18: 提供開發者實作各種型無關演算法的機會

一直以來,在 Golang 的標準函式庫中,碰到類型無關的抽象問題時,最後給出來的解法大概就兩種

  1. 所有 input / output 參數都定義成 interface{},大家一起把型別檢查往 run-time 丟
  2. 同一個 class / function 對於常用的 data type 通通實作一遍

前者最典型的大概就是 sync 這個函示庫,後者.. 大家應該都看過那個慘不忍睹的 sort..

不過這些都是過去式了,從今天開始,大家都可以寫自己想要的 generic code / library / framework。 :D

Usage

基本語法很簡單,只要在想做成 generic 的 function / struct 後面多加一個 [T TypeName] 即可, TypeName 是用原本就有的 interface{...} 語法來表示,可以自己描述這個 generic function / struct 支援的型態必須滿足什麼樣的介面。

以 Python style 的 sort by key 當例子。 我們可以定義一個 generic 的 sort function,並且明定送進來的 list 內的每個元素需要支援一個 Key 函示, 作為排序時的根據。

範例 code 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

type Keyable interface {
Key() int
}

func Sort[T Keyable](items []T) []T {
if len(items) <= 1 {
return items
}

pivot := items[0]
less, greater := []T{}, []T{}
for _, item := range items[1:] {
if item.Key() < pivot.Key() {
less = append(less, item)
} else {
greater = append(greater, item)
}
}

return append(append(less, pivot), greater...)
}

type Person struct {
Name string
Age int
}

func (n Person) Key() int {
return n.Age
}

func main() {
persons := []Person{{Name: "alice", Age: 33}, {Name: "bob", Age: 27}}

persons = Sort(persons)
}

在這個範例中,Sort 要求送進來的 []T 當中的 T 要實作 Keyable 介面 (提供 Key method)。 當我們想排序一堆 Person 時,我們可以在這個 Person 物件上定義 Key method,取出 Person 的年齡。 完成之後,我們就可以依年齡來排序 []Person 了。

期許自己未來可以多加利用這個遲來的功能.. XD

References