前几天同事在群里甩过来一张CPython源码截图,配文:老哥,你看 sum 这个函数在 C 源码里明明写着 METH_VARARGS | METH_KEYWORDS,这不就是支持不定长关键字参数吗?我写sum([1,2,3], start=10, extra=20)怎么直接 TypeError?我一看就乐了——这个问题我已经见过不下十次了。
sum 是 Python 里最常用的内置函数之一,但恰恰是这种“看起来简单”的函数,最容易在参数机制上栽跟头。这篇文章就把 sum 函数的正确用法、底层源码里的参数解析逻辑,以及为什么“源码里看起来支持不定长关键字参数、实际上又不支持”这回事,一次性讲透。无论你是刚学 Python 的新手,还是想搞懂内置函数实现的老手,这篇都能给你点实在的东西。
1. sum 函数到底怎么用:先把正确姿势过一遍
1.1 从签名开始:iterable 和 start 到底各是什么
sum 的官方签名在不同版本里略有差异,Python 3.12 之后是sum(iterable, /, start=0),3.12 之前是sum(iterable, start=0)。不管哪种写法,核心逻辑都一样: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.2 |
| Decimal / Fraction | 可以 | 别和 float 混用,会损失精度 |
| complex | 可以 | 结果是复数 |
| list / tuple | 勉强可以 | 能摊平一层,但效率差,见 1.3 |
| set / dict | 基本不行 | set 不支持+,dict 迭代的是 key |
| str | 默认不行 | 传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_iterable:
from 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.c),sum 的 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(a=1, b=2, c=3) # {'a': 1, 'b': 2, 'c': 3}只有像这样显式声明**kwargs,Python 解释器才会在字节码层面把所有多余的关键字参数收集成一个 dict,然后在函数体里随便处理。这是 Python 语法和解释器共同支持的机制。
而 sum 并不是一个 Python 函数,它的实现主体在 C 层,根本没有**kwargs这种语法声明。它只是通过方法表里的METH_KEYWORDS标志,声明了自己处于“支持关键字传参”的那一类内置函数。但从“支持关键字传参”到“支持任意数量关键字传参”,中间还隔着一整个参数解析过程。
还有人会把inspect.signature(sum)看到的斜杠也搞混:
>>> import inspect >>> inspect.signature(sum) (iterable, /, start=0)这个/是 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], start=10) # 16 sum(iterable=[1, 2, 3]) # 3.11 可以,3.12 报错 sum([1, 2, 3], 10, 20) # 报错 sum([1, 2, 3], x=1) # 报错 sum([1, 2, 3], 10, start=20) # 报错| 调用方式 | Python 3.11 | Python 3.12 | 说明 |
|---|---|---|---|
sum([1,2,3]) | 6 | 6 | 最常用 |
sum([1,2,3], 10) | 16 | 16 | start 用位置传 |
sum([1,2,3], start=10) | 16 | 16 | start 用关键字传,始终支持 |
sum(iterable=[1,2,3]) | 6 | TypeError | 3.12 后 iterable 仅位置 |
sum([1,2,3], 10, 20) | TypeError | TypeError | 最多 2 个位置参数 |
sum([1,2,3], x=1) | TypeError | TypeError | 未知关键字参数 |
sum([1,2,3], 10, start=20) | TypeError | TypeError | 同一个参数双份赋值 |
这个表格基本就是 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这个位置参数,又写了start=20,解析器发现 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(f"too many positional arguments: {len(args)}") # 第二步:检查关键字白名单 for key in kwargs: if key not in kwlist: raise TypeError(f"got 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'报错原因很简单:默认 start=0,第一步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,因为先抵消后再加 1,1 还能保住。这就是累加顺序影响结果的典型例子。
如果你的场景对浮点精度有要求,别用 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 源码问你“为什么不能传额外关键字参数”,你可以直接把这篇的思路讲给他听,比争论半天省事多了。