NumPy NEP 51 深度解析:标量 repr 变更的设计动机与源码实现
【免费下载链接】numpyThe fundamental package for scientific computing with Python.项目地址: https://gitcode.com/gh_mirrors/nu/numpy
NumPy 2.0 起,标量在交互式环境中不再"伪装"成 Python 内建类型:np.float32(3.0)的repr变成了np.float32(3.0)而不是3.0。这项变更由标准轨道提案 NEP 51(状态:Accepted)定义,其目的是让 NumPy 标量与 Python 内建标量在外观上可区分,从而暴露精度、溢出和类型差异等隐蔽行为。本文完整继承 NEP 51 的提案内容,并结合当前仓库中 scalartypes.c.src 与 arrayprint.py 的实际实现,讲清每一项 repr 规则是如何落地的、哪些影响会波及下游代码,以及如何用legacy打印选项回退到旧行为。
一、背景与动机:为什么标量要"表明身份"
1.1 变更范围
NEP 51 提出的 repr 变更覆盖以下 NumPy 标量类型:
np.bool_(单例np.True_/np.False_)np.uint8、np.int8及所有其他整型标量np.float16、np.float32、np.float64、np.longdoublenp.complex64、np.complex128、np.clongdoublenp.str_、np.bytes_np.void(结构化 dtype 版本)
此外,剩余标量的 repr 只是把前缀从numpy.统一改为np.,行为本身不变:
np.datetime64与np.timedelta64np.void(非结构化的原始字节版本)
提案同时明确了两条边界:只改__repr__,不改__str__(即打印输出print()的内容不变);数组的 repr 不受影响,因为数组 repr 在必要时已经携带dtype=信息。
1.2 动机:Python 数值类型 ≠ NumPy 标量
提案给出的核心论点是:Python 数值类型与 NumPy 标量的行为差异会被"相同的外表"掩盖:
- 低精度类型需要警惕。
uint8、float16等低精度标量应当被谨慎使用,用户需要"看见"自己正处在低精度世界中。 - 整数溢出。所有 NumPy 整型都会溢出,而 Python
int不会——np.uint8(255) + 1得到np.uint8(0)。旧 repr 下这个结果只显示为0,溢出的事实被完全隐藏。 - 即使"最像"的
np.float64也有行为差异。它继承自 Pythonfloat,但除零等边界行为并不相同(例如np.float64(1.0) / np.float64(0.0)得到inf并伴随浮点错误,而不是ZeroDivisionError)。 - 布尔值陷阱。Python 程序员习惯写
obj is True,而np.bool_标量is True会失败;当它显示为np.True_时,这种单例身份的差异才变得一目了然。
这些差异在 NEP 50(标量提升规则变更)被采纳后会进一步放大:低精度标量会在二元运算中被更频繁地保留下来,uint8、float16等值在结果中出现的频率显著上升,此时 repr 携带类型信息对调试的帮助是决定性的。
二、新 repr 规则:完整映射与 round-trip 原则
2.1 类型 → 新 repr 映射
| 标量类型 | 旧 repr | 新 repr |
|---|---|---|
np.bool_单例 | True/False | np.True_/np.False_ |
整型标量(如np.int64(34)) | 34 | np.int64(34)、np.uint8(3) |
np.float16/np.float32/np.float64 | 3.0 | np.float32(3.0) |
np.longdouble/np.clongdouble | 3.0 | np.longdouble('3.0')(带单引号) |
np.complex64/np.complex128 | (3+4j) | np.complex128(3+4j) |
np.str_/np.bytes_ | 'string'/b'byte_string' | np.str_("string")/np.bytes_(b"byte_string") |
np.void(结构化) | (3, 5) | np.void((3, 5), dtype=[('a', '<i8'), ('b', 'u1')]) |
np.datetime64/np.timedelta64 | numpy.datetime64(...) | np.datetime64(...)(仅前缀改名) |
np.void(非结构化) | void(b'\x01...') | np.void(b'\x01...')(仅前缀改名) |
与数组不同,标量 repr 必须可以 round-trip:eval(repr(x))应能重建原值。由此推出两条规则:
longdouble值必须加引号:np.longdouble('3.0')。因为转成 Pythonfloat字面量会丢精度,加引号保证按字符串重新构造,行为对齐 Python 的Decimal(Decimal('3.0'))。- 其他数值类型永远不截断,输出完整数字。
2.2 longdouble 的细节:引号 + 永不使用 float128 命名
NEP 51 顺带提出一项命名修正:longdouble的存储大小因平台而异(8 到 16 字节不等),即使某平台上它确实是 128 bit 存储,也通常不具备 128 bit 精度(clongdouble存储大小为其两倍,精度同样不翻倍)。因此 repr 一律显示为longdouble,绝不显示float128或float96——尺寸式命名会给出虚假的精度印象。提案明确指出这不包含对np.float128别名本身的弃用,该弃用可能独立于本 NEP 发生。
一个典型例子是np.sqrt(np.longdouble(2.)):结果无法用 Pythonfloat字面量无损表示,只有以带引号字符串的形式(单引号,模仿decimal)输出才能保证 round-trip。
2.3 非有限值
提案明确不支持直接复制粘贴nan/inf:repr(np.float64(np.nan))显示为np.float64(nan),其中的nan不是合法 Python 字面量。替代写法是np.float64('nan')或np.float64(np.nan)。理由:Python 内建float('nan')同样是靠nan这个名字而非可粘贴字面量,而 NumPy 2.0 之后标量类型名总会出现在 repr 中,这一取舍代价很小。
三、源码实现:scalartypes.c.src 中的 repr 机制
NEP 51 的实现主体位于 C 层模板文件 scalartypes.c.src(Tempita 模板,按类型展开生成各标量类的tp_repr/tp_str入口)。理解它的关键是一个"版本闸门"。
3.1 legacy print mode 闸门
几乎所有新 repr 代码都先调用get_legacy_print_mode()(读取np.set_printoptions(legacy=...)的数值),再判断:
// numpy/_core/src/multiarray/scalartypes.c.src (整数 repr, L593-L629 节选) static PyObject * genint_type_repr(PyObject *self) { PyObject *value_string = genint_type_str(self); ... int legacy_print_mode = get_legacy_print_mode(); ... if (legacy_print_mode <= 125) { return value_string; // 旧风格:只有数值 } ... if (PyTypeNum_ISUNSIGNED(num)) { repr = PyUnicode_FromFormat("np.uint%d(%S)", bitsize, value_string); } else { repr = PyUnicode_FromFormat("np.int%d(%S)", bitsize, value_string); } ... }可以看到源码中存在三档版本阈值:<= 113走 1.13 时代的旧格式函数(文件 L1170-L1334 有一段专门标注为 "LEGACY PRINTING MODE CODE" 的复刻代码),<= 125走 1.25 风格的无类型 repr,> 125才启用 NEP 51 的新风格。这正是用户执行np.set_printoptions(legacy="1.25")即可整体回退旧标量 repr 的底层原因(见第七节)。
浮点科学计数法切换阈值也受版本控制:@name@type_@kind@_either(L1356-L1390)中,legacy ≤ 202 时所有浮点统一以1e16为位置/科学记数分界;新规则下模板参数#max_positional = 1.e6L, 1.e16L, 1.e16L#分别对应 float、double、longdouble——即np.float32在 |x| ≥ 1e6 时切换为科学记数,np.float64/np.longdouble在 |x| ≥ 1e16 时切换。
3.2 整型:位宽命名,且区分 C 类型名
整型 repr 通过_typenum_fromtypeobj拿到类型编号,再经PyArray_DescrFromType计算bitsize = elsize * 8,最终输出np.uint%d/np.int%d格式串(L608-L628)。注意分支顺序:num == NPY_NOTYPE(例如用户自定义标量)时退回tp_name(value)的通用形式。
这里落地了 NEP 中"整型标量类型名与实例表示"一节的设计:NumPy 标量类型基于 C 类型,np.longlong这样的类型名在多数 64 位系统(Windows 除外)与np.int64指向同一底层类型。提案的取舍是——类型对象保留精确的 C 名(np.longlong),标量实例统一用位宽名(np.longlong(3)的 repr 是np.int64(3)),因为int64对使用者的信息量更大。
3.3 布尔:单例直接返回固定字符串
布尔最简单,genbool_type_repr 在legacy_print_mode > 125时直接返回字符串常量"np.True_"/"np.False_"(读obval判断),legacy ≤ 125 时回退为genbool_type_str的"True"/"False"。这也印证了 NEP 中"布尔标量是单例,故np.True_比np.bool_(True)更简洁"的替代方案讨论结论。
3.4 浮点与复数:Dragon4 最短 round-trip + 格式串模板
数值部分的字符串由format_@name@(L720-L735)生成,内部调用 Dragon4 算法的Dragon4_Scientific_@Name@/Dragon4_Positional_@Name@并使用DigitMode_Unique模式——即生成能唯一还原该值的最短十进制串,这是"标量 repr 必须 round-trip"原则的算法保障。
repr 包装则通过模板参数完成(L1337-L1354):
* #repr_format = np.float32(%S), np.float64(%S), np.longdouble('%S')# * #crepr_imag_format = np.complex64(%Sj), np.complex128(%Sj), * np.clongdouble('%Sj')# * #crepr_format = np.complex64(%S%Sj), np.complex128(%S%Sj), * np.clongdouble('%S%Sj')#注意np.longdouble('%S')的单引号正是 NEP 中 round-trip 要求的直接体现;复数有两条格式串:纯虚部(实部为 +0)走crepr_imag_format(如np.complex128(4j)),一般情形走crepr_format(如np.complex128(3+4j))。当legacy_print_mode > 125时才套上这层外壳(L1400-L1411),否则退回裸数值字符串。
3.5 字符串、void、datetime:C 与 Python 的分层协作
- 字符串标量:
stringtype_repr模板(L744-L830)重写 str/repr,先剥离内部 NUL 码点,再委托给PyUnicode/PyBytes的原生 repr 逻辑并套上np.str_(/np.bytes_(前缀。 - void 标量:voidtype_repr 分两种情况。结构化 dtype(
PyDataType_HASFIELDS)时回调到 Python 侧的_void_scalar_to_string;非结构化时按 legacy 版本决定输出np.void(b'...')(新)还是void(b'...')(旧)。 - 结构化 repr 的 Python 侧实现在 arrayprint.py 的
_void_scalar_to_string:先检查 legacy 版本(<= 125走旧式StructuredVoidFormat),新式则复用逐元素 formatter 拼出元组表示,再构造完整 repr——注意它用cls.__module__.replace("numpy", "np")把模块名归一为np.,并显式携带 dtype:
cls_fqn = cls.__module__.replace("numpy", "np") + "." + cls.__name__ void_dtype = np.dtype((np.void, x.dtype)) return f"{cls_fqn}({val_repr}, dtype={void_dtype!s})"这就是 NEP 中"类似数组、且是合法重建语法"的目标形态np.void((3, 5), dtype=[('a', '<i8'), ('b', 'u1')])的出处。由于np.record与np.void共用该路径(仅类名不同),np.record的 repr 也自动对齐为np.record((3, 5), dtype=...)。
- datetime64 / timedelta64:
datetimetype_repr/timedeltatype_repr(L930 起)负责前缀由numpy.改为np.的调整,数值格式不变。
NEP 还提到一个展示细节:在掩码数组、void/record 标量等场景需要"无类型"的内层格式——例如结构体中若某字段是 longdouble,np.void(('3.0',), dtype=[('a', 'f16')])应打印带引号的3.0保证 round-trip,但不再重复np.longdouble('3.0')(dtype 信息已经包含它)。_void_scalar_to_string中options['formatter'].setdefault('float_kind', str)等逐元素 formatter 机制承担的就是这类内层格式化。
四、连带变更:tofile、掩码数组 fill_value 与 np.record
4.1arr.tofile()默认写 str 而非 repr
NEP 51 指出了一个连锁问题:tofile()文本模式过去以repr(arr.item())落盘,新 repr 生效后 longdouble 会被写成np.longdouble('3.1'),字符串数组会带上"string"引号,显然都不是期望的文本。提案决定把默认(改回)为str,需要 repr 的用户显式传%r。
当前源码中,array_tofile 的关键字签名是{"file", "sep", "format"},format默认值为空字符串:
char *sep = ""; char *format = ""; static char *kwlist[] = {"file", "sep", "format", NULL};即默认行为走str风格;要恢复 repr 落盘,应写arr.tofile("out.txt", format="%r")(NEP 原文写作fmt=%r,实际参数名以源码为准是format)。
4.2 掩码数组 fill_value 与 record
- 掩码数组的
fill_value显示被调整为:仅当数组 dtype 与 fill_value 标量不匹配时才输出完整类型信息,如fill_value=np.float64(1e20);longdouble 在 dtype 匹配时打印为带引号的fill_value='3.1'(原理上、实践中未必严格 round-trip)。字符串因 dtype 长度常常不匹配,通常显示为np.str_("N/A")。从源码结构看,这部分显示逻辑位于 numpy/ma/core.py 的MaskedArray格式化路径。 np.record标量与np.void对齐,除类名外打印完全一致(见 3.5 节的公共实现路径)。
五、get_formatter():被提出但尚未落地的半公开 API
NEP 51 的 Implementation 一节提出引入半公开接口以取代内部_get_formatting_func:
np.core.arrayprint.get_formatter(*, data=None, dtype=None, fmt=None, options=None)相比旧函数,它新增两点能力:
data可为None(前提是传dtype),允许先取 formatter、之后批量格式化,不必一次性传入全部待格式化值;fmt=接收格式化"函数":目前接受repr/str两个单例(不是字符串"r"/"s"),用于产出不含类型信息的元素格式('3.1'而非np.longdouble('3.1'));空格式串等价于str()(传data时可能附加对齐补白)。
设计取舍上有两点值得注意:其一,选择repr/str单例而非字符串,是为将来f"{arr:r}"这类格式串预留空间——占位符"r"/"s"不会被格式串语义占用;其二,提案明确不改用 ufunc 或直调格式化函数:数组格式化通常需要预读全部值做对齐补白或统一科学计数(此时data=被用于预备计算),这种"跨值状态"与 ufunc 的逐元素模型根本不兼容。
当前仓库的落地状态:NEP 自身标注了 Implementation 一节"未在初始 PR 中实现",需要类似改动来修复结构化标量中包含 longdouble 等打印场景,未来自定义 DType 的正确打印也需要同类方案。经全仓库检索确认,当前代码中尚不存在名为get_formatter的函数(该名称仅出现在 NEP 51 文档 本身);其"无类型内层格式"的职责目前由 arrayprint.py 内部的_void_scalar_to_string与逐元素 formatter(如StructuredVoidFormat)承担。因此引用"NEP 51 的get_formatter"时,应理解为提案方向而非现成 API。
六、备选方案与取舍
NEP 的 Alternatives 一节记录了三组被讨论掉的方案,理解它们能解释最终设计:
- 前缀选
np.而非numpy.或无前缀。np.足够清晰、简短且保证 repr 可直接复制粘贴;若只用float64(3.0)更省字符,但存在 NumPy 依赖不明确的上下文,且名称可能与其他库冲突。 - 布尔不用
np.bool_(True)/bool_(True)。NumPy 布尔标量是单例,np.True_更简洁;该方向最早只有一个"仅改布尔"的早期 PR(issue #12950 / PR #17592),后被扩展为覆盖全部(或至少绝大多数)标量的实现(PR #22449)。 - 字符串标量可以延后。字符串的混淆程度相对较低,推迟变更在技术上说得通,但提案将其一并纳入以保持类型信息的完整性。
七、实践要点:回退旧显示与测试适配
7.1 用 legacy 打印选项回退
由于每个 repr 入口都以get_legacy_print_mode()的阈值分支受控(整型/布尔/浮点在 125 处分支,格式细节在 113 与 202 处分支),用户只需:
import numpy as np np.set_printoptions(legacy="1.25") # 恢复 NEP 51 之前的标量 repr print(repr(np.float32(3.0))) # 3.0 print(repr(np.bool_(True))) # True源码中legacy_print_mode <= 125的分支(如 genint_type_repr L604-L606)正是这一回退开关的实现。注意str()输出从未被 NEP 51 改变,回退只影响repr()及直接展示 repr 的交互式环境。
7.2 下游代码与文档测试的适配
NEP 明确预期了兼容性影响:
- 只用
str()的工作流基本不受影响。典型需要修改的是把repr(scalar)喂给解析器的代码,例如 NumPy 测试套件中的decimal.Decimal(repr(scalar))——repr(np.longdouble(3.1))现在是np.longdouble('3.1'),直接解析会失败,改用str(scalar)即可。 - 文档与 doctest 是最大的中期成本。大量下游库文档里的输出示例会因 repr 变化而过期,预期需要一轮较大规模的文档修订;提案还建议引入支持"近似值检查"的 doctest 工具来匹配新表示。
- 仓库内的验证入口:标量打印行为的回归测试集中在 numpy/_core/tests/test_arrayprint.py、numpy/_core/tests/test_longdouble.py 等测试文件中,断言大量形如
np.float64(、np.int64(的新风格 repr,可作为各类型新行为的权威示例集。
小结
NEP 51 是一次"只动__repr__"的小切口变更,却系统性地解决了 NumPy 标量长期"看起来像 Python 内建类型"的认知陷阱:整型以位宽自报家门(np.int64(34)),浮点靠 Dragon4 最短 round-trip 保证可重建,longdouble 以引号隔离精度、以平台无关名消除误导,布尔以单例名暴露is语义。源码侧,这一切由 scalartypes.c.src 中受legacy版本闸门控制的一组 repr 模板实现,结构化类型经 arrayprint.py 的 Python 回调完成,tofile默认回退str则是它最直接的连带修正。对使用者最重要的两条实用结论是:新 repr 都是合法可粘贴表达式,而np.set_printoptions(legacy="1.25")是回到旧显示的唯一开关。
【免费下载链接】numpyThe fundamental package for scientific computing with Python.项目地址: https://gitcode.com/gh_mirrors/nu/numpy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考