Python 3.11新加入的和类型系统相关的新特性

admin 2022年10月24日08:59:15评论75 views字数 10683阅读35分36秒阅读模式

PEP 646 – Variadic Generics

介绍这个PEP之前需要补一些知识,我们逐步深入先了解一下泛型(Generics)。

 

泛型是指在定义函数或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

对于Python这种动态语言,因为一切都是对象引用,可以在使用时直接判断类型:

In : def say_type(obj):
...:     match obj:
...:         case int():
...:             print('int')
...:         case str():
...:             print('str')
...:         case float():
...:             print('float')
...:         case _:
...:             print('other type')
...:

In : say_type(1)
int

In : say_type('ss')
str

In : say_type(1.1)
float

In : say_type([])
other type

但是在类型检查时,就会涉及到泛型问题,可以在运行前就发现问题。举个例子:

U = int | str


def max_1(a: U, b: U) -> U:
    return max(a, b)


max_1("foo"1)
max_1(1"foo")
max_1("foo""bar")
max_1(12)

在这个例子中,参数可以是数字或者字符串。但是可以明显的发现max_1("foo", 1)max_1(1, "foo")运行时会抛错,因为类型不同。但是mypy却没有发现。

在Python的类型系统中,泛型类型变量应该使用TypeVar,就可以暴露问题了:

from typing import TypeVar

T = TypeVar("T", int, str)  # 定义类型变量


def max_2(a: T, b: T) -> T:  # 泛型函数
    return max(a, b)


max_2("foo"1)
max_2(1"foo")
max_2("foo""bar")
max_2(12)

前面的2个例子中的T或者U都是类型变量,我的理解也是类型别名(Type aliases),可以被重复利用(还更容易表达复杂的结构),因为参数和返回值的类型相同就直接用它们【替代】了。TypeVar可以通过bound参数绑定某个类型,还可以按照我上面写的,让它支持int和str。我还是建议你稍微去官网看一下TypeVar的用法,因为它确实在泛型中很重要。

而这种变量还可以作为容器中的元素,举个例子:

def max_3(items: list[T]) -> T:
    return max(*items)


max_3([123])  # OK
max_3([12'3'])  # Rejected

在常见情况下,通过Union的方式组合对应的类型就可以让mypy理解程序中参数和返回值的类型有多个,如上面的例子items是一个元素为字符串或者数字的列表。Python内置的集合类型(collections.abc.Collection)可以支持各种类型元素,就是因为它们都是泛型类。而现实世界上,我们在开发中必然定义各种类,有时候我们也需要让自定义类支持泛型。

想想之前的泛型定义【不预先指定具体的类型,而在使用的时候再指定类型】。我们使用typing.Generic定义一个类:

from typing import Generic


K = TypeVar("K", int, str)
V = TypeVar("V")


class Item(Generic[K, V]):  # Item是一个泛型类,可以确定其中的2个类型
    key: K
    value: V
    def __init__(self, k: K, v: V):
        self.key = k
        self.value = v


i = Item(1'a')  # OK Item是泛型类,所以符合要求的类型值都可以作为参数
i2 = Item[int, str](1'a')  #  OK 明确的指定了Item的K, V的类型
i3 = Item[int, int](12)  #  OK 明确的指定成了另外的类型
i4 = Item[int, int](1'a')  # Rejected 因为传入的参数和指定的类型V不同

好,有了上面的铺垫,进入正题。

如PEP的标题,说的是【可变数量的泛型函数】。之前介绍的TypeVar是单个泛型,而这次引入了数量不确定的泛型类型TypeVarTuple

我们看一个例子就能理解了:

from typing import TypeVarTuple


K2 = TypeVar("K2", int, str)
V2 = TypeVarTuple("V2")


class Item2(Generic[K2, *V2]):
    def __init__(self, k: K2, *v: *V2):
        self.key = k
        self.values = v


d = Item2(12'3', {'d'4})
d = Item2(1234)
d = Item2(1, {}, set(), [])
d = Item2('1', {}, set(), [])
d = Item2('1', {})

在这里例子中,Dict的属性keyvalues都是泛型,也就说,key也可以是int也可以是str,而values是非固定长度的,由于使用TypeVarTuple时没有指定类型,所以各种类型都可以用。而因为引入了TypeVarTuple,可以让类型检查的灵活度提升了很多。

PS: 目前mypy还没有支持这个新特性,现在运行mypy会报错:

"TypeVarTuple" is not supported by mypy yet

PEP 673 – Self Type

Self顾名思义就是申明自己,举2个例子看一下过去常见的用法:

class Result:
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return f'{self.__class__.__name__}(value={self.value})'

    def add_value(self, value: int) -> 'Result':
        self.value += value
        return self

    @classmethod
    def get(cls, value) -> 'Result':
        return cls(value)


class NewResult(Result):
    ...


r = NewResult(10)
print(r.add_value(5))
print(NewResult.get(20))


class Node:
    def __init__(self, data):
        self.data = data
        self.next: 'Node'|None = None
        self.previous: 'Node'|None = None


node = Node(10)
node.next = Node(20)
node.previous = Node(5)

在这个例子中有三个地方的返回值通过类型字符串来表示引用自身:

  1. 声明实例方法add_value返回一个Result实例
  2. 声明类方法get返回一个Result实例
  3. Node初始化时声明self.nextself.previous都可以是None或者Node实例

在这里是不能直接写类名的,因为声明时类还没有创建好,mypy可以理解,但是运行时会报NameError: name 'XXX' is not defined这样的错误。

除了使用字符串定义以外,还有三种方法,我这里简单提一下就不挨个细说了(因为Python 3.11更完美的解决这个问题):

  1. Python 3.8以上使用ForwardRef
  2. 导入from __future__ import annotations(Python 3.10开始默认开启)
  3. 使用TResult = TypeVar("TResult", bound="Result")这样的方式绑定到一个TypeVar上。

但是这些方法都有一个很机械的问题,就是对于继承后的类的支持和表示。例如上面的字符串定义,NewResult继承了Result的同时也继承了方法注解,也就是说类似于NewResult.get方法的返回值,其实是一个Result的实例。当然本质上按照isinstance的逻辑看确实也是没问题的,但是并没有真实的表达self。而这个PEP 673提供了Self,这样是最美好的方案了:

from typing import Self

class Result:
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return f'{self.__class__.__name__}(value={self.value})'

    def add_value(self, value: int) -> Self:
        self.value += value
        return self

    @classmethod
    def get(cls, value) -> Self:
        return cls(value)


class NewResult(Result):
    ...


class Node:
    def __init__(self, data):
        self.data = data
        self.next: Self|None = None
        self.previous: Self|None = None

PS: 目前mypy还没有支持这个新特性,现在运行mypy会报错:

error: Variable "typing.Self" is not valid as a type
note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases

PEP 675 – Arbitrary Literal String Type

在说这个LiteralString之前,先说一下在Python 3.8引入的Literal,会让你更容易理解。Literal的意思是字面值,常见的字符串、数字、列表、字典、布尔值等都可以作为字面值。typing.Literal的意思是可以接受对应列出来的字面值:

from typing import Literal

def accepts_only_four(x: Literal[4]) -> None:
    pass

accepts_only_four(4)   # OK
accepts_only_four(19)  # Rejected
accepts_only_four(2 + 2)  # Rejected

Literal[4]表示只接受参数的值为4,所以第二个传入19不行,第三个2 + 2的结果也是4,但事实上也不行,因为所谓的字面值的意思是要直接明确的给我这个值,而不是通过计算得来的,对于这个地方,它只判断‘字面’上42+2不一样就Rejected了。这段很重要,多理解理解。

回到正题,这个PEP的动机来源于提供一个更直观且通用的方案解决SQL注入问题。先看PEP里面提供的例子:

def query_user(conn: Connection, user_id: str) -> User:
    query = f"SELECT * FROM data WHERE user_id = {user_id}"
    conn.execute(query)

query_user(conn, "user123")  # OK.

# Delete the table.
query_user(conn, "user123; DROP TABLE data;")

# Fetch all users (since 1 = 1 is always true).
query_user(conn, "user123 OR 1 = 1")

