🐍Python 语言基础

Python 面试笔记:变量绑定与参数传递

面试回答

常见问法

  • Python 的参数传递到底是值传递还是引用传递?
  • 为什么列表传进去能改到外部,整数通常不行?
  • Python 变量和对象到底谁有类型?
  • a += 1a.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]

变的是绑定关系,不是“变量类型变化”。 真正带类型信息的是对象本身,比如 10int 对象,[1, 2, 3]list 对象。


2)+= 一定是重新绑定吗?

不一定。要看对象类型是否支持原地修改。

  • intstrtuple 这类不可变对象,+= 通常会创建新对象,再重新绑定
  • 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 的绑定。 它能改到外部,只是因为 xdata 一开始指向同一个可变对象,而不是因为它拿到了“外部变量本身”。


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

这里不是复制了两个列表,而是 ab 绑定到了同一个列表对象


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]

这里 xdata 指向同一个列表对象,append 改的是这个对象本身。


4. 可变对象与不可变对象

这道题经常会追到“为什么 int 不行,list 可以”。

不可变对象

创建后内容不能改,常见有:

  • int
  • float
  • bool
  • str
  • tuple
  • frozenset
  • bytes

对这类对象做“修改”操作,本质上通常是创建新对象再重新绑定

x = 10
print(id(x))
x += 1
print(id(x))  # 变了

可变对象

创建后内容可以原地修改,常见有:

  • list
  • dict
  • set
  • 大多数自定义类实例
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'] = vobj.attr = 1,这改的是共享对象本身。如果外部变量也指向这个对象,那外部就能看到变化。

所以面试里常见现象是:列表、字典、集合、自定义对象实例传进去后可能被改到外面;而整数、字符串、元组这些不可变对象通常不会。注意这不是说“列表按引用传递,整数按值传递”,而是说传参机制相同,但对象是否可变不同

再往深一点,这和 Python 的对象模型有关系: 变量只是名字,对象才有类型;赋值和传参本质上都是绑定关系的建立。 这也解释了很多工程问题,比如默认可变参数为什么会串数据、为什么浅拷贝会共享内层对象、为什么设计函数时要明确是否允许修改入参。

实际开发里,我会特别注意副作用管理: 如果函数不应该改调用方数据,就会显式拷贝或返回新对象;如果函数就是要原地修改,我会在命名、文档和接口语义上写清楚,避免调用方误用。这样代码的可读性、可测试性和可维护性都会更好。