全面理解Python中的類型提示(Type Hints)


眾所周知,Python 是動態類型語言,運行時不需要指定變量類型。這一點是不會改變的,但是2015年9月創始人 Guido van Rossum 在 Python 3.5 引入了一個類型系統,允許開發者指定變量類型。它的主要作用是方便開發,供IDE 和各種開發工具使用,對代碼運行不產生影響,運行時會過濾類型信息。

 

Python的主要賣點之一就是它是動態類型的,這一點也不會改變。而在2014年9月,Guido van Rossum (Python BDFL) 創建了一個Python增強提議(PEP-484),為Python添加類型提示(Type Hints)。並在一年后,於2015年9月作為Python3.5.0的一部分發布了。於是對於存在了二十五年的Python,有了一種標准方法向代碼中添加類型信息。在這篇博文中,我將探討這個系統是如何成熟的,我們如何使用它以及類型提示的下一步是什么。

為什么需要類型提示?

pinguin

類型提示的優點

首先,讓我們看看為什么需要Python中的類型提示(Type Hints)。類型提示有很多優點,我將嘗試按重要性順序來列舉這些優點:

易於理解代碼

了解參數的類型,可以使得理解和維護代碼庫變得更加容易。

例如,現在有一個函數。雖然我們在創建函數時知道了參數類型,但幾個月之后就不再是這種情況了。 在代碼旁邊陳述了所有參數的類型和返回類型,可以大大加快了理解這個代碼片段的過程。永遠記住你閱讀的代碼,比你寫的代碼要多得多。 因此,你應該優化函數以便於閱讀。

有了類型提示(Type Hints),在調用函數時就可以告訴你需要傳遞哪些參數類型;以及需要擴展/修改函數時,也會告訴你輸入和輸出所需要的數據類型。 例如,想象一下以下這個發送請求的函數,

  1.  
    def send_request(request_data : Any,
  2.  
    headers: Optional[Dict[str, str]],
  3.  
    user_id: Optional[UserId] = None,
  4.  
    as_json: bool = True):
  5.  
    ...

只看這個函數簽名,我們就可以知道:

  • request_data可以是任何數據
  • header的內容是一個可選的字符串字典
  • UserId是可選的(默認為None),或者是符合編碼UserId的任何數據
  • as_json需要始終是一個布爾值(本質上是一個flag,即使名稱可能沒有提供這種提示)

事實上,我們中的許多人已經明白在代碼中提供類型信息是必不可少的。但是由於缺乏更好的選擇,往往是在文檔中提到代碼中的類型信息。

而類型提示系統可以將類型信息從文檔中移動到更加接近函數的接口,然后以一個明確定義的方式來聲明復雜的類型要求。同時,構建linters,並在每次更改代碼后運行它們,可以檢查這些類型提示約束,確保它們永遠不會過時。

lint指代碼靜態分析工具

易於重構

類型提示可以在重構時,更好得幫助我們定位類的位置。

雖然許多IDE現在采用一些啟發式方法提供了這項功能,但是類型提示可以使IDE具有100%的檢測准確率,並定位到類的位置。這樣可以更平滑,更准確地檢測變量類型在代碼中的運行方式。

請記住,雖然動態類型意味着任何變量都可以成為任何類型,但是所有變量在所有時間中都應只有一種類型。類型系統仍然是編程的核心組件,想想那些使用isinstance判斷變量類型、應用邏輯所浪費的時間吧。

易於使用庫

使用類型提示意味着IDE可以擁有更准確、更智能的建議引擎。當調用自動完成時,IDE會完全放心地知道對象上有哪些方法/屬性可用。此外,如果用戶嘗試調用不存在的內容或傳遞不正確類型的參數,IDE可以立即警告它。

editor_suggest

Type Linters

盡管IDE警告不正確的參數類型的功能很好,但使用linter工具擴展這個功能,以確保應用程序的邏輯類型則更加明智。這樣的工具可以幫助你盡早捕獲錯誤(例如,在輸入后的示例必須是str類型,傳入None會引發異常):

  1.  
    def transform(arg):
  2.  
    return 'transformed value {}'.format(arg.upper())
  3.  
     
  4.  
    transform( None) # if arg would be type hinted as str the type linter could warn that this is an invalid call

雖然在這個例子中,有些人可能會認為很容易看到參數類型不匹配,但在更復雜的情況中,這種不匹配越來越難以看到。例如嵌套函數調用:

  1.  
    def construct(param=None):
  2.  
    return None if param is None else ''
  3.  
     
  4.  
    def append(arg):
  5.  
    return arg + ' appended'
  6.  
     
  7.  
    transform( append( construct() ) )

