聊聊 python 参数类型注释

离谱的项目体验

我承认是为了吐槽公司项目,才有了这篇文章

不吐不快,真的被公司的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 个地方需要改进

  1. 确定 body 的字段,封装成class
  2. 对传入的 body 校验
  3. 利用工厂模式或者适配器统一不同云商调用

body 是一个字典类型,至于里面的字段就不得而知了。而且不同云商body还不一样,重构的时候真是让人头大。虽然 python作为动态类型编程语言,运行时不需要指定变量类型。但是那样也会对代码的可维护性带来挑战。下面聊聊为什么要写参数类型注释

为什么需要类型注释

就像上面描述的情况,良好的类型注释可以使代码更容易阅读维护和维护。除此之外还能带来以下几点便利:

编辑器提示

在这里插入图片描述

例如上面的hello 函数,在声明namestr类型后,编辑器可以自动列出/补全变量或者方法。
在这里插入图片描述

但是在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-484python3.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中可以通过加引号的方式实现延迟效果,那为什么还需要再出一个方案呢?

  1. 其一为上面提到的延迟评估 (用引号不就变字符串了吗,有点反直觉。大部分人还是更倾向直接用类名,虽然背后原理还是保存为字符串…)
  2. 其二为优化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__ 变量中增加一个描述类型的字符串。下面示例展示常规和延迟评估的区别:

  1. 创建名为anno.py 的文件,包含一个hello()方法:
# 常规类型注释
def hello(name: print("Now!")) -> None:
	print("hello", name)
  1. 常规导入
>>> import anno
Now!

>>> anno.hello.__annotations__
{'name': None}

>>> anno.hello("Jack")
hello Jack

可以看到在导入anno时,打印了Now!。 这表明类型注释内容被解析并执行

  1. 使用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种写法

  1. 使用引号 (pep-484 python3.5)

    class A:
    	@classmethod
    	def init(self) -> 'A':
    		return A()
    
  2. 使用{python}from __future__ import annotations (pep-562 python3.6)

    from __future__ import annotations
    
    class A:
    	@classmethod
    	def init(self) -> A:
    		return A()
    
  3. 使用TypeVar (pep-484 python3.5)

    from typing import TypeVar
    
    TypeA = TypeVar("TypeA", bound="A")
    
    class A:
    	@classmethod
    	def init(self) -> TypeA:
    		return A()
    
  4. 使用Self (pep-673 python3.11)

    from typing import Self
    
    class A:
    	@classmethod
    	def init(cls) -> Self:
    		return A()
    
    

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值