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

Python 增强提案

PEP 800 – 类型系统中不相交的基类

作者:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
讨论至:
Discourse 帖子
状态:
草案
类型:
标准跟踪
主题:
类型标注
创建日期:
2025年7月21日
Python 版本:
3.15
发布历史:
2025年7月18日

目录

摘要

为了精确分析 Python 程序,类型检查器需要知道两个类何时可以拥有共同的子类,以及何时不能。然而,目前类型系统中并不包含确定这些所需的信息。本 PEP 增加了一个新的装饰器 @typing.disjoint_base,它指示一个类是“不相交的基类”。具有不同、不相关的不相交基类的两个类不能拥有共同的子类。

动机

在 Python 类型检查中,一个重要的概念是可达性。Python 类型检查器通常会检测到代码的某个分支永远无法被执行到的情况,并会就此类代码向用户发出警告。这很有用,因为不可达代码不必要地使程序复杂化,并且它的存在可能表明存在 bug。

例如,在此程序中

def f(x: bool) -> None:
    if isinstance(x, str):
        print("It's both!")

pyright 和 mypy(带 --warn-unreachable)这两个流行的类型检查器都会警告 if 块的主体不可达,因为如果 xbool 类型,它就不可能同时是 str 类型。

Python 中的可达性因多重继承的存在而复杂化。如果我们将 boolstr 替换为两个用户定义的类,mypy 和 pyright 则不会显示任何警告

class A: pass
class B: pass

def f(x: A):
    if isinstance(x, B):
        print("It's both!")

这是正确的,因为一个同时继承自 AB 的类可能存在。

在另一种情况下,当我们使用 intstr 时,我们看到了类型检查器之间的分歧

def f(x: int):
    if isinstance(x, str):
        print("It's both!")

对于这段代码,pyright 不会显示任何错误,但 mypy 会声称该分支不可达。Mypy 在这里技术上是正确的:CPython 不允许一个类同时继承自 intstr,因此该分支不可达。然而,确定这些基类不兼容所需的信息目前在类型系统中不可用。实际上,Mypy 使用了一种基于不兼容方法存在的启发式方法;这种启发式方法在实践中运行良好,特别是对于内置类型,但它通常是不正确的,正如 下文 更详细讨论的那样。

实验性的 ty 类型检查器采用了第三种方法,该方法更符合 Python 的运行时行为:它将某些类识别为限制多重继承的“实基类”。广义地说,每个类必须最多继承自一个唯一的实基类,如果没有唯一的实基类,则该类不能存在;我们将在下文提供更精确的定义。然而,ty 的方法依赖于对特定内置类型的硬编码知识。“实基类”一词来源于 CPython 实现;本 PEP 改用新提出的术语“不相交基类”。

本 PEP 提出了对类型系统的扩展,使得能够表达运行时不允许多重继承的情况:一个 @disjoint_base 装饰器,将一个类标记为 不相交基类。这使得类型检查器能够更精确地理解可达性,并在几个具体领域提供帮助。

无效的类定义

以下类定义在运行时会引发错误,因为 intstr 是不同的不相交基类

class C(int, str): pass

如果没有不相交基类的知识,类型检查器目前无法检测到此类的定义无效的原因,尽管它们可能检测到,如果此类存在,其某些方法将不兼容。(当看到此类定义时,mypy 将指出 __add__ 和其他几个方法的不兼容定义。)

这本身并不是一个特别引人注目的问题,因为错误通常会在代码第一次导入时被捕获,但为了完整性在此提及。

可达性

我们已经提到了使用 isinstance() 的代码的可达性。类似的问题也出现在其他类型收窄构造中,例如 match 语句:正确推断可达性需要理解不相交基类。

class A: pass
class B: pass

def f(x: A):
    match x:
        case B():  # reachable
            print("It's both!")

def g(x: int):
    match x:
        case str():  # unreachable
            print("It's both!")

重载

使用 @overload 装饰的函数如果某些重载的参数类型重叠但返回类型不重叠,则可能不安全。例如,以下一组重载可能会被利用来 实现不健全的行为

from typing import overload

class A: pass
class B: pass

@overload
def f(x: A) -> str: ...
@overload
def f(x: B) -> int: ...

如果存在一个同时继承自 AB 的类,那么类型检查器在调用 f() 时可能会选择错误的重载。

