刚开始写 Python 时,你很容易把函数理解成“给一段代码起个名字”。这个理解能让你写出第一个 def,但还不够应付真实程序里的麻烦:参数为什么会传错位置?明明没有写 return,调用结果为什么是 None?为什么一个默认的空列表会记住上次调用的内容?函数里给变量赋值之后,外面的同名变量为什么没有变化?
这些现象并不是零散的语法坑。它们都围绕同一件事展开:函数定义了一套输入规则,调用时 Python 按规则把对象绑定给形参,然后在新的局部作用域里执行函数体,最后把一个对象交还给调用者。 本章会沿着这条执行链来学习。我们不只记写法,还要弄清楚 Python 在每一步做了什么,这样错误信息才会从“看不懂的抱怨”变成可定位的问题。
学完后,你应该能独立写出这样的函数:调用方式清楚,不会泄漏意外的共享状态;遇到无效输入时能尽早报告;有简短的类型提示和文档字符串;当逻辑变长时,知道如何拆成小函数和模块。

def 创建的是函数对象我们从一个实际需求开始:用户提交昵称时,先去掉首尾空白,再检查是否为空,最后生成欢迎语。如果把这些操作散落在多处,规则一改就得四处找代码。把它们放进函数后,程序只有一个规则入口。
def build_welcome(name):
"""清理昵称并生成欢迎语。"""
cleaned_name = name.strip()
if not cleaned_name:
raise ValueError("昵称不能为空")
return f"欢迎你,{cleaned_name}!"
message = build_welcome(" 小林 ")
print(message)def 后面依次是函数名、形参列表和冒号。缩进部分是函数体。运行到这段定义时,Python 会创建一个函数对象,并让名称 build_welcome 指向它;这时函数体并不会立刻执行。直到表达式 build_welcome(" 小林 ") 出现,Python 才开始一次调用。
你甚至可以让另一个名称指向同一个函数对象:
make_message = build_welcome
print(make_message("阿青"))
print(make_message is build_welcome) # True这里没有复制函数体。make_message 和 build_welcome 指向同一个函数对象。这个细节以后会解释为什么函数可以放进列表、作为参数传给另一个函数,也能作为返回值交出去。
在 def build_welcome(name): 中,name 是形参;在 build_welcome("阿青") 中,字符串 "阿青" 是实参。调用开始后,实参所代表的对象会绑定到本次调用的局部名称 name 上。
可以把一次普通调用拆成四个动作:
先计算调用表达式里的实参。例如传入 price * count 时,会先算出这个表达式的结果,而不是把表达式文本塞进函数。
再按照位置、关键字以及函数签名中的限制,把每个实参绑定到对应形参。缺少必需实参、重复绑定或出现未知关键字,都会在函数体执行前触发 TypeError。
Python 为这次调用建立局部作用域,然后从函数体第一条语句开始执行。下一次调用会得到另一套局部绑定,不会沿用上次调用的普通局部变量。
遇到 return 时,本次调用立即结束并交回结果;如果一路执行到函数体末尾,也会结束,只是交回的结果是 。
这四步很适合用来读报错。比如 build_welcome() 报错时,问题发生在第二步:没有对象可以绑定给必需形参 name。函数体里的 name.strip() 根本还没有机会运行。
return 和 print 解决的不是同一个问题初学时最常见的一类困惑是:“函数明明打印了结果,为什么赋值后却得到 None?”看下面两个版本:
def show_total(price, count):
print(price * count)
def calculate_total(price, count):
return price * count
result_a = show_total(12.5, 4) # 屏幕显示 50.0
result_b = calculate_total(12.5, 4) # 屏幕暂时没有输出
print(result_a) # None
print(result_b) # 50.0print() 是输出动作,它把文本发送到屏幕;return 是函数和调用者之间的数据通道,它把对象交回调用位置。show_total 没有显式 return,所以仍会返回 None。calculate_total 返回数值后,调用者可以继续求和、比较、保存或测试,这通常更灵活。
return 还有“立刻离开函数”的含义。它后面的同层语句不会执行:
def normalize_score(score):
if score < 0:
return 0
if score > 100:
return 100
return score这种提前返回适合先处理边界情况,能减少后续缩进。还要留意三种看起来不同、结果相同的情况:return None、单独写 return、没有遇到 return 就执行完函数体,三者都会把 None 交回调用者。
当 None 可能是正常结果时,调用者必须能区分“确实没有结果”和“程序漏写了 return”。设计函数前先说清返回约定,必要时让无效状态抛出异常,别把所有失败都悄悄压成 None。
一个函数也可以写 return left, right。这不是一次返回两个独立对象,而是先组成一个元组再返回;调用者常用解包接收它:
def min_max(numbers):
if not numbers:
raise ValueError("numbers 不能为空")
return min(numbers), max(numbers)
smallest, largest = min_max([7, 2, 9, 4])
print(smallest) # 2
print(largest) # 9真实程序很少只调用一层函数。一个处理订单的函数可能先验证字段,再计算金额,最后格式化结果。每次进入函数,Python 都会为本次调用保存一份执行现场,其中包括局部名称和应该返回的位置;内层函数结束后,程序回到外层调用点继续执行。
def validate_quantity(quantity):
if quantity <= 0:
raise ValueError("quantity 必须大于 0")
def calculate_subtotal(price, quantity):
validate_quantity(quantity)
return price * quantity
def build_receipt(price, quantity):
subtotal = calculate_subtotal(price, quantity)
return f"小计:{subtotal:.2f} 元"调用 build_receipt(20, 3) 时,外层先停在 calculate_subtotal 的调用位置;中层又停在 validate_quantity 的调用位置。验证结束后回到中层计算,中层返回后再回到外层格式化。每一层都可以有自己的 quantity、subtotal 等局部名称,它们属于各自调用,不会因为名字相同就自动混在一起。
如果最里面抛出异常而当前层没有处理,异常会沿调用链向外传播。这就是报错回溯为什么列出多行文件和函数:它是在告诉你程序经过哪些调用到达失败点。阅读时可以从最下面的异常类型和消息开始,先定位直接失败的操作,再向上看是谁传入了这个值。
# build_receipt(20, 0)
# ValueError: quantity 必须大于 0不要看到异常来自深层函数,就在每一层都加 try/except。中间层若不知道如何恢复,继续让异常向上传播通常更合适。最接近用户界面的那一层可以把异常转换成提示,批处理入口可以记录失败后继续下一条,而计算函数本身只需要准确报告违反了什么约定。
递归也是函数调用函数,只不过它调用的是自己。每次递归调用仍有独立的局部现场,并不会复用同一套局部变量:
def countdown(number):
if number <= 0:
return
print(number)
countdown(number - 1)这里 number <= 0 是停止条件。没有停止条件,调用层数会不断增加,最后触发递归深度相关错误。Python 中很多线性处理用循环更直接;递归适合问题本身具有树形或递归结构的场景。选择它不是为了显得高级,而是看哪种写法更容易说明问题并控制边界。
再看一个容易被忽略的细节:函数调用表达式本身可以嵌套,实参会先求值。
def double(number):
return number * 2
def add(left, right):
return left + right
result = add(double(3), double(5))Python 会先完成两个 double 调用,得到 6 和 10,再调用 add(6, 10)。如果实参表达式带有打印、文件写入或列表修改等副作用,求值顺序会影响结果。为了让流程容易看懂,复杂且带副作用的实参最好先单独计算并保存到有意义的变量,再进行主调用。
函数签名就像入口处的说明牌。它不仅写着需要哪些数据,还可以限制调用者以什么方式提供这些数据。参数越多,越需要有意识地设计这份契约。

