Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 767 – 标注只读属性

作者:
Eneg <eneg at discuss.python.org>
发起人:
Carl Meyer <carl at oddbird.net>
讨论至:
Discourse 帖子
状态:
草案
类型:
标准跟踪
主题:
类型标注
创建日期:
2024 年 11 月 18 日
Python 版本:
3.15
发布历史:
2024 年 10 月 9 日

目录

摘要

PEP 705 引入了 typing.ReadOnly 类型限定符,允许定义只读的 typing.TypedDict 项。

本 PEP 建议在类和协议 属性注解 中使用 ReadOnly,作为一种简洁的方式来标记它们为只读。

类似于 PEP 705,它不对运行时设置属性做任何更改。只读属性的正确使用仅通过静态类型检查器强制执行。

动机

Python 类型系统缺乏一种简洁的方式来标记属性为只读。此功能存在于其他静态和渐进类型语言(如 C#TypeScript)中,对于在类型检查器级别移除重新赋值或 del 一个属性的能力,以及定义结构子类型的广泛接口都很有用。

目前,实现只读属性主要有三种方式,并被类型检查器所接受:

  • 使用 typing.Final 标注属性
    class Foo:
        number: Final[int]
    
        def __init__(self, number: int) -> None:
            self.number = number
    
    
    class Bar:
        def __init__(self, number: int) -> None:
            self.number: Final = number
    
    • dataclasses 支持(以及自 typing#1669 以来的类型检查器)。
    • 覆盖 number 是不可能的——Final 的规范规定名称不能在子类中被覆盖。
  • 通过 @property 实现只读代理
    class Foo:
        _number: int
    
        def __init__(self, number: int) -> None:
            self._number = number
    
        @property
        def number(self) -> int:
            return self._number
    
    • 覆盖 number 是可能的。类型检查器对具体规则存在分歧[1]
    • 在运行时只读。[2]
    • 需要额外的样板代码。
    • dataclasses 支持,但组合性不佳——合成的 __init____repr__ 将使用 _number 作为参数/属性名称。
  • 使用“冻结”机制,例如 dataclasses.dataclass()typing.NamedTuple
    @dataclass(frozen=True)
    class Foo:
        number: int  # implicitly read-only
    
    
    class Bar(NamedTuple):
        number: int  # implicitly read-only
    
    • @dataclass 情况下,覆盖 number 是可能的。
    • 在运行时只读。[2]
    • 没有每属性控制——这些机制适用于整个类。
    • 冻结数据类会产生一些运行时开销。
    • NamedTuple 仍然是一个 tuple。大多数类不需要继承索引、迭代或拼接。

协议

假设一个 Protocol 成员 name: T 定义了两个要求

  1. hasattr(obj, "name")
  2. isinstance(obj.name, T)

这些要求在运行时可以通过以下所有方式满足

  • 一个具有属性 name: T 的对象,
  • 一个具有类变量 name: ClassVar[T] 的类,
  • 上述类的一个实例,
  • 一个具有 @property def name(self) -> T 的对象,
  • 一个具有自定义描述符的对象,例如 functools.cached_property()

当前的 类型规范 允许使用(抽象)属性创建此类协议成员

class HasName(Protocol):
    @property
    def name(self) -> T: ...

这种语法有几个缺点

  • 它有些冗长。
  • 不明显此处所表达的品质是属性的只读特性。
  • 它不能与 类型限定符 组合使用。
  • 并非所有类型检查器都同意 [3] 上述所有五个对象都可以赋值给此结构类型。

基本原理

这些问题可以通过属性级别的类型限定符解决。ReadOnly 被选中担任此角色,因为其名称很好地传达了意图,并且新提出的更改补充了其在 PEP 705 中定义的语义。

现在可以定义一个具有只读实例属性的类,如下所示

from typing import ReadOnly


class Member:
    def __init__(self, id: int) -> None:
        self.id: ReadOnly[int] = id

……并且 协议 中描述的协议现在只是

from typing import Protocol, ReadOnly


class HasName(Protocol):
    name: ReadOnly[str]


def greet(obj: HasName, /) -> str:
    return f"Hello, {obj.name}!"
  • Member 的子类可以将 .id 重新定义为可写属性或 描述符。它还可以 收窄 类型。
  • HasName 协议具有更简洁的定义,并且与属性的可写性无关。
  • greet 函数现在可以接受各种兼容对象,同时明确指出不对输入进行修改。

规范

typing.ReadOnly 类型限定符 成为类和协议 属性 的有效注解。它可以在类级别或 __init__ 中使用,以将单个属性标记为只读

class Book:
    id: ReadOnly[int]

    def __init__(self, id: int, name: str) -> None:
        self.id = id
        self.name: ReadOnly[str] = name

类型检查器应在任何尝试重新赋值或 del 标注有 ReadOnly 的属性时报错。类型检查器还应在任何尝试删除标注为 Final 的属性时报错。(目前未指定此行为。)

ReadOnly 目前没有意义的其他位置(例如局部/全局变量或函数参数)使用 ReadOnly 注解不在本 PEP 的范围之内。

类似于 Final [4]ReadOnly 不影响类型检查器对所赋值对象可变性的看法。不可变的 抽象基类 (ABCs)容器 可以与 ReadOnly 结合使用,以在类型检查器级别禁止此类值的修改

from collections import abc
from dataclasses import dataclass
from typing import Protocol, ReadOnly


@dataclass
class Game:
    name: str


class HasGames[T: abc.Collection[Game]](Protocol):
    games: ReadOnly[T]


def add_games(shelf: HasGames[list[Game]]) -> None:
    shelf.games.append(Game("Half-Life"))  # ok: list is mutable
    shelf.games[-1].name = "Black Mesa"    # ok: "name" is not read-only
    shelf.games = []                       # error: "games" is read-only
    del shelf.games                        # error: "games" is read-only and cannot be deleted


def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None:
    shelf.games.append(...)             # error: "Sequence" has no attribute "append"
    shelf.games[0].name = "Blue Shift"  # ok: "name" is not read-only
    shelf.games = []                    # error: "games" is read-only

冻结数据类和 NamedTuple 的所有实例属性都应被默认为只读。类型检查器可能会告知用 ReadOnly 标注此类属性是冗余的,但不应视为错误

from dataclasses import dataclass
from typing import NewType, ReadOnly


@dataclass(frozen=True)
class Point:
    x: int            # implicit read-only
    y: ReadOnly[int]  # ok, redundant


uint = NewType("uint", int)


@dataclass(frozen=True)
class UnsignedPoint(Point):
    x: ReadOnly[uint]  # ok, redundant; narrower type
    y: Final[uint]     # not redundant, Final imposes extra restrictions; narrower type

初始化

只读属性的赋值只能发生在声明该属性的类中。对该属性的赋值次数没有限制。根据属性的类型,它们可以在不同的位置被赋值

实例属性

对实例属性的赋值必须允许在以下上下文中进行

  • __init__ 中,在作为第一个参数接收的实例上(通常是 self)。
  • __new__ 中,通过调用超类的 __new__ 方法创建的声明类的实例上。
  • 在类体中的声明处。

此外,类型检查器可以选择允许赋值

  • __new__ 中,在声明类的实例上,不考虑实例的来源。(此选择以声音性为代价,因为实例可能已经初始化,以换取实现简单性。)
  • @classmethod 中,通过调用类或超类的 __new__ 方法创建的声明类的实例上。
from collections import abc
from typing import ReadOnly


class Band:
    name: str
    songs: ReadOnly[list[str]]

    def __init__(self, name: str, songs: abc.Iterable[str] | None = None) -> None:
        self.name = name
        self.songs = []

        if songs is not None:
            self.songs = list(songs)  # multiple assignments are fine

    def clear(self) -> None:
        # error: assignment to read-only "songs" outside initialization
        self.songs = []


band = Band(name="Bôa", songs=["Duvet"])
band.name = "Python"           # ok: "name" is not read-only
band.songs = []                # error: "songs" is read-only
band.songs.append("Twilight")  # ok: list is mutable


class SubBand(Band):
    def __init__(self) -> None:
        self.songs = []  # error: cannot assign to a read-only attribute of a base class
# a simplified immutable Fraction class
class Fraction:
    numerator: ReadOnly[int]
    denominator: ReadOnly[int]

    def __new__(
        cls,
        numerator: str | int | float | Decimal | Rational = 0,
        denominator: int | Rational | None = None
    ) -> Self:
        self = super().__new__(cls)

        if denominator is None:
            if type(numerator) is int:
                self.numerator = numerator
                self.denominator = 1
                return self

            elif isinstance(numerator, Rational): ...

        else: ...

    @classmethod
    def from_float(cls, f: float, /) -> Self:
        self = super().__new__(cls)
        self.numerator, self.denominator = f.as_integer_ratio()
        return self

类属性

只读类属性是指同时标注为 ReadOnlyClassVar 的属性。对这类属性的赋值必须允许在以下上下文中进行

  • 在类体中的声明处。
  • __init_subclass__ 中,在作为第一个参数接收的类对象上(通常是 cls)。
class URI:
    protocol: ReadOnly[ClassVar[str]] = ""

    def __init_subclass__(cls, protocol: str = "") -> None:
        cls.protocol = protocol

class File(URI, protocol="file"): ...

当类级别的声明具有初始化值时,它可以作为实例的 享元 默认值

class Patient:
    number: ReadOnly[int] = 0

    def __init__(self, number: int | None = None) -> None:
        if number is not None:
            self.number = number

注意

此功能与 __slots__ 冲突。具有类级别值的属性不能包含在 slots 中,这实际上使其成为一个类变量。

类型检查器可以选择对在实例创建后可能未初始化的只读属性发出警告(存根、协议或 ABCs 除外)

class Patient:
    id: ReadOnly[int]    # error: "id" is not initialized on all code paths
    name: ReadOnly[str]  # error: "name" is never initialized

    def __init__(self) -> None:
        if random.random() > 0.5:
            self.id = 123


class HasName(Protocol):
    name: ReadOnly[str]  # ok

子类型

无法重新赋值只读属性使它们具有协变性。这带来了一些子类型化影响。借鉴 PEP 705

  • 只读属性可以重新声明为可写属性、描述符或类变量
    @dataclass
    class HasTitle:
        title: ReadOnly[str]
    
    
    @dataclass
    class Game(HasTitle):
        title: str
        year: int
    
    
    game = Game(title="DOOM", year=1993)
    game.year = 1994
    game.title = "DOOM II"  # ok: attribute is not read-only
    
    
    class TitleProxy(HasTitle):
        @functools.cached_property
        def title(self) -> str: ...
    
    
    class SharedTitle(HasTitle):
        title: ClassVar[str] = "Still Grey"
    
  • 如果只读属性未重新声明,则保持只读
    class Game(HasTitle):
        year: int
    
        def __init__(self, title: str, year: int) -> None:
            super().__init__(title)
            self.title = title  # error: cannot assign to a read-only attribute of base class
            self.year = year
    
    
    game = Game(title="Robot Wants Kitty", year=2010)
    game.title = "Robot Wants Puppy"  # error: "title" is read-only
    
  • 子类型可以 收窄 只读属性的类型
    class GameCollection(Protocol):
        games: ReadOnly[abc.Collection[Game]]
    
    
    @dataclass
    class GameSeries(GameCollection):
        name: str
        games: ReadOnly[list[Game]]  # ok: list[Game] is assignable to Collection[Game]
    
  • 协议和 ABCs 的名义子类应重新声明只读属性以实现它们,除非基类以某种方式初始化它们
    class MyBase(abc.ABC):
        foo: ReadOnly[int]
        bar: ReadOnly[str] = "abc"
        baz: ReadOnly[float]
    
        def __init__(self, baz: float) -> None:
            self.baz = baz
    
        @abstractmethod
        def pprint(self) -> None: ...
    
    
    @final
    class MySubclass(MyBase):
        # error: MySubclass does not override "foo"
    
        def pprint(self) -> None:
            print(self.foo, self.bar, self.baz)
    
  • 在协议属性声明中,name: ReadOnly[T] 表示结构子类型必须支持 .name 访问,并且返回的值可以赋值给 T
    class HasName(Protocol):
        name: ReadOnly[str]
    
    
    class NamedAttr:
        name: str
    
    class NamedProp:
        @property
        def name(self) -> str: ...
    
    class NamedClassVar:
        name: ClassVar[str]
    
    class NamedDescriptor:
        @cached_property
        def name(self) -> str: ...
    
    # all of the following are ok
    has_name: HasName
    has_name = NamedAttr()
    has_name = NamedProp()
    has_name = NamedClassVar
    has_name = NamedClassVar()
    has_name = NamedDescriptor()
    

与其他类型限定符的交互

ReadOnly 可以与 ClassVarAnnotated 以任何嵌套顺序使用

class Foo:
    foo: ClassVar[ReadOnly[str]] = "foo"
    bar: Annotated[ReadOnly[int], Gt(0)]
class Foo:
    foo: ReadOnly[ClassVar[str]] = "foo"
    bar: ReadOnly[Annotated[int, Gt(0)]]

这与 PEP 705 中定义的 ReadOnlytyping.TypedDict 的交互一致。

属性不能同时标注为 ReadOnlyFinal,因为这两个限定符在语义上有所不同,且 Final 通常更具限制性。Final 仍然允许作为仅被隐含为只读的属性的注解。它也可以用来重新声明基类的 ReadOnly 属性。

向后兼容性

本 PEP 引入了 ReadOnly 有效的新上下文。检查这些位置的程序将不得不进行更改以支持它。预计这主要会影响类型检查器。

但是,在较旧的 Python 版本中使用回溯的 typing_extensions.ReadOnly 时,建议谨慎。检查注解的机制在遇到 ReadOnly 时可能会出现不正确行为;特别是,查找 ClassVar@dataclass 装饰器可能会错误地将 ReadOnly[ClassVar[...]] 视为实例属性。

为避免自省问题,请使用 ClassVar[ReadOnly[...]] 而不是 ReadOnly[ClassVar[...]]

安全隐患

本 PEP 没有已知的安全隐患。

如何教授此内容

建议对 typing 模块文档进行修改,遵循 PEP 705 的步骤

  • 将本 PEP 添加到列出的其他 PEP 中。
  • typing.ReadOnly 链接到本 PEP。
  • 更新 typing.ReadOnly 的描述
    一种特殊的类型构造,用于将类的属性或 TypedDict 的项标记为只读。
  • 类型限定符 部分添加 ReadOnly 的独立条目
    类属性注解中的 ReadOnly 类型限定符表示该类的属性可以读取,但不能重新赋值或 del。有关在 TypedDict 中的用法,请参阅 ReadOnly

被拒绝的想法

澄清 @property 与协议的交互

协议 一节提到了类型检查器在解释协议中的属性时存在不一致。该问题可以通过修改类型规范来解决,阐明这些属性的只读性质是如何实现的。

本 PEP 使 ReadOnly 成为在协议中定义只读属性的更好替代方案,取代了为此目的使用属性。

仅在 __init__ 和类体中赋值

本 PEP 的早期版本曾提议只读属性只能在 __init__ 和类体中赋值。后来的讨论表明,此限制将严重限制 ReadOnly 在不可变类中的可用性,因为不可变类通常不定义 __init__

fractions.Fraction 是一个不可变类的例子,其属性的初始化发生在 __new__ 和类方法中。然而,与 __init__ 不同,__new__ 和类方法中的赋值可能不健全,因为它们操作的实例可以来自任意位置,包括一个已完成初始化的实例。

我们认为此类型检查功能对只读属性最主要的用例——不可变类——至关重要。因此,本 PEP 随后进行了修改,允许在 初始化 部分描述的一组规则下,在 __new__ 和类方法中赋值。

未解决的问题

扩展初始化

dataclasses.__post_init__() 或 attrs 的 初始化钩子 等机制通过提供一组在初始化期间调用的特殊钩子来增强对象创建。

本 PEP 中定义的当前初始化规则禁止在这些方法中对只读属性进行赋值。目前尚不清楚这些规则是否能够以令人满意的方式形成,既能包含这些第三方钩子,又能保持与这些属性的只读性相关的不变性。

Python 类型系统对 __new____init__ 的行为有一个详细而冗长的 规范。期望第三方钩子达到相同的详细程度是相当不切实际的。

一个潜在的解决方案可能涉及类型检查器在此方面提供配置,要求最终用户手动指定他们希望允许初始化的方法集。然而,这很容易导致用户错误地或故意破坏上述不变性。对于一个相对小众的功能来说,这也是一个相当大的要求。

脚注


来源: https://github.com/python/peps/blob/main/peps/pep-0767.rst

最后修改: 2025-05-06 20:54:28 GMT