类型检查器可以检测到这种不安全性并发出警告,但正确的实现需要理解不相交基类,因为它依赖于了解同时是 AB 实例的值是否存在。尽管许多类型检查器已经对重叠的重载执行了此检查的一个版本,但类型规范目前没有规定此检查应如何工作。本 PEP 不打算更改这一点,但它有助于为重叠重载的健全检查提供一个构建块。

交集类型

显式交集类型(表示包含所有给定类型实例的值的类型)目前不属于类型系统。然而,它们在像 Python 这样的集合论类型系统中自然产生,作为类型收窄的结果,并且类型系统的未来扩展可能会增加对显式交集类型的支持。

对于交集类型,了解特定交集是否被实例化(即是否存在可以是该交集成​​员的值)通常很重要。这允许类型检查器理解可达性并向用户提供更精确的类型信息。

举一个具体的例子,交集类型 assignability 的一种可能实现是,给定交集类型 A & B,如果 C 可以赋值给 AB 中的至少一个,并且与 AB 的所有类型都有重叠,那么类型 C 就可以赋值给它。(这里的“重叠”表示至少一个运行时值可能存在,该值将是这两种类型的成员。也就是说,如果 A & B 被实例化,则 AB 重叠。)规则的第二部分确保 str 不能赋值给 int & Any 这样的类型:虽然 str 可以赋值给 Any,但它与 int 不重叠。但当然,只有当我们知道这两个类都是不相交基类时,我们才能知道 strint 不重叠。

概述

不相交基类在类型系统的许多方面都很有用。尽管其中一些方面未明确规定、具有推测性或重要性不大,但在每种情况下,不相交基类的概念都使类型检查器能够比当前类型系统允许的更精确地理解。因此,不相交基类为改进 Python 类型系统提供了坚实的基础(如果您愿意,可以称之为“实基类”)。

基本原理

“不相交基类”的概念使类型检查器能够理解两个类的共同子类何时可以存在,何时不能存在。为了将此概念传达给类型检查器,我们在类型系统中添加了一个 @disjoint_base 装饰器,用于将类标记为不相交基类。其语义大致是,一个类不能拥有两个不相关的、不相交的基类。

命名

本 PEP 的初始版本使用了“实基类”这一名称,沿用了 CPython 实现中使用的术语。然而,这个术语有些模糊。替代术语“不相交基类”暗示带有此装饰器的类与其他基类不相交,这是对该概念的良好一阶描述。(确切的语义更为微妙,将在下文描述。)

多重继承的运行时限制

虽然 Python 通常允许多重继承,但运行时会施加各种限制,如 CPython 文档 中所述。两组限制,围绕一致的 MRO 和一致的元类,类型检查器已经可以使用类型系统中可用的信息实现。第三个限制,围绕实例布局,是需要不相交基类知识的限制。包含非空 __slots__ 定义的类会自动成为不相交基类,许多用 C 实现的内置类也是如此。

Python 的替代实现,例如 PyPy,其行为往往与 CPython 相似,但可能在细节上有所不同,例如哪些标准库类是“不相交基类”。由于类型系统目前不包含对替代 Python 实现的任何显式支持,本 PEP 建议像 typeshed 这样的存根库使用 CPython 的行为来确定何时使用 @disjoint_base 装饰器。如果类型系统的未来扩展增加了对替代实现的支持(例如,根据 sys.implementation.name 的值进行分支),存根可以在必要时根据实现情况使用 @disjoint_base 装饰器。

尽管 CPython 实现中“不相交基类”(称为“实基类”)的概念已经存在了几十年,但决定哪些类是不相交基类的规则偶尔会发生变化。在 Python 3.12 之前,相对于基类添加 __dict__ 或支持弱引用可能会使类成为不相交基类。实际上,这通常意味着从 C 中实现的类(例如 namedtuple() 类)继承的 Python 实现类本身就是不相交基类。这种行为在 Python 3.12 中通过 python/cpython#96028 进行了更改。本 PEP 侧重于支持 Python 3.12 及更高版本的行为,这更简单易懂。类型检查器如果愿意,可以选择实现 3.12 之前的行为版本,但要正确执行此操作需要类型系统中当前不可用的信息。

在 Python 的未来版本中,运行时作为不相交基类的精确类集可能会再次更改。如果发生这种情况,类型检查器使用的类型存根可以更新以反映这一新现实。换句话说,本 PEP 将不相交基类的概念添加到类型系统中,但它不规定哪些类是不相交基类。