下面的函数创建一个分页请求:
def build_page_request(resource, page, page_size=20):
return {
"resource": resource,
"page": page,
"page_size": page_size,
}调用时,位置实参按出现顺序填入尚未绑定的形参:
build_page_request("articles", 2, 50)关键字实参则按名称绑定,顺序可以调整:
build_page_request(page=2, resource="articles", page_size=50)两次调用结果相同。位置调用短,但读者必须记住顺序;关键字调用稍长,却能把含义直接写在调用现场。像 resize(800, 600) 这种约定明确的调用,用位置实参很自然;像 connect(5, 30, False) 这种布尔值和多个数字挤在一起的调用,可读性就很差,最好用关键字说明。
位置实参必须出现在关键字实参之前,而且同一个形参不能被绑定两次:
build_page_request("articles", page=2) # 合法
# build_page_request(resource="articles", 2)
# SyntaxError:位置实参不能写在关键字实参之后
# build_page_request("articles", 2, page=3)
# TypeError:page 同时按位置和关键字得到了两个值如果把函数签名想成一排空槽位,规则就很直观:位置实参从左向右占槽位,关键字实参按名字找槽位。一个槽位不能塞两次,必需槽位不能空着,没有对应槽位的关键字也不能凭空出现,除非函数专门用 **kwargs 接住它。
/ 和 * 控制调用方式默认情况下,普通形参既可以按位置传,也可以按关键字传。Python 还允许函数作者画出两条边界:/ 左边只能按位置传,单独的 * 右边只能按关键字传。
def create_order(product_id, /, quantity=1, *, express=False, note=""):
return {
"product_id": product_id,
"quantity": quantity,
"express": express,
"note": note,
}这份签名可以分成三段:
product_id 在 / 左边,是仅限位置形参。quantity 在 / 和 * 之间,既能按位置传,也能按关键字传。express 和 note 在 * 右边,是仅限关键字形参。create_order("P-104", 2, express=True)
create_order("P-104", quantity=2, note="下班后送达")
# create_order(product_id="P-104")
# TypeError:product_id 是仅限位置形参
# create_order("P-104", 2, True)
# TypeError:express 不能由第三个位置实参绑定仅限位置并不是为了故意让调用难读。某些形参名只是实现细节,或者 API 作者希望以后可以改名而不破坏调用者代码,这时 / 很合适。仅限关键字更常用于开关、单位、模式等容易误读的选项。send(data, True, 3) 很难读,send(data, compress=True, retries=3) 就清楚得多。
下面的交互实验把一次调用拆成实参、绑定槽位与最终结果。切换调用方案时,重点观察位置实参先填哪些槽位,以及 /、* 两侧的限制如何让错误提前暴露。
默认参数让调用者省略常用选项:
def format_price(amount, currency="CNY", digits=2):
return f"{currency} {amount:.{digits}f}"
print(format_price(19.9))
print(format_price(19.9, currency="USD"))语法并不难,真正容易踩坑的是计算时机。默认值在执行 def、创建函数对象时计算一次,不是在每次调用时重新计算。
tax_rate = 0.06
def add_tax(price, rate=tax_rate):
return price * (1 + rate)
tax_rate = 0.13
print(add_tax(100)) # 106.0函数创建时,名称 tax_rate 对应的 0.06 已经成为默认值。后来把模块变量重新绑定为 0.13,不会自动改写函数保存的默认值。如果你确实需要“调用那一刻的当前税率”,就应当在函数体里读取配置,或显式把税率传进来。
这大概是 Python 函数里最经典的坑:

