🐍 让你的 Python 静态起来

date
Apr 1, 2019
slug
python-type-hint.html
status
Published
tags
python
pep
tech
summary
旧文重发
type
Post

为什么要 “静态”?

在一切开始前,我们可以先看一个小例子:
def get_weapons_status():
	weapon_status = {
		"LeviathanAxe": True,
		"BladesofChaos": False
	}
	return weapon_status,


weapons_status = get_weapons_status()
for weapon, status in weapons_status.items():
	if status:
		print(f"{weapon} is ready")
在我们的预想里,上述代码应该会输出 LeviathanAxe is ready,但实际运行则会报错:AttributeError: 'tuple' object has no attribute 'items'
眼尖的同学肯定发现了,这段代码的问题是在 get_weapons_statusreturn 语句后多加了一个 ,,所以函数的返回值并不是预期的 dict 类型,而是 tuple。然而对于这样的写法,IDE 可能不会有明显提示,所以在我们做大段的代码重构或迁移时,它们很容易被忽略,直到运行时才会冒出来。
我们常把这类问题归结于自己的粗心大意,但是没有想过它们的“原罪”其实应该是 Python 的动态
诚然,Python 的动态给我们带来了诸多酷炫的特性:monkey_patch、各种魔法方法、极为方便的 mock 测试.....但在逻辑分层设计、参数校验、代码补全时我们又无比渴望一些 “静态” 特性。
所以,如果 Python 能够 “静态” 一些,将会给我们带来几个明显的增益:
  • 大幅度提升代码的可读性
  • 能够将参数传递时的 类型错误 扼杀在摇(biān)篮(mǎ)中
  • 能够最大程度利用 IDE 提供的代码提示,减少 typo
那么该如何拥有这些“静态”的特性呢?下面我们将时间留给本文的主角——typing annotaion

Typing Annotation

从 Python 3.5 开始, type annotation 已经正式被引入了。
我们可以对任何变量进行类型注解,无论是赋值之前还是函数传参和返回
# 我们可以从类、模块、函数的 `__annotations__` 变量中获取这些注解
nine_realms: List[str] = ["Niflheim", "Muspelheim", "Asgard", "Midgard", "Jotunheim", "Vanaheim", "Alfheim", "Svartalfheim", "Helheim"]

class Yggdrasill:
	def transport(realm: str) -> bool:
		pass

类型注释?

除了“注解”,我们也可以使用类型注释
pi = 3.142 # type: float
相较于“注解”,注释对代码的侵入性更小,但同时可读性更差,只适用于不支持注解的场景。官方推荐使用类型注解,所以类型注释的内容就不再展开了。

Gradual typing

类型注解只会生存于“编码时”,并不影响运行时,我们可以放心大胆地为旧代码添加注解,而不用担心对实际功能产生影响。当然对于大型项目(只要注解本身不写错),我们可以采取“渐进式注解”,对一些关键的核心模块先进行改造,详见 gradual typing

Types 和 Classes

需要额外说明的是,在类型注解中,我们经常会使用 “类(class)” 和 “类型(type)” 做注解:
name: str = "Atreus"

from typing import List
names: List[str] = ["Atreus", "Loki"]
其中 str 既是一种 “类”,同时也是一种 “类型”,而 List 只是一种 “类型”,我们是不能够在 “运行时” 使用它们的,例如:
class Names(List[str]): ... # raises TypeError
简单来说,任何一种 “类” 都可以被当作一种 ”类型“,反过来 ”类型” 却不一定能被当作 “类” 使用。

各种使用场景

下面我们主要将常用的类型注解结合工程场景举例

基本类型

def hey_boy(name: str) -> str:
return 'Boy: ' + name
正如上面提到,任何基本类型都可以直接当做注解使用,包括但不限于:intstrlistdictsetNonebooltuple等等,

容器类型

from typing import List

# 纯容器
def print_names(names: List) -> None:
	pass

# 容器 & 容器内元素
def print_names(names: List[str]) -> None:
	pass

print_names(['Kratos', 'Thor'])
print_names(['any_type', 128, {1, 2, 8}, (1, 2, 8)]) # 不被允许,容器内元素类型不一致

def print_names(names: Dict[str, str]) -> None:
	pass

print_names({'Blues': 'Yu'})

类型别名

容器内元素会非常复杂,我们可能会有类似 Dict[str, Dict[str: SomeObject]] 这种不容易阅读的写法,此时我们也可以用类型别名来简化。
from typing import Dict

Point = Dict[str: SomeObject]

def print_points(points: Dict[Point]):
	for point in points:
		print("X:", point[0], " Y:", point[1])

函数类型