有許多的linters,但Python類型檢查的參考實現是 mypy 。 mypy是一個Python命令行應用 (Python command line application ),可以輕松集成到我們的代碼流中。

驗證運行數據

類型提示可用於在運行時進行驗證,以確保調用者不會破壞方法的約定。不再需要在函數的開始,使用一長串類型斷言(type asserts); 取而代之,我們可以使用一個重用類型提示的框架,並在業務邏輯運行之前自動檢查它們是否滿足(例如使用 pydantic):

  1.  
    from datetime import datetime
  2.  
    from typing import List
  3.  
    from pydantic import BaseModel, ValidationError
  4.  
     
  5.  
    class User(BaseModel):
  6.  
    id: int
  7.  
    name = 'John Doe'
  8.  
    signup_ts: datetime = None
  9.  
    friends: List[int] = []
  10.  
     
  11.  
    external_data = { 'id': '123', 'signup_ts': '2017-06-01 12:22',
  12.  
    'friends': [1, 2, 3]}
  13.  
    user = User(**external_data)
  14.  
     
  15.  
    try:
  16.  
    User(signup_ts= 'broken', friends=[1, 2, 'not number'])
  17.  
    except ValidationError as e:
  18.  
    print(e.json())

類型檢測不能做什么?

從一開始,Guido 明確表示類型提示並不意味着用於以下用例(當然,這並不意味着沒有這樣的 library/tools):

沒有運行時類型推斷

運行時解釋器(CPython)不會嘗試在運行時推斷類型信息,或者驗證基於此傳遞的參數。

沒有性能調整

運行時解釋器(CPython)不使用類型信息來優化生成的字節碼以獲得安全性或性能。

執行Python腳本時,類型提示被視為注釋,解釋器自動忽略。

happy_programmer

需要什么樣類型系統?

Python具有漸進的類型提示;意味着無論何時,對於給定的函數或變量,都沒有指定類型提示。

我們可以假設它可以具有任何類型(即它仍然是動態類型的一部分)。

並且逐漸使你的代碼庫感知類型,例如一次一個函數或變量:

  • function arguments,
  • function return values,
  • variables.

請記住,只有具有類型提示的代碼才會類型檢查!

當你在具有類型提示的代碼上運行linter(例如 mypy)時,如果存在類型不匹配,則會出現錯誤:

  1.  
    # tests/test_magic_field.py
  2.  
    f = MagicField(name= 1, MagicType.DEFAULT)
  3.  
    f.names()

此代碼將生成以下輸出:

  1.  
    bernat@uvm ~/python-magic (master●)$ mypy --ignore-missing-imports tests/test_magic_field.py
  2.  
    tests/test_magic_field.py: 21: error: Argument 1 to "MagicField" has incompatible type "int";
  3.  
    expected "Union[str, bytes]"
  4.  
    tests/test_magic_field.py: 22: error: "MagicField" has no attribute "names"; maybe "name" or "_name"?

注意,我們可以檢測傳入的參數的類型不兼容性,以及訪問對象上不存在的屬性。后者甚至可以提供有效的選項,使得更加容易的注意到和修復拼寫錯誤。

如何加入類型信息

一旦你決定添加類型提示,就會發現可以通過很多種方式將其添加到代碼庫中。

interested

Type annotations

  1.  
    from typing import List
  2.  
     
  3.  
    class A(object):
  4.  
    def __init__() -> None:
  5.  
    self.elements : List[int] = []
  6.  
     
  7.  
    def add(element: int) -> None:
  8.  
    self.elements.append(element)

類型標注(Type annotations)是一種直接的方式,並且是類型文檔中最常見到的那種方式。

它使用通過PEP-3107(Python 3.0+)添加的函數標注以及通過PEP-526(Python 3.6+)添加的變量標注。這些可以使得在編寫代碼時,

  • 使用語句將信息附加到變量或函數參數中。,
  • ->運算符用於將信息附加到函數/方法的返回值中。

這種方法的好處是:

  • 這是實現類型提示的規范方式,這意味着是類型提示中最干凈的一種方式。
  • 因為類型信息附加在代碼的右側,這樣我們可以立刻明晰類型。

缺點是:

  • 它不向后兼容。至少需要Python 3.6才能使用它。
  • 強制你導入所有類型依賴項,即使它們根本不在運行時使用。
  • 在類型提示中,會使用到復合類型,例如List[int]。而為了構造這些復雜類型,解釋器在首次加載此文件時需要執行一些操作。