def add_tag(tag, tags=[]):
tags.append(tag)
return tags
print(add_tag("新手")) # ['新手']
print(add_tag("函数")) # ['新手', '函数']第一次调用修改了默认列表,第二次调用又拿到了同一个列表对象。问题不在 append(),也不在“Python 忘了清空”;根源是默认值只创建一次,而列表可以原地修改。
常用修复方式是用 None 当哨兵,在每次需要默认容器时新建对象:
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tags
print(add_tag("新手")) # ['新手']
print(add_tag("函数")) # ['函数']这里要用 is None,不要草率地写 if not tags:。调用者可能故意传入空列表,后者会把这个合法对象也当成“没有传”。哨兵判断需要分清“未提供”和“提供了一个当前为空的对象”。
如果你还不确定“只创建一次”会怎样影响第二次、第三次调用,可以在下面的实验中连续追加内容,再切换到 None 哨兵版本。观察的重点不是输出差异,而是每次调用拿到的默认列表是否还是同一个对象。
当然,共享默认对象并非语法错误。如果你就是要做跨调用缓存,它可以是有意设计;不过这种隐藏状态很难察觉,也不便测试。更稳妥的做法是把缓存放进有明确名字的对象,或使用专门的缓存工具,让读代码的人一眼看见状态在哪里。
*args 与 **kwargs 收集剩余实参有些函数天然面对不固定数量的数据。*args 把未被普通形参接收的剩余位置实参收集成元组,**kwargs 把剩余关键字实参收集成字典:

def build_log(event, *details, level="信息", **context):
return {
"event": event,
"details": details,
"level": level,
"context": context,
}
log = build_log(
"支付失败",
"余额不足",
"已通知用户",
level="警告",
user_id="U-18",
名称 args 和 kwargs 只是惯例,星号才决定行为。不过遵守惯例能减少理解成本。还要看清 level:它写在 *details 后面,所以只能按关键字传。这样不管前面收集了多少条详情,调用者都不会误把某个字符串塞进日志级别。
不要因为 *args, **kwargs 灵活,就给每个函数都套上。过度宽松的入口会隐藏拼写错误:调用者写了 levle="警告",如果 **kwargs 无条件接收,它可能悄悄进入字典;明确的 level 形参则会立刻报错。只有函数确实需要转发、扩展或收集不定参数时,再使用它们,并在文档中说明支持哪些键。
* 与 ** 是把容器展开定义里的星号负责“收集”,调用里的星号负责“展开”。如果已有一组位置数据和一组关键字配置,可以这样调用:
def render_card(title, width, height, *, theme="浅色", rounded=True):
return f"{title}: {width}×{height}, {theme=}, {rounded=}"
size = (320, 180)
options
这大致相当于:
render_card(
"课程卡片",
320,
180,
theme="深色",
rounded=False,
)*size 要求右侧对象可迭代,元素会依次成为位置实参;**options 要求提供映射,键要能作为关键字名称。展开后仍然要经过正常的参数绑定检查。比如 options 里再放一个 width,而 width 已被位置实参绑定,就会因重复赋值触发 TypeError。
看到星号时先问它位于哪里:在函数定义中,args 与 **kwargs 是把剩余实参收进元组和字典;在函数调用中,items 与 **options 是把可迭代对象和映射拆成独立实参。方向正好相反。
定义处的“收集”和调用处的“展开”最容易被混在一起。下面的工作台会同时显示元组、字典和最终调用签名,你可以故意制造重复关键字,看看错误究竟发生在绑定的哪一步。
函数参数相关的报错看起来很多,其实都发生在函数体执行之前。只要按“这份签名有哪些槽位,每个实参准备填哪个槽位”来检查,通常很快就能找到问题。
def schedule_task(task, /, delay=0, *, retries=3, urgent=False):
return {
"task": task,
"delay": delay,
"retries": retries,
"urgent": urgent,
}第一类是缺少必需实参。schedule_task() 没有提供 task,而这个槽位又没有默认值,所以 Python 无法开始执行函数体。此时先对照签名,找出没有默认值、也没有在调用中得到实参的形参。
第二类是位置实参过多。schedule_task("备份", 10, 5) 会先把前两个实参绑定给 task 和 delay,第三个位置实参却没有槽位可放,因为 retries 和 urgent 都位于 * 右边。修复不是随意删掉数字,而是确认它本来想表达什么,然后写成 retries=5 或 urgent=True。
第三类是未知关键字。schedule_task("备份", retry=5) 中的 retry 与签名里的 retries 不同,又没有 **kwargs 接住额外关键字,于是报错。这类报错经常只是拼写问题,也可能说明调用者使用了旧版接口。不要为了让报错消失就贸然加上 **kwargs,否则拼错的配置可能被悄悄放过。
第四类是重复绑定。schedule_task("备份", 10, delay=20) 先用第二个位置实参填了 delay,随后又试图通过关键字填一次,所以报“得到多个值”。如果调用中混合了 *values 和 **options,重复可能藏在容器里:
values = ("备份", 10)
options = {"delay": 20, "urgent": True}
# schedule_task(*values, **options)
# delay 已由 values 绑定,又出现在 options 中第五类是违反仅限位置或仅限关键字约束。看到错误里出现 positional-only 或 keyword-only 时,直接回到签名寻找 / 和 *,别把它们当成普通乘除号。/ 是左侧仅限位置区域的终点,* 是右侧仅限关键字区域的起点。
调试时还有一个很实用的办法:先把复杂调用改写成不展开容器的显式形式。假设原代码是 schedule_task(*task_data, **settings),可以临时打印 task_data 和 settings,再手工展开成 schedule_task("备份", 10, retries=5)。当每个值的去向都摆在眼前,重复键、顺序错位和拼写错误会明显很多。
一个函数有十几个带默认值的参数,看上去“什么都能调”,实际使用时往往很难知道哪些组合有效。比如导出报告同时接收文件名、格式、纸张、主题、压缩、密码、是否上传、上传目录、重试次数,调用者很容易制造互相矛盾的状态。
先考虑哪些数据属于同一个概念。稳定的一组配置可以放进字典、数据类或专门的配置对象;互斥模式可以拆成两个明确函数;只在某个流程使用的网络选项,不应该混进纯粹负责生成内容的函数。函数签名是设计结果,不是把所有可能用到的变量排列在括号里。
还有一种危险信号:调用者几乎每次都要传同样的一长串参数。如果这些值共同描述一个用户、连接或订单,把它们聚合成对象通常更合理。这样不仅调用更短,也能在对象创建时集中验证一致性。相反,只为两个简单值创建复杂对象也会增加理解成本。判断标准仍然是:接口能否把真实概念和约束表达清楚。
“Python 到底是值传递还是引用传递”很容易把初学者绕进去。更实用的说法是:调用时,形参会绑定到实参所指向的对象。函数得到的是同一个对象的引用;但在函数体内重新绑定局部名称,和原地修改这个对象,是两件不同的事。

def add_one(number):
number = number + 1
return number
count = 10
new_count = add_one(count)
print(count) # 10
print(new_count) # 11进入函数时,局部名称 number 与外部名称 count 指向同一个整数对象 10。执行 number = number + 1 时,右侧先产生整数对象 11,随后只是让局部名称 number 改为指向它。外部的 count 仍然指向 10。
字符串、整数、浮点数、元组等不可变对象不能被原地改写。对它们进行所谓“修改”时,通常会创建新对象并重新绑定名称。
def apply_discount(prices, rate):
for index, price in enumerate(prices):
prices[index] = round(price * (1 - rate), 2)
cart_prices = [100.0, 60.0]
apply_discount(cart_prices, 0.1)
print(cart_prices) # [90.0, 54.0]形参 prices 和外部名称 cart_prices 指向同一个列表。给列表元素赋值是原地修改,所以调用结束后,外部继续观察同一个对象时会看到变化。这就是副作用:函数除了返回值,还改变了调用者可见的状态。
副作用不等于错误。列表的 sort()、文件写入、数据库更新都靠副作用工作。真正的问题是副作用是否清楚。如果一个名叫 calculate_discount 的函数悄悄改了原列表,调用者很容易误判。可以在命名和文档中明确“会原地修改”,也可以返回新列表:
def discounted_prices(prices, rate):
return [round(price * (1 - rate), 2) for price in prices]
original = [100.0, 60.0]
updated = discounted_prices(original, 0.1)
print(original) # [100.0, 60.0]
print(updated) # [90.0, 54.0]第二个版本只根据输入计算并返回结果,不修改外部状态,更接近纯函数。它通常更容易测试:给定相同输入,就能期待相同输出,也不用先清理上一次测试留下的共享状态。
为了避免改到调用者的数据,有人会立刻写 items.copy()。这对一层列表有效,但嵌套对象要额外小心:
records = [
{"name": "小林", "tags": ["新手"]},
]
copied = records.copy()
copied[0]["tags"].append("函数")
print(records[0]["tags"]) # ['新手', '函数']浅拷贝创建了新的外层列表,里面的字典和列表仍然共享。更好的第一步不是看到容器就无脑深拷贝,而是明确函数所有权:它是否允许修改传入对象?是否只读取?是否返回一个新结构?数据很大时,深拷贝还会带来不必要的时间和内存成本。
函数里的变量名不会凭空自带值。Python 需要沿作用域查找名称。对普通函数来说,可以先记住 LEGB 顺序:当前函数的局部作用域(Local)、外层函数作用域(Enclosing)、模块全局作用域(Global)、内置名称作用域(Builtins)。找到最近的绑定就停止。

tax_rate = 0.06
def make_calculator(member_discount):
service_fee = 2
def calculate(price):
subtotal = price * (1 - member_discount)
return subtotal * (1 + tax_rate) + service_fee
return calculate
vip_total = make_calculator(0.1)
print(vip_total(调用 calculate 时,price 和 subtotal 在当前局部作用域;member_discount 与 service_fee 来自外层函数;tax_rate 来自模块全局;如果代码调用 round(),这个名字通常从内置作用域找到。
下面的报错很反直觉:
count = 10
def increase():
print(count)
count = count + 1
# increase()
# UnboundLocalError: ...因为函数体里存在对 count 的赋值,Python 会把它视为这个函数的局部名称。执行 print(count) 时,局部 count 还没绑定,所以抛出 UnboundLocalError;它不会因为全局刚好有同名变量,就临时绕回去读取全局值。
最简单的修复通常不是加 global,而是把旧值作为参数传入,把新值作为结果返回:
def increased(count):
return count + 1
count = increased(count)这样依赖关系写在函数签名上,测试时也不用准备全局状态。
global 和 nonlocal 要表达明确的状态修改global 让函数内的赋值指向模块全局名称;nonlocal 让内层函数的赋值指向最近的外层函数绑定。
def make_counter(start=0):
count = start
def next_value():
nonlocal count
count += 1
return count
return next_value
counter = make_counter(10)
print(counter()) # 11
print(counter()) # 12make_counter 已经执行结束,但返回的内层函数仍保留对 count 的关联,这种结构称为闭包。nonlocal 告诉 Python:count += 1 修改的是外层绑定,不要新建同名局部变量。
闭包适合封装少量状态,例如计数器或带配置的函数生成器。不过状态一多,nonlocal 变量散在多个内层函数里就会难维护,此时用类或显式数据对象通常更清楚。global 更要克制,因为任何能访问模块状态的代码都可能改变它,函数的输入输出会变得不完整。
下面的实验可以创建两个互不共享的计数器,并对比普通读取、nonlocal 修改和循环闭包晚绑定。操作时留意:闭包保留的是哪个外层绑定,以及两个独立工厂调用为什么不会共用同一个计数。
闭包还有一个常见坑,通常出现在循环中批量创建函数。你可能期待三个函数分别乘以 1、2、3:
functions = []
for factor in (1, 2, 3):
functions.append(lambda value: value * factor)
print([func(10) for func in functions]) # [30, 30, 30]三个 lambda 记住的是外层名称 factor 的关联,不是创建那一刻数值的快照。等循环结束后再调用它们,factor 已经是 3,所以结果都乘以 3。这常被称为晚绑定:自由变量的值在函数真正调用时查找。
一种修复方式是利用默认参数在函数创建时求值,把当次循环值保存下来:
functions = []
for factor in (1, 2, 3):
functions.append(lambda value, factor=factor: value * factor)
print([func(10) for func in functions]) # [10, 20, 30]这里的 factor=factor 左侧是新函数的形参,右侧是在当前定义环境里求值的循环变量。写法虽然短,但对初学者不一定直观。业务代码里也可以用普通工厂函数,把“为某个倍率创建函数”的意图写清楚:
def make_multiplier(factor):
def multiply(value):
return value * factor
return multiply
functions = [make_multiplier(n) for n in (1, 2, 3)]理解这个坑的关键仍然是分清名称和对象。闭包保留可供以后解析的外层绑定;它不会自动把函数创建时能看到的所有值复制一份。需要快照时,就显式创建新的绑定。
作用域查找的最后一站通常是内置名称,所以 list、sum、max、str 能直接使用。如果你在局部或全局把这些名称重新绑定,较近的绑定会遮住内置对象:
def summarize(numbers):
sum = 0
for number in numbers:
sum += number
return sum这段函数目前能算总和,但如果后来有人在函数末尾加入 average = sum(numbers) / len(numbers),sum 已经是整数,调用就会失败。问题不是内置函数坏了,而是局部名称遮住了它。给变量使用 total,既能表达含义,也保留了 sum()。
自定义模块名也要小心。把自己的文件命名为 typing.py、json.py,可能让导入语句加载本地文件而不是标准库模块。遇到“标准库里明明有这个属性却提示不存在”时,除了检查拼写,也要检查当前项目里是否有同名文件或变量。
能运行只是起点。别人接手函数时,首先看到的通常是名称、签名、类型提示和文档字符串,而不是完整函数体。把这些入口信息写清楚,能减少大量试错。
函数通常执行一个动作,名称可以用动词或动词短语:load_orders、calculate_total、is_valid_email。返回布尔值的函数用 is_、has_、can_ 等前缀,调用处会更接近自然语言。
名字还要避免承诺过多。process_data 几乎什么都没说;如果函数实际是“过滤已取消订单”,filter_active_orders 更准确。如果一个精确名字长得离谱,往往说明函数塞了太多职责,需要拆分。
def average(scores: list[float]) -> float:
if not scores:
raise ValueError("scores 不能为空")
return sum(scores) / len(scores)scores: list[float] 表达期望收到浮点数列表,-> float 表达预期返回浮点数。编辑器和静态检查工具可以据此提示错误、补全代码,读者也能快速理解接口。
但 Python 默认不会因为标注而自动拦截错误类型。直接调用 average("123") 时,不会在函数入口凭标注产生统一的类型错误;程序仍会执行函数体,直到某个具体操作不支持才可能出错。类型提示是说明与静态分析信息,不等于参数验证。
当函数允许 None 时,应把它写出来:
def find_user(user_id: str) -> dict[str, str] | None:
...这里的 | None 表示返回值可能是字典,也可能是 None。它和“参数有默认值”不是同一概念。limit: int = 20 是可省略的参数,但其值仍预期为整数;只有确实接受 None 时,才写 int | None。
文档字符串是函数体第一条字符串语句,可以通过 help() 或函数的 __doc__ 查看。简短函数用一句话说明行为即可;公共函数逻辑较复杂时,再补充参数含义、返回值、可能抛出的异常与副作用。
def reserve_stock(product_id: str, quantity: int, *, allow_partial: bool = False) -> int:
"""预留商品库存并返回实际预留数量。
quantity 必须是正整数。库存不足且 allow_partial 为 False 时,
抛出 ValueError;允许部分预留时,返回当前可预留的数量。
此函数会修改库存状态。
"""
...像“reserve_stock 用来 reserve stock”这种重复没有帮助。调用者真正想知道的是单位、边界、失败方式和是否改状态。并非每个三行内部小函数都需要长文档;签名和命名已经清楚时,一句准确摘要就够了。
写完函数后,只看名称、签名和 docstring,暂时遮住函数体。如果你仍能回答“要传什么、会得到什么、可能失败在哪里、会不会改外部状态”,这份接口说明通常就够用了。
单一职责更接近“一个函数只有一个清楚的变化理由”。假设一段代码既读取 CSV、清洗订单、计算金额、打印报告、发送邮件,任何一项规则改变都要修改同一个函数,测试也必须一次准备所有外部条件。这就是职责混杂。
可以沿数据流拆开:
def normalize_order(raw_order):
"""把原始字段转换为统一的订单结构。"""
return {
"id": str(raw_order["id"]).strip(),
"price": float(raw_order["price"]),
"quantity": int(raw_order["quantity"]),
}
def calculate_order_total(order):
"""根据规范化订单计算金额。"""
return order["price"] * order["quantity"]
每个函数都能用简单输入单独测试。读取文件和发送邮件仍然会有副作用,但可以把它们放在流程边缘;中间的清洗、计算、格式化尽量写成只依赖输入并返回结果的函数。
“纯函数”不是要求 Python 项目彻底禁止状态,而是一种很实用的组织策略:能纯的计算先保持纯,把数据库、文件、网络和界面更新集中在少数明确位置。这样排查错误时,你更容易判断是计算规则错了,还是外部交互失败。
函数接到不合法输入时,最麻烦的做法是继续算,直到深处报出一个和根因无关的错误。入口处能检查的约束,应尽早检查:
def calculate_installment(total: float, months: int) -> float:
"""计算每期金额。"""
if not isinstance(months, int):
raise TypeError("months 必须是整数")
if months <= 0:
raise ValueError("months 必须大于 0")
if total < 0:
raise ValueError("total 不能为负数")
一般来说,参数类型完全不对时用 TypeError,类型可接受但取值超出约定时用 ValueError。错误信息应指出哪个参数不合格以及要求是什么。raise ValueError("输入错误") 虽然能中断程序,却把定位工作又丢给调用者。
不要在函数内部用宽泛的 except Exception: 把所有异常吞掉后返回 None。那会把编程错误、网络失败、数据格式错误都揉成同一个模糊结果。函数能恢复时再捕获具体异常;无法在当前层解决时,让异常向上传播,由更了解用户界面或重试策略的上层决定怎么处理。
def parse_quantity(text: str) -> int:
try:
quantity = int(text)
except ValueError as error:
raise ValueError(f"数量必须是整数,收到:{text!r}") from error
if quantity <= 0:
raise ValueError("数量必须大于 0")
return quantity这里捕获的是能够补充语境的具体异常,然后保留异常链重新抛出。调用者看到的错误既说明业务要求,也还能追查原始转换失败。
当一个文件里积累了几十个函数,继续向下滚动很难找到边界。这时可以按职责把函数放进不同 .py 文件。假设订单程序拆成:
shop/
├── main.py
├── pricing.py
├── validation.py
└── formatting.pypricing.py 放金额计算,validation.py 放输入检查,formatting.py 放展示文本。主程序显式导入模块:
import pricing
import validation
quantity = validation.parse_quantity("3")
total = pricing.calculate_total(price=29.9, quantity=quantity)pricing.calculate_total 比把许多名称直接灌进当前命名空间更容易追踪来源。对于经常使用且名称不会冲突的少量函数,也可以写:
from validation import parse_quantity应避免在正式代码里使用 from validation import *。它让名称来源不清楚,也可能覆盖当前模块已有名称。模块拆分也不是越碎越好:两个函数总是一起修改、共同服务一个小概念时,放在同一模块通常更自然。
lambda 适合短小表达式,不适合藏业务流程lambda 会创建匿名函数,它的函数体只能是一个表达式,表达式的结果自动成为返回值:
orders = [
{"id": "A", "total": 86.0},
{"id": "B", "total": 42.5},
]
orders.sort(key=lambda order: order["total"])这里的函数只在排序现场使用一次,逻辑短,含义也紧贴 key=,所以 lambda 很合适。若逻辑需要多个条件、异常处理、注释、类型提示或复用,就应该写普通函数:
def ranking_key(order):
"""优先按是否加急排序,再按金额降序排序。"""
return (not order["urgent"], -order["total"])
orders.sort(key=ranking_key)不要为了追求“一行代码”把复杂业务塞进嵌套条件表达式。代码行数少不等于理解成本低。普通 def 还能拥有清楚的名称、文档字符串、类型提示和独立测试位置。
函数对象可以成为另一个函数的参数。内置的 sorted() 就通过 key 接收函数:它会把每个元素交给这个函数,再依据返回值排序。自己写代码时也能使用同样的模式:
def transform_all(items, transform):
return [transform(item) for item in items]
def normalize_name(name):
return name.strip().title()
names = transform_all([" alice", "BOB "], normalize_name)
print(names) # ['Alice', 'Bob']transform_all 不需要知道具体转换规则,只约定 transform 能接收一个元素并返回转换结果。这类接收函数或返回函数的函数常被称为高阶函数。它能减少重复的遍历框架,但也需要控制抽象程度。只有一处两行逻辑时,普通循环可能更直接;多个流程确实共享同一结构、只替换一个步骤时,传入函数才会让代码更清楚。
类型提示也能描述可调用对象。下面的签名说明 validator 接收字符串并返回布尔值:
from collections.abc import Callable, Iterable
def keep_valid(
items: Iterable[str],
validator: Callable[[str], bool],
) -> list[str]:
return [item for item in items if validator(item)]如果回调还会抛异常或修改外部状态,最好在 docstring 中写明。函数作为参数以后,控制流程会跳到另一个函数体;约定不清时,读者会很难判断错误从哪里来。
测试函数时,随手给一个正常值只能说明主路径碰巧工作。更稳妥的做法是围绕契约选输入。以 parse_quantity 为例,至少要考虑正常整数文本、边界零、负数、空字符串、带小数点的文本和空白包围的文本。你不必一开始使用复杂测试框架,也可以先用断言和显式异常检查把预期写下来。
assert parse_quantity("1") == 1
assert parse_quantity(" 8 ") == 8
try:
parse_quantity("0")
except ValueError as error:
assert "大于 0" in str(error)
else:
raise AssertionError("数量为 0 时应抛出 ValueError")检查返回结果之外,也要检查输入是否被意外修改:
source = [" 小林 ", "", "阿青"]
snapshot = source.copy()
result = collect_valid_names(source)
assert result == ["小林", "阿青"]
assert source == snapshot第二个断言把“函数只读取输入”这项约定固定下来。如果以后有人为了省事改成原地清洗,这个检查会马上提醒。对于明确应该原地修改的函数,则反过来检查修改后的状态,并让名称或 docstring 清楚说明这一点。
还要把 print() 输出和返回值分开测试。一个函数在终端里显示了正确数字,不代表它返回了数字。需要组合和计算的函数应检查返回对象;专门负责展示的函数才检查输出效果。这个习惯能尽早发现漏写 return 的问题。
面对一百行函数,机械地每十行切一段,往往只会制造很多难命名的小函数。更有效的切法是寻找数据状态的变化:原始输入何时变成规范数据,规范数据何时变成计算结果,计算结果何时变成展示内容,展示内容何时被写入外部系统。
每条边界都能形成明确的输入输出。例如:
raw_rows = load_rows(path) # 文件 -> 原始行
orders = parse_orders(raw_rows) # 原始行 -> 订单
totals = calculate_totals(orders) # 订单 -> 金额
report = format_report(orders, totals) # 数据 -> 文本
save_report(report, output_path) # 文本 -> 文件load_rows 和 save_report 接触文件,属于流程边缘;中间三个函数可以只处理内存中的对象。出了问题以后,检查范围也清晰:读取内容不对就查加载,订单字段不对就查解析,金额不对就查计算,排版不对就查格式化。
重构时先保持行为不变,再改善接口。不要同时拆函数、改返回结构、换异常类型和调整业务规则,否则结果变化后很难确定是哪一步造成的。先用几个代表性输入记下现有结果,逐步拆出函数,每拆一步就重新检查;等结构稳定,再单独修改规则。
现在把前面的规则连起来。需求是:根据商品单价、数量和折扣计算应付金额。单价不能为负,数量必须是正整数,折扣范围是 0 到 1;调用者必须明确写出折扣名称,避免把它误当成数量。
def calculate_payment(
unit_price: float,
quantity: int,
*,
discount: float = 0.0,
) -> float:
"""计算折后应付金额。
unit_price 不能为负;quantity 必须是正整数;
discount 取值范围为 0 到 1。
"""
if unit_price < 0:
raise ValueError("unit_price 不能为负数")
if not isinstance(quantity, int
这段函数并不炫技,但契约很完整:名称说明结果;类型提示给读者和工具看;* 让折扣只能按关键字传;默认值是安全的不可变浮点数;非法输入尽早抛出具体异常;计算只依赖输入,没有修改外部对象;结果通过 return 交回。
调用方式也很清楚:
normal_total = calculate_payment(39.9, 2)
member_total = calculate_payment(39.9, 2, discount=0.15)
print(normal_total) # 79.8
print(member_total) # 67.83可以继续为边界编写检查:
assert calculate_payment(0, 1) == 0
assert calculate_payment(100, 2, discount=1) == 0
assert calculate_payment(19.9, 3, discount=0.1) == 53.73如果需求以后加入优惠券,不要急着把查券、读数据库、写日志都塞进 calculate_payment。让它继续负责纯计算,再由上层函数先取得优惠信息,换算成折扣后传进来。这就是单一职责在真实改动中的价值:不是追求形式上的“小”,而是让变化停留在该变化的位置。
下面这个函数同时踩中了可变默认值、隐藏副作用和返回约定模糊三个坑:
def collect_valid_names(names, result=[]):
for name in names:
cleaned = name.strip()
if cleaned:
result.append(cleaned)
print(result)请先自己改写,目标是:每次调用默认得到新列表;不修改调用者传入的列表;通过返回值交付结果;类型提示说明输入输出。
最后,给自己留一份写函数时的检查顺序:先确认函数只负责什么;再写清输入、返回值和失败方式;选择位置或关键字契约;检查默认值是否可能被修改;确认有没有意外改变传入对象或全局状态;补上有用的类型提示和 docstring;最后用正常值、边界值和错误值各调用一次。能把这几步走顺,函数就不再只是“少写几行重复代码”,而会变成可以放心组合的程序零件。
如果某个函数出了问题,也按同一条执行链倒着查:调用者实际给了哪些对象,参数绑定是否符合签名,函数体读取的是哪个作用域里的名称,对象有没有被原地修改,控制流在哪个 return 或异常处结束。把问题落到这些具体动作上,比反复猜“Python 为什么这样”有效得多。等你养成这种排查习惯,哪怕第一次见到某种参数组合,也能根据规则推断它会怎样执行。
None