引言
函数是编程中最基本的抽象单元。很多 Python 初学者写函数时,只关注"把一段逻辑包起来",却对函数的三个核心机制一知半解:参数到底是怎么传进去的?为什么函数里修改一个变量有时影响外面、有时不影响?闭包到底是什么、为什么装饰器离不开它?
这些问题不搞清楚,写复杂函数时就会频繁踩坑,也很难理解装饰器、偏函数、回调等进阶概念。本文将从底层原理出发,系统讲解 Python 函数的参数传递机制、作用域规则和闭包的本质,帮你把函数彻底搞透。
一、参数传递:既不是传值,也不是传引用
1.1 一个经典困惑
先看两段代码,你能预测结果吗?
# 代码 Adeffunc(x):x=100a=1func(a)print(a)# 输出什么?# 代码 Bdeffunc(lst):lst.append(4)my_list=[1,2,3]func(my_list)print(my_list)# 输出什么?答案:代码 A 输出1,代码 B 输出[1, 2, 3, 4]。
同样是把变量传进函数,为什么整数没被改变,列表却被改变了?这就是 Python 参数传递机制的核心问题。
1.2 Python 的传递方式:传对象引用(Pass by Object Reference)
很多教材会说 Python 是"传值"或"传引用",其实都不准确。准确的说法是传对象引用(也叫"传对象"或"赋值传递")。
它的规则只有一条:函数调用时,实参对象的引用被赋值给形参变量,形参和实参指向同一个对象。
结合我们之前讲过的"变量是标签"模型,就很好理解了:
deffunc(x):# x 这个新标签,贴到了实参指向的同一个对象上print(id(x))# 和外面的 a 同一个 ida=1print(id(a))func(a)关键在于对象本身是可变还是不可变:
- 如果对象是不可变的(int、str、tuple 等),函数里对形参重新赋值,只是让形参标签贴到新对象上,实参标签不受影响。
- 如果对象是可变的(list、dict、set 等),函数里通过形参修改对象内容(如
append),因为是同一个对象,实参看到的也会变化。
# 不可变对象:重新赋值不影响外部deffunc1(x):x=x+1# 创建新对象 2,x 贴到新对象print("函数内 x =",x)# 2a=1func1(a)print("外部 a =",a)# 1# 可变对象:原地修改影响外部deffunc2(lst):lst.append(4)# 修改同一个列表对象print("函数内 lst =",lst)# [1, 2, 3, 4]my_list=[1,2,3]func2(my_list)print("外部 my_list =",my_list)# [1, 2, 3, 4]# 可变对象:如果是重新赋值,则不影响外部deffunc3(lst):lst=[4,5,6]# 创建新列表,lst 贴到新对象print("函数内 lst =",lst)# [4, 5, 6]my_list=[1,2,3]func3(my_list)print("外部 my_list =",my_list)# [1, 2, 3]记住这个判断标准:看函数里对形参做的是"重新赋值"还是"原地修改"。重新赋值永远不影响外部;原地修改只有可变对象才影响外部。
1.3 如何避免函数意外修改外部数据
如果不希望函数修改传入的可变对象,传入副本即可:
deffunc(lst):lst.append(4)my_list=[1,2,3]func(my_list.copy())# 传入浅拷贝# 或 func(my_list[:])print(my_list)# [1, 2, 3],不受影响对于嵌套结构,需要copy.deepcopy()。
二、参数类型全解析
Python 的函数参数非常灵活,掌握各种参数类型是写出优雅函数的基础。
2.1 位置参数与关键字参数
defgreet(name,age):print(f"{name}今年{age}岁")# 位置参数:按顺序传递greet("小明",18)# 关键字参数:按名字传递,顺序可换greet(age=20,name="小红")# 混合使用:位置参数必须在前greet("小刚",age=22)# greet(name="小李", 25) # 语法错误,关键字参数不能在位置参数前2.2 默认参数
defgreet(name,age=18,city="北京"):print(f"{name}今年{age}岁,住在{city}")greet("小明")# 小明 今年 18 岁,住在 北京greet("小红",20)# 小红 今年 20 岁,住在 北京greet("小刚",city="上海")# 小刚 今年 18 岁,住在 上海经典坑点:可变对象作为默认参数
# 错误写法defadd_item(item,lst=[]):lst.append(item)returnlstprint(add_item(1))# [1]print(add_item(2))# [1, 2],不是预期的 [2]!print(add_item(3))# [1, 2, 3]原因:默认参数在函数定义时只创建一次,多次调用共享同一个对象。正确写法:
defadd_item(item,lst=None):iflstisNone:lst=[]lst.append(item)returnlstprint(add_item(1))# [1]print(add_item(2))# [2]2.3 可变参数:*args 和 **kwargs
# *args:收集任意数量的位置参数为元组defsum_all(*args):print(args)# 是一个元组returnsum(args)print(sum_all(1,2,3))# 6print(sum_all(1,2,3,4,5))# 15# **kwargs:收集任意数量的关键字参数为字典defprint_info(**kwargs):print(kwargs)# 是一个字典forkey,valueinkwargs.items():print(f"{key}:{value}")print_info(name="小明",age=18,city="北京")*args和**kwargs可以组合使用,顺序必须是:位置参数 →*args→ 默认参数 →**kwargs。
defdemo(a,b,*args,c=10,**kwargs):print(f"a={a}, b={b}, args={args}, c={c}, kwargs={kwargs}")demo(1,2,3,4,c=20,x=100,y=200)# a=1, b=2, args=(3, 4), c=20, kwargs={'x': 100, 'y': 200}2.4 参数解包
调用函数时,*和**可以把可迭代对象/字典"解包"成参数:
defadd(a,b,c):returna+b+c# 列表/元组解包为位置参数nums=[1,2,3]print(add(*nums))# 6,等价于 add(1, 2, 3)# 字典解包为关键字参数params={"a":1,"b":2,"c":3}print(add(**params))# 62.5 强制位置参数与强制关键字参数(Python 3.8+)
# / 之前的参数只能按位置传递deffunc(a,b,/,c,d):print(a,b,c,d)func(1,2,3,4)# 正确func(1,2,c=3,d=4)# 正确# func(a=1, b=2, c=3, d=4) # 错误,a 和 b 不能用关键字# * 之后的参数只能按关键字传递deffunc(a,b,*,c,d):print(a,b,c,d)func(1,2,c=3,d=4)# 正确# func(1, 2, 3, 4) # 错误,c 和 d 必须用关键字这两个语法让 API 设计更清晰,防止调用方用错误的方式传参。
三、作用域:LEGB 规则
3.1 什么是作用域
作用域决定了变量在哪些地方可以被访问。Python 中有四种作用域,按查找优先级从高到低排列:
| 字母 | 作用域 | 说明 |
|---|---|---|
| L | Local(局部) | 函数内部 |
| E | Enclosing(嵌套) | 外层函数的局部作用域 |
| G | Global(全局) | 模块顶层 |
| B | Built-in(内置) | Python 内置命名空间 |
这就是著名的LEGB 规则:查找变量时,从 L → E → G → B 依次查找,找到第一个就停止,都找不到则报NameError。
x="global"# G:全局作用域defouter():x="enclosing"# E:嵌套作用域definner():x="local"# L:局部作用域print(x)# 找到 L 中的 x,输出 "local"inner()outer()把inner里的x = "local"注释掉,就会找到 E 中的"enclosing";再注释掉 E 中的,就会找到 G 中的"global"。
3.2 全局变量与 global 关键字
函数内部可以读取全局变量,但默认不能修改全局变量——赋值会被当作创建局部变量:
count=0defincrement():count=count+1# UnboundLocalError! Python 认为 count 是局部变量,但还没赋值就用了increment()要在函数内修改全局变量,需要用global声明:
count=0defincrement():globalcount count=count+1increment()print(count)# 13.3 嵌套作用域与 nonlocal 关键字
类似地,在内层函数修改外层函数的变量,需要用nonlocal:
defouter():count=0definner():nonlocalcount count+=1returncountreturninner counter=outer()print(counter())# 1print(counter())# 2print(counter())# 3nonlocal声明的变量会沿着嵌套作用域向上查找(不包括全局),找到第一个匹配的变量。
3.4 作用域的常见坑
# 坑 1:闭包中的延迟绑定defcreate_multipliers():return[lambdax:i*xforiinrange(5)]multipliers=create_multipliers()print(multipliers[0](2))# 8,不是 0!print(multipliers[2](2))# 8,不是 4!# 所有 lambda 共享同一个 i,循环结束后 i = 4# 解决方案:用默认参数捕获当前值defcreate_multipliers():return[lambdax,i=i:i*xforiinrange(5)]multipliers=create_multipliers()print(multipliers[0](2))# 0print(multipliers[2](2))# 4四、闭包:捕获了环境的函数
4.1 什么是闭包
闭包(Closure)是指一个函数对象,它记住了定义时所在的作用域环境,即使在外部调用,也能访问那个作用域中的变量。
通俗地说:闭包 = 函数 + 它捕获的自由变量。
defouter(x):definner(y):returnx+y# x 是外层函数的变量,被 inner 捕获returninner add5=outer(5)# add5 是一个闭包,x 被固定为 5print(add5(3))# 8print(add5(10))# 15add10=outer(10)print(add10(3))# 13inner函数引用了外层函数outer的参数x。当outer执行完毕返回inner后,按常理x应该被销毁,但闭包让x存活了下来——add5和add10各自保存了不同的x值。
4.2 闭包的本质:closure属性
我们可以通过__closure__属性查看闭包捕获的变量:
defouter(x):definner(y):returnx+yreturninner add5=outer(5)print(add5.__closure__)# (<cell at 0x...: int object at 0x...>,)print(add5.__closure__[0].cell_contents)# 5,捕获的 x 值每个被捕获的变量被包装成一个cell对象,存在__closure__元组中。这就是闭包"记住环境"的底层实现。
4.3 闭包的典型应用
应用 1:工厂函数(生成配置好的函数)
defmake_multiplier(factor):defmultiply(n):returnn*factorreturnmultiply double=make_multiplier(2)triple=make_multiplier(3)print(double(5))# 10print(triple(5))# 15应用 2:计数器(状态保持)
defmake_counter():count=0defcounter():nonlocalcount count+=1returncountreturncounter c1=make_counter()print(c1())# 1print(c1())# 2print(c1())# 3c2=make_counter()# 独立的计数器print(c2())# 1闭包让count变量变成了"私有状态",外部无法直接访问,只能通过返回的函数操作——这其实就是面向对象中封装的一种轻量实现。
应用 3:装饰器的基础
Python 装饰器本质上就是一个闭包。理解了闭包,装饰器就不再神秘:
deflog_decorator(func):defwrapper(*args,**kwargs):print(f"调用函数:{func.__name__}")result=func(*args,**kwargs)print(f"函数{func.__name__}执行完毕")returnresultreturnwrapper@log_decoratordefadd(a,b):returna+bprint(add(1,2))# 调用函数: add# 函数 add 执行完毕# 3wrapper捕获了外层的func,形成闭包。装饰器的详细内容我们会在后续文章中专门讲解。
4.4 闭包 vs 类:什么时候用哪个?
闭包和类都能实现"状态 + 行为"的封装。选择原则:
- 状态简单、只有一个主要行为 → 用闭包,更轻量
- 状态复杂、有多个方法/行为 → 用类,更清晰
# 闭包版本:简单计数器defmake_counter():count=0defcounter():nonlocalcount count+=1returncountreturncounter# 类版本:支持更多操作的计数器classCounter:def__init__(self):self.count=0defincrement(self):self.count+=1returnself.countdefreset(self):self.count=0defget_value(self):returnself.count五、最佳实践总结
- 理解参数传递是传对象引用:判断是否影响外部,看是重新赋值还是原地修改,以及对象是否可变。
- 默认参数不用可变对象:用
None作为哨兵值,在函数体内初始化。 - 善用
*args和**kwargs:让函数更灵活,但不要滥用,参数清晰优先。 - 减少全局变量的使用:全局变量增加耦合、降低可读性,尽量通过参数传递和返回值通信。
global和nonlocal谨慎使用:它们打破了作用域的清晰边界,只在确实需要时使用。- 闭包用于轻量级状态封装:简单场景用闭包,复杂场景用类。
- 注意闭包的延迟绑定问题:循环中创建闭包时,用默认参数捕获当前值。
结语
函数的三个核心机制——参数传递、作用域、闭包——是层层递进的:参数传递决定了数据如何进出函数,作用域决定了变量在哪里可见,而闭包则是作用域的"延伸",让函数带着自己的环境行走天下。
理解了这三者,你不仅能避开大多数函数相关的 bug,还能轻松掌握装饰器、偏函数、回调函数等进阶概念。