這樣發現,最后兩點與我們之前列出的類型系統的初始目標相矛盾:

即在運行時基本上將所有類型信息作為注釋處理。

為了解決這些矛盾,Python 3.7引入了 PEP-563 ~ postponed evaluation of annotations 。

加入以下語句,解釋器將不再構造這些復合類型。

from __future__ import annotations

一旦解釋器解析腳本語法樹后,它會識別類型提示並繞過評估它,將其保留為原始字符串。這種機制使得類型提示發生在需要它們的地方:由linter來進行類型檢查。 在Python 4中,這種機制將成為默認行為。

Type comments

當標注語句(Type annotations)不可用時,可以使用類型注釋:

沿着這條路走下去,我們確實得到了一些好處:

  • 類型注釋適用於任何Python版本。

    盡管Python 3.5+中才將類型庫添加到標准庫中,但它可以作為Python 2.7+的PyPi包使用。此外,由於Python注釋是任何Python代碼下的有效語言特性,因此可以在Python 2.7或更高版本上代碼中加入類型提示。有一些要求:類型提示注釋(type hint comment)必須位於函數/變量定義所在的相同或下一行。 它也以 type:constant 開始。

  • 此解決方案還解決了包裝問題,因為注釋很少會被刪除。在源代碼中打包類型提示信息可以使得那些使用你開發的庫的人,使用類型提示信息來改善他們的開發體驗。

但也會產生一些新問題:

  • 缺點在於,雖然類型信息接近於參數,但是並不在更靠近參數的右邊。這使得代碼比其他方式更混亂。還有必須在一行中,這也可能會導致問題。例如,如果有一個長類型提示表達式(long type hint expression)並且你的代碼庫強制執行每一行的長度限制。
  • 另一個問題在於,類型提示信息會與使用這些類型的注釋標記的其他工具產生競爭(例如,抑制其他linter的錯誤)。
  • 除了強制您導入所有類型信息外,這也會導致處於一個更加危險的地方。現在導入的類型僅在代碼中使用,這使得大多數linter工具都認為所有這些導入都未使用。如果你允許他們刪除這些語句,將會破壞代碼的type linter。pylint通過將其AST解析器移動到 typed-ast parser 來解決這個問題,並且在Python 3.7第二個版本中發布。

為了避免將長行代碼作為類型提示,可以通過類型注釋逐個加入提示參數,然后將代碼放入在返回類型注釋后:

  1.  
    def add(element # type: List[int]
  2.  
    ):
  3.  
    # type: (...) -> None
  4.  
    self.elements.append(element)

讓我們簡單看一下類型注釋是如何使代碼變得更加混亂。

下面是一個代碼片段,它在類中交換兩個屬性值:

  1.  
    from typing import List
  2.  
     
  3.  
    class A(object):
  4.  
    def __init__():
  5.  
    # type: () -> None
  6.  
    self.elements = [] # type: List[int]
  7.  
     
  8.  
    def add(element):
  9.  
    # type: (List[int]) -> None
  10.  
    self.elements.append(element)
  1.  
    @contextmanager
  2.  
    def swap_in_state(state, config, overrides):
  3.  
    old_config, old_overrides = state.config, state.overrides
  4.  
    state.config, state.overrides = config, overrides
  5.  
    yield old_config, old_overrides
  6.  
    state.config, state.overrides = old_config, old_overrides

首先,您必須添加類型提示。

因為類型提示會很長,所以你可以通過參數附加類型提示參數:

  1.  
  2.  
    @contextmanager
  3.  
    def swap_in_state(state, # type: State
  4.  
    config, # type: HasGetSetMutable
  5.  
    overrides # type: Optional[HasGetSetMutable]
  6.  
    ):
  7.  
    # type: (...) -> Generator[Tuple[HasGetSetMutable, Optional[HasGetSetMutable]], None, None]
  8.  
    old_config, old_overrides = state.config, state.overrides
  9.  
    state.config, state.overrides = config, overrides
  10.  
    yield old_config, old_overrides
  11.  
    state.config, state.overrides = old_config, old_overrides
  12.  
     
  13.  

