聊聊 python 类型注释
离谱的项目体验
不吐不快,真的被公司的 xx 项目折磨太久了。包括但不限于以下方面:
- 类或者方法缺少说明
- 参数类型注释缺失
- 结构划分不合理,常常出现循环引用
- 随意的变量名
- 混乱的逻辑和重复出现的代码
- 糟糕的性能(例如批量任务串行, 数据库表设计不合理导致的多表连接以及字段冗余等)
最令人痛苦的莫过于第二点参数类型注释的缺失。举个例子:
@api.route('/server/create',methods=["post"])defserver_create():body=request.json provider=body.get("provider")ifprovider=="aliyun":aliyun_ecs.create_server(**body)elifprovider=="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 中介绍了函数注释的写法
deffoo(a:expression,b:expression)->expression:...此时并未提及有关类型提示的相关的内容,只是规定了注释的定义。
所以早期的python 代码中,基本上是没有写类型注释的习惯。 因为python 本身就是动态语言,不需要提前知道传入值的类型。规范一点的项目可能会在定义方法的时候,增加文档说明。
如果要获取函数注释,可以通过__annotations__获取。例如:
defhello(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 类型检测lintermypy的启发, 2014年9月 pep-484 在python3.5版本中引入了typing包,里面定义了几乎所有的python class例如None,Any,Union,Callable等。同时也允许用户自定义泛型
defsend_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)
延迟注释评估,简单来说就是类型提示包含的类还未被声明,如果直接使用会产生错误。例如:
classA:@classmethoddefinit(cls)->A:returnA()a=A()# NameError: name 'A' is not defined其实在 pep-484 Forward references 中提到了一种叫做正向引用的方案, 即利用引号将类型定义为一个字符串,这会使得它在类型声明完成后再解析。所以上面示例代码可以这样改动:
classA:@classmethoddefinit(cls)->'A':returnA()a=A()# it's ok!在pep-0563中,上面代码也可以这样写:
from__future__importannotationsclassA:@classmethoddefinit(cls)->A:returnA()通过引入from __future__ import annotations来实现延迟。一旦解释器解析脚本语法树后,它会识别类型提示并绕过评估它,将其保留为原始字符串。这种机制使得类型提示发生在需要它们的地方:由linter来进行类型检查。 在Python 4中,这种机制将成为默认行为。
既然在pep-484中可以通过加引号的方式实现延迟效果,那为什么还需要再出一个方案呢?
- 其一为上面提到的延迟评估 (用引号不就变字符串了吗,有点反直觉。大部分人还是更倾向直接用类名,虽然背后原理还是保存为字符串…)
- 其二为优化
type hints性能
关于性能问题,在pep-0560 有提及。主要是因为下标的泛型类型导致的 例如:
fromtypingimportTypeVar,Generic T=TypeVar('T')classMyGenericClass(Generic[T]):def__init__(self,value:T)->None:self.value=value因为泛型类创建时,会调用GenericMeta.__new__这会导致性能下降。
除此之外,常规情况下注释在变量或者方法声明的时候会进行解析和执行,这个操作也是有代价的。
所以pep-0563 规定了在函数和变量类型注释不需要再定义的时候进行评估了,取而代之的是会在__annotations__变量中增加一个描述类型的字符串。下面示例展示常规和延迟评估的区别:
- 创建名为
anno.py的文件,包含一个hello()方法:
# 常规类型注释defhello(name:print("Now!"))->None:print("hello",name)- 常规导入
>>>importanno Now!>>>anno.hello.__annotations__{'name':None}>>>anno.hello("Jack")hello Jack可以看到在导入anno时,打印了Now!。 这表明类型注释内容被解析并执行
- 使用
pep-0563延迟类型注释评估
from__future__importannotationsdefhello(name:print("Now!"))->None:print("hello",name)测试:
>>>importanno>>>anno.hello.__annotations__{'name':'print("Now!")'}>>>anno.hello("Jack")hello Jack可以发现没有打印Now!, 这是因为在__annotations__里面是已字符串的形式保存了注释。如果需要获取真正的注释内容,可以通过typing.get_type_hints()或者eval()
>>>importtyping>>>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__importannotations转而采用一种更优的延迟评估方案,这就是pep-0649。 先留个坑,毕竟3.14还未普及。之后有机会再补上
- python3.14 what’s new
- pep-0649
其它补充
到这里python的类型注释就差不多接近完全体了,当然后续还有一些补充规范的pep。可以参考typing topic
一些使用建议
避免type hints导致的循环引用
type hints 一大缺陷就是会另项目导入一堆依赖的类型,即使它们根本不在运行的时候使用,仅仅用作类型提示。某些情况下可能会导致循环引用的问题
frombimportPointdefpointer_checker(p:Point)->bool:returnisinstance(p.x,int)andisinstance(p.y,int)fromaimportpointer_checerclassPoint:def__init__(self,x:int,y:int)->None:self.x=x self.y=yifnotpointer_checker(self):raiseValueError("x, y should be integer!")由于a.py,b.py二者互相导入。因此会引起循环导入问题, 可以通过修改a.py解决:
from__future__importannotationsfromtypingimportTYPE_CHECKINGifTYPE_CHECKING:frombimportPointdefpointer_checker(p:Point)->bool:returnisinstance(p.x,int)andisinstance(p.y,int)测试:
>>>frombimportpointer_checker,Point>>>pointer_checker.__annotations__{'p':'Point','return':'bool'}>>>p=Point(1,2)>>>p=Point('x','y')Traceback(most recent call last):File"<stdin>",line1,in<module>File"/Users/walkerliu/projects/py_practise/b.py",line18,in__init__raiseValueError("x,y should be integer!")ValueError:x,y should be integer!>>>容易被忽略的Callable
适用于接受参数为函数类型或者实现了__call__方法的类
常用情况有装饰器,内置函数filter等
defdecorator(f:Callable)->Callable:defwrapper(*args,**kwargs):returnf(*args,**kwargs)returnwrapper当然也能对回调函数加入类型提示:
# syntax is Callable[[Arg1Type, Arg2Type], ReturnType]Callable[[int],int]Union 的用途
Union 即联合类型,语法为Union[X, Y]。意味着类型需要为X或者Y
defdouble(x:Union[int,float]):print("double x",x*2)在python3.10版本之后Union[X,Y]可以被简写为X | Y
为什么要避免使用Any
Any是一种特殊类型,有点类似golang中的空接口。它可以兼容任何类型。当未指定参数类型或者返回值类型是,都会隐式的使用Any
如果你不确定参数或者返回值类型的时候,可以使用Any。除了少数特殊场景,否则应该避免使用Any
为什么要使用泛型
泛型主要是针对那些在定义时尚不清楚具体的传入类型,只有在使用时才能确定类型的情况。泛型可以通过typing.TypeVar的工厂方法来创建
fromtypingimportSequence,TypeVar T=TypeVar('T')deffirst(l:Sequence[T])->T:returnl[0]如上述例子,l可能为类型的序列。利用泛型T表示,其中T类型由所传入的序列类型决定。
值得注意的是T必须全程保持一致,如果传入的是[1,2,3]此时T取值为int,意味着返回值也必须为int, 若返回非整形类型则会出错。
如果你用的是python3.12或者更高的版本,也可以使用下面的简化版写法:
fromtypingimportSequencedeffirst[T](l:Sequence[T])->T:returnl[0]None vs NoReturn
typing里面提供了一个NoRetrun的类型。这个类型比较特殊,只能用于返回值类型提示。例如:
# okdefexit()->NoReturn:sys.exit(1)# not okdefhello(name:NoReturn)->None:print("Hello",name)需要注意的是NoReturn意味的函数没有返回值,这和返回值为空是有本质区别的。
因此NoReturn常用与抛出异常,或者sys.exit等不需要返回值的场景。
# okdefpanic()->NoReturn:raiseRuntimeError("panic!")# not ok# 所声明的返回类型为 "NoReturn" 的函数无法返回 "None"defpanic2()->NoReturn:print("panic!")panic2()虽然没有显示声明return None, 其实二者效果都是一样。此时编辑器会提示错误。
当然,如果你直接调用的话还是能正常运行,就像上面所说的,类型提示并不会对程序运行产生影响,编译器会将它们当成注释一般忽略。即使你使用了错误的类型提示
如何在类中返回当前类型
大概有4种写法
使用引号 (pep-484 python3.5)
classA:@classmethoddefinit(self)->'A':returnA()使用
{python}from __future__ import annotations(pep-562 python3.6)from__future__importannotationsclassA:@classmethoddefinit(self)->A:returnA()使用
TypeVar(pep-484 python3.5)fromtypingimportTypeVar TypeA=TypeVar("TypeA",bound="A")classA:@classmethoddefinit(self)->TypeA:returnA()使用
Self(pep-673 python3.11)fromtypingimportSelfclassA:@classmethoddefinit(cls)->Self:returnA()
参考资料
- 全面理解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