在类似装饰器、回调等场景下,我们需要对函数参数作注解,基本的格式为:Callable[[Arg1Type, Arg2Type], ReturnType]。其中 [Arg1Type, Arg2Type] 是输入参数列表,ReturnType 是返回内容。
from typing import Callable

def foo(func: Callable[[int], str], argument: str) -> None:
	print(func(argument))

def bar(name: int) -> str:
	return f"Hello {name}"

foo(create_greeting, "Jekyll")

“泛” 型

Any

有时候我们对于某些“泛”型变量,我们可以用 Any 来表明
from typing import TypeVar, AnyStr, Any

# 任意类型
# Any 兼容任意类型
def concat(a: Any, b: Any) -> Any:
	pass

TypeVar

TypeVar 表示 Type 类型。简单来说,她就是“类型”的“类型”。和 type 可以创造 “类” 一样,我们可以通过 TypeVar 来创造 “类型”。
例如 typing 中提供的 AnyStr 其实就是通过 TypeVar('AnyStr', Text, bytes) 来表示的。
# 任意字符型
# AnyStr = TypeVar('AnyStr', Text, bytes)

def concat(a: AnyStr, b: AnyStr) -> AnyStr:
	return a + b

concat(u"foo", u"bar") 
concat(b"foo", b"bar") 
concat(u"foo", b"bar")

# 以上三种写法都会被类型检查接受,但是运行时,最后一种调用会报错 `TypeError: can't concat str to bytes`

函数的“泛”型

有时候,函数可能需要支持多种类型输入输出,我们可以通过多种方法来实现注解。
使用 typing.overload
from typing import overload, List


@overload
def switch_names(developers: str) -> List:
...


@overload
def switch_names(developers: List[str]) -> str:
...


# 这里不能有类型注解,并且需要实现一个通用的做法
def switch_names(developers):
	if isinstance(developers, str):
		# 例如 "ponyma,jackma,allenzhang"
		developers = developers.split(",")
		return developers
	elif isinstance(developers, List):
		# 例如 ["ponyma", "jackma", "allenzhang"]
		developers = ','.join(developers)
		return developers
使用 Union[Type1, Type2]
# Union[X, Y] 意味着是 XY 类型
def switch_names(name: Union[str, List]):
	if isinstance(developers, str):
		# 例如 "ponyma,jackma,allenzhang"
		developers = developers.split(",")
		return developers
	elif isinstance(developers, List):
		# 例如 ["ponyma", "jackma", "allenzhang"]
		developers = ','.join(developers)
		return developers

Union 和 Optional

from typing import Union

# 通常的 Union 用法
def print_grade(grade: Union[int, str]):
	if isinstance(grade, str):
		print(grade + ' percent')
	else:
		print(str(grade) + ' percent')

# Optional[X] 相当于 Union[X, None]
from typing import Optional

def foo(optional_info: Optional[Dict] = None):
	return optional_info or {}

Django model

from typing import Union, List
from django.db.models import QuerySet
from my_app.models import MyModel

# 在后续代码中,records 的类型将被等同于 MyModel 的 QuerySet
# 不过同时需要注意,如果此时传入一个单纯的 List[MyModel],
# 并调用 QuerySet 的方法,静态检查是不会探测出问题的
def foo(records: Union[QuerySet, List[MyModel]]):
	pass

Protocol

协议类型是一个特殊的结构化类型,它表示一种静态的鸭子类型,有点像是 Go 中的接口定义,但依旧只存在于“编码时”。Protocol 的好处是,我们可以很明确的定义和使用鸭子类型,而不用人工核对方法的输入输出。
举个例子:
from typing import Union, Optional, Protocol

class Exitable(Protocol):
	def exit(self) -> int: ...

class Quittable(Protocol):
	def quit(self) -> Optional[int]: ...

def finish(task: Union[Exitable, Quittable]) -> int: ...

class QuitJob:
	def quit(self) -> int:
		return 0

class ExitJob:
	def exit(self) -> int:
		return 0

class Server: 
	def run_forever(self): -> NoReturn:
        """no return"""

finish(QuitJob()) # OK
finish(ExitJob()) # OK
finish(Server()) # Error
在这个例子中,我们定义了两种协议,ExitableQuittable,同时在传参时支持它们俩的并集,那么只要实现了 exit(self) -> intquit(self) -> int 的对象都会被接受。
关于 Protocol 的其他定义和示例,可以通过 PEP 544 了解更多,这里就不再展开了。

自定义类型

普通用法

可以使用 Type 来接受子类
from typing import Type

class User: ...
class BasicUser(User): ...
class ProUser(User): ...
class TeamUser(User): ...

# Accepts User, BasicUser, ProUser, TeamUser, ...
def make_new_user(user_class: Type[User]) -> User:
	return user_class()

