函数是可调用的对象:调用时,Python 把实参绑定到形参,在新的局部作用域中执行函数体,再返回一个值或抛出异常。
默认值在执行 def 时只创建一次;把列表或字典用作默认值,会让不同调用意外共享状态。
用清楚的签名表达调用约定,用 None 表示需要在每次调用时新建的默认对象,并让返回值与副作用一目了然。
是什么,为什么存在
函数把一段行为命名,并为它划出输入、输出和错误边界。def 语句创建函数对象,再把函数名绑定到这个对象。函数体此时不会因为定义而执行;只有调用该对象时,函数体才会运行。
定义中的名称叫作 形参(parameter) ,调用时提供的值或表达式叫作 实参(argument) 。例如,在 def convert(amount): 中,amount 是形参;在 convert(25) 中,25 是实参。区分这两个词,可以准确描述错误发生在接口定义还是某次调用。
函数解决的不是“避免复制几行代码”这么简单。它把变化隐藏在稳定的调用约定后面,让调用者只依赖名称、参数、返回值和已说明的异常。测试也因此能直接给出输入并检查结果,而不必复制整条业务流程。
你会在数据转换、验证、资源访问、回调和类的方法中遇到函数。Python 还把函数当作普通对象:它们可以赋给名称、放进容器、作为实参传递,也可以作为返回值交给调用者。这个 一等函数(first-class function) 模型是装饰器、闭包和许多标准库 API 的基础。
工作原理
调用表达式由一个可调用对象和一组实参组成。Python 先求值可调用对象和每个实参表达式,再按照函数签名把得到的对象绑定到形参。绑定成功后,本次调用取得自己的局部命名空间并执行函数体;绑定失败则直接抛出 TypeError,函数体不会开始执行。
一次普通调用可以按下面的顺序理解:
- 执行
def,创建函数对象,并在定义所在作用域绑定函数名。 - 求值调用表达式中的可调用对象。
- 从左到右求值实参表达式。
- 按位置、关键字和默认值把实参绑定到形参。
- 在本次调用的局部作用域中执行函数体。
- 遇到
return时把值交回调用方;执行到末尾则返回None,异常则向调用栈传播。
实参传入的是对象引用这个值。调用开始后,形参成为指向相应对象的新局部绑定。函数重新绑定形参不会改变调用方的名称,但函数通过形参修改同一个可变对象时,调用方能看到对象内容的变化。
参数种类
函数签名(function signature) 说明函数接受哪些形参,以及调用者能用什么形式提供实参。/ 和 * 是签名中的分隔符,不是要传入的值。它们能把只适合按位置提供的值与应该明确命名的选项区分开。
| 形参种类 | 定义位置 | 合法调用形式 |
|---|---|---|
| 仅限位置 | / 之前 | 只能按位置传入 |
| 位置或关键字 | / 之后、* 之前 | 可按位置或名称传入 |
| 可变位置 | *items | 收集额外位置实参为元组 |
| 仅限关键字 | * 或 *items 之后 | 必须写出形参名称 |
| 可变关键字 | **options | 收集额外关键字实参为字典 |
没有 / 和 * 时,普通形参既可按位置传入,也可按关键字传入。仅限位置形参允许实现者以后改名而不影响按位置调用;仅限关键字形参能让布尔选项、单位和超时等容易混淆的值在调用处保持可读。需要系统掌握 *args、**kwargs 和解包时,再阅读相关专题。
默认表达式在函数定义时求值,而不是在每次调用时求值。某个实参缺失时,绑定过程会复用已保存的默认对象。整数、字符串和 None 等不可变对象通常适合作为默认值;调用之间不应共享的可变对象则应在函数体中创建。
返回、异常与注解
每次调用要么返回一个值,要么抛出异常。return expression 立即结束当前调用并返回表达式的值;单独的 return 和执行到函数体末尾都返回 None。所谓“返回多个值”实际上是返回一个元组,调用方可以再把它解包到多个名称。
异常不是另一种返回值。未捕获的异常会中断当前函数的正常路径并沿调用栈传播。一个稳定接口应明确哪些输入会正常返回、哪些输入会被拒绝,而不是让同一种失败有时返回 None、有时返回字符串、有时又抛出异常。
形参和返回位置可以带 类型注解(type annotation) 。注解帮助静态检查器、编辑器和文档工具理解接口,但 Python 语言本身不会根据注解自动验证调用实参或返回值。Python 3.14 默认延迟求值注解;运行时读取注解的框架应使用 annotationlib.get_annotations() 等受支持的接口。
文档字符串是函数体第一条语句中的字符串字面量。它说明契约中无法仅靠名称和类型表达的内容,例如单位、允许的空值、外部副作用和异常条件。内部辅助函数不一定需要长文档,但公开函数的调用者不应靠阅读实现来猜这些规则。
示例
下面四个示例从一个普通函数开始,逐步收紧调用约定、组织返回值,并把函数本身作为数据传递。每个输出都来自实际运行对应文件。
定义一个小型边界
这个运费函数有一个必需形参、一个带默认值的形参和一个返回注解。调用方可以按位置提供订单金额,并用关键字明确目的地。
def calculate_shipping(
subtotal: float,
destination: str = "domestic",
) -> float:
"""Return the shipping fee for one order."""
if subtotal < 0:
raise ValueError("subtotal must not be negative")
if destination == "international":
return round(max(12.0, subtotal * 0.08), 2)
return 5.0
order_total = 80.0
international_fee = calculate_shipping(
order_total,
destination="international",
)
print(f"subtotal={order_total:.2f}")
print(f"international fee={international_fee:.2f}")
print(f"domestic fee={calculate_shipping(order_total):.2f}")subtotal=80.00
international fee=12.00
domestic fee=5.00order_total 的当前对象引用绑定到 subtotal,关键字实参绑定到 destination。最后一次调用省略目的地,因此使用定义时保存的 "domestic"。函数只返回费用,不修改订单金额,也不负责打印业务日志。
负数输入会走异常路径。注解中的 float 没有执行这个检查;真正的运行时约束来自函数体中的条件和 ValueError。这使失败策略对直接调用和测试都保持一致。
让调用位置消除歧义
订单编号适合仅按位置传入,因为它短而且含义稳定;priority 是布尔选项,强制写成关键字能避免调用处出现难懂的 True。中间的 customer 可按位置或名称提供。
def format_order(
order_id: int,
/,
customer: str,
*,
priority: bool = False,
) -> str:
prefix = "PRIORITY" if priority else "STANDARD"
return f"{prefix} #{order_id} for {customer}"
print(format_order(1042, "Mina", priority=True))
print(format_order(1043, customer="Noah"))PRIORITY #1042 for Mina
STANDARD #1043 for Noahformat_order(order_id=1042, customer="Mina") 会失败,因为 / 前的 order_id 不接受关键字形式。format_order(1042, "Mina", True) 也会失败,因为 * 后的 priority 只接受关键字实参。这些限制在进入函数体前由绑定过程执行。
分隔符表达的是 API 设计,不是越多越好。小型私有辅助函数通常使用普通形参已经足够;公开接口中有稳定位置值或容易混淆的选项时,限制才真正有价值。
用一致的返回形状表示结果
这个函数忽略负数读数,没有有效数据时返回 None,否则返回包含数量和平均值的二元组。调用方必须先处理缺失结果,再解包正常结果。
def summarize_readings(
readings: list[float],
) -> tuple[int, float] | None:
valid = [reading for reading in readings if reading >= 0]
if not valid:
return None
average = round(sum(valid) / len(valid), 1)
return len(valid), average
datasets = [[18.0, 21.5, -1.0], [-1.0, -2.0]]
for readings in datasets:
result = summarize_readings(readings)
if result is None:
print("no valid readings")
continue
count, average = result
print(f"count={count}, average={average}")count=2, average=19.8
no valid readings提前 return None 让除以零的路径无法发生。正常分支总是返回同一种二元组形状,因此调用方不必猜第二个元素是否存在。真实系统还应说明负数是应该忽略还是应该抛出异常;这里的选择是示例契约的一部分。
列表推导式创建了新列表,所以函数没有修改传入的 readings。如果改成就地删除无效项,调用方持有的列表也会变化,那就必须在名称和文档中明确说明副作用。
把行为作为实参传递
函数对象可以像其他值一样放进字典并传给另一个函数。apply_pricing 只负责调用传入规则和统一舍入,不需要知道规则是普通函数、闭包还是其他可调用对象。
from collections.abc import Callable
def apply_pricing(
subtotal: float,
rule: Callable[[float], float],
) -> float:
return round(rule(subtotal), 2)
def regular_price(subtotal: float) -> float:
return subtotal
def loyalty_price(subtotal: float) -> float:
return subtotal * 0.9
rules = {
"regular": regular_price,
"loyalty": loyalty_price,
}
for customer_type in ("regular", "loyalty"):
total = apply_pricing(75.0, rules[customer_type])
print(f"{customer_type}: {total:.2f}")regular: 75.00
loyalty: 67.50字典中保存的是函数对象,不是调用结果;名称后没有圆括号。apply_pricing 中的 rule(subtotal) 才执行所选规则。把策略作为实参适合行为很小且共享同一种签名的场景,更复杂的状态与多项操作通常适合用类表达。
Callable[[float], float] 描述静态接口,却不会在运行时阻止传入不兼容对象。静态检查和针对每条规则的测试仍然必要。需要保留配置或状态时,可以继续学习闭包;需要在调用前后包装行为时,则可以学习装饰器。
陷阱
修复方法: 用 None 作为哨兵,并在函数体中执行 if items is None: items = []。如果共享缓存确实是契约的一部分,应把它放进有明确名称和所有者的对象,而不是藏在默认值里。这个问题对应 可变默认实参(mutable default argument) 。
修复方法: 为成功结果选择一种稳定形状,并为“没有结果”选择明确策略。可预期的缺失可以统一返回 None 或领域对象;无效输入或失败操作通常应抛出具体异常,不能在同一含义上混用几套表示。
修复方法: 决定函数是转换输入还是就地更新输入,并让名称、返回值和文档保持一致。转换函数创建并返回新对象;就地函数则应明确其副作用,并测试调用前后的对象标识与内容。
修复方法: 在不可信数据进入系统的边界进行显式解析和验证,再把已验证对象交给内部函数。用静态检查器发现开发期的不匹配,用运行时测试覆盖无效输入;两者解决的问题不同。
修复方法: 普通业务函数应声明自己真正支持的形参。只有通用适配器和装饰器确实需要透传未知签名时才使用可变参数,并测试位置实参、关键字实参、返回值和异常是否原样保留。
函数定义时与调用时
def 是一条可执行语句。程序执行到它时,Python 创建函数对象,保存代码、默认值、关键字专用默认值、注解元数据和对定义环境的必要引用,再把函数名绑定到对象。条件语句或循环中的 def 只有在控制流到达时才会创建相应函数对象。
函数体与默认表达式处在不同时间线上。函数体每次调用都会执行,默认表达式却在对应 def 执行时求值一次。模块顶层的函数通常在导入模块时定义,所以它的默认对象通常也在导入期间创建。
默认值和注解的时机
默认值的“一次”是针对某次函数定义,不一定是整个进程永远一次。如果外层函数每次调用都会执行一个内层 def,那么每次外层调用都会创建新函数对象和新的一组默认值。闭包工厂可以利用这个事实,让不同返回函数拥有独立配置。
Python 3.14 中,注解默认采用延迟求值,因此它们与普通默认值的时机不同。注解仍然不改变函数的普通调用语义;只有静态工具或主动读取注解的运行时代码会使用它们。框架作者不应假定直接读取 __annotations__ 总能得到已经求值的类型对象。
装饰器表达式也与函数定义阶段有关:它们在定义函数时求值,并把创建出的函数对象依次交给装饰器处理。装饰后的名称可能绑定到另一个可调用对象,所以调试签名或元数据时要确认查看的是包装对象还是原函数。完整的包装规则属于装饰器专题。
实参绑定失败
调用用户定义函数时,绑定发生在函数体逻辑之前。缺少必需实参、同一形参同时收到位置值和关键字值、未知关键字没有 **kwargs 接收,或违反仅限位置与仅限关键字约束,都会产生 TypeError。函数体中的日志、计数器和 try 块尚未运行,因此不能在函数内部捕获这种进入前的绑定错误。
实参表达式本身则会先求值。如果 load_order() 在 process(load_order()) 中抛出异常,process 根本不会被调用。实参中含有副作用时,求值顺序会变成可观察行为,因此调用处最好保持简单,把复杂准备步骤放到有名称的语句中。
*iterable 和 **mapping 会在绑定前展开实参。展开后的值仍须满足同一签名约束,例如两个映射为同一个形参提供值时会失败。需要详细掌握组合顺序、转发和多次解包时,应阅读 *args 与 **kwargs 专题。
函数对象与签名
函数名只是指向函数对象的一个绑定。执行 alias = calculate_shipping 后,alias 和 calculate_shipping 指向同一个对象;调用任一名称都会执行同一实现。只有写出圆括号才发生调用,callbacks.append(calculate_shipping) 保存的是对象,而 callbacks.append(calculate_shipping(...)) 保存的是调用结果。
用户定义函数暴露 __name__、__qualname__、__doc__、__defaults__ 和 __kwdefaults__ 等元数据。它们有助于调试和工具开发,但业务代码通常不应直接修改默认值元组或依赖内部代码对象。元数据能描述接口的一部分,不能替代契约测试。
inspect.signature() 提供统一的高层接口来查看许多可调用对象的签名。得到的 Signature 和 Parameter 对象可以区分参数种类、默认值与注解,还能在不执行函数体的情况下尝试绑定一组实参。依赖注入、命令行适配和测试工具会使用这类能力。
并非每个可调用对象都是用户定义函数。内置函数、绑定方法、类和实现了 __call__() 的实例也能出现在调用表达式中,它们的结果规则各不相同。需要接受广义可调用对象时,先说明所需签名和行为,不要只检查 type(value) 是否为函数。
文档字符串、注解与元数据
文档字符串通过 __doc__ 暴露,help() 和文档工具会读取它。好的文档字符串优先说明调用者需要遵守的契约,不复述函数名或逐行翻译实现。输入单位、返回值含义、可观察副作用与抛出的领域异常通常比算法步骤更重要。
注解是元数据,不限于类型提示,但现代 Python 代码通常用它们表达静态类型。Python 3.14 提供 annotationlib.get_annotations() 来按指定格式取得注解;typing.get_type_hints() 还会处理类型提示相关语义。读取注解可能触发延迟求值,因此只应对可信代码这样做,并处理名称无法解析的情况。
包装函数时,签名与文档可能偏离实际代理的函数。functools.wraps() 能复制常用元数据并设置 __wrapped__,让许多检查工具找到原函数,但它不会自动保证包装器正确转发所有实参、返回值和异常。这些行为仍需测试。
让接口能够演进
签名是调用者依赖的公开表面。增加带默认值的仅限关键字形参通常比插入新的位置形参更容易兼容旧调用;删除形参、改变默认行为或把关键字可用的形参改为仅限位置,则可能破坏现有代码。发布库时,应把这些变化当作 API 变化审查。
仅限位置形参适合名称不应成为契约的值。例如,调用方只关心“对这个对象执行操作”,实现者以后可以改进内部形参名称。仅限关键字形参适合多个相同类型但含义不同的选项,因为调用处会保留名称。
布尔形参尤其需要谨慎。render(report, True, False) 无法从调用处看出两个值控制什么;render(report, include_header=True, compact=False) 更清楚。如果选项组合开始形成多种模式,枚举或配置对象可能比不断增加布尔形参更稳定。
稳定不等于永远保留错误设计。先找到真实调用点,用静态搜索和测试确认使用方式,再通过弃用期或新函数名迁移。AI 生成的接口重构尤其要检查关键字调用,因为只修改位置调用测试很容易漏掉这类兼容性问题。
测试函数契约
函数测试应从调用者可观察的行为出发,而不是复述实现中的每个分支。输入对象、返回值、抛出的异常和明确声明的副作用构成测试边界。局部变量名或内部辅助步骤通常不是契约,重构时不应迫使测试跟着改写。
正常、边界与失败路径
一个代表性正常样例只能证明最顺利的路径。还要选择刚好位于约束边界的输入,例如空集合、零、最大允许长度或恰好达到阈值的值。边界两侧各一个样例,通常比大量随机中间值更能说明条件是否写对。
失败测试应断言具体异常类型,并在消息属于公开契约时才锁定消息。只写“任何异常都算通过”会把 KeyError、TypeError 等意外实现错误误认为预期验证。相反,过度锁定完整错误文本会让不影响调用者的措辞调整也破坏测试。
对函数签名本身,也要覆盖调用形式。公开函数如果承诺关键字调用,就至少保留一个关键字调用测试;仅限位置或仅限关键字的限制,则应有一个失败样例证明边界存在。这样重构不会在业务结果仍然正确时悄悄破坏调用约定。
一组紧凑测试通常覆盖以下问题:
- 典型有效输入是否返回正确类型和内容。
- 空值、零值或阈值边界是否遵守契约。
- 无效输入是否抛出预期的具体异常。
- 连续调用是否意外共享默认状态或修改调用方对象。
状态与副作用
纯计算函数只依赖显式实参并返回结果,测试通常最直接。带有时钟、随机数、文件、网络或数据库依赖的函数仍然可以测试,但这些依赖应通过清楚的边界提供。偷偷读取模块全局值会让输入在测试报告中消失。
修改可变实参的函数需要同时断言返回值和输入对象的最终状态。只检查返回值可能漏掉重复追加,只检查对象内容又可能漏掉函数返回了错误对象。是否保持对象标识也取决于契约:就地更新通常保持标识,转换通常返回新对象。
打印也是副作用。命令行入口可以打印,但负责计算的内部函数通常应返回结构化数据,让入口决定如何显示。这样同一个函数能被测试、日志系统、Web 处理器和其他调用者复用,而不会捕获标准输出才能得到结果。
从示例到性质
示例测试检查几个已知输入,性质测试检查一条应对许多输入成立的规则。折扣函数的性质可能是结果不大于原价且不小于零,排序键函数的性质可能是调用不修改记录。先写清楚性质,再决定是否需要生成大量输入。
性质不能替代领域样例。一个函数可能满足宽泛数值范围,却在税率阈值处选错分支。最可靠的组合是少量可读样例、明确边界、具体失败断言,再加真正有价值的通用性质。
测试若用实现自身的公式计算预期值,可能会复制同一个错误。应根据需求独立推导预期结果,并加入能区分不同实现的输入。测试只有在预期值来源独立时,才提供新的证据。
替换外部依赖
函数把外部操作作为形参接收时,测试可以传入一个小型替代函数。替代函数应采用同样的调用约定,并返回契约要求的形状。这样能控制时间、失败和结果,而不需要访问真实网络或数据库。
记录调用的替代函数还能证明被测函数传出了正确实参。记录内容应限于契约相关信息,例如资源 ID 和重试次数,不要把所有局部步骤都固化。否则实现顺序稍有变化,测试就会在行为仍然正确时失败。
替代函数的签名过于宽泛时,测试可能接受生产实现会拒绝的调用。支持自动规格的测试工具可以从真实可调用对象约束替代品,但仍要保留至少一个集成测试,确认边界两侧对参数和返回形状的理解一致。
同步函数与异步函数也不能随意互换。普通函数返回一个值,async def 调用则先返回协程对象;调用方必须等待它才会执行函数体。测试替代品如果弄错这一点,可能产生从未等待的协程或掩盖错误的调度行为。
AI 生成替代函数时,要求它先写出原函数签名、返回契约和异常契约,再生成测试实现。审查生成结果是否保留仅限位置与仅限关键字规则,以及失败是抛出异常还是返回数据。能被调用不等于遵守了同一接口。
4个问题 · 2 道输出预测题 · 1 道找错题