Python 面试笔记:变量绑定与参数传递
面试回答
常见问法
- Python 的参数传递到底是值传递还是引用传递?
- 为什么列表传进去能改到外部,整数通常不行?
- Python 变量和对象到底谁有类型?
a += 1和a.append(1)本质一样吗?- 为什么说 Python 里“变量只是名字”?
回答
更准确的说法是:
Python 既不是很多人直觉里的“纯值传递”,也不完全等同于 C++/Java 语境里的“引用传递”。它的本质是:函数调用时,形参会绑定到实参所指向的同一个对象。 常见表述有两个,都比“值传递/引用传递”更准确:
- 传递对象引用
- 对象共享 / 共享传参(call by sharing)
面试里可以这样回答:
Python 传参的本质是“名字绑定到对象”。函数调用时,形参只是拿到了实参对象的一个新名字,不会自动拷贝对象本身。 所以如果传入的是可变对象,并且函数内部做的是原地修改,那外部能看到变化;如果函数内部只是把形参重新绑定到一个新对象,外部不会受影响。 这也是为什么列表、字典、集合常常会被“改到外面”,而整数、字符串、元组通常不会。
一个最典型的例子:
def change_list(items):
items.append(4) # 原地修改,影响外部
def change_int(x):
x += 1 # 重新绑定,不影响外部
a = [1, 2, 3]
b = 10
change_list(a)
change_int(b)
print(a) # [1, 2, 3, 4]
print(b) # 10
这里不是因为“列表按引用传递、整数按值传递”,而是因为列表是可变对象,append 修改的是同一个对象;整数是不可变对象,x += 1 会生成新对象并让局部名字 x 重新绑定过去。
追问
1)为什么说“变量没有类型,对象才有类型”?
因为 Python 变量本质是名字,名字可以今天绑定整数,明天绑定列表:
x = 10
x = [1, 2, 3]
变的是绑定关系,不是“变量类型变化”。
真正带类型信息的是对象本身,比如 10 是 int 对象,[1, 2, 3] 是 list 对象。
2)+= 一定是重新绑定吗?
不一定。要看对象类型是否支持原地修改。
- 对
int、str、tuple这类不可变对象,+=通常会创建新对象,再重新绑定 - 对
list这类可变对象,+=往往是原地修改
a = 1
print(id(a))
a += 1
print(id(a)) # 通常变了
lst = [1, 2]
print(id(lst))
lst += [3]
print(id(lst)) # 通常不变
所以不能死记“+= 就是不影响外部”,要看对象是否可变,以及该类型的原地操作语义。
3)Python 有没有真正的“按引用传递”?
从经典语言理论上说,更推荐说 Python 是 call by sharing,而不是 pass by reference。
因为“按引用传递”在很多语言里意味着: 被调用方可以直接改掉调用方变量本身的绑定关系。
Python 不是这样:
def rebind(x):
x = [9, 9, 9]
data = [1, 2]
rebind(data)
print(data) # [1, 2]
这里函数内不能把外部变量 data 改绑到新对象上,它只能改自己局部名字 x 的绑定。
它能改到外部,只是因为 x 和 data 一开始指向同一个可变对象,而不是因为它拿到了“外部变量本身”。
4)如何避免函数偷偷修改外部数据?
工程上常见做法有三种:
- 约定函数不修改入参
- 在函数内部先拷贝再改
- 优先使用不可变数据结构或返回新对象
def safe_process(items):
items = list(items) # 浅拷贝
items.append(4)
return items
这类问题在真实项目里比“概念定义”更重要,因为它关系到副作用、可测试性和 bug 排查成本。
5)默认参数为什么容易出坑?
因为默认参数只在函数定义时计算一次,如果默认值是可变对象,就会被多次调用共享。
def add_item(item, container=[]):
container.append(item)
return container
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2]
正确写法:
def add_item(item, container=None):
if container is None:
container = []
container.append(item)
return container
这题本质上也和“名字绑定 + 可变对象共享”完全相关。
原理展开
1. Python 的变量本质是名字绑定,不是“盒子装值”
很多初学者会把变量理解成一个存数据的格子,这在 Python 里不够准确。
更贴近本质的理解是:
- 对象实际存在于内存中
- 变量名只是对对象的绑定
- 赋值语句做的事,不是复制对象,而是建立/修改绑定关系
a = [1, 2, 3]
b = a
print(a is b) # True
这里不是复制了两个列表,而是 a 和 b 绑定到了同一个列表对象。
2. 函数调用时发生了什么
函数调用时,形参会在函数局部作用域中绑定到实参对象。
def f(x):
print(x)
n = 100
f(n)
本质上类似于函数开始时执行了这样一件事:
x = n指向的那个对象
但注意,绑定的是对象,不是“外部变量本身”。
所以:
- 改对象内容,可能影响外部
- 改局部名字绑定,不影响外部
3. 重新绑定 vs 原地修改
这是这道题最核心的区分。
重新绑定
让名字指向一个新对象。
def rebind(x):
x = [9, 9, 9]
data = [1, 2]
rebind(data)
print(data) # [1, 2]
这里函数内只是让 x 指向新列表,外部 data 还指向原来的对象。
原地修改
不改变绑定关系,而是直接修改原对象内容。
def mutate(x):
x.append(9)
data = [1, 2]
mutate(data)
print(data) # [1, 2, 9]
这里 x 和 data 指向同一个列表对象,append 改的是这个对象本身。
4. 可变对象与不可变对象
这道题经常会追到“为什么 int 不行,list 可以”。
不可变对象
创建后内容不能改,常见有:
intfloatboolstrtuplefrozensetbytes
对这类对象做“修改”操作,本质上通常是创建新对象再重新绑定。
x = 10
print(id(x))
x += 1
print(id(x)) # 变了
可变对象
创建后内容可以原地修改,常见有:
listdictset- 大多数自定义类实例
lst = [1, 2]
print(id(lst))
lst.append(3)
print(id(lst)) # 通常不变
所以面试时不要说“整数传值,列表传引用”, 要说:参数传递机制一样,区别来自对象是否可变,以及操作是否是原地修改。
5. id() 能说明什么,不能说明什么
id() 常用于演示“是不是同一个对象”,但要注意边界。
def f(x):
print(id(x))
n = 100
print(id(n))
f(n)
很多场景下你会看到一样的 id,这能说明函数内外绑定的是同一个对象。
但面试时不要过度依赖 id() 来证明所有语义,因为:
- 小整数、短字符串等对象可能有缓存优化
- 实现细节与解释器有关,CPython 和其他实现未必完全一致
所以 id() 适合辅助说明,不适合作为唯一论据。
真正的判断依据仍然是:名字绑定、对象可变性、是否原地修改。
6. 自定义对象为什么也能“改到外部”
因为类实例通常是可变对象,改属性就是在改同一个对象的内部状态。
class Box:
def __init__(self, content):
self.content = content
def open_box(box):
box.content = "修改后"
my_box = Box("原始内容")
open_box(my_box)
print(my_box.content) # 修改后
这里函数并没有改掉外部变量 my_box 的绑定,
而是通过 box 这个局部名字,修改了同一个实例对象的属性。
7. 与作用域的关系:为什么局部赋值不会改到外部
函数内部直接赋值,会创建或更新局部作用域中的绑定。
x = 10
def foo():
x = 20
foo()
print(x) # 10
因为这里函数里的 x 默认是局部变量。
它只是局部名字,不会自动映射到外部同名变量。
如果真要修改外层绑定,需要显式声明:
x = 10
def foo():
global x
x = 20
foo()
print(x) # 20
这说明 Python 操作的核心对象从来不是“变量槽位”,而是不同作用域中的名字绑定关系。
8. 与拷贝语义的关系
很多工程 bug 不是出在“传参概念不会背”,而是出在共享对象被误改。
直接赋值:不拷贝
a = [[1, 2], [3, 4]]
b = a
b[0].append(99)
print(a) # [[1, 2, 99], [3, 4]]
浅拷贝:只拷贝最外层
import copy
a = [[1, 2], [3, 4]]
b = copy.copy(a)
b[0].append(99)
print(a) # [[1, 2, 99], [3, 4]]
深拷贝:递归拷贝内部对象
import copy
a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)
b[0].append(99)
print(a) # [[1, 2], [3, 4]]
所以当面试官继续追问“怎么避免副作用”时,可以自然引到:
- 明确函数是否允许修改入参
- 需要隔离时用浅拷贝/深拷贝
- 对嵌套结构尤其小心
9. 一个高频陷阱:元组不可变,但不代表里面元素都绝对不能变
元组本身不可变,是指元组内部存放的对象引用不能改; 但如果元组里放的是可变对象,那可变对象本身仍然能被改。
t = ([1, 2], [3, 4])
t[0].append(99)
print(t) # ([1, 2, 99], [3, 4])
所以“不可变”说的是容器自身的结构绑定,不是递归地让里面所有对象都不可变。
对比总结
| 概念 | 本质 | 会不会影响外部 | 典型场景 | 易混点 |
|---|---|---|---|---|
赋值 b = a | 新名字绑定到同一对象 | 会共享同一对象状态 | 变量别名、对象复用 | 误以为是复制 |
重新绑定 x = 新对象 | 局部名字改指向 | 否 | 函数内生成新结果 | 常被误认为“改了参数” |
原地修改 x.append() / x[key]=v | 修改对象内容 | 是,前提是外部也指向该对象 | 更新列表、字典、实例属性 | 常与重新赋值混淆 |
不可变对象操作 x += 1 | 常常创建新对象再绑定 | 否 | 数值计算、字符串拼接 | 误以为 += 一定是原地 |
可变对象操作 lst += [1] | 往往原地修改 | 是 | 列表扩展 | 以为所有 += 行为一样 |
浅拷贝 copy.copy() | 只复制外层容器 | 内层共享,可能影响 | 一层结构隔离 | 误以为完全独立 |
深拷贝 copy.deepcopy() | 递归复制 | 通常不影响 | 嵌套结构完全隔离 | 成本更高,不宜滥用 |
| 值传递 | 传入值副本 | 否 | C/C++ 某些场景 | Python 不是这个语义 |
| 引用传递 | 直接操作调用方变量别名 | 某些语言中可改调用方绑定 | C++ 引用参数 | Python 不推荐这样表述 |
| call by sharing | 共享对象,形参是新名字 | 改对象可见,改绑定不可见 | Python 最准确描述 | 容易被简化成“传引用” |
易错点
- 把“Python 传对象引用”直接等同于“引用传递”,表述不严谨。
- 说成“列表按引用传递,整数按值传递”,这是错的。传参机制一样,差别在对象可变性和操作类型。
- 混淆重新绑定和原地修改。
- 认为变量本身有类型。其实是对象有类型,变量只是名字。
- 认为不可变对象“完全不能变”。更准确地说,是对象状态不能原地修改,只能创建新对象。
- 误以为所有
+=都是原地操作。 - 误以为“元组不可变”就意味着元组里的成员也都不可变。
- 以为函数里给参数重新赋值,外部变量也会跟着变。
- 忽略默认可变参数带来的共享副作用。
- 只会背定义,不会落到工程实践:副作用控制、拷贝策略、接口设计。
记忆技巧
-
一句话记忆: Python 传参不是复制对象,而是给同一个对象再起一个局部名字。
-
判断口诀: 看两件事:对象是否可变,操作是否原地。
-
区分口诀:
- 改对象内容:可能影响外部
- 改名字指向:不会影响外部
-
面试高频模板: “Python 是基于对象共享的绑定关系。形参和实参开始时指向同一对象,原地修改会影响外部,重新绑定不会。”
-
工程实践记忆: 输入可变对象时,先问自己一句: “这个函数应该有副作用吗?” 如果不该有,优先考虑拷贝或返回新对象。
面试速答版
Python 参数传递更准确地说不是简单的值传递,也不建议直接说成引用传递,而是对象共享。函数调用时,形参会绑定到实参所指向的同一个对象,相当于函数内部拿到了这个对象的一个新名字。
所以如果传入的是可变对象,函数内部做原地修改,比如 append、改字典、改对象属性,外部能看到变化;如果只是让形参重新绑定到新对象,外部不会受影响。
这也是为什么列表常常能改到外面,而整数、字符串通常不行,本质原因不是传参机制不同,而是可变性不同。Python 里变量只是名字,对象才有类型。
面试加分版
如果让我更准确地描述,我会说 Python 的参数传递本质是名字绑定到对象,或者叫 call by sharing。函数调用时,形参不会得到实参对象的副本,而是绑定到同一个对象,所以函数内外一开始共享的是同一份对象状态。
这里最关键的是区分两类操作。
第一类是重新绑定。比如在函数里写 x = [1, 2, 3],这只是让局部变量 x 指向了一个新对象,不会影响调用方原来的变量绑定。
第二类是原地修改。比如 x.append(1)、x['k'] = v、obj.attr = 1,这改的是共享对象本身。如果外部变量也指向这个对象,那外部就能看到变化。
所以面试里常见现象是:列表、字典、集合、自定义对象实例传进去后可能被改到外面;而整数、字符串、元组这些不可变对象通常不会。注意这不是说“列表按引用传递,整数按值传递”,而是说传参机制相同,但对象是否可变不同。
再往深一点,这和 Python 的对象模型有关系: 变量只是名字,对象才有类型;赋值和传参本质上都是绑定关系的建立。 这也解释了很多工程问题,比如默认可变参数为什么会串数据、为什么浅拷贝会共享内层对象、为什么设计函数时要明确是否允许修改入参。
实际开发里,我会特别注意副作用管理: 如果函数不应该改调用方数据,就会显式拷贝或返回新对象;如果函数就是要原地修改,我会在命名、文档和接口语义上写清楚,避免调用方误用。这样代码的可读性、可测试性和可维护性都会更好。