但是等到加入使用的類型之后:

  1.  
    from typing import Generator, Tuple, Optional
  2.  
    from magic import RunSate
  3.  
     
  4.  
    HasGetSetMutable = Union[Dict, List]
  5.  
     
  6.  
    @contextmanager
  7.  
    def swap_in_state(state, # type: State
  8.  
    config, # type: HasGetSetMutable
  9.  
    overrides # type: Optional[HasGetSetMutable]
  10.  
    ):
  11.  
    # type: (...) -> Generator[Tuple[HasGetSetMutable, Optional[HasGetSetMutable]], None, None]
  12.  
    old_config, old_overrides = state.config, state.overrides
  13.  
    state.config, state.overrides = config, overrides
  14.  
    yield old_config, old_overrides
  15.  
    state.config, state.overrides = old_config, old_overrides

現在這樣的代碼會導致靜態linter(例如pylint)中產生的一些誤報,所以需要為此添加一些抑制注釋:

  1.  
    from typing import Generator, Tuple, Optional, Dict, List
  2.  
    from magic import RunSate
  3.  
     
  4.  
    HasGetSetMutable = Union[Dict, List] # pylint: disable=invalid-name
  5.  
     
  6.  
    @contextmanager
  7.  
    def swap_in_state(state, # type: State
  8.  
    config, # type: HasGetSetMutable
  9.  
    overrides # type: Optional[HasGetSetMutable]
  10.  
    ): # pylint: disable=bad-continuation
  11.  
    # type: (...) -> Generator[Tuple[HasGetSetMutable, Optional[HasGetSetMutable]], None, None]
  12.  
    old_config, old_overrides = state.config, state.overrides
  13.  
    state.config, state.overrides = config, overrides
  14.  
    yield old_config, old_overrides
  15.  
    state.config, state.overrides = old_config, old_overrides

現在已經完成了。然后,六行代碼變為十六行,需要維護更多代碼!

如果你通過編寫的代碼行數獲得報酬,並且經理正在抱怨你最近表現不好,那么增加代碼庫聽起來不錯。

Interface stub files

這種方式以如下方式維護代碼:

  1.  
    class A(object):
  2.  
    def __init__() -> None:
  3.  
    self.elements = []
  4.  
     
  5.  
    def add(element):
  6.  
    self.elements.append(element)

與其它方式不同,這種方式在源文件旁邊添加另一個 pyi 文件:

  1.  
    # a.pyi alongside a.py
  2.  
    from typing import List
  3.  
     
  4.  
    class A(object):
  5.  
    elements = ... # type: List[int]
  6.  
    def __init__() -> None: ...
  7.  
    def add(element: int) -> None: ...

接口文件並不是一件新鮮事,C/C++ 已經使用了幾十年了。

因為Python是一種解釋性語言,通常不需要它,但是因為計算機科學中的每個問題都可以通過添加新的間接層來解決,我們可以添加它來存儲類型信息。

這樣做的好處是:

  • 不需要修改源代碼,可以在任何Python版本下工作,因為解釋器從不執行它們。
  • 在 stub 文件中,可以使用最新語法(例如類型注記,type annotations),因為應用在執行期間從不查看這些語法。
  • 因為沒有觸及源代碼,這意味着通過添加類型提示不會引入新的錯誤,添加的內容也不會與其他linter工具沖突。
  • 這是方式經過了良好設計, typeshed 項目使用它來對整個標准庫加入類型提示,以及一些其他流行的庫,如requests,yaml,dateutil,等等
  • 可用於為那些你不擁有的源代碼或者不能輕松改變的代碼提供類型信息。

當然也要付出一些相對沉重的代價:

  • 需要復制代碼庫,每個函數現在都會有兩個定義(請注意不需要復制正文或默認參數,... 省略號 用作這些的占位符)。
  • 會有一些額外的文件需要打包並隨代碼一起提供。
  • 不能再在函數內部注釋內容(這會導致方法存在內部方法以及局部變量)。
  • 不會檢查實現文件是否與stub的簽名匹配(此外IDE最常使用stub定義)。
  • 但是,最沉重的代價是無法通過stub檢查加入類型提示的代碼。stub 文件類型提示(stub file type hints),旨在檢查使用該庫的代碼,而不是檢查你自己加入類型提示的代碼庫。

最后兩個缺點使得,特別難以通過stub文件檢查加入類型提示代碼庫是否同步。在當前表單中,類型stubs是一種向用戶提供類型提示的方法,但不是為您自己提供,並且難以維護。

為了解決這些問題,原文作者已經承擔了將 stub 文件與 mypy 中的源文件合並的任務。從理論上講,可以解決這兩個問題,可以在 python/mypy ~ issue 5208 中查看進展。

Docstrings

也可以將類型信息添加到文檔字符串中。即使這不是為Python設計的類型提示框架的一部分,但它也受到大多數主流IDE的支持。使用這種方式大多是傳統的做法。

