IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    dongweiming发表于 2022-10-23 10:18:03
    love 0

    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(1, 2)
    

    在这个例子中,参数可以是数字或者字符串。但是可以明显的发现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(1, 2)
    

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

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

    def max_3(items: list[T]) -> T:
        return max(*items)
    
    
    max_3([1, 2, 3])  # OK
    max_3([1, 2, '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](1, 2)  #  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(1, 2, '3', {'d': 4})
    d = Item2(1, 2, 3, 4)
    d = Item2(1, {}, set(), [])
    d = Item2('1', {}, set(), [])
    d = Item2('1', {})
    

    在这里例子中,Dict 的属性key和values都是泛型,也就说,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.next 和 self.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,但事实上也不行,因为所谓的字面值的意思是要直接明确的给我这个值,而不是通过计算得来的,对于这个地方,它只判断‘字面’上4和2+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(1, 2)  # 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 会抛错:

    ``` python pep655.py:12: error: Unsupported operand types for / ("str" and "int") pep655.py:12: error: Unsupported operand types for / ("List[str]"...

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



沪ICP备19023445号-2号
友情链接