离谱的项目体验
我承认是为了吐槽公司项目,才有了这篇文章
不吐不快,真的被公司的cmdb 项目折磨太久了。包括但不限于以下方面:
- 类或者方法缺少说明
- 参数类型注释缺失
- 结构划分不合理,常常出现循环引用
- 随意的变量名
- 混乱的逻辑和重复出现的代码
- 糟糕的性能(例如批量任务串行, 数据库表设计不合理导致的多表连接以及字段冗余等)
最令人痛苦的莫过于第二点参数类型注释的缺失。举个例子:
@api.route('/server/create', methods=["post"])
def server_create():
body = request.json
provider = body.get("provider")
if provider == "aliyun":
aliyun_ecs.create_server(**body)
elif provider == "huawei":
huawei_ecs.create_server(**body)
...
上面这段代码起码有 3 个地方需要改进
- 确定 body 的字段,封装成class
- 对传入的 body 校验
- 利用工厂模式或者适配器统一不同云商调用
body
是一个字典类型,至于里面的字段就不得而知了。而且不同云商body
还不一样,重构的时候真是让人头大。虽然 python作为动态类型编程语言,运行时不需要指定变量类型。但是那样也会对代码的可维护性带来挑战。下面聊聊为什么要写参数类型注释
为什么需要类型注释
就像上面描述的情况,良好的类型注释可以使代码更容易阅读维护和维护。除此之外还能带来以下几点便利:
编辑器提示
例如上面的hello
函数,在声明name
为str
类型后,编辑器可以自动列出/补全变量或者方法。
但是在bye
函数中就无法补全,而且也无法显示capitalize()
方法文档
编辑器类型检查
如果你的编辑器开启了类型检查 linter
, 例如mypy
。再或者使用的是vscode
编辑器并且安装了 python 插件,可以在设置中打开type checking mode
。
可以提示类型错误,帮助你尽早发现异常。
[!Tip]
- 运行时解释器(CPython)不会尝试在运行时推断类型信息,或者验证基于此传递的参数。
- 运行时解释器(CPython)不使用类型信息来优化生成的字节码以获得安全性或性能。
- 执行Python脚本时,类型提示被视为注释,解释器自动忽略。
python 类型注释发展历史
大家可能会好奇,以前写代码的时候基本都没有类型注释啊, 甚至各种python教程中几乎都没有和类型注释相关内容,怎么现在突然开始提倡了?
因为早期的python版本 (3.0之前) 这方面的规范还不完善,没有统一标准规定类型注释怎么写。如果要实现类型注释以及检测这类的功能只能依赖第三方组件,例如mypy
。 社区也意识到这个问题,同时受到第三方linter启发,逐渐形成了当前的注释规范。
接下来简单盘点下python 类型注释的发展历程
pep-3107 提出注释语法 (2006)
在2006年提出的 (PEP-3107)[https://peps.python.org/pep-3107/] 中介绍了函数注释的写法
def foo(a: expression, b: expression) -> expression:
...
此时并未提及有关类型提示的相关的内容,只是规定了注释的定义。
所以早期的python 代码中,基本上是没有写类型注释的习惯。 因为python 本身就是动态语言,不需要提前知道传入值的类型。规范一点的项目可能会在定义方法的时候,增加文档说明。
如果要获取函数注释,可以通过__annotations__
获取。例如:
def hello(name: "your first name" = "jack") -> None:
print("hello", name)
hello.__annotations__
# {'name': 'your first name', 'return': None}
值得一提的是lambda
匿名函数不支持注释。
pep-484 提出类型提示 (2014年9月, python3.5)
受python 类型检测linter mypy
的启发, 2014年9月 pep-484 在python3.5
版本中引入了typing
包,里面定义了几乎所有的python class例如 None
, Any
, Union
, Callable
等。同时也允许用户自定义泛型
def send_http_request(url: str, timeout: int) -> bool:
...
pep-0562 提出变量类型提示 (2016年8月, python3.6)
由于pep-484
中只定义了针对方法参数类型提示的规范,如果想为变量声明类型又该怎么做呢?
2016年8月 pep-562 中定义了这一规范。
例如我想声明一个数字列表:
some_list: List[int] = []
pep-0563 延迟注释评估 (2017/09 python3.7)
延迟注释评估,简单来说就是类型提示包含的类还未被声明,如果直接使用会产生错误。例如:
class A:
@classmethod
def init(cls) -> A:
return A()
a = A() # NameError: name 'A' is not defined
其实在 pep-484 Forward references] 中提到了一种叫做正向引用
的方案, 即利用引号将类型定义为一个字符串,这会使得它在类型声明完成后再解析。所以上面示例代码可以这样改动:
class A:
@classmethod
def init(cls) -> 'A':
return A()
a = A() # it's ok!
在pep-0563
中,上面代码也可以这样写:
from __future__ import annotations
class A:
@classmethod
def init(cls) -> A:
return A()
通过引入from __future__ import annotations
来实现延迟。一旦解释器解析脚本语法树后,它会识别类型提示并绕过评估它,将其保留为原始字符串。这种机制使得类型提示发生在需要它们的地方:由linter来进行类型检查。 在Python 4中,这种机制将成为默认行为。
既然在pep-484
中可以通过加引号的方式实现延迟效果,那为什么还需要再出一个方案呢?
- 其一为上面提到的延迟评估 (用引号不就变字符串了吗,有点反直觉。大部分人还是更倾向直接用类名,虽然背后原理还是保存为字符串…)
- 其二为优化
type hints
性能
关于性能问题,在(pep-0560)[https://peps.python.org/pep-0560/#performance] 有提及。主要是因为下标的泛型类型导致的 例如:
from typing import TypeVar, Generic
T = TypeVar('T')
class MyGenericClass(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
因为泛型类创建时,会调用GenericMeta.__new__
这会导致性能下降。
除此之外,常规情况下注释在变量或者方法声明的时候会进行解析和执行,这个操作也是有代价的。
所以pep-0563 规定了在函数和变量类型注释不需要再定义的时候进行评估了,取而代之的是会在__annotations__
变量中增加一个描述类型的字符串。下面示例展示常规和延迟评估的区别:
- 创建名为
anno.py
的文件,包含一个hello()
方法:
# 常规类型注释
def hello(name: print("Now!")) -> None:
print("hello", name)
- 常规导入
>>> import anno
Now!
>>> anno.hello.__annotations__
{'name': None}
>>> anno.hello("Jack")
hello Jack
可以看到在导入anno
时,打印了Now!
。 这表明类型注释内容被解析并执行
- 使用pep-0563延迟类型注释评估
from __future__ import annotations
def hello(name: print("Now!")) -> None:
print("hello", name)
测试:
>>> import anno
>>> anno.hello.__annotations__
{'name': 'print("Now!")'}
>>> anno.hello("Jack")
hello Jack
可以发现没有打印Now!
, 这是因为在__annotations__
里面是已字符串的形式保存了注释。如果需要获取真正的注释内容,可以通过typing.get_type_hints()
或者 eval()
>>> import typing
>>> typing.get_type_hints(anno.hello)
Now!
{'name': <class 'NoneType'>}
>>> eval(anno.hello.__annotations__["name"])
Now!
pep-0649 使用描述符对注释进行延迟评估(2021/1 python3.14)
在未发布的python3.14
版本中计划废弃pep-0563
提案。
即下面这种写法在3.14
以及之后的版本中将不复存在。
from __future__ import annotations
转而采用一种更优的延迟评估方案,这就是pep-0649
。 先留个坑,毕竟3.14还未普及。之后有机会再补上
其它补充
到这里python的类型注释就差不多接近完全体了,当然后续还有一些补充规范的pep
。可以参考typing topic
一些使用建议
避免type hints导致的循环引用
type hints 一大缺陷就是会另项目导入一堆依赖的类型,即使它们根本不在运行的时候使用,仅仅用作类型提示。某些情况下可能会导致循环引用的问题
from b import Point
def pointer_checker(p: Point) -> bool:
return isinstance(p.x, int) and isinstance(p.y, int)
from a import pointer_checer
class Point:
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
if not pointer_checker(self):
raise ValueError("x, y should be integer!")
由于a.py
, b.py
二者互相导入。因此会引起循环导入问题, 可以通过修改a.py
解决:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from b import Point
def pointer_checker(p: Point) -> bool:
return isinstance(p.x, int) and isinstance(p.y, int)
测试:
>>> from b import pointer_checker, Point
>>> pointer_checker.__annotations__
{'p': 'Point', 'return': 'bool'}
>>> p = Point(1, 2)
>>> p = Point('x', 'y')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Users/walkerliu/projects/py_practise/b.py", line 18, in __init__
raise ValueError("x,y should be integer!")
ValueError: x,y should be integer!
>>>
容易被忽略的Callable
适用于接受参数为函数类型或者实现了__call__
方法的类
常用情况有装饰器,内置函数filter等
def decorator(f: Callable) -> Callable:
def wrapper(*args, **kwargs):
return f(*args, **kwargs)
return wrapper
当然也能对回调函数加入类型提示:
# syntax is Callable[[Arg1Type, Arg2Type], ReturnType]
Callable[[int], int]
Union 的用途
Union 即联合类型,语法为Union[X, Y]
。意味着类型需要为X或者Y
def double(x: Union[int, float]):
print("double x", x*2)
在python3.10
版本之后Union[X,Y]
可以被简写为 X | Y
为什么要避免使用Any
Any
是一种特殊类型,有点类似golang中的空接口。它可以兼容任何类型。当未指定参数类型或者返回值类型是,都会隐式的使用Any
如果你不确定参数或者返回值类型的时候,可以使用Any
。除了少数特殊场景,否则应该避免使用Any
为什么要使用泛型
泛型主要是针对那些在定义时尚不清楚具体的传入类型,只有在使用时才能确定类型的情况。泛型可以通过typing.TypeVar
的工厂方法来创建
from typing import Sequence, TypeVar
T = TypeVar('T')
def first(l: Sequence[T]) -> T:
return l[0]
如上述例子,l
可能为类型的序列。利用泛型T
表示,其中T
类型由所传入的序列类型决定。
值得注意的是T
必须全程保持一致,如果传入的是[1,2,3]
此时T
取值为int
,意味着返回值也必须为int
, 若返回非整形类型则会出错。
如果你用的是python3.12
或者更高的版本,也可以使用下面的简化版写法:
from typing import Sequence
def first[T](l: Sequence[T]) -> T:
return l[0]
None vs NoReturn
typing
里面提供了一个NoRetrun
的类型。这个类型比较特殊,只能用于返回值类型提示。例如:
# ok
def exit() -> NoReturn:
sys.exit(1)
# not ok
def hello(name: NoReturn) -> None:
print("Hello", name)
需要注意的是NoReturn
意味的函数没有返回值,这和返回值为空是有本质区别的。
因此NoReturn
常用与抛出异常,或者sys.exit
等不需要返回值的场景。
# ok
def panic() -> NoReturn:
raise RuntimeError("panic!")
# not ok
# 所声明的返回类型为 "NoReturn" 的函数无法返回 "None"
def panic2() -> NoReturn:
print("panic!")
panic2()
虽然没有显示声明return None
, 其实二者效果都是一样。此时编辑器会提示错误。
当然,如果你直接调用的话还是能正常运行,就像上面所说的,类型提示并不会对程序运行产生影响,编译器会将它们当成注释一般忽略。即使你使用了错误的类型提示
如何在类中返回当前类型
大概有4种写法
-
使用引号 (pep-484 python3.5)
class A: @classmethod def init(self) -> 'A': return A()
-
使用
{python}from __future__ import annotations
(pep-562 python3.6)from __future__ import annotations class A: @classmethod def init(self) -> A: return A()
-
使用
TypeVar
(pep-484 python3.5)from typing import TypeVar TypeA = TypeVar("TypeA", bound="A") class A: @classmethod def init(self) -> TypeA: return A()
-
使用
Self
(pep-673 python3.11)from typing import Self class A: @classmethod def init(cls) -> Self: return A()
参考资料
- 全面理解Python中的类型提示(Type Hints)
- python3.7 typing enhancements
- pep typing topic
- 泛型
- pep 3107 - Function Annotations
- pep 484 - Type Hints
- pep 526 - Syntax forVariable Annotations
- pep 560 - Core support for typing module and generic typing
- pep 563 - Postponed Evaluation of Annotations
- pep 604 - Allow writing union types as X|Y
- pep 673 - Self Type