從積極的一面看:

  • 在任何Python版本下工作,這在 PEP-257 中定義。
  • 不與其他linter工具沖突,因為大多數工具不檢查文檔字符串,通常只檢查其他代碼部分。

但是,它有以下形式的嚴重缺陷:

  • 沒有一個標准方法來指定復雜類型提示(例如int或bool)。PyCharm有其專有方法,但Sphinx則使用不同的方法。
  • 需要更改文檔,並且難以保持准確/最新,因為沒有工具來檢查其有效性。
  • 文檔字符串類型會與類型提示代碼不兼容。如果同時指定了類型注釋和文檔字符串,哪個優先於哪個?

哪些需要加入類型信息

deep_dive

讓我們深入了解具體細節。有關可添加的類型信息的詳盡列表,請參閱官方文檔。在這里,我將為您快速做3分鍾的概述,以便了解它。

有兩種類型分類:nominal types 和 duck types (protocols)。

Nominal type

Nominal type 是那些在Python解釋器中具有名稱的類型。 例如所有內置類型(int,bolean,float,type,object等),然后我們有通用類型 (generic types),它們主要以容器的形式表現出來:

  1.  
    t : Tuple[int, float] = 0, 1.2
  2.  
    d : Dict[str, int] = { "a": 1, "b": 2}
  3.  
    d : MutableMapping[str, int] = { "a": 1, "b": 2}
  4.  
    l : List[int] = [ 1, 2, 3]
  5.  
    i : Iterable[Text] = [ u'1', u'2', u'3']

對於復合類型,一次又一次地寫入它會變得很麻煩,因此系統允許通過以下方式對別名進行別名:

OptFList = Optional[List[float]]

甚至可以提升內置類型來表示它們自己的類型,這對於避免錯誤是有用的,例如,您將錯誤順序中具有相同類型的兩個參數傳遞給函數:

  1.  
    UserId = NewType( 'UserId', int)
  2.  
    user_id = UserId( 524313)
  3.  
    count = 1
  4.  
    call_with_user_id_n_times(user_id, count)

對於namedtuple,可以直接附加您的類型信息(請注意與Python 3.7+ 的數據類(data class)或 attrs 庫非常相似):

  1.  
    class Employee(NamedTuple):
  2.  
    name: str
  3.  
    id: int

有以下的類型表示 one of 和 optional of

  1.  
    Union[ None, int, str] # one of
  2.  
    Optional[float] # either None or float

甚至可以對回調函數加入類型提示:

  1.  
    # syntax is Callable[[Arg1Type, Arg2Type], ReturnType]
  2.  
    def feeder(get_next_item: Callable[[], str]) -> None:

也可以使用TypeVar構造定義它自己的通用容器:

  1.  
    T = TypeVar( 'T')
  2.  
    class Magic(Generic[T]):
  3.  
    def __init__(self, value: T) -> None:
  4.  
    self.value : T = value
  5.  
     
  6.  
    def square_values(vars: Iterable[Magic[int]]) -> None:
  7.  
    v.value = v.value * v.value

最后,使用Any類型提示,則禁用任何不需要的類型檢查:

  1.  
    def foo(item: Any) -> int:
  2.  
    item.bar()

Duck types - protocols

在這種情況下,並不是一個實際的類型可以使得代碼更加Pythonic,而是這樣的一種考量:如果它像鴨子一樣嘎嘎叫,並行為像鴨子一樣,那么絕大多數預期認為它是一只鴨子。

在這種情況下,您可以定義對象所期望的操作和屬性,而不是顯式聲明其類型。詳見 PEP-544 ~ Protocols

  1.  
    KEY = TypeVar( 'KEY', contravariant=true)
  2.  
     
  3.  
    # this is a protocol having a generic type as argument
  4.  
    # it has a class variable of type var, and a getter with the same key type
  5.  
    class MagicGetter(Protocol[KEY], Sized):
  6.  
    var : KEY
  7.  
    def __getitem__(self, item: KEY) -> int: ...
  8.  
     
  9.  
    def func_int(param: MagicGetter[int]) -> int:
  10.  
    return param['a'] * 2
  11.  
     
  12.  
    def func_str(param: MagicGetter[str]) -> str:
  13.  
    return '{}'.format(param['a'])

Gotchas 陷阱

一旦開始向代碼庫添加類型提示,可能會遇到一些奇怪的問題。

gotcha

