Python 参数传递到底是值传递还是引用传递?从底层模型到实战避坑全面解析
在 Python 编程中,有一个问题几乎每个开发者都会遇到:
Python 的参数传递,到底是值传递,还是引用传递?
很多初学者会这样理解:
def change(x):
x = 100
a = 1
change(a)
print(a) # 1
于是得出结论:Python 是值传递。
但再看下面这段代码:
def add_item(items):
items.append("Python")
languages = ["Java"]
add_item(languages)
print(languages) # ['Java', 'Python']
这一次,函数内部修改了外部变量,于是又有人说:Python 是引用传递。
那么问题来了:
为什么有时候函数内部改不了外部变量,有时候又能改?
Python 参数传递到底是哪一种模型?
答案是:
Python 既不是传统意义上的值传递,也不是传统意义上的引用传递,而是“对象引用传递”,也常被称为“共享传参”或“传对象引用”。
更准确地说:
函数调用时,Python 会把实参所绑定的对象引用,赋值给函数的形参。形参和实参最初指向同一个对象,但形参本身是函数内部的局部变量名。
理解这句话,你就能真正理解 Python 的变量、对象、函数参数、可变对象、不可变对象,以及那些常见但隐蔽的 bug。
一、先从 Python 的变量模型讲起
在很多语言里,变量像一个盒子,值被放进盒子里。
但在 Python 中,更合适的理解是:
变量名是标签,对象才是真正的数据实体。
例如:
a = [1, 2, 3]
可以理解为:
变量名 a ---> 列表对象 [1, 2, 3]
当我们写:
b = a
并不是复制了一个新列表,而是让 b 也指向同一个列表对象:
a ----\
---> [1, 2, 3]
b ----/
所以:
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4]
print(b) # [1, 2, 3, 4]
因为 a 和 b 指向同一个对象。
我们可以用 id() 来验证:
a = [1, 2, 3]
b = a
print(id(a))
print(id(b))
print(a is b) # True
is 判断两个变量是否引用同一个对象,== 判断两个对象的值是否相等:
x = [1, 2]
y = [1, 2]
print(x == y) # True,内容相等
print(x is y) # False,不是同一个对象
这是理解 Python 参数传递的基础。
二、函数调用时到底发生了什么?
看一段简单代码:
def show(value):
print(value)
name = "Python"
show(name)
当调用 show(name) 时,Python 做的事情可以理解为:
value = name
也就是说,函数形参 value 成为了一个新的局部变量名,它和外部变量 name 指向同一个字符串对象。
示意图如下:
调用前:
name ---> "Python"
调用 show(name) 后:
name ----\
---> "Python"
value ----/
这就是 Python 参数传递的核心:
参数传递不是复制整个对象,而是把对象引用绑定给形参。
但是,形参是一个新的局部变量名。你可以让它重新绑定到别的对象,而不会影响外部变量名。
三、为什么不可变对象看起来像“值传递”?
看这个例子:
def change_number(x):
x = 100
print("函数内:", x)
n = 1
change_number(n)
print("函数外:", n)
输出:
函数内: 100
函数外: 1
很多人据此认为 Python 是值传递。其实不是。
调用函数时:
n ---> 1
x ---> 1
进入函数后执行:
x = 100
这不是把原来的整数对象 1 改成 100,而是让局部变量 x 重新绑定到整数对象 100。
n ---> 1
x ---> 100
外部变量 n 仍然指向 1,所以不会改变。
字符串也是类似:
def change_text(s):
s += " world"
print("函数内:", s)
text = "hello"
change_text(text)
print("函数外:", text)
输出:
函数内: hello world
函数外: hello
因为字符串是不可变对象。s += " world" 实际上创建了一个新字符串,并让局部变量 s 指向新对象。
所以,不可变对象在函数里被“修改”时,通常表现得像值传递。
但这只是表象。
四、为什么可变对象看起来像“引用传递”?
再看列表:
def add_item(items):
items.append("Python")
languages = ["Java"]
add_item(languages)
print(languages)
输出:
['Java', 'Python']
调用函数时:
languages ----\
---> ['Java']
items ----/
形参 items 和实参 languages 指向同一个列表对象。
执行:
items.append("Python")
这不是让 items 重新绑定,而是修改它所指向的那个列表对象本身。
修改后:
languages ----\
---> ['Java', 'Python']
items ----/
所以函数外部看到的列表也变了。
这就是为什么可变对象看起来像引用传递。
常见可变对象包括:
list
dict
set
bytearray
大多数自定义类实例
常见不可变对象包括:
int
float
bool
str
tuple
frozenset
bytes
None
五、Python 真正的模型:对象共享传参
为了避免“值传递”和“引用传递”带来的误解,建议记住下面这句话:
Python 的参数传递是对象共享传参:函数形参和外部实参共享同一个对象引用,但形参变量名本身可以在函数内部重新绑定。
它有两个关键点。
第一,函数参数接收的是对象引用,不是对象完整副本。
第二,函数内部给形参重新赋值,只会改变局部变量绑定,不会改变外部变量绑定。
看这个例子:
def demo(items):
print("进入函数:", id(items))
items.append(3)
print("append 后:", items, id(items))
items = ["new"]
print("重新赋值后:", items, id(items))
data = [1, 2]
print("调用前:", data, id(data))
demo(data)
print("调用后:", data, id(data))
可能输出:
调用前: [1, 2] 1400000001
进入函数: 1400000001
append 后: [1, 2, 3] 1400000001
重新赋值后: ['new'] 1400000999
调用后: [1, 2, 3] 1400000001
解释一下:
items.append(3)
修改的是共享列表对象,所以外部能看到变化。
但:
items = ["new"]
只是让局部变量 items 指向新列表,不会影响外部变量 data。
这段代码是理解 Python 参数传递模型的经典例子。
六、+= 的特殊陷阱:它到底是修改还是重新绑定?
+= 在 Python 中很容易让人误判。
对于不可变对象:
def add_number(x):
x += 1
n = 10
add_number(n)
print(n) # 10
整数不可变,x += 1 等价于创建新整数并重新绑定 x,不会影响外部 n。
但对于列表:
def extend_list(items):
items += [3, 4]
data = [1, 2]
extend_list(data)
print(data) # [1, 2, 3, 4]
列表的 += 通常是原地扩展,等价于:
items.extend([3, 4])
所以外部列表会被修改。
再对比:
def add_list(items):
items = items + [3, 4]
data = [1, 2]
add_list(data)
print(data) # [1, 2]
items = items + [3, 4] 会创建一个新列表,然后让局部变量 items 指向它,因此不影响外部。
这就是 Python 中非常值得注意的区别:
items += other
可能原地修改。
items = items + other
通常创建新对象并重新绑定。
在代码审查中,我经常会特别留意 +=。它不是语法糖那么简单,它背后调用的是对象自己的原地操作协议。
七、默认参数陷阱:参数传递模型的经典事故现场
Python 中最经典的坑,莫过于可变默认参数:
def add_task(task, task_list=[]):
task_list.append(task)
return task_list
print(add_task("写文档"))
print(add_task("写测试"))
print(add_task("发布上线"))
你可能以为输出是:
['写文档']
['写测试']
['发布上线']
实际输出却是:
['写文档']
['写文档', '写测试']
['写文档', '写测试', '发布上线']
原因是:函数默认参数在函数定义时只创建一次。
也就是说,task_list=[] 这个列表对象会被多次调用共享。
正确写法是:
def add_task(task, task_list=None):
if task_list is None:
task_list = []
task_list.append(task)
return task_list
print(add_task("写文档"))
print(add_task("写测试"))
print(add_task("发布上线"))
输出:
['写文档']
['写测试']
['发布上线']
这个问题在 Web 接口、数据处理、任务队列、配置构建中非常常见。尤其是写工具函数时,千万不要把 list、dict、set 作为默认参数。
错误写法:
def create_user(name, tags=[]):
tags.append("new")
return {"name": name, "tags": tags}
推荐写法:
def create_user(name, tags=None):
if tags is None:
tags = []
return {"name": name, "tags": tags}
这不是“风格问题”,而是稳定性问题。
八、实战案例:清洗用户数据时,函数应该修改原数据吗?
假设我们有一批用户数据:
users = [
{"name": " alice ", "email": "ALICE@example.com"},
{"name": " bob ", "email": "BOB@example.com"},
]
现在要清洗数据:去掉姓名两边空格,把邮箱转小写。
第一种写法:原地修改。
def normalize_users_inplace(users):
for user in users:
user["name"] = user["name"].strip().title()
user["email"] = user["email"].lower()
normalize_users_inplace(users)
print(users)
优点是节省内存,适合数据量很大、明确允许修改原数据的场景。
缺点是副作用明显,调用者如果不知道函数会修改原数据,就可能踩坑。
第二种写法:返回新数据。
def normalize_users(users):
result = []
for user in users:
normalized = {
"name": user["name"].strip().title(),
"email": user["email"].lower(),
}
result.append(normalized)
return result
new_users = normalize_users(users)
print(users)
print(new_users)
这种写法更安全,函数没有悄悄修改传入对象。
在真实项目中,我建议遵循一个原则:
默认不要修改传入参数;如果要原地修改,必须在函数名、文档或注释中明确表达。
例如:
def sort_records_inplace(records):
"""原地排序 records,会修改传入列表。"""
records.sort(key=lambda item: item["created_at"])
而如果函数返回新对象,可以这样命名:
def sorted_records(records):
"""返回排序后的新列表,不修改原 records。"""
return sorted(records, key=lambda item: item["created_at"])
清晰的命名,本质上是在降低团队沟通成本。
九、浅拷贝与深拷贝:避免“我明明复制了,怎么还会变?”
参数传递和对象共享还会引出另一个问题:复制。
def update_first(items):
items[0].append(99)
data = [[1, 2], [3, 4]]
copied = data.copy()
update_first(copied)
print(data)
print(copied)
输出:
[[1, 2, 99], [3, 4]]
[[1, 2, 99], [3, 4]]
为什么 data 也变了?
因为 copy() 是浅拷贝,只复制外层列表,内层列表仍然共享。
data ---> 外层列表 A ---> 内层列表 [1, 2]
copied ---> 外层列表 B ----/
如果需要完全隔离嵌套对象,可以使用深拷贝:
import copy
data = [[1, 2], [3, 4]]
copied = copy.deepcopy(data)
copied[0].append(99)
print(data) # [[1, 2], [3, 4]]
print(copied) # [[1, 2, 99], [3, 4]]
但深拷贝不是万能药。它可能带来性能开销,也可能在复杂对象、文件句柄、数据库连接等场景中产生意料之外的问题。
实践建议是:
简单一层数据:浅拷贝通常足够
嵌套可变数据:考虑深拷贝
配置和常量:优先设计为不可变对象
大型数据:避免盲目复制,明确所有权
十、面向对象中的参数传递:self 也是一个引用
很多人学类的时候会问:self 到底是什么?
看代码:
class Counter:
def __init__(self):
self.value = 0
def increase(self):
self.value += 1
counter = Counter()
counter.increase()
print(counter.value) # 1
调用:
counter.increase()
可以粗略理解为:
Counter.increase(counter)
也就是说,self 是指向当前实例对象的局部变量名。
counter ----\
---> Counter 实例对象
self ----/
在方法内部:
self.value += 1
修改的是实例对象的属性,所以外部的 counter.value 会变化。
但如果你在方法内部写:
class Counter:
def reset_wrong(self):
self = Counter()
这并不会让外部对象被替换,只是让局部变量 self 重新绑定到了一个新对象。
正确的重置应该是:
class Counter:
def __init__(self):
self.value = 0
def reset(self):
self.value = 0
这再次说明:
重新绑定局部变量不会影响外部绑定,修改共享对象本身才会影响外部。
十一、进阶视角:参数传递与不可变设计
当项目变大后,最可怕的不是代码报错,而是状态被悄悄改掉。
例如:
def apply_discount(order):
order["price"] *= 0.8
return order
这个函数会修改传入的订单字典。如果调用方还在其他地方使用原订单对象,就可能出现难以追踪的问题。
更稳妥的写法是返回新对象:
def apply_discount(order):
return {
**order,
"price": order["price"] * 0.8,
}
对于业务核心数据,还可以使用不可变数据模型:
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Order:
id: int
price: float
def apply_discount(order: Order) -> Order:
return replace(order, price=order.price * 0.8)
order = Order(id=1, price=100)
new_order = apply_discount(order)
print(order) # Order(id=1, price=100)
print(new_order) # Order(id=1, price=80.0)
不可变对象的好处是:
更容易推理
更适合缓存
更适合并发
更少出现隐藏副作用
更方便测试
这也是为什么很多现代编程范式都强调“少修改共享状态”。
十二、与装饰器结合:观察函数是否修改了参数
在调试复杂项目时,我们可以写一个简单装饰器,观察函数调用前后参数的变化。
import copy
import functools
def watch_mutation(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
before_args = copy.deepcopy(args)
before_kwargs = copy.deepcopy(kwargs)
result = func(*args, **kwargs)
if before_args != args or before_kwargs != kwargs:
print(f"[警告] {func.__name__} 修改了传入参数")
return result
return wrapper
使用示例:
@watch_mutation
def add_item(items):
items.append("new")
data = []
add_item(data)
输出:
[警告] add_item 修改了传入参数
这类工具在排查副作用问题时非常有用。不过要注意,deepcopy 有性能成本,不建议在线上高频路径中随意使用,更适合作为调试工具或测试辅助工具。
十三、单元测试:把“是否修改参数”写进测试
优秀的 Python 最佳实践,不只是写出功能,还要明确函数边界。
假设我们希望 normalize_users() 不修改原始数据,可以这样测试:
def normalize_users(users):
return [
{
"name": user["name"].strip().title(),
"email": user["email"].lower(),
}
for user in users
]
def test_normalize_users_does_not_mutate_input():
users = [
{"name": " alice ", "email": "ALICE@example.com"}
]
original = [user.copy() for user in users]
normalize_users(users)
assert users == original
这个测试看似简单,但它保护的是函数设计契约:
调用这个函数不会改变传入参数。
在团队协作中,这类测试非常有价值,因为它能防止后续重构时无意引入副作用。
十四、性能视角:复制更安全,但不是越多越好
既然修改传入参数容易出问题,那是不是所有函数都应该复制数据?
也不是。
对于小型数据,复制通常没问题。
但对于大型列表、数据表、图结构、机器学习样本、实时流数据,盲目复制会带来明显的内存和性能压力。
比较合理的做法是:
业务核心对象:优先安全,避免隐式修改
性能敏感路径:允许原地修改,但必须明确约定
工具函数:默认返回新对象
底层算法:可提供 inplace 参数
例如很多数据处理库会采用这样的设计:
def normalize(records, inplace=False):
if not inplace:
records = [record.copy() for record in records]
for record in records:
record["name"] = record["name"].strip().title()
return records
调用方可以明确选择:
new_records = normalize(records)
normalize(records, inplace=True)
这比函数偷偷修改参数要清晰得多。
十五、常见面试题:下面代码输出什么?
例 1:整数重新绑定
def f(x):
x += 1
a = 10
f(a)
print(a)
答案:
10
整数不可变,x += 1 重新绑定局部变量。
例 2:列表原地修改
def f(x):
x.append(1)
a = []
f(a)
print(a)
答案:
[1]
列表可变,append() 修改共享对象。
例 3:列表重新绑定
def f(x):
x = x + [1]
a = []
f(a)
print(a)
答案:
[]
x + [1] 创建新列表,x = ... 只重新绑定局部变量。
例 4:列表原地扩展
def f(x):
x += [1]
a = []
f(a)
print(a)
答案:
[1]
列表的 += 会原地扩展。
例 5:字典修改
def f(d):
d["lang"] = "Python"
config = {}
f(config)
print(config)
答案:
{'lang': 'Python'}
字典是可变对象,函数内部修改会影响外部。
十六、最佳实践清单
在真实项目中,建议记住这些原则:
-
不要简单说 Python 是值传递或引用传递。
更准确的说法是对象共享传参,或者对象引用按赋值传递。 -
区分“重新绑定”和“修改对象”。
x = new_obj是重新绑定;x.append()、x.update()是修改对象。 -
警惕可变默认参数。
不要写def func(items=[]),优先写def func(items=None)。 -
函数是否修改参数,要通过命名和文档说清楚。
例如normalize()返回新对象,normalize_inplace()原地修改。 -
嵌套结构复制时,区分浅拷贝和深拷贝。
copy()只复制外层,deepcopy()递归复制内部对象。 -
业务核心数据尽量不可变。
可以使用tuple、frozenset、dataclass(frozen=True)等方式降低副作用。 -
性能敏感场景允许原地修改,但要显式声明。
例如提供inplace=True参数,让调用者自己选择。 -
用测试保护函数边界。
如果函数承诺不修改输入,就应该写测试验证。
十七、总结:理解参数传递,就是理解 Python 的对象世界
Python 的参数传递问题,看似只是基础语法,背后却连接着变量模型、对象模型、可变性、函数设计、面向对象、测试、性能优化和工程实践。
一句话总结:
Python 函数调用时,形参会绑定到实参所引用的对象。函数内部如果修改这个共享对象,外部能看到变化;如果只是让形参重新绑定到新对象,外部不会受影响。
所以,Python 不是传统意义上的值传递,也不是 C++ 那种典型引用传递。它更像是:
对象引用被传入函数;
函数形参成为新的局部名字;
局部名字和外部名字最初共享同一个对象;
修改对象会影响共享者;
重新绑定名字只影响当前作用域。
当你真正理解这一点,很多 Python 行为都会变得清晰:
为什么列表参数会被函数修改?
为什么整数参数不会变?
为什么默认参数里的列表会被复用?
为什么 copy 后嵌套列表还会互相影响?
为什么 self 改属性有效,self = NewObject() 却无效?
Python 的魅力,恰恰在于它表面简洁,内部统一。
你越理解它的对象模型,就越能写出稳定、优雅、可维护的代码。
最后留给你两个问题:
你在项目中是否遇到过函数意外修改参数导致的 bug?
在你的团队里,是否会明确区分“返回新对象”和“原地修改”的函数命名?
欢迎在评论区分享你的经历。真正的 Python 成长,往往不是从背语法开始,而是从理解这些“看似简单却影响深远”的细节开始。


1万+

被折叠的 条评论
为什么被折叠?



