线上环境凌晨报警,排查半天,最后发现只是一个函数改了外部列表。这样的场景在团队协作中并不少见。代码逻辑没有报错,语法没有问题,但结果就是“不对劲”。有人开始怀疑是Python的“引用传递”,有人说是“值传递”,讨论半天,其实都没说到点子上。

函数参数传递这件事,看似简单,却是很多工程师真正理解Python的分水岭。理解透了,很多“玄学Bug”会自动消失;理解偏了,哪怕语法滚瓜烂熟,项目里依然会踩坑。
所有问题都可以回到一个核心事实:Python里没有变量装着数据,只有名字绑定对象。
写下一行代码:
a = [1, 2, 3]
表面看是“a里有一个列表”。真实发生的事情是:内存里创建了一个列表对象,然后名字a绑定到了这个对象上。a不是盒子,a只是标签。对象在后面,名字在前面。
这个认知一旦模糊,函数参数问题就会变得混乱。
看一个最普通的函数调用:
def f(x): pass
a = [1, 2, 3]
f(a)
很多人潜意识里以为“a被传进去了”。其实函数调用只做了一件事:把对象的引用,再绑定给一个新的名字x。
执行f(a)时,解释器会计算实参表达式a,得到那个列表对象的引用。进入函数后,新建一个局部名字x,让x指向同一个列表对象。没有复制,没有转移所有权,没有神秘操作。只是多贴了一张标签。
理解到这里,就能解释那个经典疑问:为什么有时候函数改了参数,外面会变,有时候不会?
关键在于——改的是对象,还是改的是绑定关系。
看这段代码:
def f(lst): lst.append(100) #函数内:原地修改对象,对象内容发生变化
a = [1, 2, 3] #列表对象 #调用函数 f(a) print(a) # 函数外:输出的结果也会发生变化,a=[1,2,3,100]
这里lst和a指向同一个列表对象。append是原地修改操作,改变的是对象内部状态,而不是绑定关系。对象被改了,自然所有指向它的名字看到的都是新内容。
再看另一种写法:
#定义函数f() def f(lst): lst = lst + [100] #生成新的对象,并绑定到lst,原对象未变
a = [1, 2, 3] #列表对象 #调用函数f() f(a) print(a)
这里发生的事情完全不同。lst + [100]会创建一个新的列表对象,然后让局部名字lst指向它。原来的列表对象没有被动过。a依然绑定在旧对象上,自然毫发无损。
这就是判断准则:修改对象本身,会影响外部;重新绑定形参,不会影响外部。
很多争论“值传递还是引用传递”的讨论,其实都在绕圈。Python既不是传统意义上的值传递,也不是C++那种引用传递。更准确的说法是“对象共享调用”或者“对象引用传递”。传递的是对象的引用,函数内部拿到的是同一个对象。
再看不可变对象。
#定义函数 def f(x): x += 1 # 生成新对象,并绑定到形参x上,原对象未变
n = 10 # 不可变的对象 #调用函数 f(n) print(n) # 实参引用的对象未变
这里很多人会说“整数是值传递”。其实不是。整数也是对象。区别在于,整数是不可变对象。x += 1并没有修改原整数,而是创建了一个新整数,然后让x指向它。n始终指向旧整数。
注意一个细节:+=到底是原地修改还是新建对象,取决于对象类型。列表的+=通常是原地扩展,整数的+=一定是新建对象。这不是运算符的差异,而是对象实现的差异。
真正让人崩溃的,是默认参数。
#函数定义(里面有个“万恶天坑”的默认参数catch) def f(x, cache=[]): cache.append(x) return cache
默认参数表达式只在函数定义时执行一次。cache这个列表对象在函数被定义时就创建好了,之后每次调用都复用它。多次调用之间共享同一个对象,这种行为对习惯C/C++局部变量的人来说非常反直觉。
于是第二次调用时发现结果“叠加”了,怀疑人生。
更稳妥的写法是:
def f(x, cache=None):
if cache is None: cache = []
这种写法把“是否传参”和“是否创建新对象”分开处理。cache为None时才创建新列表。这样每次默认调用都是独立的。
很多线上Bug都源自这个细节。不是语法错,而是理解不到位。
在工程实践中,函数设计需要明确一个问题:是否允许修改传入对象。
如果函数的职责是“填充列表”“更新字典”,那就应该清晰地在文档或命名中表达副作用,然后直接原地修改。如果函数的职责是“计算并返回新结果”,那就应该避免修改输入参数,必要时在内部做副本,比如lst.copy()。
团队代码风格混乱时,最常见的问题不是性能,而是副作用不透明。一个函数悄悄改了外部状态,排查成本会指数级上升。
当理解了名字绑定机制,很多现象都会变得自然。函数调用本质上是创建新的局部名字,把它们绑定到已有对象上。函数内部能改变对象内容,但改变不了外部名字的绑定关系。不可变对象永远无法被“就地修改”。默认参数在定义阶段就已经定型。
回过头看那些纠结“到底是值传递还是引用传递”的争论,会发现方向本身就错了。Python的世界里,一切都是对象,名字只是访问路径。参数传递不过是多了一条访问路径。
真正重要的,不是记住几条规则,而是建立那种“对象在后,名字在前”的直觉。写函数时自然会思考:这是在改对象,还是在改绑定?这个副作用是否符合预期?这个默认参数是否会共享?
当这种底层理解扎实之后,函数参数不再是陷阱,而是工具。代码变得可预测,行为变得透明。那些深夜排查的迷雾,也会慢慢散开。
技术成长的关键节点,往往不是掌握多少框架,而是理解多少语言本质。函数参数传递只是其中一环,但它牵动的是对“对象”这个概念的认知深度。理解这一层之后,再回头看Python,会发现很多机制其实一脉相承,像一张网,轻轻一拉,整片结构都跟着清晰起来……