AIGC标识 Python 的 list[int] 为什么不是 list?拆开 __class_getitem__ 和那层 80 字节的薄包装

先看几行我在本机跑出来的结果(CPython 3.13.14):

>>> type(list[int])
<class 'types.GenericAlias'>
>>> list[int] is list[int]
False
>>> list[int] == list[int]
True
>>> list[int]((1, 2, 3))
[1, 2, 3]
>>> list[int](['a', 'b'])
['a', 'b']          # 说好的 int 呢?

list[int] 不是 list,不是字符串,也不是类型注解专用的语法糖。它是一个实实在在的对象,占 80 字节,可以被调用,而且调用时完全不检查 int。

很多人写过几百遍 list[int],从没拆过它到底是什么。这篇就拆这一层。

一、它是 types.GenericAlias,不是 list

import types, sys
g = list[int]

print(isinstance(g, types.GenericAlias))   # True
print(g.__origin__)                        # <class 'list'>
print(g.__args__)                          # (<class 'int'>,)
print(sys.getsizeof(g))                    # 80
print(str(g))                              # 'list[int]'

结构很简单:一个 __origin__ 指向原始容器,一个 __args__ 存着方括号里的东西,再加几个标准方法。80 字节,就是这么个薄包装。

它是每次求值都新建一个对象:

>>> list[int] is list[int]
False
>>> list[int] == list[int]
True

== 按 __origin__ 和 __args__ 比,所以相等;is 比身份,所以不等。别拿 is 去判断两个泛型别名是否相同。

二、它居然能被调用,且不检查类型

>>> callable(list[int])
True
>>> list[int]((1, 2, 3))
[1, 2, 3]
>>> list[int](['a', 'b'])
['a', 'b']

GenericAlias.__call__ 直接把参数转交给 __origin__,也就是 list(...)。方括号里的 int 在这一步完全不参与,纯属贴在包装上的标签。

这不难理解:类型注解在运行时默认是零成本的。如果 list[int](...) 真去逐个校验元素,那每次构造列表都要多一遍遍历,标准库不会这么设计。类型检查是 mypy / pyright 这些静态工具的事,不是运行时的活。

一个容易踩的对比,来自 typing 模块:

import typing
tg = typing.List[int]
print(type(tg).__name__)     # _GenericAlias
print(callable(tg))          # True
tg((1, 2))                   # TypeError: Type List cannot be instantiated; use list() instead

typing.List[int] 是另一套实现(_GenericAlias),明确禁止实例化;内置的 list[int] 允许。写业务代码时统一用内置的 list[int],typing.List 是 Python 3.9 之前的老写法。

三、为什么自定义类写 C[int] 会报错

class C:
    pass

>>> C[int]
TypeError: type 'C' is not subscriptable

方括号这个动作,走的是 __getitem__ 协议;对类对象来说,特殊入口叫 __class_getitem__。内置容器(list / dict / tuple / set / type 等)在 C 层面实现了它,普通类没有,所以报错。

自己加一个就行,返回值随便你定:

class D:
    def __class_getitem__(cls, item):
        return ('D_GENERIC', item)

>>> D[int]
('D_GENERIC', <class 'int'>)

注意:__class_getitem__ 不要求返回 GenericAlias,它只是个钩子。标准库里 list[int] 返回 types.GenericAlias,但你自己完全可以返回字符串、元组,甚至直接返回 cls。

顺带一个细节:

>>> hasattr(list[int], '__mro_entries__')
True

__mro_entries__ 是给「把泛型别名当基类」用的:class MyList(list[int]): ... 时,Python 会靠它把 list[int] 还原成 list 再建类。这也是 PEP 560 那套机制的一部分。

四、性能账:11.7 倍

import timeit
# 50 万次取平均
list[int]  -> 178.2 ns/次
list       -> 15.2 ns/次

本机实测 list[int] 比光秃秃一个 list 名字慢 11.7 倍。原因也清楚:前者要构造一个 80 字节的对象、填 __origin__ 和 __args__,后者只是从名字空间里读一个已存在的全局。

所以我自己遵守两条:

  1. 注解写在函数签名里,别写在热循环里。 函数定义时求值一次,之后不再碰;循环里每轮 list[int] 就是每轮新建对象。
  2. from __future__ import annotations(或 3.14 起的默认延迟求值)能把注解变成字符串,连这一次构造都省了——需要内省时用 typing.get_type_hints() 再还原。

顺带说清楚一个常见误会:list[int] 慢,不代表「类型注解让 Python 变慢」。慢的是运行时真的去构造这个别名对象这件事,而正常注解只在定义时发生一次。

五、它到底解决什么问题

说白了,list[int] 的存在感几乎全在给工具看:

  • 静态检查器靠 __origin__ / __args__ 知道这是个「元素为 int 的列表」;
  • IDE 靠它补全;
  • dataclasses、pydantic、FastAPI 这类库在运行时用 get_type_hints() 读它,决定怎么校验、怎么序列化;
  • 对你自己写的 for x in data:,它什么都不做。

一个 80 字节的标签,把「人类和工具需要的信息」挂在类型上,又不给运行时添麻烦——这是 Python 处理类型提示的典型思路:信息要留着,但别在跑的时候付账。

实验脚本在 d51-probe.py,数值随机器和版本会变,但「每次新建、可调用、不检查、比裸名字慢一个数量级」这几条是稳定的。

posted @ 2026-11-05 15:08  小小张说故事  阅读(5)  评论(0)    收藏  举报