mypyc 类型注解优化完全指南:用对类型,让 Python 编译提速的钥匙
【免费下载链接】mypyOptional static typing for Python项目地址: https://gitcode.com/GitHub_Trending/my/mypy
导读
mypyc 将 Python 模块编译为 C 扩展,而类型注解的质量直接决定了生成的机器码有多快。本文基于 mypyc 官方文档 using_type_annotations.rst,系统讲解原始类型(primitive types)、原生类(native classes)、元组/联合/特征类型、擦除类型(erased types)以及值类型与堆类型的区别,并给出i64/i32/i16/u8原生整数类型的完整使用规范。读完本文,你将掌握"哪些注解能触发底层优化、哪些注解会被降级为通用操作"的完整判定方法,从而写出真正发挥 mypyc 性能优势的代码。
核心结论:注解精度决定性能上限
mypyc 使用标准 Python 类型提示(PEP 484)生成快速代码。但并非所有类型注解都能带来同等的性能收益。在编译时,mypyc 会把 mypy 的类型系统映射为内部 IR 的"运行时类型"(RType),这一映射逻辑定义在 mypyc/irbuild/mapper.py 的Mapper.type_to_rtype中,而 RType 的具体定义位于 mypyc/ir/rtypes.py。
关键结论是:
- 能带来重大性能提升的类型:原始类型、原生类、联合类型、特征(trait)类型、元组类型。尽可能多地使用它们,是相对于 CPython 获得显著性能提升的关键。
- 只带来一般收益的类型:
Any等擦除类型。对擦除类型的操作会退化为"对任意对象工作的通用操作",与 CPython 解释器的行为类似。如果只使用擦除类型,相对于 CPython 的主要收益只剩下:编译消除了解释器开销,以及少量早期绑定(early binding)带来的收益——这通常只是小幅度的性能提升。
从 mapper.py 可以看到映射规则的全貌:builtins.int/float/bool/str/bytes/bytearray/list/dict/set/tuple/range被映射到对应原始类型;AnyType、CallableType、TypeType等一律被映射为object_rprimitive(即擦除);TypeVarLikeType被擦除到其上限类型;UnionType通过RUnion.make_simplified_union构建运行时联合。
原始类型(Primitive Types)
以下内建类型被 mypyc 视为原始类型,对这些类型的大量操作都有高效的定制实现(称为native operations):
| 类型 | 说明 |
|---|---|
int | 任意精度整数,原生操作参考 |
i64/i32/i16/u8 | 原生整数类型,文档、原生操作参考 |
float | 浮点数,原生操作参考 |
bool | 布尔值,原生操作参考 |
str | 字符串,原生操作参考 |
bytes | 字节串,原生操作参考 |
bytearray | 可变字节数组,原生操作参考 |
list[T] | 列表,原生操作参考 |
dict[K, V] | 字典,原生操作参考 |
set[T] | 集合,原生操作参考 |
tuple[T, ...] | 变长元组,原生操作参考 |
None | 空值 |
range | 范围对象 |
上表每个类型后的链接列出了该类型支持的全部原生、优化操作。注意:Python 支持的所有操作你都可以用,只是其中native operations拥有定制的优化实现,而其他操作会回退到通用实现(速度通常较慢)。例如 native_operations.rst 列出了跨类型的通用原生操作,如isinstance()、len()、abs()、for ... in seq循环、@property/@staticmethod/@classmethod装饰器等。
在 IR 层,每种原始类型都有明确的底层表示(见 rtypes.py):
int_rprimitive使用CPyTagged表示——小整数直接以未装箱的 tagged 值存储,大整数则退化为指向 Pythonint对象的指针,最低位用作标记位;- 原生整数
i16/i32/i64分别对应 C 的int16_t/int32_t/int64_t,u8对应uint8_t,全部未装箱、不参与引用计数; float_rprimitive用 Cdouble表示,bool_rprimitive用char(0 为 False、1 为 True、2 为错误值),none_rprimitive用char(0 为 None、2 为错误值);list/dict/set/str/bytes/bytearray/tuple/range则保持为引用计数的PyObject *指针。
原始容器:元素类型在运行时被擦除
原始容器对象(如list、dict)在运行时并不保存元素类型的信息——元素类型在运行时被擦除(erased)。
这意味着元素类型是在访问元素时检查的,而不是在容器作为参数传递或赋值给另一个变量时检查。例如下面的代码在example的最后一行才发生运行时类型错误(Any表示任意、未检查的值):
from typing import Any def example(a: list[Any]) -> None: b: list[int] = a # No error -- items are not checked print(b[0]) # Error here -- got str, but expected int example(["x"])这与原生类的严格性形成了鲜明对比,也与"编译代码保证非擦除类型在运行时只有兼容值"这一总体原则相互呼应。
原生类(Native Classes)
被编译为 C 扩展的类称为原生类。对其实例的大多数常见操作都被优化,包括构造、属性访问和方法调用。
原生类的定义看起来与普通 Python 类完全一致。位于编译模块中的类通常是原生类(不过存在一些例外)。例如:
class Point: def __init__(self, x: int, y: int) -> None: self.x = x self.y = y def shift(p: Point) -> Point: return Point(p.x + 1, p.y + 1)如果该文件被编译,上面示例中的所有操作都会使用原生操作。
原生类与 Python 类的显著差异
原生类有几个与普通 Python 类不同的重要特性:
- 只支持在类体或方法中定义的属性和方法。如果在类定义之外给未定义的属性赋值,会抛出
AttributeError。这保证了高效的内存布局和快速的方法调用。 - 通常不定义
__dict__属性(没有属性字典)。这正是"只拥有一组固定属性"的推论。 - 不能有任意的元类(metaclass),也不能使用大多数类装饰器。
继承限制
原生类只支持单继承。一种受限的多重继承通过*特征类型(trait types)*来支持。通常你必须继承自另一个原生类(或object)。默认情况下,你不能让一个 Python 类继承原生类(但存在一个覆盖此限制的开关,详见 native_classes.rst)。
从源码角度,原生类在 IR 中对应RInstance(见 rtypes.py),当 mapper 发现目标类型位于self.type_to_ir(即已编译的类集合)时,就生成RInstance以启用快速方法分派与属性访问。
元组类型:固定长度元组是值类型
固定长度的元组类型(如tuple[int, str])在存储为变量、作为参数传递或作为函数返回值时,被表示为值类型(value types)。值类型分配在底层机器栈或 CPU 寄存器中,这与从堆上动态分配的*堆类型(heap types)*相反。
与其他值类型一样,元组在以下场景会被装箱(boxed),即转换为对应的堆类型:存入 Python 容器时,或传给非原生代码时。装箱后的元组值就是一个普通的 Python 元组对象。
从 mapper.py 可以看到:原生tuple类型且不带*展开项时映射为RTuple(未装箱内联元组),而NamedTuple、变长元组则回退到tuple_rprimitive(装箱)。
联合类型与可选类型:永远装箱但依然高效
包含原始类型、原生类类型和特征类型的联合类型与可选类型同样高效。如果联合类型中含有擦除项,访问其中非擦除类型的项通常仍然相当高效。
一个关键约束:联合类型的值永远是装箱的,即使其中包含具有未装箱表示的值(如整数或布尔值)。例如使用Optional[int]相当高效,但值总是被装箱;而普通的int值通常更快,因为它有未装箱的表示。
在 IR 层面对应RUnion,通过RUnion.make_simplified_union构建(见 mapper.py)。一个值得注意的细节是:协议类型(protocol)在编译时被映射为RUnion([inst, object_rprimitive])(见 mapper.py),这样在显式继承协议时可以走快速方法调用,未继承时才回退到通用操作。
特征类型(Trait Types):原生类的受限多重继承
特征类型为原生类提供了一种多重继承形式。一个原生类可以继承任意数量的特征。特征类型用mypy_extensions.trait装饰器定义:
from mypy_extensions import trait @trait class MyTrait: def method(self) -> None: ...特征可以定义方法、属性和特性(property),通常还会定义抽象方法。特征可以是泛型的。
基类列表中的位置约束
如果一个类同时继承非特征类与特征,特征必须放在基类列表的末尾:
class Base: ... class Derived(Base, MyTrait, FooTrait): # OK ... class Derived2(MyTrait, FooTrait, Base): # Error: traits should come last ...特征的特殊性质
- 不应该创建特征的实例(虽然 mypyc 目前还没有阻止这一点)。
- 特征可以继承其他特征或原生类,但MRO 必须是线性的(与原生类一致)。
- 通过特征类型访问方法或属性的效率略低于通过原生类类型,但远快于通过 Python 类类型或其他擦除类型。
安装依赖
使用@trait需要安装mypy-extensions:
pip install --upgrade mypy-extensionsmypy-extensions同时提供了原生整数类型i64/i32/i16/u8的导入来源(见下文)。
擦除类型(Erased Types):哪些注解享受不到定制优化
除了上述类型,mypy 支持的其他许多类型 mypyc 也支持,但它们没有定制的操作,而是通过类型擦除实现:在运行时,所有其他类型都等价于无类型值,即等价于Any类型。擦除类型包括:
- Python 类(包括 ABC)
- 非 mypyc 扩展类型及非原始的内建类型(包括不是原始类型的内建类型)
- 可调用类型(Callable types)
- 类型变量(Type variable types)
Any类型- 协议类型(Protocol types)
从 mapper.py 可以印证:CallableType、AnyType、TypeType都映射为object_rprimitive,类型变量被擦除到上限(type_to_rtype(typ.upper_bound)),Overloaded同样映射为object_rprimitive。
擦除类型仍有价值:帮助推断出更优类型
使用擦除类型仍然可以改善性能,因为它们能让使用这些类型的表达式被推断出更好的类型。例如,类型为Callable[[], int]的值不允许原生调用,但其返回类型是原始类型,我们可以对返回值使用快速操作:
from typing import Callable def call_and_inc(f: Callable[[], int]) -> int: # Slow call, since f has an erased type n = f() # Fast increment; inferred type of n is int (primitive type) n += 1 return n如果参数f的类型是Any,那么n的类型也会是Any,增量操作就会退化为通用的、较慢的操作。
严格的运行时类型检查
编译后的代码保证任何非擦除类型的变量或表达式在运行时只包含兼容的值。这与可选静态类型检查(例如只使用 mypy 而不做运行时检查)形成对比:mypy 的注解在运行时并不强制。mypyc 在静态和运行时两个层面都保证了类型安全。
Any类型以及一般的擦除类型可能会破坏类型安全,但这是有意设计的:为所有可能的值插入严格的运行时类型检查代价过高,与追求高性能的目标相悖。
值类型与堆类型(Value and Heap Types)
在 CPython 中,所有对象的内存都从堆上动态分配,因此所有 Python 类型都是堆类型。而在编译后的代码中,某些类型是值类型——(不一定)需要在堆上分配对象。bool、float、None、原生整数类型以及固定长度元组都是值类型。
int是混合体
int是混合类型:对于典型的整数值,它是值类型;而足够大的整数值(超过 63 位,或在 32 位平台上超过 31 位)则使用基于堆的表示(与 CPython 相同)。这一设计在 IR 层的int_rprimitive(ctype="CPyTagged")中有精确体现(见 rtypes.py)。
值类型与堆类型的差异
- 装箱(boxing):值类型实例被用在期望堆值的地方(例如作为列表元素)时,会透明地切换到基于堆的表示。
- 拆箱(unboxing):mypyc 也会透明地从基于堆的表示切换到值表示。
- 对象同一性不保留:整数、浮点数和元组的对象同一性不被保留。比较两个整数、浮点数或固定长度元组时,应该用
==而不是is。 - 隐式类型转换:当值类型子类的实例被转换为基类型时,会隐式转换为目标类型的实例。例如,
bool值赋给int类型的变量时会转换为对应的整数。
最后这条转换是mypyc 程序中唯一发生的隐式类型转换。
示例:
def example() -> None: # A small integer uses the value (unboxed) representation x = 5 # A large integer uses the heap (boxed) representation x = 2**500 # Lists always contain boxed integers a = [55] # When reading from a list, the object is automatically unboxed x = a[0] # True is converted to 1 on assignment x = True浮点类型的收窄限制
由于整数和浮点数具有不同的运行时表示,且任何一方都无法表示另一方的所有值,编译代码中禁止通过赋值对浮点值进行类型收窄。为保持一致,mypyc 甚至在变量初始化时也拒绝将整数值赋给浮点变量,必须显式转换:
def narrowing(n: int) -> None: # Error: Incompatible value representations in assignment # (expression has type "int", variable has type "float") x: float = 0 y: float = 0.0 # Ok if f(): y = n # Error if f(): y = float(n) # Ok原生整数类型(Native Integer Types)
如果你确定整数值始终落在固定范围内,可以使用原生整数类型:i64(64 位有符号整数)、i32(32 位有符号整数)、i16(16 位有符号整数)、u8(8 位无符号整数)。这些类型比任意精度的int更快,因为它们的运算不需要溢出检查;同时它们可能比int值占用更少的内存。这些类型从mypy_extensions模块导入(通过pip install mypy_extensions安装)。
示例:
from mypy_extensions import i64 def sum_list(l: list[i64]) -> i64: s: i64 = 0 for n in l: s += n return s # Implicit conversions from int to i64 print(sum_list([1, 3, 5]))在 IR 层,这些类型对应int16_rprimitive/int32_rprimitive/int64_rprimitive/uint8_rprimitive,分别使用 C 的int16_t/int32_t/int64_t/uint8_t表示,全部未装箱、不参与引用计数,并且error_overlap=True(错误值 -113 可能与合法值重叠,见 rtypes.py 中的说明)。
溢出风险警告
注意:由于原生整数运算没有溢出检查,如果上述函数的和可能超出 64 位范围,就会发生溢出或其他未定义行为。
当作为解释执行的 Python 程序运行时,溢出时的行为会不同。声明原生整数类型在不编译代码时没有任何效果——原生整数类型在被解释时实际上等价于
int。
原生整数类型的附加属性
int与原生整数类型之间可以隐式转换(双向)。- 不同原生整数类型之间的转换必须显式进行。转换为更窄的原生整数类型时会截断值且不做运行时溢出检查。从 int_operations.rst 可以看到完整的转换函数清单,例如
i32(x: i64)(截断)、i64(x: str)、i64(x: str, base: int)等。 - 如果二元运算(如
+)或增强赋值(如+=)混用原生整数与int值,int操作数会被隐式强制转换为原生整数类型(原生整数类型具有"粘性",sticky)。 - 不同的原生整数类型不能在二元运算中混用,需要显式转换。
实战建议:如何写出对 mypyc 友好的类型注解
综合以上内容,可以总结出编写 mypyc 高性能代码的实践准则:
- 参数、返回值与局部变量尽量使用原始类型(
int、float、bool、str、bytes、list[T]、dict[K, V]、set[T]、tuple[T, ...]),这是获得原生操作的最直接途径。 - 把热路径上的数据结构定义为原生类,并注意只在类体内声明属性;避免在类外动态添加属性(会触发
AttributeError)。 - 固定长度元组是值类型,适合在性能敏感的传参与返回场景使用;注意与
is比较相关的同一性语义。 - 联合/可选类型依然高效但永远装箱;若性能要求苛刻,优先使用单一的非装箱类型。
- 需要多重继承时使用
@trait,并遵守"特征放基类列表末尾"的规则。 - 避免热路径上的
Any、裸 Python 类、可调用类型、协议类型——它们会被擦除为通用操作;如无法避免,可利用其返回值的原始类型推断来间接获得加速。 - 在确定数值边界时使用
i64/i32/i16/u8,但要清楚溢出行为在编译与解释两种模式下不一致,且不同原生整数类型混用时需要显式转换。 - 始终用 mypy 做静态检查——mypyc 在静态与运行时双重层面保证非擦除类型的类型安全,注解写错会在编译阶段暴露出来。
延伸阅读
- 原生类的详细规则(继承、
__init__、属性布局):native_classes.rst - 编译代码与 CPython 的行为差异: differences_from_python.rst
- 编译单元与多模块编译:compilation_units.rst
- 各类原生操作参考:int_operations.rst、list_operations.rst、dict_operations.rst、str_operations.rst、tuple_operations.rst 等
- 性能调优综合建议:performance_tips_and_tricks.rst
- mypyc 使用入门:getting_started.rst
【免费下载链接】mypyOptional static typing for Python项目地址: https://gitcode.com/GitHub_Trending/my/mypy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考