在本節中,我將嘗試介紹其中的一些內容,讓你了解在向代碼庫添加類型信息時可能遇到的奇怪之處。

Gotchas

str difference in between Python 2/3

這是一個類的repr方法的快速實現:

  1.  
    from __future__ import unicode_literals
  2.  
     
  3.  
    class A(object):
  4.  
    def __repr__(self) -> str:
  5.  
    return 'A({})'.format(self.full_name)

這段代碼有錯誤。雖然這在Python 3下是正確的,但在python 2下錯誤(因為Python 2期望從repr返回字節,但是unicode_literals導入使得類型為unicode的返回值)。這種導入意味着不可能編寫滿足Python 2和3類型要求的repr。需要添加運行時的邏輯來使得代碼正確運行:

  1.  
    from __future__ import unicode_literals
  2.  
     
  3.  
    class A(object):
  4.  
    def __repr__(self) -> str:
  5.  
    res = 'A({})'.format(self.full_name)
  6.  
    if sys.version_info > (3, 0):
  7.  
    # noinspection PyTypeChecker
  8.  
    return res
  9.  
    # noinspection PyTypeChecker
  10.  
    return res.encode('utf-8')

為了使IDE接受這樣的形式,還需加入一些 linter 注釋,如上所示。這也導致了閱讀代碼變得更加困難,更重要的是,會存在額外的運行檢查(runtime check)強制加入到類型檢查器中。

Multiple return types

想象一下,你想編寫一個函數,將一個字符串或一個int乘以2。 首先要考慮的是:

  1.  
    def magic(i: Union[str, int]) -> Union[str, int]:
  2.  
    return i * 2

您的輸入是strint,因此您的返回值也是strint。 但是如果這樣做的話,應告訴類型提示它確實可以是兩種類型的輸入。因此在調用端,需要斷言調用的類型:

  1.  
    def other_func() -> int:
  2.  
    result = magic( 2)
  3.  
    assert isinstance(result, int)
  4.  
    return result

這種不便可能會使一些人通過使返回值為Any來避免麻煩。

但是,有一個更好的解決方案。類型提示系統允許您定義重載。重載表示對於給定的輸入類型,僅返回給定的輸出類型。所以在這種情況下:

  1.  
    from typing import overload
  2.  
     
  3.  
    @overload
  4.  
    def magic(i: int) -> int:
  5.  
    pass
  6.  
     
  7.  
    @overload
  8.  
    def magic(i: str) -> str:
  9.  
    pass
  10.  
     
  11.  
    def magic(i: Union[int, str]) -> Union[int, str]:
  12.  
    return i * 2
  13.  
     
  14.  
    def other_func() -> int:
  15.  
    result = magic( 2)
  16.  
    return result