实现文件中的 @disjoint_base

@disjoint_base 装饰器最明显的用例将是在 C 库的存根文件中,例如标准库,用于标记用 C 实现的不相交基类。

然而,也有在实现文件中标记不相交基类的用例,其作用是禁止存在继承自被装饰类和另一个不相交基类(例如标准库类或另一个用 @disjoint_base 装饰的用户类)的子类。例如,这可以允许类型检查器标记只有在存在一个同时继承自用户类和标准库类(例如 intstr)的类时才可达的代码,这在技术上可能实现,但在实践中不太可能。

@disjoint_base
class BaseModel:
    # ... General logic for model classes
    pass

class Species(BaseModel):
    name: str
    # ... more fields

def process_species(species: Species):
    if isinstance(species, str):  # oops, forgot `.name`
        pass  # type checker should warn about this branch being unreachable
        # BaseModel and str are disjoint bases, so a class that inherits from both cannot exist

这与现有的 @final 装饰器原则相似,后者也限制子类化:在存根中,它用于标记以编程方式禁止子类化的类,但在实现文件中,它通常用于指示类不打算被子类化,而无需运行时强制。

特殊类上的 @disjoint_base

@disjoint_base 装饰器主要用于名义类,但类型系统包含一些在语法上使用类定义的其他构造,因此我们必须考虑是否也应该允许它们使用此装饰器,如果允许,这意味着什么。

对于 Protocol 定义,最一致的解释是,唯一可以实现该协议的类将是使用名义继承自该协议的类,或者实现该协议的 @final 类。其他类要么具有,要么可能具有一个不是该协议的不相交基类。这很复杂且无用,因此我们不允许在 Protocol 定义上使用 @disjoint_base

类似地,“不相交基类”的概念对于 TypedDict 定义没有意义,因为 TypedDict 是纯结构类型。

尽管 NamedTuple 定义在类型系统中受到一些特殊处理,但它们创建了可以拥有子类的真实名义类,因此允许在它们上使用 @disjoint_base 并为了不相交基类机制的目的将其视为常规类是有意义的。所有 NamedTuple 类在其 MRO 中都包含一个不相交基类 tuple,因此它们不能从其他不相交基类多重继承。

规范

类型系统新增了装饰器 @typing.disjoint_base。它只能用于名义类,包括 NamedTuple 定义;在函数、TypedDict 定义或 Protocol 定义上使用该装饰器会引发类型检查器错误。

我们定义了(名义)类的两个属性:一个类可能 不相交基类,也可能 不是;每个类都必须 拥有 一个有效的不相交基类。

如果一个类被 @typing.disjoint_base 装饰,或者它包含一个非空的 __slots__ 定义,那么它就是一个不相交基类。这包括由于 @dataclass(slots=True) 装饰器或由于使用 dataclass_transform 机制添加插槽的类。通用基类 object 也是一个不相交基类。

为了确定一个类的不相交基类,我们查看它的所有基类,以确定一组候选不相交基类。对于每个本身就是不相交基类的基类,候选就是该基类本身;否则,它就是该基类的不相交基类。如果候选集只包含一个不相交基类,那么它就是该类的不相交基类。如果有多个候选,但其中一个候选是所有其他候选的子类,那么该类就是不相交基类。如果不存在这样的候选,则该类没有有效的不相交基类,因此不能存在。

类型检查器在检查类定义时必须检查是否存在有效的不相交基类,如果遇到缺少有效不相交基类的类定义,则发出诊断。类型检查器还可以使用不相交基类机制来确定类型是否不相交,例如在检查像 isinstance() 这样的类型收窄构造是否导致不可达分支时。

示例

from typing import disjoint_base, assert_never

@disjoint_base
class Disjoint1:
    pass

@disjoint_base
class Disjoint2:
    pass

@disjoint_base
class DisjointChild(Disjoint1):
    pass

class C1:  # disjoint base is `object`
    pass

# OK: candidate disjoint bases are `Disjoint1` and `object`, and `Disjoint1` is a subclass of `object`.
class C2(Disjoint1, C1):  # disjoint base is `Disjoint1`
    pass

# OK: candidate disjoint bases are `DisjointChild` and `Disjoint1`, and `DisjointChild` is a subclass of `Disjoint1`.
class C3(DisjointChild, Disjoint1):  # disjoint base is `DisjointChild`
    pass

