资讯详情

资讯详情

Python sum函数参数解析:源码中的关键字参数陷阱与TypeError根源

前几天同事在群里甩过来一张CPython源码截图配文老哥你看 sum 这个函数在 C 源码里明明写着 METH_VARARGS | METH_KEYWORDS这不就是支持不定长关键字参数吗我写sum([1,2,3], start10, extra20)怎么直接 TypeError我一看就乐了——这个问题我已经见过不下十次了。sum 是 Python 里最常用的内置函数之一但恰恰是这种“看起来简单”的函数最容易在参数机制上栽跟头。这篇文章就把 sum 函数的正确用法、底层源码里的参数解析逻辑以及为什么“源码里看起来支持不定长关键字参数、实际上又不支持”这回事一次性讲透。无论你是刚学 Python 的新手还是想搞懂内置函数实现的老手这篇都能给你点实在的东西。1. sum 函数到底怎么用先把正确姿势过一遍1.1 从签名开始iterable 和 start 到底各是什么sum 的官方签名在不同版本里略有差异Python 3.12 之后是sum(iterable, /, start0)3.12 之前是sum(iterable, start0)。不管哪种写法核心逻辑都一样sum 就是把一个可迭代对象里的元素从头到尾加一遍最后再加上 start 这个初始值。 sum([1, 2, 3]) 6 sum([1, 2, 3], 10) 16 sum(range(1, 101)) # 1 加到 100 5050start 这个参数看起来不起眼实际很有用。我经常拿它做“有初始金额的累加”比如今天钱包里已经有 1000 块再收三笔账 start_amount 1000 incomes [200, 350, 480] sum(incomes, start_amount) 2030你把它理解成一个展开的result start然后循环result result item就行。需要注意一个版本差异Python 3.12 把 iterable 改成了 positional-only仅位置参数也就是说sum(iterable[1,2,3])这种“按关键字传 iterable”的写法在 3.12 里会直接报错。这一点后面实操章节再细说。1.2 可以传哪些数据类型哪些又是雷区sum 不做类型推导它只做一件事把两个对象用加到一起。所以只要是支持运算的同类型对象都能用 sum 求和不支持的直接崩。数据类型能不能用 sum注意事项int / float可以浮点数有精度问题见 4.2Decimal / Fraction可以别和 float 混用会损失精度complex可以结果是复数list / tuple勉强可以能摊平一层但效率差见 1.3set / dict基本不行set 不支持dict 迭代的是 keystr默认不行传start才能拼但别这么用很多新手在字典上翻过车 sum({a: 1, b: 2}) # 迭代的是 key实际在算 a b TypeError: unsupported operand type(s) for : int and str想对字典的值求和必须显式取出来 sum({a: 1, b: 2}.values()) 31.3 使用场景求均值、计 True 个数、摊平列表sum 最常用的几个场景我顺手列一下基本都是面试和日常高频题求均值sum(nums) / len(nums)。注意先判空否则空列表直接 ZeroDivisionError。数 True 的个数sum([True, False, True, True])得到 3因为 bool 是 int 子类。求多个列表的总长度sum(map(len, list_of_lists))。生成器求平方和sum(x * x for x in range(10))生成器也能直接吃。摊平一层列表sum([[1, 2], [3, 4]], [])会得到[1, 2, 3, 4]。最后一个技巧看起来聪明实际是个性能陷阱。它每加一个子列表都要通过 List生成一个新列表元素越多越慢时间复杂度是 O(n²)。数据量小无所谓数据量上去了建议老老实实用itertools.chain.from_iterablefrom itertools import chain list(chain.from_iterable([[1, 2], [3, 4]])) # [1, 2, 3, 4]2. 源码视角为什么“源码支持不定长关键字参数”是个误解2.1 CPython 里 sum 的真实 C 代码如果你去翻 CPython 源码Python 3.12 里在Python/bltinmodule.csum 的 C 实现核心部分长这样static PyObject * builtin_sum_impl(PyObject *module, PyObject *iterable, PyObject *start) { PyObject *result start; PyObject *temp, *item, *iter; iter PyObject_GetIter(iterable); if (iter NULL) return NULL; if (result NULL) { result PyLong_FromLong(0); if (result NULL) { Py_DECREF(iter); return NULL; } } for (;;) { item PyIter_Next(iter); if (item NULL) { if (PyErr_Occurred()) { Py_DECREF(iter); Py_DECREF(result); return NULL; } break; } temp PyNumber_Add(result, item); Py_DECREF(item); if (temp NULL) { Py_DECREF(iter); Py_DECREF(result); return NULL; } Py_DECREF(result); result temp; } Py_DECREF(iter); return result; }注意看这个 C 函数的形参module、iterable、start一共就三个。它内部就是拿到迭代器循环PyIter_Next取下一个元素然后用PyNumber_Add把结果和当前元素加起来。没有并行、没有分组、没有魔法就是纯顺序累加。那么问题来了既然这个函数本身只有三个参数为什么网上很多人说它“支持不定长关键字参数”因为真正接收 Python 调用参数的并不是builtin_sum_impl本身而是它上面的方法表声明。2.2 METH_VARARGS | METH_KEYWORDS 到底是什么含义在同一个源文件里有这样一个方法表{sum, (PyCFunction)(void(*)(void))builtin_sum, METH_VARARGS | METH_KEYWORDS, sum_doc},METH_VARARGS | METH_KEYWORDS这串东西就是罪魁祸首。我先解释这两个宏METH_VARARGS表示这个内置函数在 C 层会收到一个 tuple包含调用者传入的所有位置参数。METH_KEYWORDS表示除了 tuple还会收到一个 dict包含调用者传入的所有关键字参数。两个加起来等于告诉解释器“这个函数支持 Python 层的关键字传参语法调用时请把所有关键字参数打包好再传进来。”看到这里很多人就以为“支持关键字参数 支持任意多个关键字参数 那就是 **kwargs 呗。” 这是一个非常自然的误解但也是错的。打个比方公司前台会接收所有快递包裹但真的送到你手里的包裹必须是收件人姓名、数量都匹配的那几个。前台接收所有包裹不代表你的工位就是个无限制储物柜。METH_VARARGS | METH_KEYWORDS只是前台的接收窗口不代表函数内部会照单全收。真正决定 sum 能收几个参数的是函数内部的解析逻辑。sum 在 C 层用的是PyArg_ParseTupleAndKeywords这类参数解析函数它会根据一个格式串和关键字名单来严格校验位置参数个数最多 2 个第一个必选第二个可选关键字参数名单里只有start一个名字3.12 之后连iterable都不在名单里不在名单里的关键字直接报错数量超了也直接报错。所以sum 实际上支持的关键字参数只有一个start。在 3.12 之前iterable也能通过关键字传但 3.12 之后被改成了仅位置参数于是sum(iterable[...])这种写法成了历史。你以为的“不定长关键字参数”从来就不存在。2.3 真正的“不定长关键字参数”长什么样为了彻底厘清这个概念我们看看 Python 层面真正的“不定长关键字参数”是什么。def collect_info(**kwargs): print(kwargs) collect_info(a1, b2, c3) # {a: 1, b: 2, c: 3}只有像这样显式声明**kwargsPython 解释器才会在字节码层面把所有多余的关键字参数收集成一个 dict然后在函数体里随便处理。这是 Python 语法和解释器共同支持的机制。而 sum 并不是一个 Python 函数它的实现主体在 C 层根本没有**kwargs这种语法声明。它只是通过方法表里的METH_KEYWORDS标志声明了自己处于“支持关键字传参”的那一类内置函数。但从“支持关键字传参”到“支持任意数量关键字传参”中间还隔着一整个参数解析过程。还有人会把inspect.signature(sum)看到的斜杠也搞混 import inspect inspect.signature(sum) (iterable, /, start0)这个/是 Python 3.8 引入的 positional-only 标记表示它前面的参数不能用关键字传。它是一个“限制规则”和“不定长”三个字没有半点关系。真正的变长位置参数是*args变长关键字参数是**kwargs斜杠只是说“这里封死别想用关键字”。3. 实操拆解用真实调用验证 sum 的参数解析过程3.1 各种调用形式在 3.11 和 3.12 下的行为对照理论说完了直接上实测。我在 Python 3.11 和 3.12 两个版本里各跑了一轮把所有典型的调用方式都测了一遍sum([1, 2, 3]) # 6 sum([1, 2, 3], 10) # 16 sum([1, 2, 3], start10) # 16 sum(iterable[1, 2, 3]) # 3.11 可以3.12 报错 sum([1, 2, 3], 10, 20) # 报错 sum([1, 2, 3], x1) # 报错 sum([1, 2, 3], 10, start20) # 报错调用方式Python 3.11Python 3.12说明sum([1,2,3])66最常用sum([1,2,3], 10)1616start 用位置传sum([1,2,3], start10)1616start 用关键字传始终支持sum(iterable[1,2,3])6TypeError3.12 后 iterable 仅位置sum([1,2,3], 10, 20)TypeErrorTypeError最多 2 个位置参数sum([1,2,3], x1)TypeErrorTypeError未知关键字参数sum([1,2,3], 10, start20)TypeErrorTypeError同一个参数双份赋值这个表格基本就是 sum 参数规则的“全量测试”。你不需要背下来只需要记住一个核心判断思路sum 能接受的关键字只有一个 start位置参数最多两个。3.2 错误信息逐条解读每一种报错都有明确的含义我逐条拆开说TypeError: sum() takes at most 2 arguments (3 given)这个最容易理解。C 层解析格式串是O|O:sum意思是第一个 O 必选第二个 O 可选。你给三个超出了上限解释器连函数体都进不去。TypeError: sum() got an unexpected keyword argument x这个说明关键字名单里没有x。C 层的关键字名单是写死的开头是start后面跟一个 NULL 表示结束只有这个白名单里的名字才收。TypeError: sum() got multiple values for argument start当你既写了10这个位置参数又写了start20解析器发现 start 被同时塞了两个值直接拒绝。这和普通 Python 函数的行为是一致的C 层解析同样遵守这条规则。Python 3.12 里sum(iterable[1,2,3])的 TypeError在 3.12 里 iterable 被标成 positional-only按关键字传它不在允许名单里于是直接挂掉。具体报错文案在不同小版本里可能略有差别但类型永远都是 TypeError。这些错误信息看着繁琐其实都在做同一件事把一切不符合固定签名的调用拦在真正执行累加逻辑之前。这也正好回答了标题里的疑问——源码表面上有“关键字参数”的接收口但真正能走进去的关键字只有一个 start不定长想多了。3.3 从报错反推内部解析步骤如果你觉得 C 层源码读起来费劲我用 Python 模拟一下 sum 的参数解析过程保你看完就懂kwlist {start} # 3.12 之后 iterable 不在关键字白名单 def fake_parse_sum_args(args, kwargs): # 第一步检查位置参数数量 if len(args) 2: raise TypeError(ftoo many positional arguments: {len(args)}) # 第二步检查关键字白名单 for key in kwargs: if key not in kwlist: raise TypeError(fgot an unexpected keyword argument {key!r}) # 第三步检查重复赋值 if len(args) 2 and start in kwargs: raise TypeError(got multiple values for argument start) # 第四步真正解包参数 if len(args) 1: iterable, start args[0], kwargs.get(start, 0) else: iterable, start args[0], args[1] return iterable, start这个模拟把 C 层PyArg_ParseTupleAndKeywords干的事简化了一下但逻辑顺序是一致的打包Python 调用时所有位置参数打包成 tuple所有关键字参数打包成 dict校验数量位置参数范围必须是对应格式串允许的范围校验关键字名字多余的名字一律拒绝校验重复一个参数不能同时出现在位置和关键字两部分全部通过才真正拿到 iterable 和 start进入累加循环。所以你会发现哪怕 sum 的“入口”确实是METH_VARARGS | METH_KEYWORDS看起来什么都能往里面扔但第二步到第四步的校验把所有“多余的东西”全部拦截了。“支持关键字传参”和“支持任意关键字传参”之间隔着一整套参数校验逻辑。这就是误解的根源也是源码表象和实际行为之间最本质的差距。4. 常见问题与排查技巧实录4.1 那点字符串的执念sum 到底能不能拼接字符串先说结论默认不行但也不是绝对不行。 sum([a, b]) TypeError: unsupported operand type(s) for : int and str报错原因很简单默认 start0第一步0 a就崩了。但如果你显式传一个空字符串当初始值它确实能拼 sum([a, b], start) ab那么问题来了既然能拼为什么 Python 官方文档还特意说“拼接字符串请用.join(seq)”因为性能。sum 每加一个字符串都要通过创建一个全新的字符串对象。拼接 n 个字符串时间复杂度是 O(n²)。.join是线性扫描、一次性分配复杂度是 O(n)。字符串一多差距非常明显。所以我的建议很简单别拿 sum 拼字符串它本来就不是干这个的。哪怕你在面试里表演了sum([a,b], )这种骚操作面试官追问一句“为什么不用 join”你还是得老老实实说一句“性能差”。4.2 浮点精度和超大数累加的坑sum 对浮点数的处理是“从左到右顺序累加”这会导致误差累积。经典例子 sum([0.1, 0.1, 0.1]) 0.30000000000000004更狠的是这种 sum([1e16, 1, -1e16]) 0.0为什么是 0.0因为1e16 1在 IEEE 754 双精度浮点里1 小于 1e16 的浮点精度间隔直接被舍入成1e16然后1e16 (-1e16)正好抵消那个“1”就人间蒸发了。如果改成1e16、-1e16、1的顺序结果是 1.0因为先抵消后再加 11 还能保住。这就是累加顺序影响结果的典型例子。如果你的场景对浮点精度有要求别用 sum用math.fsum import math math.fsum([1e16, 1, -1e16]) 1.0math.fsum用的是补偿式求和算法能保住那些被 big number 吃掉的小数。金额类数据更不用说了直接用 Decimal而且确保列表里全是 Decimal别混 float。4.3 内置 sum 的性能边界什么时候别用它我自己跑过简单的基准测试对一个十万个元素的整数列表求和内置 sum 比手写 for 循环快大约 1.5 到 2 倍比functools.reduce(operator.add, lst)也快不少。原因很简单sum 的循环在 C 层跑省去了大量 Python 字节码解释开销虽然它内部还是在对 Python 对象做加法但整体已经很快了。这是 sum 的舒适区普通 Python 列表、元组、生成器元素量不至于恐怖到内存装不下用它没毛病。但有一个地方我劝你千万别用内置 sum——NumPy 数组。import numpy as np arr np.arange(100000) sum(arr) # 极慢 arr.sum() # 极快内置 sum 会把 NumPy 数组当成普通序列迭代每加一次都产生一个 NumPy 标量对象临时对象一堆速度被arr.sum()甩开一个数量级。对 NumPy 数组永远用数组自己的.sum()或np.sum()。还有一个容易忽略的点列表摊平也别用sum(list_of_lists, [])。它每加一个子列表都会新建一个列表数据量一大就是 O(n²) 的灾难。用chain.from_iterable或者列表推导式哪个都比它强。4.4 来自实战的避坑清单把这些年踩过的坑整理一下按优先级排求和前确认元素类型一致。混着 int 和 str 的列表sum 会在中途某个位置崩还不一定是在第一个元素。字符串列表拼接用.join不要用 sum 秀操作。求均值前先判空。sum([]) / 0会给你一个 ZeroDivisionError。金额类数据用 Decimal浮点 sum 会悄悄吃掉分。NumPy 数组别用内置 sum用arr.sum()或np.sum(arr)。不确定内置函数能接受哪些关键字时先help(sum)看一眼比猜强一万倍。看到 C 源码里 METH_KEYWORDS 的时候先冷静。它只代表“能用关键字形式调用”代表不了“支持任意关键字参数”。真正收几个参数看 kwlist 和格式串。如果项目要兼容 Python 3.12注意sum(iterable[...])这种写法已经废了。这些经验看着零碎但都是实打实会再遇到的。比起记住 sum 的所有细节更重要的是养成一个习惯遇到内置函数行为出乎意料先看签名和文档再看底层实现不要在“猜”上浪费时间。我自己刚接触 CPython 源码时也被METH_VARARGS | METH_KEYWORDS骗过一次。后来养成了一个习惯凡是遇到内置函数的调用问题先别急着猜翻一下它的 C 方法表和 kwlist看它到底声明了哪些参数名。“声明了关键字支持”和“接收任意关键字参数”之间差着一个完整的参数校验过程。这个认知不止对 sum 有效对 pow、divmod、sorted 这些内置函数同样适用。以后再有人拿 sum 源码问你“为什么不能传额外关键字参数”你可以直接把这篇的思路讲给他听比争论半天省事多了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →