Following system colour scheme - Python 增強提案 Selected dark colour scheme - Python 增強提案 Selected light colour scheme - Python 增強提案

Python 增強提案 (Python Enhancement Proposals)

PEP 742 – 使用 TypeIs 縮小類型

作者:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
討論於:
Discourse 討論串
狀態:
最終 (Final)
類型:
標準軌跡 (Standards Track)
主題:
類型標註 (Typing)
建立日期:
2024年02月07日
Python 版本:
3.13
公告歷史:
2024年02月11日
取代:
724
決議:
2024年04月03日

目錄

重要資訊

本 PEP 為歷史文件:請參閱 TypeIstyping.TypeIs 以獲取最新的規範與說明文件。標準類型規範維護於 typing 規範網站;執行時期的類型行為描述於 CPython 說明文件中。

×

有關如何提議更改型別規格,請參閱 typing 規格更新流程

摘要

本 PEP 提議新增一個特殊形式 TypeIs,允許對函式進行標註,使其可用於縮小數值的類型,類似於內建的 isinstance()。與現有的 typing.TypeGuard 特殊形式不同,TypeIs 可以在條件式的 ifelse 分支中縮小類型。

動機

具類型的 Python 程式碼經常要求使用者根據條件來縮小變數的類型。例如,如果一個函式接受兩種類型的聯集(Union),它可能會使用 isinstance() 檢查來區分這兩種類型。類型檢查器通常支援基於各種內建函式與運算的類型縮小,但有時使用使用者自定義的函式來執行類型縮小也很有用。

為了支援此類使用場景,PEP 647 引入了 typing.TypeGuard 特殊形式,允許使用者定義類型防護(type guards)。

from typing import assert_type, TypeGuard

def is_str(x: object) -> TypeGuard[str]:
    return isinstance(x, str)

def f(x: object) -> None:
    if is_str(x):
        assert_type(x, str)
    else:
        assert_type(x, object)

遺憾的是,typing.TypeGuard 的行為存在一些限制,使其在許多常見場景下的實用性降低,正如 PEP 724 的「動機」章節所述。特別是:

  • 如果類型防護回傳 True,類型檢查器必須精確地將 TypeGuard 的回傳類型作為縮小後的類型。它們無法利用關於該變數類型的既有知識。
  • 在類型防護回傳 False 的情況下,類型檢查器無法應用任何額外的縮小。

標準函式庫中的 inspect.isawaitable() 函式就是一個例子。它會回傳引數是否為可等待(awaitable)物件,而 typeshed 目前將其標註為:

def isawaitable(object: object) -> TypeGuard[Awaitable[Any]]: ...

一位使用者向 mypy 回報 了關於此函式行為的問題。他們觀察到了以下行為:

import inspect
from collections.abc import Awaitable
from typing import reveal_type

async def f(t: Awaitable[int] | int) -> None:
    if inspect.isawaitable(t):
        reveal_type(t)  # Awaitable[Any]
    else:
        reveal_type(t)  # Awaitable[int] | int

此行為與 PEP 647 一致,但與使用者的預期不符。相反地,他們期望在 if 分支中,t 的類型能縮小為 Awaitable[int],而在 else 分支中縮小為 int。本 PEP 提議了一種能精確達成此目標的新結構。

TypeGuard 當前行為而產生的其他問題範例包括:

原理

typing.TypeGuard 當前行為的問題迫使我們改進類型系統,以允許不同的類型縮小行為。PEP 724 提議更改現有的 typing.TypeGuard 結構的行為,但我們 認為 該變更對向後相容性的影響過於嚴重。因此,我們提議新增一個具有所需語義的新特殊形式。

我們承認這導致了一種不幸的局面,即存在兩個目的相似、語義也相似的結構。我們相信使用者更有可能需要本 PEP 提議的新形式 TypeIs 之行為,因此我們建議說明文件應強調 TypeIs,視其為一種更通用的工具。不過,TypeGuard 的語義在某些情況下仍有用處,我們並不打算廢除或移除它。從長遠來看,大多數使用者應使用 TypeIs,而 TypeGuard 應保留給需要其特定行為的極少數情況。

規範

一個新的特殊形式 TypeIs 被加入到 typing 模組中。其用法、行為與執行時期實作皆與 typing.TypeGuard 類似。

它接受單個引數,並可用作函式的回傳類型。被標註為回傳 TypeIs 的函式被稱為「類型縮小函式」。類型縮小函式必須回傳 bool 值,且類型檢查器應驗證所有回傳路徑皆回傳 bool

類型縮小函式必須至少接受一個位置引數(positional argument)。類型縮小行為會應用於傳入該函式的第一個位置引數。函式可以接受額外的引數,但它們不會受到類型縮小的影響。若類型縮小函式實作為實例方法或類別方法,第一個位置引數會映射到第二個參數(在 selfcls 之後)。

類型縮小行為

為了定義 TypeIs 的行為,我們使用以下術語:

  • I = TypeIs 的輸入類型
  • R = TypeIs 的回傳類型
  • A = 傳入類型縮小函式的引數類型(縮小前)
  • NP = 縮小後的類型(正向;用於 TypeIs 回傳 True 時)
  • NN = 縮小後的類型(負向;用於 TypeIs 回傳 False 時)
def narrower(x: I) -> TypeIs[R]: ...

def func1(val: A):
    if narrower(val):
        assert_type(val, NP)
    else:
        assert_type(val, NN)

回傳類型 R 必須與 I 一致(consistent)。若不滿足此條件,類型檢查器應發出錯誤。

形式上,類型 NP 應縮小為 ARAR 的交集),而類型 NN 應縮小為 A∧¬RAR 的補集的交集)。實務上,嚴格類型防護的理論類型無法精確地在 Python 類型系統中表示。類型檢查器應回退到這些類型的實務近似值。作為經驗法則,類型檢查器在處理 isinstance() 時,應使用相同的類型縮小邏輯,並獲得一致的結果。此指引允許在未來擴展類型系統時進行變更與改進。

範例

類型縮小會同時應用於正向與負向情況。

from typing import TypeIs, assert_type

def is_str(x: object) -> TypeIs[str]:
    return isinstance(x, str)

def f(x: str | int) -> None:
    if is_str(x):
        assert_type(x, str)
    else:
        assert_type(x, int)

最終縮小後的類型可能比 R 更窄,這取決於該引數先前已知類型的限制。

from collections.abc import Awaitable
from typing import Any, TypeIs, assert_type
import inspect

def isawaitable(x: object) -> TypeIs[Awaitable[Any]]:
    return inspect.isawaitable(x)

def f(x: Awaitable[int] | int) -> None:
    if isawaitable(x):
        # Type checkers may also infer the more precise type
        # "Awaitable[int] | (int & Awaitable[Any])"
        assert_type(x, Awaitable[int])
    else:
        assert_type(x, int)

縮小為與輸入類型不一致的類型屬於錯誤。

from typing import TypeIs

def is_str(x: int) -> TypeIs[str]:  # Type checker error
    ...

子型別

TypeIs 也適合作為可呼叫物件的回傳類型,例如在回呼協定(callback protocols)與 Callable 特殊形式中。在這些上下文中,它被視為 bool 的子類型。例如,Callable[..., TypeIs[int]] 可指派給 Callable[..., bool]

TypeGuard 不同,TypeIs 在其引數類型上是「不變的」(invariant):即使 BA 的子類型,TypeIs[B] 也不是 TypeIs[A] 的子類型。若要了解原因,請考慮以下範例:

def takes_narrower(x: int | str, narrower: Callable[[object], TypeIs[int]]):
    if narrower(x):
        print(x + 1)  # x is an int
    else:
        print("Hello " + x)  # x is a str

def is_bool(x: object) -> TypeIs[bool]:
    return isinstance(x, bool)

takes_narrower(1, is_bool)  # Error: is_bool is not a TypeIs[int]

(請注意 boolint 的子類型。) 此程式碼在執行時期會失敗,因為該縮小器回傳 False(1 不是 bool),且 takes_narrower() 進入了 else 分支。如果呼叫 takes_narrower(1, is_bool) 被允許,類型檢查器將無法偵測到此錯誤。

回溯相容性

由於本 PEP 僅提議一個新的特殊形式,因此不會對向後相容性產生影響。

安全性影響

目前已知無。

如何教學

類型檢查入門介紹應涵蓋在討論如何縮小類型時使用 TypeIs,以及討論其他縮小結構,例如 isinstance()。說明文件應強調 TypeIs 而非 typing.TypeGuard;雖然前者並未被廢除,且其行為在某些情況下有用,但我們預期 TypeIs 的行為通常更直觀,大多數使用者應優先選擇 TypeIs。本章節其餘部分包含一些可用於面向使用者的入門說明文件的範例內容。

何時使用 TypeIs

Python 程式碼經常使用如 isinstance() 這類函式來區分一個數值的不同可能類型。類型檢查器能理解 isinstance() 與其他各種檢查,並使用它們來縮小變數的類型。然而,有時您希望在多個地方重複使用更複雜的檢查,或者您使用的檢查是類型檢查器無法理解的。在這些情況下,您可以定義一個 TypeIs 函式來執行檢查,並允許類型檢查器利用它來縮小變數的類型。

TypeIs 函式接受一個引數,並標註為回傳 TypeIs[T],其中 T 是您想要縮小到的類型。若引數屬於類型 T,則函式必須回傳 True,否則回傳 False。該函式隨後可用於 if 檢查中,就像您使用 isinstance() 一樣。例如:

from typing import TypeIs, Literal

type Direction = Literal["N", "E", "S", "W"]

def is_direction(x: str) -> TypeIs[Direction]:
    return x in {"N", "E", "S", "W"}

def maybe_direction(x: str) -> None:
    if is_direction(x):
        print(f"{x} is a cardinal direction")
    else:
        print(f"{x} is not a cardinal direction")

撰寫安全的 TypeIs 函式

TypeIs 函式允許您覆寫類型檢查器的類型縮小行為。這是一個強大的工具,但也可能存在危險,因為編寫不正確的 TypeIs 函式可能導致錯誤的類型檢查,且類型檢查器無法偵測到此類錯誤。

為了確保回傳 TypeIs[T] 的函式是安全的,若且唯若引數與類型 T 相容時,它必須回傳 True,否則回傳 False。若不滿足此條件,類型檢查器可能會推斷出錯誤的類型。

以下是一些正確與錯誤的 TypeIs 函式範例:

from typing import TypeIs

# Correct
def good_typeis(x: object) -> TypeIs[int]:
    return isinstance(x, int)

# Incorrect: does not return True for all ints
def bad_typeis1(x: object) -> TypeIs[int]:
    return isinstance(x, int) and x > 0

# Incorrect: returns True for some non-ints
def bad_typeis2(x: object) -> TypeIs[int]:
    return isinstance(x, (int, float))

此函式示範了在使用編寫不當的 TypeIs 函式時可能發生的錯誤。這些錯誤不會被類型檢查器偵測到:

def caller(x: int | str, y: int | float) -> None:
    if bad_typeis1(x):  # narrowed to int
        print(x + 1)
    else:  # narrowed to str (incorrectly)
        print("Hello " + x)  # runtime error if x is a negative int

    if bad_typeis2(y):  # narrowed to int
        # Because of the incorrect TypeIs, this branch is taken at runtime if
        # y is a float.
        print(y.bit_count())  # runtime error: this method exists only on int, not float
    else:  # narrowed to float (though never executed at runtime)
        pass

這是一個針對較複雜類型使用正確 TypeIs 函式的範例:

from typing import TypedDict, TypeIs

class Point(TypedDict):
    x: int
    y: int

def is_point(x: object) -> TypeIs[Point]:
    return (
        isinstance(x, dict)
        and all(isinstance(key, str) for key in x)
        and "x" in x
        and "y" in x
        and isinstance(x["x"], int)
        and isinstance(x["y"], int)
    )

TypeIsTypeGuard

TypeIstyping.TypeGuard 都是基於使用者自定義函式來縮小變數類型的工具。兩者皆可用於標註接受一個引數並根據輸入引數是否與縮小後的類型相容而回傳布林值的函式。這些函式隨後可用於 if 檢查中以縮小變數的類型。

TypeIs 通常具有最直觀的行為,但它引入了更多限制。TypeGuard 在以下情況是合適的工具:

  • 您想要縮小到與輸入類型不相容的類型,例如從 list[object] 縮小到 list[int]TypeIs 僅允許在相容類型之間進行縮小。
  • 您的函式對於所有與縮小類型相容的輸入值並不都回傳 True。例如,您可以定義一個僅對正整數回傳 TrueTypeGuard[int]

TypeIsTypeGuard 在以下方面存在差異:

  • TypeIs 要求縮小後的類型必須是輸入類型的子類型,而 TypeGuard 則無此要求。
  • TypeGuard 函式回傳 True 時,類型檢查器會將變數的類型縮小為與 TypeGuard 類型完全一致。當 TypeIs 函式回傳 True 時,類型檢查器可以推斷出更精確的類型,將變數先前已知的類型與 TypeIs 類型結合(技術上稱為「交集類型」)。
  • TypeGuard 函式回傳 False 時,類型檢查器完全無法縮小變數的類型。當 TypeIs 函式回傳 False 時,類型檢查器可以將變數的類型縮小為排除 TypeIs 類型。

這種行為可見於以下範例:

from typing import TypeGuard, TypeIs, reveal_type, final

class Base: ...
class Child(Base): ...
@final
class Unrelated: ...

def is_base_typeguard(x: object) -> TypeGuard[Base]:
    return isinstance(x, Base)

def is_base_typeis(x: object) -> TypeIs[Base]:
    return isinstance(x, Base)

def use_typeguard(x: Child | Unrelated) -> None:
    if is_base_typeguard(x):
        reveal_type(x)  # Base
    else:
        reveal_type(x)  # Child | Unrelated

def use_typeis(x: Child | Unrelated) -> None:
    if is_base_typeis(x):
        reveal_type(x)  # Child
    else:
        reveal_type(x)  # Unrelated

參考實作

TypeIs 特殊形式 已在 typing_extensions 模組中實作,並將於 typing_extensions 4.10.0 版本發布。

已有幾個類型檢查器提供了實作:

否決的想法

變更 TypeGuard 的行為

PEP 724 先前提議變更 typing.TypeGuard 的指定行為,若防護的回傳類型與輸入類型一致,則應用此處提議給 TypeIs 的行為。該提案有一些重要優點:因為它不需要任何執行時期的變更,僅需更改類型檢查器,使得使用者更容易利用這種通常更直觀的新行為。

然而,此方法存在一些重大問題。那些編寫了 TypeGuard 函式並期望 PEP 647 所規定的現有語義之使用者,將會看到類型檢查器解釋其程式碼的方式發生細微且可能具破壞性的變更。 TypeGuard 的分裂行為(在回傳類型與輸入類型一致時以一種方式運作,不一致時以另一種方式運作)可能會讓使用者感到困惑。Typing Council 無法就 PEP 724 達成一致意見;因此,我們提出了這個替代方案的 PEP。

不採取行動

本 PEP 與 PEP 724 提出的替代方案各有缺陷。後者已於上方討論。至於本 PEP,它引入了兩個語義非常相似的特殊形式,並可能為目前使用 TypeGuard 但若使用不同縮小語義會更好的使用者,創造了一條漫長的遷移路徑。

因此,一個前進的方向是維持現狀,忍受類型系統當前的限制。然而,我們認為如「動機」章節所述,當前 TypeGuard 的限制顯著到值得透過變更類型系統來解決。如果我們不做任何改變,使用者將繼續遭遇 TypeGuard 同樣不直觀的行為,且類型系統將無法正確表示像 inspect.isawaitable 這樣常見的類型縮小函式。

替代命名

本 PEP 目前提議命名為 TypeIs,強調特殊形式 TypeIs[T] 回傳引數是否為類型 T,並仿照 TypeScript 的語法。在該 PEP 的早期版本中,還考慮過其他名稱。

選項包括:

  • IsInstance (Paul Moore 的文章):強調新結構的行為類似於內建的 isinstance()
  • NarrowedNarrowedTo:比 TypeNarrower 短,但保持了與「類型縮小」的連結(由 Eric Traut 建議)。
  • PredicateTypePredicate:仿照 TypeScript 對該功能使用的名稱「類型謂詞(type predicates)」。
  • StrictTypeGuard (PEP 724 的早期草案):強調該新結構執行比 typing.TypeGuard 更嚴格的類型縮小。
  • TypeCheck (Nicolas Tessore 的文章):強調該檢查的二元本質。
  • TypeNarrower:強調該函式縮小了其引數類型。用於本 PEP 的早期版本。

致謝

本 PEP 的大部分動機與規範源自 PEP 724。雖然本 PEP 為當前問題提議了不同的解決方案,但 PEP 724 的作者 Eric Traut、Rich Chiodo 與 Erik De Bonte 為他們的提案提供了強有力的論據,若沒有他們的工作,本提案將不可能實現。


來源:https://github.com/python/peps/blob/main/peps/pep-0742.rst

最後修改:2024-10-17 12:49:39 GMT