# error: candidate disjoint bases are `Disjoint1` and `Disjoint2`, but neither is a subclass of the other
class C4(Disjoint1, Disjoint2):
    pass

def narrower(obj: Disjoint1) -> None:
    if isinstance(obj, Disjoint2):
        assert_never(obj)  # OK: child class of `Disjoint1` and `Disjoint2` cannot exist
    if isinstance(obj, C1):
        reveal_type(obj)  # Shows a non-empty type, e.g. `Disjoint1 & C1`

运行时实现

一个新的装饰器 @disjoint_base 将被添加到 typing 模块中。其运行时行为(与 @final 等类似装饰器一致)是在被装饰对象上设置属性 .__disjoint_base__ = True,然后返回其参数

def disjoint_base(cls):
    cls.__disjoint_base__ = True
    return cls

__disjoint_base__ 属性可用于运行时内省。然而,对于用户定义的类,此装饰器没有运行时强制。

验证在存根中是否应该应用 @disjoint_base 装饰器将很有用。虽然 CPython 没有精确记录哪些类是不相交基类,但可以通过运行时内省来复制解释器的行为(示例实现)。存根验证工具,例如 mypy 的 stubtest,可以使用此逻辑来检查 @disjoint_base 装饰器是否正确应用于存根中的类。

向后兼容性

为了与早期版本的 Python 兼容,@disjoint_base 装饰器将被添加到 typing_extensions 反向移植包中。

在运行时,新的装饰器不会造成兼容性问题。

在存根中,即使并非所有类型检查器都已理解该装饰器,也可以将其添加到不相交基类中;此类类型检查器应简单地将该装饰器视为无操作。

当类型检查器增加对本 PEP 的支持时,用户可能会在可达性和交集方面看到一些类型检查行为的变化。这些变化应该是积极的,因为它们将更好地反映运行时行为,并且用户可见变化的规模可能有限,类似于类型检查器版本之间的正常变化量。关注此变化影响的类型检查器可以使用诸如选择加入标志之类的过渡机制。

安全隐患

未知。

如何教授此内容

大多数用户不需要直接使用或理解 @disjoint_base 装饰器,因为预计它将主要用于低级库的库存根中。Python 教师可以引入“不相交基类”的概念,以解释为什么某些情况下不允许多重继承。Python 类型化教师可以在教授 isinstance() 等类型收窄构造时引入该装饰器,以向用户解释为什么类型检查器将某些分支视为不可达。

参考实现

@disjoint_base 装饰器的运行时实现可在 typing-extensions 4.15.0 中找到。python/mypy#19678 在 mypy 和 stubtest 工具中实现了对不相交基类的支持。astral-sh/ruff#20084 在 ty 类型检查器中实现了对不相交基类的支持。

附录

本附录更详细地讨论了类型系统和 CPython 运行时中多重继承的现有情况。

CPython 中的“实基类”

“实基类”的概念长期以来一直是 CPython 实现的一部分;该概念可以追溯到 2001 年的一次提交。然而,该概念在文档中鲜有提及。尽管该机制的细节与 CPython 的内部对象表示密切相关,但从高层次解释 CPython 为什么以及如何以这种方式工作仍然很有用。

CPython 中的每个对象本质上都是一个指向 C 结构体的指针,它是一块连续的内存,包含有关对象的信息。一些信息由解释器管理并由许多或所有对象共享,例如对对象类型的引用,以及用户定义对象的 __dict__ 属性。一些类包含特定于该类的附加信息。例如,带有 __slots__ 的用户定义类为每个插槽包含一个内存位置,内置的 float 类包含一个存储浮点值的 C double 值。这种内存布局必须为类的所有实例保留:与 float 交互的 C 代码期望在对象的内存中以特定偏移量找到该值。

当创建一个子类时,CPython 必须为新类创建一个与所有父类兼容的内存布局。例如,当创建一个 float 的子类时,必须能够将子类的实例传递给直接与 float 类的底层结构交互的 C 代码。因此,这样的子类必须在与父 float 类相同的偏移量处存储 double 值。但是,它可以在结构体的末尾添加额外的字段。CPython 知道如何使用 __dict__ 属性来做到这一点,这就是为什么可以创建一个添加 __dict__float 的子类。

然而,无法将必须在其结构中包含 doublefloat 与另一个 C 类型(例如 int,它在相同位置存储不同的数据)组合。因此,floatint 的共同子类不能存在。我们称 floatint 为实基类。

