Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
1 | 作者:李晓辉 |
- Python 系列(一) 从零开始认识 Python,入门其实没那么难
- Python 系列(二) 学 Python 的第一步,把开发环境搭建好
- Python 系列(三) 基础语法避坑指南,缩进、大小写、续行与注释
- Python 系列(四) Python 输入输出详解,print()、input() 与常见避坑指南
- Python 系列(五) 数值类型与运算符详解,整除、浮点精度与进制转换实战
- Python 系列(六) 字符串处理实战,查找、替换、切片与日志分析
- Python 系列(七) 类型转换详解,int()、float()、str() 与金额计算避坑
- Python 系列(八) Python 流程控制入门,掌握 if、elif 和 else
- Python 系列(九) Python 逻辑运算符详解,and、or、not 一篇搞懂
- Python 系列(十) while、for 循环实战,彻底搞懂 range、break 与 continue
- Python 系列(十一) list、tuple、set、dict,到底该怎么选?
- Python 系列(十二) List 列表实战,搞定数据增删改查与批量处理
- Python 系列(十三) Tuple 元组实战,搞懂不可变特性与 enumerate() 遍历
- Python 系列(十四) Set 集合实战,搞定数据去重与集合运算
- Python 系列(十五) Dict 字典详解,掌握快速查找与键值操作
- Python 系列(十六) sort() 和 sorted() 详解,掌握数据排序技巧
- Python 系列(十七) 自定义函数入门,让重复代码一次编写多次使用
- Python 系列(十八) 函数参数详解,搞懂位置参数、关键字参数和默认参数
- Python 系列(十九) 从 *args 到 **kwargs,让函数调用更灵活
- Python 系列(二十) 变量作用域是什么?一文搞懂 global 和 nonlocal
- Python 系列(二十一) 搞懂变量、对象和引用,理解函数参数传递机制
- Python 系列(二十二) map() 和 filter() 详解,轻松实现批量数据处理与筛选
前面我们学习了 Python 的变量作用域,了解了局部变量、全局变量,以及
global和nonlocal的使用方法。但还有一个问题值得深入研究:当我们把一个变量传递给函数时,函数拿到的究竟是什么?
为什么把列表传给函数,在函数内部添加一个元素,外面的列表也跟着变了?而把整数传给函数,在函数内部进行加法运算,外面的整数却没有变化?
难道 Python 对不同的数据类型,采用了不同的参数传递机制?
这篇文章就来把这个问题讲清楚。理解了变量、对象和引用之间的关系,后面学习列表、字典、函数以及
map()、filter()等知识时,就会轻松很多。
变量传递给函数时,到底发生了什么?
先来看两个非常简单的例子。
第一个例子,传递一个整数:
1 | def modify_number(number): |
运行结果:
1 | 函数内部:11 |
函数内部的 number 已经变成了 11,为什么外面的 x 还是 10?
再来看第二个例子,传递一个列表:
1 | def modify_list(numbers): |
运行结果:
1 | 函数内部:[1, 2, 3, 4] |
这一次,函数内部的操作却影响了外部的列表。
两个例子看起来非常相似,结果却完全不同。
如果只记住“整数是不可变对象,列表是可变对象”,虽然能够解释一部分现象,却还没有真正理解背后的原因。
想要彻底弄明白,我们需要先认识三个概念:变量、对象和引用。
变量、对象和引用是什么关系?
在 Python 中,我们经常说变量保存了一个值,但更准确地理解,变量名是一个用于引用对象的名称。
例如:
1 | server_name = "web-01" |
这条语句可以理解为:让变量名 server_name 绑定到字符串对象 "web-01"。
再来看一个例子:
1 | cpu_usage = 75 |
这里,变量名 cpu_usage 绑定到整数对象 75。
可以用下面这张图来理解它们之间的关系。
flowchart TD
A["变量名 server_name"] --> B["字符串对象 web-01"]
C["变量名 cpu_usage"] --> D["整数对象 75"]这里需要注意:变量名和对象不是同一个东西。
变量名相当于一个标识,可以让程序找到对应的对象。多个变量名也可以同时绑定到同一个对象。
例如:
1 | server_a = [1, 2, 3] |
运行结果:
1 | [1, 2, 3] |
server_a is server_b 的结果是 True,说明两个变量名引用的是同一个对象。
如果我们通过 server_b 修改列表:
1 | server_b.append(4) |
运行结果:
1 | [1, 2, 3, 4] |
为什么修改 server_b 后,server_a 看到的内容也发生了变化?
原因很简单:它们指向同一个列表对象。修改这个对象后,通过任何一个仍然引用该对象的变量名访问,都能看到修改后的内容。
可以用下面的示意图表示:
flowchart TD
A["server_a"] --> C["同一个列表对象"]
B["server_b"] --> C
C --> D["[1, 2, 3, 4]"]这就是理解 Python 函数参数传递的重要基础。
Python 函数参数采用什么传递机制?
当调用函数时,Python 会将实参所引用的对象传递给函数,并让形参在函数内部绑定到该对象。
这种机制通常称为按对象引用传递,也常称为共享传参(Call by Sharing)。
例如:
1 | def check_server(server): |
返回web-01
调用 check_server(server_name) 时,函数中的形参 server 会绑定到与 server_name 相同的字符串对象。
可以将这个过程理解为:
flowchart TD
A["外部变量 server_name"] --> C["字符串对象 web-01"]
B["函数形参 server"] --> C注意,这并不意味着 Python 把外部变量 server_name 本身交给了函数,也不意味着函数获得了一个自动复制出来的独立对象。
函数中的 server 是另一个变量名,它与外部的 server_name 可以引用同一个对象,但两者是不同的名称绑定。
因此,函数内部究竟会不会影响外部,关键要看函数执行的是哪种操作:
- 修改对象本身:如果对象允许修改,外部通过该对象访问时也能看到变化。
- 让形参重新绑定到另一个对象:这只会改变函数内部形参的绑定,不会自动改变外部变量的绑定。
接下来,我们分别看看这两种情况。
不可变对象:为什么重新赋值不会影响外部?
Python 中常见的不可变对象包括:
- 整数,例如
10 - 浮点数,例如
3.14 - 布尔值,例如
True - 字符串,例如
"Linux" - 元组,例如
(1, 2, 3)
所谓不可变,指的是对象创建之后,其自身的内容不能被原地修改。
需要注意,不可变不代表变量名不能重新赋值。变量名完全可以重新绑定到另一个对象。
来看一个整数的例子:
1 | def modify_number(number): |
运行结果:
1 | 函数内部:11 |
这个过程可以拆成几个步骤。
首先,执行:
1 | x = 10 |
变量名 x 绑定到整数对象 10。
接着调用:
1 | modify_number(x) |
函数形参 number 也绑定到整数对象 10。
此时可以简化理解为:
flowchart TD
A["x"] --> C["整数对象 10"]
B["number"] --> C然后执行:
1 | number = number + 1 |
这里的 number + 1 会计算出结果 11,而不是把整数对象 10 原地修改为 11。
接下来,赋值操作让 number 绑定到整数对象 11。
此时,两个变量名的绑定关系变成了:
flowchart TD
A["x"] --> B["整数对象 10"]
C["number"] --> D["整数对象 11"]外部的 x 仍然绑定到整数对象 10,所以它的值没有变化。
真正的关键不是函数不能修改外部变量,而是这里的赋值只改变了局部形参 number 的绑定关系。
字符串也是同样的道理。
1 | def modify_name(name): |
运行结果:
1 | 函数内部:web-01-prod |
字符串拼接会得到新的字符串结果,然后让局部变量 name 绑定到这个结果。外部的 server_name 并没有被重新赋值。
所以,对于不可变对象,常见的运算往往会产生一个新对象,并让变量名绑定到结果对象,而不是直接修改原对象。
可变对象:为什么修改列表会影响外部?
Python 中常见的可变对象包括:
- 列表
list - 字典
dict - 集合
set
可变对象允许在对象存在期间修改其内容。
例如,列表可以添加、删除或替换元素,字典可以新增、删除或修改键值对。
先看列表:
1 | def modify_list(numbers): |
运行结果:
1 | [1, 2, 3, 4] |
为什么外部的列表发生了变化?
调用函数时,形参 numbers 与外部变量 nums 绑定到同一个列表对象。
执行:
1 | numbers.append(4) |
不是让 numbers 重新绑定到一个新列表,而是直接修改它所引用的列表对象,在列表末尾添加一个元素。
因此,函数执行结束后,外部变量 nums 仍然引用原来的列表,只不过列表的内容已经改变。
可以将这个过程理解为:
flowchart LR
A["函数调用前"] --> B["nums 和 numbers 引用同一列表"]
B --> C["执行 numbers.append(4)"]
C --> D["修改原列表对象"]
D --> E["函数结束,nums 仍然引用该列表"]
E --> F["外部看到 [1, 2, 3, 4]"]字典也是如此。
假设我们需要记录一台 Linux 服务器的监控信息,可以将服务器状态保存到字典中:
1 | def update_server(server): |
运行结果:
1 | {'name': 'web-01', 'cpu_usage': 85, 'status': 'warning'} |
函数内部修改了字典对象中的两个键值对,外部的 server_info 也能看到变化。
因为 server 和 server_info 引用的是同一个字典对象,所以通过 server 修改字典内容,就相当于修改了外部变量所引用的对象。
不过,这里有一个很重要的区别:
可变对象可以被修改,不代表函数内部对形参的所有操作都会影响外部变量。
如果函数只是让形参重新绑定到另一个列表,结果就完全不同了。
修改对象和重新赋值,有什么区别?
这是本文最值得掌握的知识点。
我们继续使用列表来演示。
先看第一种情况:修改原列表。
1 | def modify_list(numbers): |
运行结果:
1 | [1, 2, 3, 4] |
这里的 append() 修改了原来的列表对象,因此外部能看到变化。
再看第二种情况:重新给形参赋值。
1 | def modify_list(numbers): |
运行结果:
1 | 函数内部:[7, 8, 9] |
这一次,为什么外部的列表没有变化?
调用函数时,numbers 和 nums 原本绑定到同一个列表对象:
flowchart TD
A["nums"] --> C["原列表 [1, 2, 3]"]
B["numbers"] --> C执行:
1 | numbers = [7, 8, 9] |
Python 创建了一个新的列表对象,并让 numbers 绑定到新对象。
原来的 nums 并没有被重新赋值,因此仍然引用 [1, 2, 3]。
此时的关系变成了:
flowchart TD
A["nums"] --> B["原列表 [1, 2, 3]"]
C["numbers"] --> D["新列表 [7, 8, 9]"]两个变量名不再引用同一个列表。
这里可以记住一个非常实用的对比:
| 操作 | 是否修改原对象 | 外部能否看到对象内容变化 |
|---|---|---|
numbers.append(4) | 是 | 能 |
numbers[0] = 100 | 是 | 能 |
numbers.clear() | 是 | 能 |
numbers = [7, 8, 9] | 否,重新绑定形参 | 原列表不变 |
numbers = numbers + [4] | 通常是创建新列表并重新绑定 | 原列表不变 |
最后一行尤其值得注意。
对于列表,numbers + [4] 会创建一个新的列表,而 numbers += [4] 通常会原地扩展列表。对于可变对象,增强赋值运算符 += 可能修改原对象;具体行为取决于对象类型的实现,不能一概而论。
判断函数会不会影响外部,不要只看数据类型,还要看具体执行的是修改对象,还是重新绑定名称。
不可变对象也可以在函数内部重新绑定
有些初学者会误以为,不可变对象不能在函数内部修改,所以变量名也不能变化。
其实并不是这样。
来看一个字符串的例子:
1 | def update_name(name): |
运行结果:
1 | 函数内部:web-01-prod |
字符串的 += 运算会产生新的字符串结果,并让局部变量 name 绑定到结果对象。它并没有修改原来的字符串对象,也没有重新绑定外部的 server_name。
同样,对于整数:
1 | def add_cpu_usage(usage): |
运行结果:
1 | 函数内部:60 |
这也说明,函数参数的传递机制并没有因为参数是整数还是列表而改变。
Python 使用的是同一种参数传递机制,最终表现不同,是因为对象是否可变,以及函数内部究竟执行了什么操作。
== 和 is 有什么区别?
前面我们使用过:
1 | server_a is server_b |
这里顺便补充一个非常重要的知识点:== 和 is 并不是一回事。
==用来比较两个对象的值是否相等。is用来判断两个变量名是否引用同一个对象。
来看一个例子:
1 | server_a = [1, 2, 3] |
运行结果:
1 | True |
为什么会出现这样的结果?
因为两个列表的内容相同,所以 == 返回 True。
但是它们是分别创建的两个列表对象,因此 is 返回 False。
这与前面共享同一个列表的例子形成了对比:
1 | server_a = [1, 2, 3] |
运行结果:
1 | True |
理解这两个运算符,有助于我们分析函数参数传递、对象共享和对象修改等问题。
日常业务代码中,判断值是否相等通常使用 ==。判断对象身份时才使用 is,例如判断某个值是否为 None:
1 | result = None |
不要使用 is 来代替普通的数值或字符串相等比较。
实际开发中应该怎么处理函数参数?
理解参数传递机制之后,我们就可以把这些知识应用到实际开发中了。
假设我们需要检查多台服务器,并记录检查结果:
1 | def check_server(server): |
运行结果:
1 | {'name': 'web-01', 'status': 'checked', 'cpu_usage': 35} |
这种写法可以工作,但函数会直接修改传入的字典。如果其他代码也在使用这个字典,就需要考虑这些修改会不会产生意外影响。
如果希望函数返回处理结果,而不修改原始字典,可以创建一份浅拷贝:
1 | def check_server(server): |
运行结果:
1 | {'name': 'web-01', 'status': 'unknown', 'cpu_usage': 0} |
这一次,原始字典保持不变,函数返回了新的字典。
这里的 copy() 是浅拷贝。如果字典内部还包含列表或其他可变对象,复制后的字典与原字典仍可能共享这些嵌套对象。如果需要隔离嵌套对象的修改,就需要根据实际数据结构考虑更合适的复制方式,例如 copy.deepcopy()。
在实际开发中,可以根据函数的职责选择不同的设计方式:
- 如果函数负责更新传入的对象,可以明确约定它会修改对象。
- 如果函数负责计算结果,可以通过
return返回新结果,减少对外部状态的影响。 - 如果函数不应该修改原始数据,可以根据需要创建副本。
- 如果多个函数共享同一个可变对象,要特别留意一个函数的修改是否会影响其他代码。
对于 Linux 运维自动化脚本,这一点同样重要。例如,多个函数共享一份服务器清单时,如果某个函数意外清空了列表,后续的检查任务可能就没有服务器可以处理了。
还有一个常见陷阱:可变的默认参数
在理解了可变对象之后,还有一个 Python 初学者很容易踩到的坑:使用列表或字典作为函数的默认参数。
例如:
1 | def add_server(server, servers=[]): |
运行结果:
1 | ['web-01'] |
为什么第二次调用时,结果中还保留着第一次添加的 "web-01"?
因为默认参数表达式通常在函数定义时求值,而不是每次调用函数时重新创建一个列表。因此,这几次调用共享同一个默认列表对象。
每次调用 add_server() 时,函数都在修改这个列表,所以之前的内容会保留下来。
更稳妥的写法是使用 None 作为默认值,在函数内部创建新列表:
1 | def add_server(server, servers=None): |
运行结果:
1 | ['web-01'] |
这样,每次没有传入 servers 参数时,函数都会创建一个新的列表,不会意外共享之前调用留下的数据。
这个问题本质上也与对象和引用有关:默认参数中的列表是一个可变对象,多次函数调用反复修改了同一个对象。
写在最后
这篇文章我们从变量、对象和引用的关系出发,理解了 Python 函数参数传递的基本机制。
最后,再记住几个核心知识点:
- Python 函数参数采用共享传参(Call by Sharing)机制。调用函数时,形参会绑定到实参所引用的对象。函数内部可以修改可变对象的内容,但如果只是让形参重新绑定到另一个对象,并不会自动改变外部变量的绑定关系。
- 多个变量名可以引用同一个对象。
- 可变对象可以被原地修改,修改结果可能被外部代码观察到。
- 函数内部让形参重新绑定到另一个对象,不会自动改变外部变量的绑定。
- 不可变对象不能原地修改,但变量名仍然可以重新绑定到其他对象。
- 使用可变对象作为默认参数时,要特别注意对象可能在多次调用之间被共享。
理解这些内容后,我们就能更准确地解释:为什么列表在函数内部被修改后,外部也会发生变化;为什么整数在函数内部重新赋值后,外部却没有变化。
下一篇,我们将继续学习 Python 的 map() 和 filter(),看看如何用它们对列表等可迭代对象进行转换与筛选,让数据处理代码更加简洁。
