Python Type Hints 從入門到實踐

雲叔_又拍雲發表於2021-10-26

Python 想必大家都已經很熟悉了,甚至關於它有用或者無用的論點大家可能也已經看膩了。但是無論如何,它作為一個將加入高考科目的語言還是有它獨到之處的,今天我們就再展開聊聊 Python。

Python 是一門動態強型別語言

《流暢的 Python》一書中提到,如果一門語言很少隱式轉換型別,說明它是強型別語言,例如 Java、C++ 和 Python 就是強型別語言。

Python 的強型別體現

同時如果一門語言經常隱式轉換型別,說明它是弱型別語言,PHP、JavaScript 和 Perl 是弱型別語言。

動態弱型別語言:JavaScript

當然上面這種簡單的示例對比,並不能確切的說 Python 是一門強型別語言,因為 Java 同樣支援 integer 和 string 相加操作,且 Java 是強型別語言。因此《流暢的 Python》一書中還有關於靜態型別和動態型別的定義:在編譯時檢查型別的語言是靜態型別語言,在執行時檢查型別的語言是動態型別語言。靜態語言需要宣告型別(有些現代語言使用型別推導避免部分型別宣告)。

綜上所述,關於 Python 是動態強型別語言是比較顯而易見沒什麼爭議的。

Type Hints 初探

Python 在 PEP 484(Python Enhancement Proposals,Python 增強建議書)[https://www.python.org/dev/pe...]中提出了 Type Hints(型別註解)。進一步強化了 Python 是一門強型別語言的特性,它在 Python3.5 中第一次被引入。使用 Type Hints 可以讓我們編寫出帶有型別的 Python 程式碼,看起來更加符合強型別語言風格。

這裡定義了兩個 greeting 函式:

  • 普通的寫法如下:
name = "world"

def greeting(name):
    return "Hello " + name

greeting(name)
  • 加入了 Type Hints 的寫法如下:
name: str = "world"

def greeting(name: str) -> str:
    return "Hello " + name

greeting(name)

以 PyCharm 為例,在編寫程式碼的過程中 IDE 會根據函式的型別標註,對傳遞給函式的引數進行型別檢查。如果發現實參型別與函式的形參型別標註不符就會有如下提示:

常見資料結構的 Type Hints 寫法

上面通過一個 greeting 函式展示了 Type Hints 的用法,接下來我們就 Python 常見資料結構的 Type Hints 寫法進行更加深入的學習。

預設引數

Python 函式支援預設引數,以下是預設引數的 Type Hints 寫法,只需要將型別寫到變數和預設引數之間即可。

def greeting(name: str = "world") -> str:
    return "Hello " + name

greeting()

自定義型別

對於自定義型別,Type Hints 同樣能夠很好的支援。它的寫法跟 Python 內建型別並無區別。

class Student(object):
    def __init__(self, name, age):
        self.name = name
        self.age = age


def student_to_string(s: Student) -> str:
    return f"student name: {s.name}, age: {s.age}."

student_to_string(Student("Tim", 18))

當型別標註為自定義型別時,IDE 也能夠對型別進行檢查。

容器型別

當我們要給內建容器型別新增型別標註時,由於型別註解運算子 [] 在 Python 中代表切片操作,因此會引發語法錯誤。所以不能直接使用內建容器型別當作註解,需要從 typing 模組中匯入對應的容器型別註解(通常為內建型別的首字母大寫形式)。

from typing import List, Tuple, Dict

l: List[int] = [1, 2, 3]

t: Tuple[str, ...] = ("a", "b")

d: Dict[str, int] = {
    "a": 1,
    "b": 2,
}

不過 PEP 585[https://www.python.org/dev/pe...]的出現解決了這個問題,我們可以直接使用 Python 的內建型別,而不會出現語法錯誤。

l: list[int] = [1, 2, 3]

t: tuple[str, ...] = ("a", "b")

d: dict[str, int] = {
    "a": 1,
    "b": 2,
}

型別別名

有些複雜的巢狀型別寫起來很長,如果出現重複,就會很痛苦,程式碼也會不夠整潔。

config: list[tuple[str, int], dict[str, str]] = [
    ("127.0.0.1", 8080),
    {
        "MYSQL_DB": "db",
        "MYSQL_USER": "user",
        "MYSQL_PASS": "pass",
        "MYSQL_HOST": "127.0.0.1",
        "MYSQL_PORT": "3306",
    },
]

def start_server(config: list[tuple[str, int], dict[str, str]]) -> None:
    ...

start_server(config)

此時可以通過給型別起別名的方式來解決,類似變數命名。

Config = list[tuple[str, int], dict[str, str]]


config: Config = [
    ("127.0.0.1", 8080),
    {
        "MYSQL_DB": "db",
        "MYSQL_USER": "user",
        "MYSQL_PASS": "pass",
        "MYSQL_HOST": "127.0.0.1",
        "MYSQL_PORT": "3306",
    },
]

def start_server(config: Config) -> None:
    ...

start_server(config)

這樣程式碼看起來就舒服多了。

可變引數

Python 函式一個非常靈活的地方就是支援可變引數,Type Hints 同樣支援可變引數的型別標註。

def foo(*args: str, **kwargs: int) -> None:
    ...

foo("a", "b", 1, x=2, y="c")

IDE 仍能夠檢查出來。

泛型

使用動態語言少不了泛型的支援,Type Hints 針對泛型也提供了多種解決方案。

TypeVar

使用 TypeVar 可以接收任意型別。

from typing import TypeVar

T = TypeVar("T")

def foo(*args: T, **kwargs: T) -> None:
    ...

foo("a", "b", 1, x=2, y="c")

Union

如果不想使用泛型,只想使用幾種指定的型別,那麼可以使用 Union 來做。比如定義 concat 函式只想接收 str 或 bytes 型別。

from typing import Union

T = Union[str, bytes]

def concat(s1: T, s2: T) -> T:
    return s1 + s2

concat("hello", "world")
concat(b"hello", b"world")
concat("hello", b"world")
concat(b"hello", "world")

IDE 的檢查提示如下圖:

TypeVar 和 Union 區別

TypeVar 不只可以接收泛型,它也可以像 Union 一樣使用,只需要在例項化時將想要指定的型別範圍當作引數依次傳進來來即可。跟 Union 不同的是,使用 TypeVar 宣告的函式,多引數型別必須相同,而 Union 不做限制。

from typing import TypeVar

T = TypeVar("T", str, bytes)

def concat(s1: T, s2: T) -> T:
    return s1 + s2

concat("hello", "world")
concat(b"hello", b"world")
concat("hello", b"world")

以下是使用 TypeVar 做限定型別時的 IDE 提示:

Optional

Type Hints 提供了 Optional 來作為 Union[X, None] 的簡寫形式,表示被標註的引數要麼為 X 型別,要麼為 None,Optional[X] 等價於 Union[X, None]。

from typing import Optional, Union

# None => type(None)
def foo(arg: Union[int, None] = None) -> None:
    ...


def foo(arg: Optional[int] = None) -> None:
    ...

Any

Any 是一種特殊的型別,可以代表所有型別。未指定返回值與引數型別的函式,都隱式地預設使用 Any,所以以下兩個 greeting 函式寫法等價:

from typing import Any

def greeting(name):
    return "Hello " + name


def greeting(name: Any) -> Any:
    return "Hello " + name

當我們既想使用 Type Hints 來實現靜態型別的寫法,也不想失去動態語言特有的靈活性時,即可使用 Any。

Any 型別值賦給更精確的型別時,不執行型別檢查,如下程式碼 IDE 並不會有錯誤提示:

from typing import Any

a: Any = None
a = []  # 動態語言特性
a = 2

s: str = ''
s = a  # Any 型別值賦給更精確的型別

可呼叫物件(函式、類等)

Python 中的任何可呼叫型別都可以使用 Callable 進行標註。如下程式碼標註中 Callable[[int], str],[int] 表示可呼叫型別的引數列表,str 表示返回值。

from typing import Callable

def int_to_str(i: int) -> str:
    return str(i)

def f(fn: Callable[[int], str], i: int) -> str:
    return fn(i)

f(int_to_str, 2)

自引用

當我們需要定義樹型結構時,往往需要自引用。當執行到 init 方法時 Tree 型別還沒有生成,所以不能像使用 str 這種內建型別一樣直接進行標註,需要採用字串形式“Tree”來對未生成的物件進行引用。

class Tree(object):
    def __init__(self, left: "Tree" = None, right: "Tree" = None):
        self.left = left
        self.right = right

tree1 = Tree(Tree(), Tree())

IDE 同樣能夠對自引用型別進行檢查。

此形式不僅能夠用於自引用,前置引用同樣適用。

鴨子型別

Python 一個顯著的特點是其對鴨子型別的大量應用,Type Hints 提供了 Protocol 來對鴨子型別進行支援。定義類時只需要繼承 Protocol 就可以宣告一個介面型別,當遇到介面型別的註解時,只要接收到的物件實現了介面型別的所有方法,即可通過型別註解的檢查,IDE 便不會報錯。這裡的 Stream 無需顯式繼承 Interface 類,只需要實現了 close 方法即可。

from typing import Protocol

class Interface(Protocol):
    def close(self) -> None:
        ...

# class Stream(Interface):
class Stream:
    def close(self) -> None:
        ...

def close_resource(r: Interface) -> None:
    r.close()

f = open("a.txt")
close_resource(f)

s: Stream = Stream()
close_resource(s)

由於內建的 open 函式返回的檔案物件和 Stream 物件都實現了 close 方法,所以能夠通過 Type Hints 的檢查,而字串“s”並沒有實現 close 方法,所以 IDE 會提示型別錯誤。

Type Hints 的其他寫法

實際上 Type Hints 不只有一種寫法,Python 為了相容不同人的喜好和老程式碼的遷移還實現了另外兩種寫法。

使用註釋編寫

來看一個 tornado 框架的例子(tornado/web.py)。適用於在已有的專案上做修改,程式碼已經寫好了,後期需要增加型別標註。

使用單獨檔案編寫(.pyi)

可以在原始碼相同的目錄下新建一個與 .py 同名的 .pyi 檔案,IDE 同樣能夠自動做型別檢查。這麼做的優點是可以對原來的程式碼不做任何改動,完全解耦。缺點是相當於要同時維護兩份程式碼。

Type Hints 實踐

基本上,日常編碼中常用的 Type Hints 寫法都已經介紹給大家了,下面就讓我們一起來看看如何在實際編碼中中應用 Type Hints。

dataclass——資料類

dataclass 是一個裝飾器,它可以對類進行裝飾,用於給類新增魔法方法,例如 __init__() 和 __repr__() 等,它在 PEP 557[https://www.python.org/dev/pe...]中被定義。

from dataclasses import dataclass, field


@dataclass
class User(object):
    id: int
    name: str
    friends: list[int] = field(default_factory=list)


data = {
    "id": 123,
    "name": "Tim",
}

user = User(**data)
print(user.id, user.name, user.friends)
# > 123 Tim []

以上使用 dataclass 編寫的程式碼同如下程式碼等價:

class User(object):
    def __init__(self, id: int, name: str, friends=None):
        self.id = id
        self.name = name
        self.friends = friends or []


data = {
    "id": 123,
    "name": "Tim",
}

user = User(**data)
print(user.id, user.name, user.friends)
# > 123 Tim []

注意:dataclass 並不會對欄位型別進行檢查。

可以發現,使用 dataclass 來編寫類可以減少很多重複的樣板程式碼,語法上也更加清晰。

Pydantic

Pydantic 是一個基於 Python Type Hints 的第三方庫,它提供了資料驗證、序列化和文件的功能,是一個非常值得學習借鑑的庫。以下是一段使用 Pydantic 的示例程式碼:

from datetime import datetime
from typing import Optional

from pydantic import BaseModel


class User(BaseModel):
    id: int
    name = 'John Doe'
    signup_ts: Optional[datetime] = None
    friends: list[int] = []


external_data = {
    'id': '123',
    'signup_ts': '2021-09-02 17:00',
    'friends': [1, 2, '3'],
}
user = User(**external_data)

print(user.id)
# > 123
print(repr(user.signup_ts))
# > datetime.datetime(2021, 9, 2, 17, 0)
print(user.friends)
# > [1, 2, 3]
print(user.dict())
"""
{
    'id': 123,
    'signup_ts': datetime.datetime(2021, 9, 2, 17, 0),
    'friends': [1, 2, 3],
    'name': 'John Doe',
}
"""

注意:Pydantic 會對欄位型別進行強制檢查。

Pydantic 寫法上跟 dataclass 非常類似,但它做了更多的額外工作,還提供瞭如 .dict() 這樣非常方便的方法。

再來看一個 Pydantic 進行資料驗證的示例,當 User 類接收到的引數不符合預期時,會丟擲 ValidationError 異常,異常物件提供了 .json() 方法方便檢視異常原因。

from pydantic import ValidationError

try:
    User(signup_ts='broken', friends=[1, 2, 'not number'])
except ValidationError as e:
    print(e.json())
"""
[
  {
    "loc": [
      "id"
    ],
    "msg": "field required",
    "type": "value_error.missing"
  },
  {
    "loc": [
      "signup_ts"
    ],
    "msg": "invalid datetime format",
    "type": "value_error.datetime"
  },
  {
    "loc": [
      "friends",
      2
    ],
    "msg": "value is not a valid integer",
    "type": "type_error.integer"
  }
]
"""

所有報錯資訊都儲存在一個 list 中,每個欄位的報錯又儲存在巢狀的 dict 中,其中 loc 標識了異常欄位和報錯位置,msg 為報錯提示資訊,type 則為報錯型別,這樣整個報錯原因一目瞭然。

MySQLHandler

MySQLHandler[https://github.com/jianghushi...]是我對 pymysql 庫的封裝,使其支援使用 with 語法呼叫 execute 方法,並且將查詢結果從 tuple 替換成 object,同樣也是對 Type Hints 的應用。

class MySQLHandler(object):
    """MySQL handler"""

    def __init__(self):
        self.conn = pymysql.connect(
            host=DB_HOST,
            port=DB_PORT,
            user=DB_USER,
            password=DB_PASS,
            database=DB_NAME,
            charset=DB_CHARSET,
            client_flag=CLIENT.MULTI_STATEMENTS,  # execute multi sql statements
        )
        self.cursor = self.conn.cursor()

    def __del__(self):
        self.cursor.close()
        self.conn.close()

    @contextmanager
    def execute(self):
        try:
            yield self.cursor.execute
            self.conn.commit()
        except Exception as e:
            self.conn.rollback()
            logging.exception(e)

    @contextmanager
    def executemany(self):
        try:
            yield self.cursor.executemany
            self.conn.commit()
        except Exception as e:
            self.conn.rollback()
            logging.exception(e)

    def _tuple_to_object(self, data: List[tuple]) -> List[FetchObject]:
        obj_list = []
        attrs = [desc[0] for desc in self.cursor.description]
        for i in data:
            obj = FetchObject()
            for attr, value in zip(attrs, i):
                setattr(obj, attr, value)
            obj_list.append(obj)
        return obj_list

    def fetchone(self) -> Optional[FetchObject]:
        result = self.cursor.fetchone()
        return self._tuple_to_object([result])[0] if result else None

    def fetchmany(self, size: Optional[int] = None) -> Optional[List[FetchObject]]:
        result = self.cursor.fetchmany(size)
        return self._tuple_to_object(result) if result else None

    def fetchall(self) -> Optional[List[FetchObject]]:
        result = self.cursor.fetchall()
        return self._tuple_to_object(result) if result else None

執行期型別檢查

Type Hints 之所以叫 Hints 而不是 Check,就是因為它只是一個型別的提示而非真正的檢查。上面演示的 Type Hints 用法,實際上都是 IDE 在幫我們完成型別檢查的功能,但實際上,IDE 的型別檢查並不能決定程式碼執行期間是否報錯,僅能在靜態期做到語法檢查提示的功能。

要想實現在程式碼執行階段強制對型別進行檢查,則需要我們通過自己編寫程式碼或引入第三方庫的形式(如上面介紹的 Pydantic)。下面我通過一個 type_check 函式實現了執行期動態檢查型別,來供你參考:

from inspect import getfullargspec
from functools import wraps
from typing import get_type_hints


def type_check(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        fn_args = getfullargspec(fn)[0]
        kwargs.update(dict(zip(fn_args, args)))
        hints = get_type_hints(fn)
        hints.pop("return", None)
        for name, type_ in hints.items():
            if not isinstance(kwargs[name], type_):
                raise TypeError(f"expected {type_.__name__}, got {type(kwargs[name]).__name__} instead")
        return fn(**kwargs)

    return wrapper


# name: str = "world"
name: int = 2

@type_check
def greeting(name: str) -> str:
    return str(name)

print(greeting(name))
# > TypeError: expected str, got int instead

只要給 greeting 函式打上 type_check 裝飾器,即可實現執行期型別檢查。

附錄

如果你想繼續深入學習使用 Python Type Hints,以下是一些我推薦的開源專案供你參考:

推薦閱讀

TypeScript 列舉指南

實戰經驗分享:使用 PyO3 來構建你的 Python 模組

相關文章