但這也有存在缺點。靜態linter工具會警告,正在重新定義具有相同名稱的函數; 這是一個誤報,所以添加靜態linter禁用注釋標記(#pylint:disable = function-redefined)。

Type lookup

想象一下,有一個類允許將包含的數據表示為多個類型,或者具有不同類型的字段。

你希望用戶有一種快速簡便的方法來引用它們,因此您添加了一個具有內置類型名稱的函數:

  1.  
    class A(object):
  2.  
    def float(self):
  3.  
    # type: () -> float
  4.  
    return 1.0

一旦運行了linter,你會看到:

test.py:3: error: Invalid type "test.A.float"

有人可能會問,這到底是什么?我已將返回值定義為float而不是test.A.float。

這種模糊錯誤的原因是類型提示器(type hinter)通過評估定義位置的每個作用域范圍來解析類型。一旦找到名稱匹配,它就會停止查找。這樣它查找第一個級別是在A類中,然后它找到一個float(一個函數)並替換浮點數類型float

解決這個問題的方案是明確定義我們不只是想要任何float,而是我們想要 builtin.float

  1.  
    if typing.TYPE_CHECKING:
  2.  
    import builtins
  3.  
     
  4.  
    class A(object):
  5.  
    def float(self):
  6.  
    # type: () -> builtins.float
  7.  
    return 1.0

注意到要執行此操作,還需要導入內置函數,並且為了避免在運行時出現問題,可以使用typing.TYPE_CHECKING標志來保護它,該標志僅在類型linter評估期間為true,否則始終為false。

Contravariant argument

考慮以下用例。定義包含常見操作的抽象基類,然后特定的類只處理一種類型和一種類型。你可以控制類的創建,以確保傳遞正確的類型,並且基類是抽象的,因此這似乎是一個令人愉快的設計:

  1.  
    from abc import ABCMeta, abstractmethod
  2.  
    from typing import Union
  3.  
     
  4.  
    class A(metaclass=ABCMeta):
  5.  
    @abstractmethod
  6.  
    def func(self, key): # type: (Union[int, str]) -> str
  7.  
    raise NotImplementedError
  8.  
     
  9.  
    class B(A):
  10.  
    def func(self, key): # type: (int) -> str
  11.  
    return str(key)
  12.  
     
  13.  
    class C(A):
  14.  
    def func(self, key): # type: (str) -> str
  15.  
    return key

但是,一旦運行了linter檢查,你會發現:

  1.  
    test.py: 12: error: Argument 1 of "func" incompatible with supertype "A"
  2.  
    test.py: 17: error: Argument 1 of "func" incompatible with supertype "A"

這樣做的原因是類的參數是逆變的(contravariant )。這將被轉換到派生類中,你必須處理父類的所有類型。但是,也可以添加其他類型。在函數參數中,可以擴展您所涵蓋的內容,但不能以任何方式約束它:

  1.  
    from abc import ABCMeta, abstractmethod
  2.  
    from typing import Union
  3.  
     
  4.  
    class A(metaclass=ABCMeta):
  5.  
    @abstractmethod
  6.  
    def func(self, key): # type: (Union[int, str]) -> str
  7.  
    raise NotImplementedError
  8.  
     
  9.  
    class B(A):
  10.  
    def func(self, key): # type: (Union[int, str, bool]) -> str
  11.  
    return str(key)
  12.  
     
  13.  
    class C(A):
  14.  
    def func(self, key): # type: (Union[int, str, List]) -> str
  15.  
    return key

Compatibility

考慮以下代碼片段,注意其中的錯誤:

  1.  
    class A:
  2.  
    @classmethod
  3.  
    def magic(cls, a: int) -> 'A':
  4.  
    return cls()
  5.  
     
  6.  
    class B(A):
  7.  
    @classmethod
  8.  
    def magic(cls, a: int, b: bool) -> 'B':
  9.  
    return cls()

如果還沒有發現,請考慮如果編寫以下腳本會發生什么:

  1.  
    from typing import List, Type
  2.  
     
  3.  
    elements : List[Type[A]] = [A, B]
  4.  
    print( [e.magic( 1) for e in elements])

如果您嘗試運行它,則會因以下運行時錯誤而失敗:

  1.  
    print( [e.magic( 1) for e in elements])
  2.  
    TypeError: magic() missing 1 required positional argument: 'b'

原因在於BA的子類型。因此B可以進入A類型的容器中(因為B擴展了A,因此B可以比A更多)。但是B的類方法定義違反了這個契約,它只能用一個參數調用magic。此外,類型linter將無法指出這一點:

test.py:9: error: Signature of "magic" incompatible with supertype "A"

一個快速和簡單的辦法解決,就是使第二個參數可選,以確保B.magic帶有1個參數可以正常工作。

現在看看下面這段代碼:

  1.  
    class A:
  2.  
    def __init__(self, a: int) -> None:
  3.  
    pass
  4.  
     
  5.  
    class B(A):
  6.  
    def __init__(self, a: int, b: bool) -> None:
  7.  
    super().__init__(a)

您認為這會發生什么? 注意我們將類方法移動到構造函數中,並且沒有進行其他更改,因此我們的腳本也只需要稍作修改:

  1.  
    from typing import List, Type
  2.  
     
  3.  
    elements : List[Type[A]]= [A, B]
  4.  
    print( [e( 1) for e in elements])

這是運行錯誤,與之前大致相同,只是在__init__而不是magic

  1.  
    print( [e( 1) for e in elements])
  2.  
    TypeError: __init__() missing 1 required positional argument: 'b'

那么mypy會有什么反應嗎?

如果你運行它,會發現mypy選擇保持沉默。它會將此標記為正確,即使在運行時它會失敗。

The mypy creators said that they found too common of type miss-match to prohibit incompatible __init__ and __new__.

mypy 創建者認為他們發現太多的類型錯誤匹配,所以禁止了不兼容的 __init____new__

當遇到了問題

總之,請注意類型提示有時會引起奇怪的警告,例如以下tweet:

tweet

有一些工具可以幫助發現、理解並處理這些情況:

  • 使用reveal_type查看推斷類型

    1.  
      a = [ 4]
    2.  
      reveal_type(a) # -> error: Revealed type is 'builtins.list[builtins.int*]'

     

  • 使用cast來強制給定類型:

    1.  
      from typing import List, cast
    2.  
      a = [ 4]
    3.  
      b = cast(List[int], a) # passes fine
    4.  
      c = cast(List[str], a) # type: List[str] # passes fine (no runtime check)
    5.  
      reveal_type(c) # -> error: Revealed type is 'builtins.list[builtins.str]'

     

  • 使用type ignore marker 來禁用行中的錯誤:

    x = confusing_function() # type: ignore # see mypy/issues/1167

     

  • 詢問社區。在Gitter chat的 python/typing 下示出代碼與錯誤。

Tools 工具

這是圍繞類型提示系統構建的一個不完全的工具列表。

type checkers

使用這些工具檢查庫或應用程序中的類型安全性:

type annotation generators

如果要將類型標注添加到現有代碼庫,可以使用這些工具來完成一些可以自動完成的部分:

  • mypy stubgen command line(see)
  • pyannotate - Dropbox - 使用你的測試生成類型信息。
  • monkeytype - Instagram. Instagram實際上使用它在他們的生產系統中運行它:每百萬調用觸發一次(使代碼運行速度慢五倍,但每一百萬次調用使其不那么明顯)。

runtime code evaluator

可以使用這些工具在運行時檢查,函數/方法的輸入參數類型是否正確:

  1. pydantic
  2. enforce
  3. pytypes

Documentation enrichment - merge docstrings and type hints

在這篇文章的第一部分中,我們提到人們很多已經在docstrings中存儲了類型信息。這樣做的原因是你的類型數據是希望在文檔中包含庫的類型信息契約的一部分。 所以問題仍然是你沒有選擇使用docstrings作為主要的類型信息存儲系統,那么怎么能在文檔的文檔字符串中使用它們?

答案取決於用於生成該文檔的工具。但是,我將在這里提供一個選項,使用Python中最流行的工具和格式:Sphinx和HTML。

在類型提示和文檔字符串中明確聲明類型信息是最終在它們之間發生沖突的可靠方法。你可以指望有人在某個地方將在一個地方更新但在另一個地方不會更新。因此,讓我們從docstring中刪除所有類型數據,並將其僅作為類型提示。現在,我們需要做的就是在文檔構建時從類型提示中獲取它並將其插入到文檔中。

在Sphinx中,您可以通過插件實現此目的。最流行的版本是agronholm/sphinx-autodoc-typehints.

  • 首先,對於要記錄的每個函數/變量,它獲取類型提示信息;
  • 然后,它將Python類型轉換為docstring表示(這涉及遞歸展開所有嵌套類型類,並用其字符串表示替換類型);
  • 最后,將正確的參數附加到docstring中。

例如,Any映射到py:data:~ingtings.Any。對於復合類型,例如Mapping [str,bool]也需要轉換,這樣事情會變得更復雜:class:~intinging.Mapping\\[:class:str,:class:bool]。在這里,正確的轉換(例如,具有類或數據命名空間)是必不可少的,這樣intersphinx插件才能夠正常工作(將類型直接鏈接到各自的Python標准庫文檔鏈接的插件)。

為了使用它,需要通過pip install sphinx-autodoc-types> = 2.1.1安裝,然后在conf.py文件中啟用:

  1.  
    # conf.py
  2.  
    extensions = [ 'sphinx_autodoc_typehints']

That’s it all.

一個示例是RookieGameDevs/revived的文檔。 例如,以下源代碼:

src

可以得到輸出:

doc

Conclusion 總結

所以在這篇長篇博文的最后,你可能會問:是否值得使用類型提示,或何時應該使用它們? 我認為類型提示基本上與單元測試相同,只是在代碼中表達不同。它們提供的是標准(並可重用於其他目標)方式,以測試代碼庫的輸入和輸出類型。

因此,只要單元測試值得寫,就應該使用類型提示。 哪怕只有十行代碼,只要之后需要維護它。

同樣,每當開始編寫單元測試時,都應該開始添加類型提示。我不想添加它們的唯一地方是我不編寫單元測試,例如REPL lines,或丟棄一次性使用腳本。

請記住,與單元測試類似,雖然它確實使您的代碼庫包含額外數量的行,但是添加的所有代碼都是自動檢查並強制糾正后的。它可以作為一個安全措施,以確保當你改變事物后,代碼可以繼續工作,所以值得承受這個額外代價。

thats_all_folks

作者: Sikasjc

鏈接: http://sikasjc.github.io/2018/07/14/type-hint-in-python/

知識共享署名-非商業性使用 4.0 國際許可協議


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM