ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入 Python sum() 参数机制:positional-only 与关键字参数的底层原理

深入 Python sum() 参数机制:positional-only 与关键字参数的底层原理 1. sum() 的常规用法比你想象的更灵活1.1 参数签名与 start 的真实作用sum()大概是 Python 里最长被“想当然”的内置函数。绝大多数人只看过它的最简单形态sum([1, 2, 3])返回 6。但它的完整签名是sum(iterable, /, start0)这一行里藏着两个经常被忽略的信息一个是参数start另一个是那个不起眼的斜杠/。start的作用不是“初始值”三个字能概括的。它实质上充当的是加法链最左侧的操作数sum([1, 2, 3], 100)的实际计算过程是100 1 2 3而不是1 2 3 100。虽然整数加法满足交换律最终结果看着一样但这个顺序在浮点数、字符串拼接以及自定义对象的__add__方法里是有区别的。# start 的典型用法 total sum([1, 2, 3, 4, 5]) # 15 total sum([1, 2, 3, 4, 5], 100) # 115等价于 100 1 2 3 4 5 total sum([], 100) # 100空可迭代对象直接返回 start total sum([], 0.0) # 0.0类型保持为 float另一个值得说的是start参数让sum()天然适合处理“空列表”这种边界情况。没有 start 的话sum([])返回 0 是默认行为但如果你的业务里空列表应该返回Decimal(0.00)或者某个自定义类型第二个参数就能派上用场。实测下来这个参数在数据清洗脚本里出镜率极高。1.2 高频场景生成器、嵌套列表、空 iterablesum()最常见的进阶用法是配合生成器表达式做内存友好的聚合。sum(x * x for x in range(1, 101))这类写法比先构建列表再求和省下一份内存数据量大的时候差异很明显。# 生成器表达式边算边加不生成中间列表 total sum(x * x for x in range(1, 101)) # 338350 # 浮点求和 total sum(0.1 for _ in range(10)) # 0.9999999999999999 # 嵌套列表扁平化小数据量可用大数据别用 flat sum([[1, 2], [3, 4], [5]], []) # [1, 2, 3, 4, 5]嵌套列表那个例子我特意列出来因为它看起来很聪明实际是个性能陷阱。sum()用的是循环内反复执行的方式对列表来说就是一次次的list.__add__时间复杂度接近 O(n²)。数据量小的时候无所谓上百个小列表拼起来你就知道什么叫慢了。正规做法是itertools.chain.from_iterable或者列表推导展开。浮点求和的例子也值得留意。0.1 0.1 ...这种底层二进制的精度误差sum()解决不了它是从头开始累加误差会积累。如果对浮点精度有硬性要求numpy 的sum()内部用成对求和或者math.fsum()才是正解这也是“同样是 sum差别很大”的一个典型场景。1.3 sum() 不适合做什么写代码多了你会发现sum()最容易被误用的地方是字符串拼接。sum([a, b, c], )能跑但每一次都是新建字符串性能是灾难级的而且特容易踩到“str 不能和 int 相加”的类型错误。官方文档里写得很直白拼接字符串请用.join(seq)。# 错误示范能用但别用 # result sum([a, b, c], ) # TypeError: can only concatenate str # 正确姿势 result .join([a, b, c]) # abc同样sum()也不是用来做二维数组按行求和的。Pandas 里df.sum(axis1)是向量化实现快得多普通 Python 列表的二维求和[sum(row) for row in matrix]也比把整个矩阵塞进sum()再处理要清晰。什么时候用sum()可迭代对象里是同一类型的数值你想从左到右累加出一个标量结果。超出这个范围就该找别的工具了。2. 那个怪脾气源码里有 **kwargs调用时却不让你传2.1 先复现这个反直觉的报错有一天我重构代码随手把一处sum(data)改成了sum(iterabledata)想着“参数名写出来更可读嘛”。结果直接被一行报错打脸。# 在 CPython 3.8 环境里实测 sum(iterable[1, 2, 3]) TypeError: sum() got some positional-only arguments passed as keyword arguments: iterable我当时的第一反应是这报错是不是搞错了参数名明明是iterable写在函数签名里为什么不能当关键字传更让我懵的是翻 CPython 源码时看到的是另一个景象——C 层那个builtin_sum函数的签名里明明白白有个kwds参数这玩意儿在 Python 世界里对应的就是不定长关键字参数**kwargs。一个挂着小兜子收关键字参数的函数实际却拒绝关键字传参这不是精神分裂吗别急把两件事分开看就通透了C 函数“有参数接收关键字”不等于“Python 函数接受任意关键字”。中间还隔着一道相当严格的关卡。2.2 C 层函数签名里kwds 到底扮演什么角色在较老的 CPython比如 3.7 时代里builtin_sum大概长这样static char *sum_kwlist[] {iterable, start, NULL}; static PyObject * builtin_sum(PyObject *self, PyObject *args, PyObject *kwds) { PyObject *iterable NULL; PyObject *start NULL; if (!PyArg_ParseTupleAndKeywords(args, kwds, O|O:sum, sum_kwlist, iterable, start)) { return NULL; } return builtin_sum_impl(self, iterable, start); }这段代码里的第三个参数PyObject *kwds就是 CPython 对 Python 层**kwargs的 C 语言化身所有关键字参数会被 Python 解释器收集成一个 dict塞进这个指针传进来。单看函数签名你确实可以说“源码支持不定长关键字参数”。但真正决定一个关键字能不能被接受不是这个参数有没有而是那行PyArg_ParseTupleAndKeywords手里拿的那张名单sum_kwlist。它是白名单机制只有名单里出现的名字关键字传参才被认可。在 3.7 的名单里iterable和start都在所以你老环境里写sum(iterable[1, 2], start3)是能正常跑的。到了 Python 3.8 之后事情又往前走了一步。PEP 570 给 Python 正式带来了位置专属参数positional-only语法CPython 顺势把大量内置函数的参数语义收紧了。sum的签名演进成了sum(iterable, /, start0)语义上明确宣布iterable只能按位置传不接受关键字start还保留着关键字的通道。2.3 关键字参数过了白名单这关才叫“支持”那 Python 3.8 的源码里sum 是怎么实现“位置专属 start 关键字可选”的Argument Clinic 生成的解析代码大概是这样的思路/*[clinic input] sum as builtin_sum iterable: object / start: object 0 [clinic start generated code]*/注意 clinic 声明里iterable下面那个独立的/它标记了位置专属的边界。后续生成的解析器最关键的两个配置是关键字名单只剩{start, NULL}外加一个positional_only_mask 0x1的位掩码表示第一个参数只能出现在位置参数列表里不允许出现在关键字里。所以你现在看到的完整行为是这样的 sum([1, 2, 3], start5) # OKstart 在白名单里可以当关键字 11 sum(iterable[1, 2, 3]) # 不行iterable 是位置专属参数 TypeError: sum() got some positional-only arguments passed as keyword arguments: iterable sum([1, 2, 3], foo5) # 更不行foo 根本不在白名单里 TypeError: sum() got an unexpected keyword argument foo你看到的“不支持关键字”实际上要分两种情况iterable是被位置专属规则拦下来的foo是被白名单机制拦下来的。这也就解释了文章标题的疑惑——不定长关键字参数在 C 层是“收得到”但能不能用是另一套规则说了算。kwds参数只是通道通道尽头那张白名单才是真正的哨兵。3. 不定长关键字参数在 CPython 里到底怎么流转3.1 Python 层 **kwargs 与 C 层参数表的对应关系要彻底搞懂这件事得把 Python 和 C 的参数模型并排看。Python 层一个函数如果写成def f(a, *args, **kwargs)*args收集多余的位置参数形成一个 tuple**kwargs收集多余的键值对形成一个 dict。C 层的内置函数也有对应的两样东西只是一般不会同时长得一模一样。早期内置函数走的是METH_VARARGS | METH_KEYWORDS这条老路Python 层概念C 层表示说明位置参数整体PyObject *args一个 tuple按位置顺序包着所有实参不定长关键字参数PyObject *kwds一个 dict键是参数名字符串值是实参函数自身PyObject *self模块或类型对象解析辅助PyArg_ParseTupleAndKeywords按格式串关键字名单拆包到了 Python 3.12 之后的版本很多内置函数改用了METH_FASTCALL | METH_KEYWORDS协议args不再是一个 tuple而是一个PyObject *const *args数组加一个元素个数nargskwds也从 dict 变成了一个kwnames元组里面只存关键字的名字值都放在数组尾部。这么做的好处是省掉了中途构建 tuple 和 dict 的开销调用内置函数的性能又提了一截。// 3.12 风格的内置函数签名 static PyObject * builtin_sum(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) { // kwnames 是关键字名字组成的 tuple // 实参值紧跟在 args 数组里按顺序排 ... }换汤不换药的是不管收 keyword 用的是 dict 还是 kwnames解析时依然要过名单关卡。所以别觉得 Python 3.12 的 sum 就“变大方”了它的白名单规则跟 3.8 一脉相承。3.2 METH_* 宏背后的调用约定C 扩展开发者看METH_VARARGS、METH_KEYWORDS、METH_FASTCALL这些宏就像 Python 开发者看*args、**kwargs一样。它们决定了解释器怎么把调用方传来的参数组织好递到函数手里。简单总结三者的差异宏标志函数签名关键字支持性能特点METH_VARARGSfunc(PyObject *self, PyObject *args)完全不支持关键字慢每次构建 args tupleMETH_VARARGS | METH_KEYWORDSfunc(self, args, kwds)kwnames 被封装成 dict 传进来较慢额外构建 dictMETH_FASTCALL | METH_KEYWORDSfunc(self, args, nargs, kwnames)支持关键字名和值分离快省去 tuple/dict 构建旧版 sum 用的是中间那档新版 sum 用的是最后一档。但注意一个细节就算函数按最后一档的签名写好了如果解析代码里对kwnames直接忽略比如只用_PyArg_ParseStack解析位置参数不去碰关键字那么一旦调用方传了关键字解释器就会报“takes no keyword arguments”。这就是“源码支持、实际不支持”的另一层含义C 函数有这个参数和这个能力不代表实现真的启用了它。3.3 _PyArg_ParseStackAndKeywords 的解析流程现代 CPython 里解析参数的核心是_PyArg_ParseStackAndKeywords它做的事情可以脑补成一条流水线如果kwnames为空或NULL直接按格式串比如O|O从位置参数数组里取数简单无风险。如果kwnames非空遍历每个关键字名去_keywords名单里查表。查到名字再看positional_only_mask里对应位是否被置 1。如果置 1说明这个参数被声明为位置专属关键字命中也要报错。查到名字且没被位置专属标记拦住就把实参绑定到对应槽位。查不到名字直接抛TypeError: function() got an unexpected keyword argument xxx。这套设计非常像安检通道kwnames是乘客队伍_keywords是名单positional_only_mask是“本通道不接受预约”的牌子。每个环节都在拦人最终能合法进入函数体的关键字就那么零星几个。4. 手把手复现与源码阅读路径4.1 用 inspect 和text_signature还原签名如果你不想翻源码也别急着相信我上面说的自己动手验证最快。Python 内置函数的真实签名可以从__text_signature__属性里读出来 sum.__text_signature__ (iterable, /, start0) import inspect inspect.signature(sum) (iterable, /, start0)注意那个/的位置它右边的参数可以当关键字传左边的不能。用这个规则你可以快速判断一个内置函数到底怎么传参不用瞎猜。我统计手头环境里常见的几个内置函数时发现大家耳熟能详的len()、abs()、divmod()现在基本都锁死了位置传参而round(number, ndigitsNone)里的ndigits是允许关键字传参的。这个细节在不同解释器版本里可能有差异但方向一致看斜杠比看文档更直观。4.2 简化版 C 扩展演示同款“白名单”行为为了把“白名单 位置专属”的机制讲透我写过一个简单的 C 扩展做过实验。核心代码浓缩下来就是这样一个函数static PyObject * demo_func(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) { static const char * const _keywords[] {start, NULL}; PyObject *iterable NULL; PyObject *start NULL; if (!_PyArg_ParseStackAndKeywords(args, nargs, kwnames, O|O:demo, _keywords, 0x1, // 第 0 位是位置专属 iterable, start)) { return NULL; } // 真正的累加逻辑省略 return result; }这里的, O|O:demo表示第一个位置参数必传第二个可省略_keywords只放了start最后的0x1掩码声明第一个参数位置专属。用这个扩展一测行为跟内置sum完全一致start5能传iterable[1,2,3]直接报positional-only错误。这个实验我做了两次结论都指向同一句话不是 C 层不给你收关键字是人家收之前就给每道门上了锁。4.3 源码阅读建议与历史版本差异想亲眼看源码的同学可以在 GitHub 上搜索 CPython 仓库里的Python/bltinmodule.c搜“builtin_sum”就能定位。新版源码的核心逻辑其实已经被 Argument Clinic 生成到了clinic/bltinmodule.c.h里主文件只剩函数实现。读的时候不建议从头啃而是先看sum_doc这个 PyDoc 字符串它写的就是对外暴露的文本签名然后顺着_keywords和positional_only_mask两个配置走一遍理解负担会小很多。历史版本的差异也值得提一句。Python 3.7 及更早版本里sum 的sum_kwlist同时包含iterable和start所以老项目里sum(iterable[1, 2])是能跑的。Python 3.8 引入位置专属参数语义后这个行为被收紧了很多老代码在升级后直接报警。如果你在维护老项目遇到sum()相关报错第一反应应该是查一下代码里有没有把iterable当关键字传的习惯。5. 常见问题速查与避坑清单5.1 现象速查表把平时踩过的坑整理成了一张速查表照着查能省不少排查时间调用写法结果原因sum([1, 2, 3])6正常位置传参sum([1, 2, 3], 10)16正常位置传 startsum([1, 2, 3], start10)16start 在白名单里可关键字传sum(iterable[1, 2, 3])TypeErroriterable 是位置专属参数sum([1, 2, 3], foo10)TypeErrorfoo 不在关键字名单里sum(abc)TypeError字符串元素不能直接相加sum([a, b], )TypeErrorstr 不能隐式拼接还有个我常跟新人讲的点别用sum()处理 None。sum([1, None, 2])会直接炸TypeError: unsupported operand type(s) for : int and NoneType如果你的数据里可能有空值先做好清洗再求和。5.2 几个容易被误导的常见认知顺着上面这个表有三个常见认知特别容易误导人。第一个“sum 既然有 start 参数那所有内置函数都支持关键字传参”。完全不是len(iterable...)、abs(x...)这类写法在现代 Python 里一概被拒。它们的 C 层实现里positional_only_mask 把所有参数都锁死了。看签名里的/是第一判断标准。第二个“既然源码有 kwds 参数那我去写自定义 C 扩展时也可以随便接 kwargs”。这里必须提醒一句C 层接了 kwds 或 kwnames如果没有配套的 whitelist 解析轻则参数被静默忽略重则直接段错误。这个坑我早年写扩展时踩过血泪教训C 扩展里处理参数老老实实走_PyArg_ParseStackAndKeywords自己写手搓解析要格外小心空 keyword 的边界条件。第三个“sum 的 start 既然支持关键字那 PyPy 等其他 Python 实现也应该支持”。实现细节未必一致。PyPy 的 RPython 层对内置函数的参数解析是单独实现的不一定跟 CPython 的positional_only_mask完全对齐。我实测过一些边缘 case不同解释器在“位置专属参数被当关键字传”时的报错文案就有差异。写跨解释器兼容代码时尽量不要把 start 用关键字传用位置传最稳。5.3 实操建议该记住的三句话把这一长串原理浓缩成三句话之后再遇到sum()或者任何内置函数的参数谜题直接用这三条去套第一内置函数参数能不能用关键字看最新文档签名里的斜杠最可靠。/左边的位置专属右边的通常支持关键字没有白名单的名字一律不支持。第二C 源码里有 kwds 或 kwnames 参数只代表“收得到”不代表“放得行”。真正决定关键字能不能用是 whitelist 和 positional_only_mask这是 CPython 为了安全、性能和契约清晰度刻意设计的。第三遇到“奇怪但合理”的报错先花两分钟查文本签名再花五分钟翻源码对应位置的解析配置比自己拍脑袋改成斜杠传参要稳妥得多。我在实际排查这种问题的时候常用的组合拳是inspect.signature先看轮廓sum.__text_signature__看原始契约最后带着positional_only_mask这个关键词去源码里搜。多数情况下答案在第三个步骤之前已经水落石出了。这个套路后来被我用到排查max、round、range等一系列内置函数的参数争议屡试不爽算是长期写 Python 攒下的一个小经验吧。
返回列表