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
定义了两个要求
hasattr(obj, "name")
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: ...
这种语法有几个缺点
基本原理
这些问题可以通过属性级别的类型限定符解决。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}!"
规范
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
类属性
只读类属性是指同时标注为 ReadOnly
和 ClassVar
的属性。对这类属性的赋值必须允许在以下上下文中进行
- 在类体中的声明处。
- 在
__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
可以与 ClassVar
和 Annotated
以任何嵌套顺序使用
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 中定义的 ReadOnly
和 typing.TypedDict
的交互一致。
属性不能同时标注为 ReadOnly
和 Final
,因为这两个限定符在语义上有所不同,且 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
的独立条目
被拒绝的想法
澄清 @property
与协议的交互
协议 一节提到了类型检查器在解释协议中的属性时存在不一致。该问题可以通过修改类型规范来解决,阐明这些属性的只读性质是如何实现的。
本 PEP 使 ReadOnly
成为在协议中定义只读属性的更好替代方案,取代了为此目的使用属性。
仅在 __init__
和类体中赋值
本 PEP 的早期版本曾提议只读属性只能在 __init__
和类体中赋值。后来的讨论表明,此限制将严重限制 ReadOnly
在不可变类中的可用性,因为不可变类通常不定义 __init__
。
fractions.Fraction
是一个不可变类的例子,其属性的初始化发生在 __new__
和类方法中。然而,与 __init__
不同,__new__
和类方法中的赋值可能不健全,因为它们操作的实例可以来自任意位置,包括一个已完成初始化的实例。
我们认为此类型检查功能对只读属性最主要的用例——不可变类——至关重要。因此,本 PEP 随后进行了修改,允许在 初始化 部分描述的一组规则下,在 __new__
和类方法中赋值。
未解决的问题
扩展初始化
dataclasses.__post_init__()
或 attrs 的 初始化钩子 等机制通过提供一组在初始化期间调用的特殊钩子来增强对象创建。
本 PEP 中定义的当前初始化规则禁止在这些方法中对只读属性进行赋值。目前尚不清楚这些规则是否能够以令人满意的方式形成,既能包含这些第三方钩子,又能保持与这些属性的只读性相关的不变性。
Python 类型系统对 __new__
和 __init__
的行为有一个详细而冗长的 规范。期望第三方钩子达到相同的详细程度是相当不切实际的。
一个潜在的解决方案可能涉及类型检查器在此方面提供配置,要求最终用户手动指定他们希望允许初始化的方法集。然而,这很容易导致用户错误地或故意破坏上述不变性。对于一个相对小众的功能来说,这也是一个相当大的要求。
脚注
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源: https://github.com/python/peps/blob/main/peps/pep-0767.rst
最后修改: 2025-05-06 20:54:28 GMT