类型自定义时

不使用 TypeVar
class Animal:
@classmethod
def newborn(cls, name: str) -> 'Animal':
	return cls(name, date.today())
正如上面提到,我们还可以用 TypeVar 来创造类型,
TAnimal = TypeVar("TAnimal")

class Animal(Generic("TAnimal")):
	@classmethod
	def newborn(cls, name: str) -> TAnimal:
		return cls(name, date.today())
倘若此时还有继承,需要添加参数 bound
from datetime import date
from typing import Type, TypeVar

TAnimal = TypeVar("TAnimal", bound="Animal")

class Animal:
	@classmethod
	def newborn(cls: Type[TAnimal], name: str) -> TAnimal:
		return cls(name, date.today())

class Dog(Animal):
	def bark(self) -> None:
		print(f"{self.name} says woof!")

fido = Dog.newborn("Fido")
fido.bark()

辅助函数

有时候我们并不想在简单类型上多费精力,可以直接使用辅助函数来快捷创建类型。
from typing import NewType

UserId = NewType('UserId', int)
some_id = UserId(524313)

# 类型可以再次被创建
ProUserId = NewType('ProUserId', UserId)
some_pro_id = ProUserId(12345)

typing-extensions

由于 typing 模块已经被引入标准库,其发布频率需要和 Python 发行版保持一致,所以有很多新类型被放到了 typing-extensions 中,我们可以通过 pip 安装它。
pip install typing-extensions
typing-extensions中有很多特殊类型,下面我们简单举一个小例子,其他的大家可以自行探索。

实例:简化的枚举类型

from typing_extensions import Literal

# Literal 表示字面类型
def fetch_data(raw: Literal["red", "blue", "yellow"]) -> bytes: ...
原则上这里就只能输入字符串 "red" "blue" "yellow",某些简单的场景下,我们不用额外定义枚举类型。

工程技巧

避免循环引用

A 模块:
from B import AppFooController

class App:
	def __init__(name: str):
		self.name = name


def create_app():
	controller = AppFooController(App('bar'))
在这个场景下,B 模块并不是真正的需要引用 A 模块的内容,而是需要获得 A 模块内容的 typing hint,如果直接引用会导致循环 import,可以通过如下方法规避:
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from A import App
    
class AppFooController:
    # 注意:TYPE_CHECKING 语句下 import 的内容,都必须用引号包裹
    app: 'App'
这里的 TYPING_CHECKING 在运行时将永远为 False,所以并不会真正 import A 模块,同时也能起到 hint 的作用。

mypy

mypy 原来是一个兼容大部分 Python 语法的静态类型的 Python 发行版,后来在官方受到启发,并加入类型注解之后,mypy 已经演化成了一个静态类型检查器,我们可以通过 pip 来安装
pip install mypy
在某些场景下,我们可以通过 mypy 来对项目进行全局扫描
➜ mypy some-python-project-path/
foo/utils/sanitizer.py:18: error: Cannot find module named 'bleach.encoding'
foo/utils/sanitizer.py:19: error: Cannot find module named 'bleach.sanitizer'
foo/fake/tee/gitlab/client.py:5: error: Cannot find module named 'bar'
foo/fake/tee/gitlab/client.py:6: error: Cannot find module named 'bar'
foo/fake/tee/gitlab/client.py:7: error: Cannot find module named 'bar.objects'
foo/accessories/fake/tags.py:48: error: "type" has no attribute "tag_type"
foo/utils/log.py:4: error: Cannot find module named 'logstash'
foo/utils/log.py:5: error: No library stub file for module 'redis'
foo/utils/detector.py:32: error: Need type annotation for 'detect_map'
foo/utils/basic.py:3: error: Cannot find module named 'past.utils'
foo/utils/basic.py:9: error: Cannot find module named 'bar'
foo/utils/basic.py:10: error: Cannot find module named 'aenum'
在我们项目整体类型注解完善的情况下,可以考虑将 mypy 放到 CI 的流程中。
比如 Tornado 的源码基本已经做到 100% 静态了,扫描整个工程只有两个小问题:
➜ mypy tornado/
tornado/testing.py:263: error: Return type of "run" incompatible with supertype "TestCase"
tornado/testing.py:270: error: Incompatible return value type (got "Optional[TestResult]", expected "TestCase")

再让我们看看那个例子

以下是在 Pycharm 里提示的样子:
 
notion image
 
好了,只要加上了类型注解,那个 企图让所有变量都变成元组 的"元凶"——逗号,就无处遁形了 xD

结语

类型注解给 Python 带来了全新的开发体验,相信此刻的你也有了答案,让 Python “静态” 起来吧。
参考文档:

© bluesyu 2019 - 2024

powered by nobelium