本节目标:掌握函数定义、参数的五种形态与调用解包,理解默认值的求值时机陷阱,能用 LEGB 规则和
global/nonlocal解释作用域问题,并会写签名类型注解、会用lru_cache与partial。
适用版本:Python 3.12+(实测 3.14.6)
4.2 函数:参数传递、默认值与作用域
上一节的循环和分支解决了「怎么走」,但把同样的逻辑抄三遍显然不行。函数是组织代码的第一道工具,也是 Python 里最容易被低估的部分——你以为会 def 就算学会了,其实参数形态、默认值时机、作用域规则这三处才是真正会写出 bug 的地方。
4.2.1 函数定义与文档字符串
def greet(name: str) -> str:
"""返回一句问候语。
:param name: 收件人姓名
:return: 形如 "Hello, Ada" 的字符串
"""
return f"Hello, {name}"
print(greet("Ada"))
print(greet.__doc__)
实测输出:
Hello, Ada
返回一句问候语。
:param name: 收件人姓名
:return: 形如 "Hello, Ada" 的字符串
第一行字符串就是文档字符串(docstring),会被存进 __doc__ 属性,help() 和各类 IDE 都读它。约定是:公开函数都该有 docstring,第一行写一句话概括,需要时再补参数与返回值说明。空函数体用 pass 占位,别用 ... 之外的花招。
4.2.2 参数的五种形态
Python 的参数系统相当灵活。按从左到右的顺序,一共五种形态:
def config(name, retries=3, *tags, verbose=False, **options):
"""演示五种参数形态。"""
print("name =", name)
print("retries =", retries)
print("tags =", tags)
print("verbose =", verbose)
print("options =", options)
config("api", 5, "prod", "eu", verbose=True, timeout=30, tls=True)
实测输出:
name = api
retries = 5
tags = ('prod', 'eu')
verbose = True
options = {'timeout': 30, 'tls': True}
| 形态 | 写法 | 作用 |
|---|---|---|
| 位置参数 | name | 按调用顺序绑定,必填 |
| 默认参数 | retries=3 | 不传时取默认值,必须排在位置参数之后 |
| 可变位置参数 | *tags | 收集多余的位置实参成元组 |
| 仅关键字参数 | *, verbose 或 *tags 之后的参数 | 只能以 name=value 形式传 |
| 可变关键字参数 | **options | 收集多余的关键字实参成字典 |
只传必填参数时,其余都取默认:
config("db", verbose=False)
实测输出:
name = db
retries = 3
tags = ()
verbose = False
options = {}
注意 *tags 之后出现的 verbose 自动变成仅关键字参数——这是 Python 强制调用方写清参数名的常用手段。如果你不想收集多余位置参数、只想让后面都变仅关键字,可以直接写一个裸星号:def f(a, *, b):。
4.2.3 调用时的 * 与 ** 解包
定义时的 */** 是「收集」,调用时的 */** 则是「展开」:
args = ("web", 2)
kwargs = {"verbose": True, "region": "us"}
config(*args, **kwargs)
实测输出:
name = web
retries = 2
tags = ()
verbose = True
options = {'region': 'us'}
*args 把元组展开成位置实参,**kwargs 把字典展开成关键字实参。这个机制让「转发参数」变得极其简单——装饰器和包装函数全靠它,第 8 章会看到大量 def wrapper(*args, **kwargs): 的写法。要提醒的是:* 后面的对象必须是可迭代的,** 后面必须是字符串键的映射,否则运行时直接报 TypeError。
4.2.4 默认值只在定义时求值一次
这是 Python 新手最容易踩的坑,也是 3.3 节「可变与引用语义」在函数上的直接体现:
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
print(add_item("a"))
print(add_item("b"))
print(add_item("c"))
实测输出:
['a']
['a', 'b']
['a', 'b', 'c']
预期的「每次都是新列表」没有出现,因为默认值表达式只在函数定义时求值一次,之后所有调用共享同一个列表对象。bucket 指向的那个列表被反复追加,结果越滚越长。
正确写法是用 None 作哨兵,在函数体内新建:
def add_item_ok(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(add_item_ok("a"))
print(add_item_ok("b"))
实测输出 ['a'] 和 ['b'],符合直觉。这条规则不只针对可变默认参数:任何有副作用的默认值表达式都只在定义时跑一次,包括 datetime.now()、random.random() 这类——它们会在整个程序生命周期里冻结成同一个值。想每次重新求值,就放进函数体。
4.2.5 返回值与多返回值
函数用 return 返回结果,没有 return 则返回 None。Python 没有「多返回值」语法糖,所谓多返回值其实是返回了一个元组,调用方解包而已:
def min_max(nums):
return min(nums), max(nums)
lo, hi = min_max([3, 1, 4, 1, 5])
print(lo, hi)
实测输出 1 5。记住它本质是元组后,就能解释为什么 result = min_max(...) 拿到的是 (1, 5) 而不是两个变量。
4.2.6 LEGB 作用域规则
Python 查找一个名字时,按固定的四层顺序,取首字母简称 LEGB:
| 层 | 名称 | 含义 |
|---|---|---|
| L | Local | 当前函数内部 |
| E | Enclosing | 外层嵌套函数 |
| G | Global | 模块(文件)级 |
| B | Built-in | 内置命名空间 |
读取时由内向外找第一个命中,所以内层能读到外层的变量:
x = "global"
def f():
x = "enclosing"
def g():
print(x) # 从 E 层读到 f 的 x
g()
f()
实测输出 enclosing。这里 g 内部没有 x,就向外层 f 找。
写入是另一回事。 函数体内对名字赋值,默认创建的是局部变量,不会影响外层:
x = 1
def bad():
x = 2 # 这是一个新的局部变量,全局 x 不变
bad()
print(x) # 仍然是 1
一旦函数里出现对某个名字的赋值,Python 在整个函数体内就把它当局部名字——所以「先读后写」同名变量会报 UnboundLocalError,这不是玄学,而是「赋值即声明局部」的直接后果。
4.2.7 global 与 nonlocal
要在函数内修改模块级变量,用 global 声明:
count = 0
def bump():
global count
count += 1
bump(); bump()
print("global count =", count)
实测输出 global count = 2。global 只对当前模块有效,跨模块共享状态应当改用参数与返回值,而不是到处 global。
要修改外层嵌套函数的变量,用 nonlocal:
def outer():
total = 0
def inner():
nonlocal total
total += 10
inner(); inner()
return total
print("nonlocal total =", outer())
实测输出 nonlocal total = 20。nonlocal 绑定的不是全局,而是最近一层定义了该名字的 Enclosing 作用域。用 global 和 nonlocal 都应该克制:它们让数据流向变隐晦,多数场景下返回新值更清晰。
4.2.8 闭包的最小示例
当内层函数引用了外层函数的变量并存活到外层返回之后,就形成了闭包:
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
c = make_counter()
print(c(), c(), c())
实测输出 1 2 3。make_counter 已经返回了,但 counter 依然记得并持有 count——它没有随着外层栈帧销毁而消失。这就是闭包的本质:函数 + 它捕获的环境。闭包是装饰器(第 8 章)的基石,本节只需建立这个直觉。
4.2.9 签名里的类型注解
Python 的类型注解是可选的、运行时不强制的元数据,写在参数名后和 -> 之后:
from collections.abc import Iterable
def total(values: Iterable[int], scale: float = 1.0) -> float:
return sum(values) * scale
print(total([1, 2, 3], 2.0))
print(total.__annotations__)
实测输出:
12.0
{'values': collections.abc.Iterable[int], 'scale': <class 'float'>, 'return': <class 'float'>}
注解被存进 __annotations__,但解释器不会据此检查——传错类型照样能跑,直到真的出错。真正做检查的是 pyright / mypy 这类静态工具,第 9 章会系统展开。现在只需记住写法:参数 name: 类型,返回值 -> 类型,可选用 X | None。 写注解的最大收益不是运行时安全,而是编辑器的补全与静态检查。
4.2.10 lru_cache 与 partial
functools 里有两个和函数参数密切相关的实用工具。lru_cache 给纯函数加记忆化:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print("fib(30) =", fib(30))
print("缓存命中信息:", fib.cache_info())
实测输出 fib(30) = 832040 和 CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)。没有缓存时朴素递归的 fib(30) 要算上百万次,加了缓存后每个 n 只算一次。要点:被缓存的函数参数必须可哈希,且函数得是纯函数(同样输入永远同样输出),否则缓存会返回过期结果。
partial 用来固定部分参数、生成新函数:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print("square(5) =", square(5))
print("cube(3) =", cube(3))
实测输出 square(5) = 25 和 cube(3) = 27。partial 把 power 的 exponent 预先绑好,得到一个只等 base 的新可调用对象——这在回调、排序 key、事件处理器里很常见。它和 lru_cache 都属于「用函数本身做参数」的范畴,下一节会从「函数是一等对象」的角度把它们串起来。
小结
- 公开函数应写 docstring,它存进
__doc__,供help()与 IDE 读取。 - 参数按「位置 → 默认 →
*args→ 仅关键字 →**kwargs」排列;定义时*/**收集,调用时*/**展开。 - 默认值只在定义时求值一次:可变默认参数会跨调用共享,用
None哨兵规避。 - 名字查找按 LEGB(局部 → 外层 → 全局 → 内置);函数内赋值即声明局部,改外层要用
global/nonlocal,但应克制使用。 - 闭包 = 函数 + 它捕获的环境,是装饰器的基石。
- 类型注解只是元数据,运行时不强制;
lru_cache做记忆化(要求纯函数、可哈希参数),partial固定部分参数生成新函数。
下一节我们换个角度:既然函数本身就是一个对象,那它就能被赋值、放进容器、当参数传、当返回值——这正是 lambda、sorted(key=...)、functools.singledispatch 存在的前提。
阅读导航:上一节:4.1 条件、循环与推导式 · 下一节:4.3 一等函数、lambda 与 functools 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。