🐍 让你的 Python 静态起来
date
Apr 1, 2019
slug
python-type-hint.html
status
Published
tags
python
pep
tech
summary
旧文重发
type
Post
为什么要 “静态”?Typing Annotation类型注释?Gradual typingTypes 和 Classes各种使用场景基本类型容器类型类型别名函数类型“泛” 型AnyTypeVar函数的“泛”型Union 和 OptionalDjango modelProtocol自定义类型普通用法类型自定义时辅助函数typing-extensions实例:简化的枚举类型工程技巧避免循环引用mypy再让我们看看那个例子结语
为什么要 “静态”?
在一切开始前,我们可以先看一个小例子:
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_status
的return
语句后多加了一个 ,
,所以函数的返回值并不是预期的 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
正如上面提到,任何基本类型都可以直接当做注解使用,包括但不限于:
int
、str
、list
、dict
、set
、None
、bool
、tuple
等等,容器类型
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] 意味着是 X 或 Y 类型
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
在这个例子中,我们定义了两种协议,
Exitable
和 Quittable
,同时在传参时支持它们俩的并集,那么只要实现了 exit(self) -> int
或 quit(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 里提示的样子:
好了,只要加上了类型注解,那个 企图让所有变量都变成元组 的"元凶"——逗号,就无处遁形了 xD
结语
类型注解给 Python 带来了全新的开发体验,相信此刻的你也有了答案,让 Python “静态” 起来吧。
参考文档: