一、一个令人困惑的场景

你刚学 Python 不久,写了这样一段代码:

1
2
3
4
5
6
def try_change(x):
x = 100

num = 42
try_change(num)
print(num) # 输出:42 —— 为什么不是 100?

你期望 num 被改成 100,但它纹丝不动。然而换一种写法:

1
2
3
4
5
6
def try_append(lst):
lst.append(4)

nums = [1, 2, 3]
try_append(nums)
print(nums) # 输出:[1, 2, 3, 4] —— 居然改了!

列表却被成功修改了。同一个函数、同一种"传参",为什么有时改了外面,有时改不了?Python 到底是"传值"还是"传引用"?

答案是:都不是。Python 使用的是一种更精确的机制——传对象引用(Pass by Object Reference)

二、核心概念:传对象引用

2.1 变量是"标签",不是"盒子"

在 C++ 中,变量是一个"盒子",里面装着值。赋值就是把新值放进盒子。

在 Python 中,变量是一个"标签"(也叫"名字"),贴在对象上。赋值是把标签撕下来,贴到另一个对象上。

1
2
a = 42    # 创建整数对象 42,把标签 a 贴上去
a = 100 # 创建整数对象 100,把标签 a 从 42 撕下来,贴到 100 上

2.2 函数调用时发生了什么

当你调用 f(x) 时,Python 做的事情是:

  1. 在函数的局部框架中创建一个新的标签(形参名)
  2. 让这个标签指向实参所指向的同一个对象

也就是说,函数内外有两个标签指向同一个对象。这就是"传对象引用"——传的是引用(标签的副本),但引用指向的对象是同一个。

1
2
调用前:  num ──→ [42]
调用时: num ──→ [42] ←── x(函数内的标签)

两个标签指向同一个对象,但它们是独立的标签。接下来会发生什么,取决于这个对象是可变的还是不可变的。

三、关键区分:可变对象 vs 不可变对象

3.1 不可变对象:= 断开了联系

不可变对象包括:intfloatstrtuplefrozenset 等。它们的特征是一旦创建,值就无法被原地修改

1
2
3
4
5
6
7
def try_change(x):
x = 100 # x 这个标签从 [42] 撕下来,贴到了 [100] 上
# 但外面的 num 仍然贴在 [42] 上

num = 42
try_change(num)
print(num) # 输出:42

图解分析:

1
2
3
4
5
6
7
调用前:  num ──→ [42]

进入函数:num ──→ [42] ←── x

执行 x = 100:
num ──→ [42] x ──→ [100]
(num 和 x 已经没有任何关系了)

= 赋值操作的本质是重新绑定标签,而不是修改对象。对不可变对象来说,你无法修改对象本身,只能让标签指向新对象。而函数内的标签是局部的,它的重新绑定不会影响外面的标签。

3.2 可变对象:原地修改影响外部

可变对象包括:listdictset 等。它们的特征是可以在原地修改内容,而不创建新对象

1
2
3
4
5
6
7
def try_append(lst):
lst.append(4) # 直接在 [1,2,3] 这个对象上追加元素
# 函数内外的标签仍然指向同一个对象

nums = [1, 2, 3]
try_append(nums)
print(nums) # 输出:[1, 2, 3, 4]

图解分析:

1
2
3
4
5
6
7
调用前:  nums ──→ [1, 2, 3]

进入函数:nums ──→ [1, 2, 3] ←── lst

执行 lst.append(4):
nums ──→ [1, 2, 3, 4] ←── lst
(同一个对象被修改了,两个标签都能看到变化)

append() 是原地修改操作,它没有创建新对象,而是直接改了共享的那个列表。所以函数外部的 nums 也能看到变化。

3.3 可变对象 + = 赋值:联系断开

如果对可变对象使用 =,结果和不可变对象一样——标签重新绑定,联系断开:

1
2
3
4
5
6
7
def try_replace(lst):
lst = [10, 20, 30] # lst 标签指向了一个全新的列表
# 外面的 nums 仍然指向旧列表

nums = [1, 2, 3]
try_replace(nums)
print(nums) # 输出:[1, 2, 3] —— 没有被修改!

图解分析:

1
2
3
4
5
6
调用前:  nums ──→ [1, 2, 3]

进入函数:nums ──→ [1, 2, 3] ←── lst

执行 lst = [10, 20, 30]:
nums ──→ [1, 2, 3] lst ──→ [10, 20, 30]

关键结论:决定外部变量是否被修改的,不是对象的类型,而是你做了什么操作——是原地修改,还是 = 重新绑定。

四、深入:= 赋值 vs 原地修改

4.1 = 永远是重新绑定

无论对象是可变还是不可变,= 的语义始终一致:让左边的标签指向右边的新对象。

1
2
3
4
a = [1, 2]
b = a # a 和 b 指向同一个列表
a = [3, 4] # a 指向新列表,b 不受影响
print(b) # 输出:[1, 2]

4.2 常见的原地修改操作

类型 原地修改方法 说明
list .append(x) 末尾追加元素
list .extend(iterable) 追加多个元素
list .insert(i, x) 在位置 i 插入元素
list .remove(x) 删除第一个等于 x 的元素
list .pop([i]) 弹出并返回元素
list .sort() 原地排序
list .reverse() 原地反转
list lst[i] = x 修改指定位置的元素
dict .update(other) 合并字典
dict .pop(key) 删除键
dict d[key] = value 添加或修改键值对
set .add(x) 添加元素
set .discard(x) 删除元素

这些操作的共同点是:不创建新对象,直接在原对象上改

4.3 对比:原地排序 vs 返回新排序

1
2
3
4
5
6
7
8
9
10
# 原地排序 —— 影响原对象
nums = [3, 1, 2]
nums.sort()
print(nums) # 输出:[1, 2, 3]

# 返回新列表 —— 不影响原对象
nums = [3, 1, 2]
new_nums = sorted(nums)
print(nums) # 输出:[3, 1, 2]
print(new_nums) # 输出:[1, 2, 3]

五、实践技巧:如何"模拟"引用传递

5.1 方案一:使用 return 返回值(最推荐)

这是最 Pythonic 的方式。函数负责计算,调用者负责更新:

1
2
3
4
5
6
def add_ten(x):
return x + 10

num = 42
num = add_ten(num)
print(num) # 输出:52

优点:意图清晰,没有副作用,易于测试和理解。

5.2 方案二:使用可变容器包装

将不可变对象放入可变容器中,通过修改容器来间接修改:

1
2
3
4
5
6
def add_ten(wrapper):
wrapper[0] += 10

num_wrapper = [42]
add_ten(num_wrapper)
print(num_wrapper[0]) # 输出:52

这种方式在需要返回多个值时也有用:

1
2
3
4
5
6
7
def swap(wrapper_a, wrapper_b):
wrapper_a[0], wrapper_b[0] = wrapper_b[0], wrapper_a[0]

a = [1]
b = [2]
swap(a, b)
print(a[0], b[0]) # 输出:2 1

5.3 方案三:利用类的属性

将数据封装在类中,通过方法修改属性:

1
2
3
4
5
6
7
8
9
10
class Counter:
def __init__(self, value=0):
self.value = value

def increment(self, step=1):
self.value += step

counter = Counter(42)
counter.increment(10)
print(counter.value) # 输出:52

这是面向对象编程中管理状态的标准方式,适合需要维护多个相关状态的场景。

六、总结表格

操作 不可变对象(int/str/tuple) 可变对象(list/dict/set)
函数内 = 赋值 外部不变 ✓ 外部不变 ✓
函数内原地修改 不可能(不可变) 外部改变 ✗
函数内 lst[i] = x 不可能(不可变) 外部改变 ✗
函数内 .append() 不可能(不可变) 外部改变 ✗

核心记忆口诀:

= 断联系,改对象传影响。可变能改,不可变只能换。

七、最佳实践

  1. 优先使用 return:函数应该通过返回值来传达结果,而不是偷偷修改传入的参数。这使代码的行为可预测、易测试。

  2. 避免修改可变参数:除非函数的明确目的就是修改传入的可变对象(如 list.sort()),否则不要在函数内部原地修改参数。

  3. 需要修改时返回副本:如果函数需要基于输入产生新结果,返回一个新对象而非修改原对象:

1
2
3
4
5
6
7
8
def add_item(lst, item):
new_lst = lst + [item] # 创建新列表
return new_lst

original = [1, 2, 3]
updated = add_item(original, 4)
print(original) # [1, 2, 3] —— 原列表不受影响
print(updated) # [1, 2, 3, 4]
  1. id() 调试:当你不确定两个变量是否指向同一个对象时,用 id() 查看:
1
2
3
4
5
6
a = [1, 2]
b = a
print(id(a) == id(b)) # True —— 同一个对象

b = [1, 2]
print(id(a) == id(b)) # False —— 不同对象(即使值相同)

Python 的参数传递机制并不复杂,关键在于理解"标签"和"对象"的关系,以及区分"重新绑定"和"原地修改"这两种操作。掌握这两点,所有看似矛盾的行为都能得到清晰的解释。