一个用 C 实现的类,如果它有一个底层结构体,该结构体在固定偏移量处存储数据,并且该结构体与其父类的结构体不同,那么它就是一个实基类。一个 C 类也可能存储一个可变大小的数据数组(例如字符串的内容);如果这与父类不同,该类也成为一个实基类。CPython 的实现通过类型对象的 tp_itemsizetp_basicsize 字段推断出这一点,这些字段也可以通过 Python 代码作为类型对象上未文档化的属性 __itemsize____basicsize__ 访问。

类似地,用 Python 实现的类如果具有 __slots__,则它们是实基类,因为插槽会强制特定的内存布局。

Mypy 的不兼容性检查

mypy 类型检查器认为如果两个类具有不兼容的方法,则它们是不兼容的。例如,mypy 认为 intstr 类是不兼容的,因为它们对各种方法有不兼容的定义。给定一个类定义,例如

class C(int, str):
    pass

Mypy 将输出 Definition of "__add__" in base class "int" is incompatible with definition in base class "str",以及其他一些方法的类似错误。这些错误是正确的,因为两个类中 __add__ 的定义确实不兼容:int.__add__ 期望一个 int 参数,而 str.__add__ 期望一个 str。如果这个类存在,在运行时 __add__ 将解析为 int.__add__C 的实例也将是 str 类型的成员,但它们不支持 str 支持的一些操作,例如与另一个 str 的连接。

到目前为止,一切顺利。但 mypy 也使用非常相似的逻辑来得出结论,即没有类可以同时继承自 intstr。然而,它接受以下类定义而没有错误

from typing import Never

class C(int, str):
    def __add__(self, other: object) -> Never:
        raise TypeError
    def __mod__(self, other: object) -> Never:
        raise TypeError
    def __mul__(self, other: object) -> Never:
        raise TypeError
    def __rmul__(self, other: object) -> Never:
        raise TypeError
    def __ge__(self, other: int | str) -> bool:
        return int(self) > other if isinstance(other, int) else str(self) > other
    def __gt__(self, other: int | str) -> bool:
        return int(self) >= other if isinstance(other, int) else str(self) >= other
    def __lt__(self, other: int | str) -> bool:
        return int(self) < other if isinstance(other, int) else str(self) < other
    def __le__(self, other: int | str) -> bool:
        return int(self) <= other if isinstance(other, int) else str(self) <= other
    def __getnewargs__(self) -> Never:
        raise TypeError

属性方面也有类似的情况。给定两个具有不兼容属性的类,mypy 声称不存在共同的子类,但它接受一个子类,该子类重写这些属性以使其兼容

from typing import Never

class X:
    a: int

class Y:
    a: str

class Z(X, Y):
    @property
    def a(self) -> Never:
        raise RuntimeError("no luck")
    @a.setter
    def a(self, value: int | str) -> None:
        pass

虽然到目前为止给出的例子都依赖于返回 Never 的重写,但 mypy 的规则也可以拒绝那些具有更实际有用实现的类

from typing import Literal

class Carnivore:
    def eat(self, food: Literal["meat"]) -> None:
        print("devouring meat")

class Herbivore:
    def eat(self, food: Literal["plants"]) -> None:
        print("nibbling on plants")

class Omnivore(Carnivore, Herbivore):
    def eat(self, food: str) -> None:
        print(f"eating {food}")

def is_it_both(obj: Carnivore):
    # mypy --warn-unreachable:
    # Subclass of "Carnivore" and "Herbivore" cannot exist: would have incompatible method signatures
    if isinstance(obj, Herbivore):
        pass

Mypy 决定两个类是否相交的规则在实践中运作良好。大多数作为不相交基类的内置类碰巧以不兼容的方式实现了常见的双下划线方法(如 __add____iter__),因此 mypy 会认为它们不兼容。存在一些例外:mypy 允许 class C(BaseException, int): ...,尽管这两个类都是不相交基类,并且类定义在运行时被拒绝。反之,当实际使用多重继承时,通常父类不会有不兼容的方法。

因此,mypy 判断两个类不能相交的方法既过于宽泛(它错误地认为某些交集无法实例化),也过于狭窄(它遗漏了一些由于不相交基类而无法实例化的交集)。这在 mypy 跟踪器上的一个问题 中进行了讨论。


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

最后修改:2025-08-25 19:44:56 GMT