Python函数参数传递是值传递还是引用传递
核心结论先知:Python 的参数传递既不是传统 C/C++ 意义上的“值传递”,也不是典型语言中的“引用传递”,其官方规范定义的专有机制为“对象的引用传递”(Pass by Object Reference),亦称“赋值传递”(Pass by Assignment)。函数调用时,系统传递的是变量所指向的对象引用(指针拷贝),而函数内部对实参的影响完全取决于该对象是可变(Mutable)还是不可变(Immutable),以及执行的是“就地修改”还是“重新赋值”。
假设你在日常开发中写下了两段简短的 Python 测试代码:第一段定义了一个函数,在内部对传入的列表执行 append() 操作,函数返回后,外部的原始列表被实实在在地修改了;第二段函数试图对传入的一个整数执行 x += 1,但函数执行结束后,外层定义的整数变量却毫无波澜。
这种看似“精神分裂”的现象常常让习惯了其他编程语言的开发者陷入争论:如果说它是“值传递”,为什么列表会在外部被改掉?如果说它是“引用传递”,为什么整数在里面自增后,外部变量却纹丝不动?要彻底理清这个问题,我们必须抛开传统教科书里的二元划分,深入到 Python 底层的“名字-对象绑定”模型中。
一、为什么传统二元分类在 Python 中失效?
在传统的编译型语言(如 C/C++ 或 Pascal)中,参数传递主要分为两种经典形式:
- 传统值传递(Pass by Value):调用函数时,直接在内存中把实参的值完完整整地复制一份副本给形参。形参在栈上拥有独立的内存地址,函数内部对形参的任何读写操作,绝不会触碰到调用方的原始数据。
- 传统引用传递(Pass by Reference):形参直接成为实参变量本身的“别名(Alias)”。形参并不开辟存储对象地址的新独立变量,形参与实参在语法与地址级别完全等价,在函数内修改形参绑定的目标,外部实参变量直接被同步改写。
而在 Python 的运行哲学中,“一切皆对象,变量只是贴在对象上的便利贴(标签)”。当你写下 a = [1, 2, 3] 时,Python 先在堆内存中创建了一个列表对象 [1, 2, 3],然后把名字 a 绑定到了这个对象所在的内存地址上。变量本身并不存储数据实体,它只是一个指针引用。
当调用函数 def func(param): 并传入 func(a) 时,Python 实际执行的底层操作类似于:param = a。这就是为什么官方文档称其为“赋值传递”。形参 param 获得的是对象指针的一份拷贝,因此在函数刚启动时,param 和 a 指向完全相同的堆内存对象。
二、核心分水岭:可变对象与不可变对象
既然所有参数传递的都是对象指针的拷贝,那为什么整数、字符串表现得像“值传递”,而列表、字典表现得像“引用传递”?这完全取决于 Python 对象的可变性(Mutability):
| 分类 | 包含的主要内置类型 | 修改属性/内容时的底层行为 | 函数内表现特征 |
|---|---|---|---|
| 不可变对象 (Immutable) | int, float, str, tuple, bytes, frozenset, bool |
对象内存块只读。任何修改操作都会在堆中开辟新对象,并将变量重新绑定到新地址。 | 类似“值传递”。外部实参永远不会因为函数内的计算而改变。 |
| 可变对象 (Mutable) | list, dict, set, bytearray 以及多数自定义类实例 |
支持就地修改(In-place Mutation),在保持自身内存地址不变的前提下改变内容。 | 就地修改会同步影响外部实参;但若执行整块重新赋值,外部实参不受影响。 |
三、不可变对象传参推导:为什么外部数据绝对安全?
我们通过一段具体的示例代码和 id() 内存地址追踪,来看不可变对象在函数调用中的三个关键阶段:
def increment(x):
print(f"[函数内部] 刚进入时 x 的内存地址: {id(x)}, 值: {x}")
# 执行 += 操作
x += 1
print(f"[函数内部] 执行 x += 1 后 x 的内存地址: {id(x)}, 值: {x}")
num = 100
print(f"[外部调用方] 调用前 num 的内存地址: {id(num)}, 值: {num}")
increment(num)
print(f"[外部调用方] 调用后 num 的内存地址: {id(num)}, 值: {num}")
运行上述代码,你会得到如下格式的输出结果:
[外部调用方] 调用前 num 的内存地址: 140733856238496, 值: 100
[函数内部] 刚进入时 x 的内存地址: 140733856238496, 值: 100
[函数内部] 执行 x += 1 后 x 的内存地址: 140733856238528, 值: 101
[外部调用方] 调用后 num 的内存地址: 140733856238496, 值: 100
观察输出地址的变化,背后的原理一目了然:
- 初始绑定相同:刚进入
increment函数时,形参x和实参num的id是一模一样的,证明此时确实传的是同一个整数对象100的引用。 - 不可变类型的重绑定:由于整数类型在 Python 中是不可变的,表达式
x += 1等价于x = x + 1。Python 先计算100 + 1 = 101,并在内存中生成了一个新的整数对象101(地址变为了...528),然后把局部变量x的标签贴到了这个新对象上。 - 实参保持完好:外部变量
num依然老老实实地贴在原始的100上,它的指针从未被动摇过。随着函数执行完毕,局部变量x被销毁,新生成的101如果没有被return,也会随后被垃圾回收。
四、可变对象传参的巨大差异:就地修改 vs 重新赋值
对于列表(list)、字典(dict)等可变类型,很多人常脱口而出“这就是引用传递”。但请务必注意:你在函数体内写出的操作语法,会直接逆转其行为走向!
1. 操作 A:就地修改(In-place Mutation)
所谓就地修改,是指调用对象自身的方法或索引下标来修改其内部内容,例如 lst.append()、lst.pop()、lst[0] = 99、dict['key'] = val 等。这些操作不会改变对象本身的内存地址(id 保持不变)。
由于实参和形参此时共同绑定在这个唯一的列表对象上,函数内部改动列表内部的元素,外部实参自然一清二楚地“看到”了这个改动:
def mutate_list(target):
# 就地修改:调用 append 方法
target.append(999)
my_list = [1, 2]
mutate_list(my_list)
print(my_list) # 输出: [1, 2, 999] -> 外部列表被修改了!
2. 操作 B:重新赋值(Reassignment)
很多初学者会掉进这个经典陷阱:在函数内直接写 target = [1, 2, 3],试图通过形参替换整个外部列表。然而,这不仅不会改动外层列表,反而会导致形参彻底与外层脱钩:
def reassign_list(target):
# 重新赋值:使用赋值符号 = 绑定全新对象
target = [999, 888]
my_list = [1, 2]
reassign_list(my_list)
print(my_list) # 输出: [1, 2] -> 外部列表丝毫不受影响!
这是因为在 Python 中,= 运算符的语义永远是“把左侧的名字标签绑定到右侧的对象”,它绝不会去改写原本绑定的那个对象本身。一旦你对形参执行了 =,形参 target 就会带着它的标签头也不回地投奔新列表,而外部实参 my_list 仍然静静守着旧列表。
五、工程开发中最容易踩的两个“大坑”及解决规范
理解了 Python 参数传递的本质后,我们就能轻松避开实际业务代码中出现频率最高的两个陷阱:
陷阱 1:绝不能使用可变对象作为函数的默认参数
在定义函数时,很多开发者喜欢偷懒写下这样的代码:
# 错误示范:使用可变对象 [] 作为默认值
def append_item(item, box=[]):
box.append(item)
return box
print(append_item("苹果")) # 输出: ['苹果']
print(append_item("香蕉")) # 预期: ['香蕉'],实际输出: ['苹果', '香蕉'] !
原因剖析:在 Python 中,def 语句是一个可执行的代码块。默认参数只会在函数定义被解释加载的那一刻创建一次,随后这个默认对象就会永久存放在该函数的 __defaults__ 元组中。后续多次调用该函数且不传实参时,所有调用都共享这唯一的一个列表对象!
标准最佳实践:使用不可变对象 None 作为哨兵值,并在函数内部动态初始化:
def append_item(item, box=None):
if box is None:
box = [] # 每次调用时独立创建新的空列表
box.append(item)
return box
陷阱 2:函数隐式修改了外部传入的业务数据
有时一个负责数据清洗或格式化的辅助函数,为了“图省事”在内部调用了 pop() 或 del,导致外部传递进来的字典或列表丢失了关键数据:
def clean_user_data(user_dict):
# 就地删除了外部实参里的敏感字段
user_dict.pop("password", None)
return user_dict
# 外部调用者如果后续还要校验 password,就会直接抛出 KeyError!
防御性编程对策:若函数需要修改容器且不希望产生副作用,应在进入函数的第一时间进行拷贝隔离:
- 浅拷贝(适合一维纯标量容器):使用
data = incoming.copy()或切片data = incoming[:]。 - 深拷贝(适合包含嵌套列表/字典的复杂树状结构):导入标准库模块并执行
import copy; data = copy.deepcopy(incoming)。
六、传参行为判定与防错决策全景图
在日常编写函数或进行 Code Review 时,可以通过下面的决策树快速确认你的代码行为与潜在风险:
七、全文终极总结
回到最初的问题:“Python函数参数传递是值传递还是引用传递?”
准确的回答应当是:Python 是“赋值传递(对象引用传递)”。不要试图用传统语言的二元框架去强行套用 Python,只要记住以下四句核心规律,就能在任何场景下准确预判参数的行为:
- 变量是标签,对象是容器:变量并不存储实体,传参传递的是贴在对象上的“指针拷贝”。
- 不可变对象自动解耦:数值、字符串和元组无法原地修改,任何重新赋值或运算符都会引发新对象的诞生,与调用方彻底脱钩,表现如同“值传递”。
- 可变对象就地修改共享生效:列表、字典的原地修改(如
append、pop)会作用于唯一的堆对象上,调用方可见。 - 赋值符号
=永远切断联系:无论实参是何种类型,在函数内直接对形参执行=重新赋值,仅仅是改变了形参的指向,绝不会波及外部实参。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