在正常情况下,user_id是一个符合要求的字符串,但是由于user_id可能是外部参数获取的,来源是不可靠的,就可能出现拼装SQL语句实现一些额外的目的的安全风险。举个例子:

query_user(conn, input())

在这里我用input函数表示外部渠道传入这个user_id。如果按照过去的str声明看不出来问题,但是使用新增的LiteralString就会让语句不通过:

from typing import LiteralString

def query_user(conn: Connection, user_id: LiteralString) -> User:
    query = f"SELECT * FROM data WHERE user_id = {user_id}"
    conn.execute(query)

query_user(conn, input())  # Rejected

因为user_id并不是直接传入了字符串,而是通过input计算而来。LiteralString就如PEP的标题,可以表示任意的字符串字面值,不想前面的typing.Literal,只能规定几个对应的确定的值,灵活性太差。

另外如果字符串是拼接的,需要所有部分都是字面值:

def execute_sql(query: LiteralString):
    execute_sql("SELECT * FROM " + user_input)


user_input = input()
execute_sql("SELECT * FROM " + user_input)  # Rejected
execute_sql(f"SELECT * FROM {user_input}")  # Rejected

上述2个例子也会被Rejected,因为后半部分的user_input不是一个字面值。

这个特性我觉得主要针对于f-string,毕竟input这种用法很少见

PS: 目前mypy还没有支持这个新特性,所以有问题的地方还不会抛错。

PEP 681 – Data Class Transforms

目前类型检查对于标准库内各个包的支持都是很好的,包含dataclasses,二这个PEP实现了一种把普通类的一些和标准库dataclasses相似的行为的类型检查自动转换的方案。这些行为包含:

  1. 根据声明的数据字段合成的__init__方法。
  2. 可选的合成__eq____ne____lt____le____gt____ge__方法。
  3. 支持frozen参数,静态类型检查时会确认字段的不可变。
  4. 支持【字段说明符】,静态类型检查时会了解的各个字段的属性,例如是否为该字段提供了默认值。

本小节相关的dataclasses和attrs可以看之前我的博客文章: attrs 和 Python3.7 的 dataclasses,这里就不具体展开了。

在没有这个PEP的实现前,当你在项目中使用了相关的库,如attrs, pydantic, 各种ORM(如SQLAlchemy、Django等),那么在静态类型检查时这些库就需要提供对应的类型注解,否则就得自己写一遍或者想办法忽略相关的检查。而这个PEP就是为了降低这种成本,通过dataclass_transform可以方便的在装饰器、类、元类三个级别不需要额外写注释就支持对应的类型检查。

我个人觉得这个PEP主要旨在帮助库的作者,除非在项目中自己造了有类似dataclasses库那些行为的轮子,所以对于开发者开始影响较小。

举个例子,可能更好能理解。我个人是比较喜欢用attrs的,在我的项目中会这么定义Model(为了举例做了极大的简化):

import attr


@attr.define()
class Model:
    id: int
    title: str



Model(12)  # Rejected

我没有定义__init__方法,但是当我使用attrs后,它会帮助我自动创建一系列对应的方法。用的时候Model(1, 2)是应该不通过类型检查的(因为title应该是字符串,我传入了int)

接着先安装未支持这个特性的attrs版本,运行pyright(另外一个静态检查工具,mypy现在还没有支持这个PEP)试试:

➜ pip install attrs==20.3.0
➜ pip install pyright
➜ pyright pep681.py
...
pyright 1.1.276
/home/ubuntu/mp/2022-10-23/pep681.py
  /home/ubuntu/mp/2022-10-23/pep681.py:11:1 - error: Expected no arguments to "Model" constructor (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations

pyright特别傻,它认为没有在这个类中定义构造方法__init__。这个时候attrs还没有支持对应的类型注解。有兴趣可以看对应的PR: Implement pyright support via dataclass_transforms

在之后,pyright会理解attrs里面的上述用法:

➜ pip install attrs==22.1.0
➜ pyright pep681.py
...
pyright 1.1.276
/home/ubuntu/mp/2022-10-23/pep681.py
  /home/ubuntu/mp/2022-10-23/pep681.py:11:10 - error: Argument of type "Literal[2]" cannot be assigned to parameter "title" of type "str" in function "__init__"
    "Literal[2]" is incompatible with "str" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations

可以看到上面这个错误就正确了。

PEP 655 – Marking individual TypedDict items as required or potentially-missing

TypedDict是Python 3.8时加入的一个非常有用的类型,我们先把它说清楚。在日常开发中经常会定义一个复杂的字典类型,如果你希望mypy对这个字典键值类型做验证,大概需要这样:

def get_summary() -> dict[str, int|str|list[str]]:
    return {
        'total'100,
        'title''test',
        'items': ['1''2']
    }

上面的例子中我会尽量的具体出值的类型,但是由于这个字典的值的类型太多,只能使用Union的方法串起来。但是它在mypy哪里却不够明确。比如执行这么一段逻辑:

summary = get_summary()
total = summary['total']
items = summary['items']
print(total / len(items))

mypy会抛错:

pep655.py:12: error: Unsupported operand types for / ("str" and "int")
pep655.py:12: error: Unsupported operand types for / ("List[str]" and "int")
pep655.py:12: note: Left operand is of type "Union[int, str, List[str]]"
pep655.py:12: error: Argument 1 to "len" has incompatible type "Union[int, str, List[str]]"; expected "Sized"

所以很多时候对于这个返回值就无法具体的确认类型了。而TypedDict就是解决这个问题的:

from typing import TypedDict


class Summary(TypedDict):
    total: int
    title: str
    items: list[str]



def get_summary() -> Summary:
    return {
        'total'100,
        'title''test',
        'items': ['1''2']
    }


summary = get_summary()
total = summary['total']
items = summary['items']
print(total / len(items))

TypedDict通过一个类似于dataclass的形式明确个各个键值的类型。可以帮助mypy更理解这个返回的值的结构,从而在逻辑中判断出更多类型问题:

x = summary['x']  # TypedDict "Summary" has no key "x"
summary['total'] = 'total'  # Value of "total" has incompatible type "str"; expected "int"

但是在Python 3.11前,它在实现上对于定义的键值的要求很极端,要不然需要全部存在,要不然不关心缺少哪一个键:

class Summary(TypedDict):
    total: int
    title: str
    items: list[str]

s: Summary = {'total'10}  # Missing keys ("title", "items") for TypedDict "Summary"


class Summary2(TypedDict, total=False):  # 使用total=False会让类型检查不关注是否缺少键
    total: int
    title: str
    items: list[str]


s2: Summary2 = {'total'10}  # OK
s3: Summary2 = {}  # OK

过去为了分别对于不同的键是否是一个Optional,只能用继承的方式:

class Summary3(TypedDict):
    total: int
    title: str


class Summary4(Summary3, total=False):
    items: list[str]


s4: Summary4 = {}  # Missing keys ("total", "title") for TypedDict "Summary4"
s5: Summary4 = {'total'10'title''Title'}  # OK,缺少了items也没关系

而PEP 655通过引入Required[]NotRequired[]可以让TypedDict里面的键的定义明确是否强依赖:

from typing import Required, NotRequired


class Summary5(TypedDict):
    total: Required[int]  # 明确total是必选的
    title: str  # 默认就是Required
    items: NotRequired[list[str]]  # 明确items是可选的


s6: Summary5 = {}  # Missing keys ("total", "title") for TypedDict "Summary5"
s7: Summary4 = {'total'10'title''Title'# OK,实现和上面一样的效果

后记

  1. 除了PEP 655以外,目前mypy都还没有支持,具体的可以看延伸阅读连接6的进度。 这... 💊。
  2. 准备写一篇文章完整的介绍Python类型系统的前世今生

代码目录

本文代码可以在 mp 项目找到

延伸阅读

  1. https://peps.python.org/pep-0646/
  2. https://peps.python.org/pep-0673/
  3. https://github.com/python/mypy/issues/12840
  4. https://mypy.readthedocs.io/en/stable/more_types.html#typeddict


原文始发于微信公众号(Python之美):Python 3.11新加入的和类型系统相关的新特性

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年10月24日08:59:15
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Python 3.11新加入的和类型系统相关的新特性http://cn-sec.com/archives/1367072.html

发表评论

匿名网友 填写信息