Python 函数与模块:建立可测试的订单规则
今天从哪里开始
打开昨天的批量校验器。假设现在增加一个单笔入口,你把数量规则复制了过去;后来只修好其中一处,另一处仍把负数算进金额。今天先复现这次漏改,再讨论怎样让两个入口共用同一条规则。
我们从一段重复金额代码提取函数,逐步讲参数、返回值和副作用;确认调用行为不变后,再把独立职责放进模块。随后让同一个报价入口接收不同优惠策略,观察函数作为对象与闭包怎样服务商城活动试算。
flowchart LR A[昨日批量校验] --> B[提取规则函数与模块] B --> C[可替换的优惠试算]
今天要学会什么
- 用参数和返回值复用金额与状态规则
- 解释作用域、副作用和可变默认参数风险
- 用函数对象、闭包和 lambda 完成优惠试算
- 用模块、类型提示和断言保护重构后的行为
课堂结束时,用自己的项目检查:
- 能做出来:改变优惠门槛与减额,只修改策略配置并回归旧规则,并展示正常输入的结果。
- 能查出问题:针对“打印代替返回、隐式共享状态、导入就执行交互”中的风险,说明输入、预期与实际差异;失败后核对数据没有被误改。
- 能解释修改:提交代码、测试和修改前后结果,讲清自己接受或拒绝了哪一处 AI 建议。
先让旧办法真实失败
我们先只修单笔入口,再运行批量路径:
输入:quantity = -1
单笔入口:拒绝
批量入口:仍然累计金额这次红灯暴露了四个维护问题:
- 相同业务规则被复制,规则来源不唯一;
- 改一处容易漏一处;
- 金额、校验、状态和输出混在长流程里,影响范围难判断;
- 想检查一条规则时,只能运行整段脚本并人工读输出。
现在请先给方案提要求,不急着命名技术:规则应该只有一个实现位置;调用者能把数据交进去;结果或错误能交回来;可以脱离终端单独运行;修复后原有路径不能被破坏。
同一段金额规则复制到两个入口,下一次修改会有什么风险?
容易只改一处,导致同一订单从不同入口得到不同金额。抽出共同规则并让两处调用,修改后用原有案例回归。
满足这些要求的方法叫函数

这张图先帮助回忆“参数进入、结果返回”的直觉,随后必须回到真实代码。机器比喻没有解释异常、副作用和业务边界,所以不能停在图上背“函数、参数、返回值”三个名词。
函数(function)是一段有名称、可以接收输入、完成一个明确职责并把结果交回调用处的代码。Python 用 def 定义函数。
我们今天不是按固定行数拆代码,而是把“有独立业务职责、会重复使用、需要单独验证或经常一起变化”的代码提取出来。函数越多不一定越好,边界是否清楚才重要。
动结构前,先冻结正确行为
如果昨天的批量校验器本来就错了,不能把错误结果写进“行为保持”。先选三条固定路径:
| 基线路径 | 输入 | 预期 | 状态前后 |
|---|---|---|---|
| 合法批量金额 | 冻结的固定样本数据(Fixture) | 手算与运行一致 | 不涉及状态写入 |
| 坏商品/数量 | 未知商品或 0 | 明确失败,不进入累计 | 原值 |
| 状态正反例 | 待支付支付、已支付取消 | 一成一败 | 失败无半更新 |
保存原始命令、输出和 Git 快照。重构(refactoring)是指在保持外部可观察行为不变的前提下改善代码内部结构。结构变化以后,用同一批输入重跑,才能证明这次工作是安全重构。
把代码拆成很多文件,运营就一定能得到更正确的结果吗?
文件数量不是业务收益。重构应保留正常、错误和边界行为,同时让规则更容易复用和测试;固定输入比较输出与状态才有说服力。
从重复金额块提取第一个函数
先看提取前的一处金额代码:
from decimal import Decimal
unit_price_yuan = Decimal("129.00")
quantity = 2
line_total_yuan = unit_price_yuan * quantity
print(f"行金额:{line_total_yuan:.2f} 元")我们把“单价 × 数量”这个独立职责命名,并让它返回业务结果:
from decimal import Decimal
def calculate_line_total_yuan(unit_price_yuan, quantity):
line_total_yuan = unit_price_yuan * quantity
return line_total_yuan
result = calculate_line_total_yuan(Decimal("129.00"), 2)
print(f"行金额:{result:.2f} 元")调用发生时:
- 调用处先计算实参;
- 实参分别交给形参;
- 函数体在一次新的调用环境中执行;
- 遇到
return把结果交回调用处; - 调用处继续执行。
只写 def 不会执行函数体;调用以后才执行。函数定义要出现在第一次调用之前。接下来把原来的重复块逐处替换为调用,再用相同输入比较提取前后的金额;这一步才完成第一次重构闭环。
参数:调用者必须提供什么
位置参数
以下金额片段默认已执行 from decimal import Decimal。
calculate_line_total_yuan(Decimal("129.00"), 2)按位置匹配,顺序写反可能不报错,却产生业务错值。
关键字参数
calculate_line_total_yuan(unit_price_yuan=Decimal("129.00"), quantity=2)关键字让含义更清楚,也能减少同类型参数写反。下面继续用报价与备注说明默认值、仅关键字参数和参数收集;它们都要能回到可执行的合同。
别把不需要的数据都塞进去
金额函数若只需要单价和数量,就不要传整个全局订单列表。参数越宽,隐藏依赖越多,函数越难单独验证。
默认参数与可变默认值:第二位顾客为什么看见第一位的备注
默认参数让调用者省略常用值;默认表达式在函数定义时求值,并非每次调用重新创建。
def wrong_note(note, notes=[]):
notes.append(note)
return notes
print(wrong_note("不要辣"))
print(wrong_note("开发票")) # 两条备注串在一起这是独立故障实验。商城订单备注不能串单,我改用 None 表示“本次没有提供列表”:
def append_note(note, notes=None):
if notes is None:
notes = []
notes.append(note)
return notes连续调用应各有自己的列表。显式传入同一列表仍会共享;函数没有自动复制入参。学生分别运行“省略参数”与“显式共享”两组,说明区别。
参数收集、展开与类型提示
客服报价可选择优惠策略。* 后的参数必须写名称;*args 收集额外位置参数为元组,**kwargs 收集额外关键字参数为字典。调用时的 */** 做相反的展开。
def show_request(*args, **kwargs):
print(args, kwargs)
show_request("P1001", 2, channel="客服")
values = ("P1001", 2)
options = {"channel": "客服"}
show_request(*values, **options)两次输出相同;kwargs 的键必须匹配被调用函数接受的参数,展开不会校验业务数据。项目普通金额函数仍写明确参数,D05 包装函数时才用通用转发。
quantity: int 与 -> Decimal 是类型提示,帮助编辑器与读者理解合同,不会拦住 quantity=True 或自动转换文本。运行时仍用昨天的显式校验。
return、print 和 None
函数现在能算出金额了,但调用者接下来还要汇总、校验或测试。只把数字打印在屏幕上,调用者能继续使用这个结果吗?
def show_total(total_yuan):
print(f"订单金额:{total_yuan} 元")
result = show_total(Decimal("258.00"))
print(result)函数没有显式 return 时,返回 None。print 把文字展示给人,return 把值交给调用者继续计算或测试。
订单规则函数通常返回业务值或明确结果;CLI(Command-Line Interface,命令行界面)入口负责 input() 和 print()。若规则函数只能打印,随后编写的测试就只能解析文字,很难稳定检查金额和状态。
打印金额和返回金额为什么不是同一件事?
print 产生输出副作用,调用者通常得到 None;return 把值交给调用者继续累计、校验和测试。业务函数优先返回结果,由 CLI 或 API 决定如何展示。
局部变量、外部名称和作用域
unit_price_yuan = Decimal("50.00")
def calculate_demo_total(quantity):
unit_price_yuan = Decimal("129.00")
return unit_price_yuan * quantity
print(calculate_demo_total(2))
print(unit_price_yuan)函数内部赋值的 unit_price_yuan 是局部名称,不会自动覆盖外部同名值。Python 查找名称有局部、外围、全局和内置等层次;W01 先掌握最关键的两点:
- 本次调用需要的数据优先通过参数传入;
- 不用
global把业务状态变成所有函数都能随意修改的共享变量。
可变参数:传入字典后,函数可能改到原对象

W01-D02 已经用字典表示订单。这里快速回忆 key: value,因为函数接收到订单字典后,读取或修改的都是具体业务字段。图中示例中的姓名示例不作为业务数据;课堂代码继续使用冻结的订单合同字段。
def mark_paid(order):
order["order_status"] = "PAID"
order = {"order_id": "O1001", "order_status": "PENDING_PAYMENT"}
mark_paid(order)
print(order["order_status"])字典是可变对象。函数拿到它后修改字段,调用者会看到变化。这不是一定错误,但必须写进合同:
- 哪个函数允许修改状态?
- 修改前检查哪些条件?
- 失败是否保持原值?
- 返回什么,让调用者知道动作成功或失败?
金额计算和查询适合纯规则:相同输入得到可预测结果,不修改传入对象。支付/取消是受控副作用:先检查,成功路径只更新一次。
函数收到订单字典后直接修改它,调用者会看到什么?怎样区分计算和修改?
字典是可变对象,调用者可能看到原数据被改动。金额计算优先返回新值;确实要更新状态的函数应明确副作用、前置条件和失败行为。
从第一个函数继续形成函数合同
| 合同项 | 要回答的问题 |
|---|---|
| 职责 | 这个函数只解决什么业务问题? |
| 参数 | 调用者必须提供哪些值?单位和类型是什么? |
| 返回 | 成功时返回什么?失败怎样表达? |
| 异常/边界 | 哪些输入拒绝?是否抛异常或返回失败结果? |
| 副作用 | 会不会修改传入对象?哪个字段? |
| 非职责 | 明确不读取键盘、不打印或不访问什么 |
“处理订单”太宽。更可审的职责可能是:
- 计算一条明细金额;
- 校验商品与数量;
- 按 ID 查询订单;
- 尝试执行一次状态迁移。
名称可以不同,合同必须让另一位开发者知道怎样调用、怎样判断失败。
中文 docstring 写什么
重要函数用 docstring(函数文档字符串)说明调用合同。下段只展示文档写法,不是可替换前面函数的完整实现;实际函数要保留计算与 return:
def calculate_line_total_yuan(unit_price_yuan, quantity):
"""计算单条商品明细金额。
参数:
unit_price_yuan: 商品单价,单位为元。
quantity: 购买数量,必须不小于 1。
返回:
单价乘数量后的 Decimal 元金额。
异常:
数量不合法时应由调用链中的校验职责拒绝。
"""注释不翻译每一行。它要解释业务逻辑、边界、为什么这样设计,以及 AI(人工智能) 生成时最值得审查的点。
先纯规则,后受控状态动作
纯规则候选
单价元 + 数量 → 行金额元
商品集合 + 商品 ID → 商品或明确未找到
订单集合 + 订单 ID → 订单或明确未找到有副作用的状态动作
订单当前状态 + 请求动作
→ 先校验迁移
→ 成功才更新状态
→ 返回动作结果与状态后值不要为了追求“纯函数”假装订单状态不会变化;也不要因为需要变化,就允许任意函数修改任何字段。
同一份报价,怎样换满减方案而不复制计算
我先保留已有行金额函数;新增的商城需求是“客服试算两种优惠”,只返回试算结果,不修改商品价格或历史订单。
函数本身也是对象:写 no_discount 是把函数交出去,写 no_discount(total) 才是调用。接收或返回函数的函数称为高阶函数。
from decimal import Decimal
def no_discount(total: Decimal) -> Decimal:
return total
def quote(unit_price_yuan, quantity, *, strategy=no_discount):
subtotal = calculate_line_total_yuan(unit_price_yuan, quantity)
return strategy(subtotal)这里是接在本人已完成金额函数后的小例,strategy 必须用关键字传入。生产合同还要检查策略不能把优惠后金额变为负数或高于原价;日包任务要求补这道返回值校验。
闭包保存本次活动的配置
闭包是内部函数连同它用到的外围变量。外层已经返回,内部函数仍可在以后调用时使用这组配置:
def make_threshold_discount(threshold, reduction):
def discount(total):
if total >= threshold:
return total - reduction
return total
return discount
promotion = make_threshold_discount(Decimal("200.00"), Decimal("20.00"))
print(promotion(Decimal("258.00"))) # 238.00
print(promotion(Decimal("199.99"))) # 199.99这段只展示捕获与调用;项目工厂先校验满减参数非负、有限、最多两位小数,且减额不超过门槛。再创建另一组满300减30,交替调用,确认配置不串线。
LEGB 表示名称查找顺序:本次函数局部、外围函数、模块全局、内置。这里只读取外围 threshold 和 reduction。nonlocal 用于重新绑定外围变量,global 用于重新绑定模块变量;报价核心不需要两者,显式返回新金额更容易复验。
lambda 用于小型选择,不藏业务流程
offers = [{"label": "原价", "amount": Decimal("258.00")},
{"label": "活动价", "amount": Decimal("238.00")}]
print(sorted(offers, key=lambda offer: offer["amount"])[0]["label"])lambda 参数: 表达式 创建只含一个表达式的函数。这里给 sorted 一个取金额的规则,输出“活动价”;它没有更改 offers。带校验、异常、多步骤的优惠规则保留 def,方便查错。
断言:把预期写成可运行检查
actual = calculate_line_total_yuan(Decimal("129.00"), 2)
assert actual == Decimal("258.00")断言失败会显示实际路径与行号。今天用简单断言建立回归意识,W01-D05 再用 pytest 组织测试。
至少覆盖:
- 正常金额;
- 未知商品或数量边界;
- 合法支付与非法取消;
- 失败状态前后相同。
断言不是给最终用户看的输入处理,也不替代明确业务错误。它用于开发与检查,帮助我们证明重构前后行为一致。
重构循环:一次只提取一个职责
每次遵循同一节奏:
选一个职责
→ 写合同
→ 写一个能调用它的检查
→ 提取最小代码
→ 看 git diff
→ 直接调用
→ 跑三条旧路径
→ 小步提交不要同时改变量名、函数结构、业务规则和输出文案。否则回归失败时,很难知道是哪类改动造成的。
重构拆文件之后,怎样证明业务没有变?
先用固定正常、错误和边界输入记录旧结果,再修改职责和 import,重新比较返回值、异常与状态变化。仅证明能 import 不够。
模块、包和入口副作用
同一文件里的函数已经可以单独调用。文件继续增长时,再按职责拆模块;先看拆分怎样改变 import(导入),不要一口气建齐未来所有目录。
先使用下面的职责图理解拆分方向。模型和存储要到明天才接入,今天只提取已存在的规则与调用入口,不要求提前实现图中的全部模块:
src/order_management/
├─ models.py
├─ errors.py
├─ storage.py
├─ service.py
└─ cli.py下段是入口结构示意,前提是已经定义 main()。业务运行不要放模块顶层;W01-D05 会把它接成完整命令行入口:
if __name__ == "__main__":
raise SystemExit(main())这样导入测试时不会自动创建订单或改文件。
循环导入怎样排查
若 models 导入 service、service 又导入 models,先画依赖箭头。Model 应处于较内层,不依赖 CLI/Service。不要用到处移动 import 掩盖反向职责。
同步调用:先看清函数什么时候返回
我调用 calculate_total(items) 时,调用方要等函数返回,才能把返回值用于下一句。这里说的是调用顺序,不代表函数一定很慢。W02 接入网络以后,我们再观察:程序等待外部结果时,能不能处理别的请求。
def calculate_total(prices):
return sum(prices)
print("开始计算")
total = calculate_total([10, 20])
print("拿到结果", total)这是独立示例。先预测输出顺序,再运行。把第二个 print 放进函数和放在调用方,职责有什么区别?今天先把同步函数、返回值和副作用讲清,后面学习 async/await 时继续使用这套判断方法。
调用一个普通函数时后面的语句何时执行?
通常等这次调用返回或抛异常后再继续,这叫同步调用。函数内部如果等待磁盘或网络,调用者也在等;同步是调用关系,阻塞描述等待时执行资源能否去做别的事。
接回本人的批量项目
我把昨天的金额、校验、状态动作逐项提取到 src/order_rules.py;src/__init__.py 是空文件,让 src 成为显式包。批次入口导入这些函数,原数据逐条结果保持一致。新增 src/promotions.py 仅负责优惠试算,避免把活动逻辑塞进商品资料。
python checks/regression_checks.py
python checks/check_promotions.py骨架首次应因 STUDENT_TODO 失败。完成后原金额258.00、满200减20为238.00;满减门槛前后、减额大于门槛、连续不同订单、非法数量都要检查。独立变体由同伴给门槛与减额,作者先预测再改配置,不能复制整个报价函数。
AI 修改范围限定 promotions.py,一次只改一条策略;看 diff 确认没有变动已有状态或金额类型,再跑原三条业务回归。
学生自己动手,AI 只能改你允许改的地方
我先保存当前可运行版本,再完成“改变优惠门槛与减额,只修改策略配置并回归旧规则”。让 AI 一次只修改负责该行为的文件;修改前预测正常与失败结果,检查差异,再运行回归。每人需要解释一处接受或拒绝的建议。
错误与边界怎样验收
今天重点检查:打印代替返回、隐式共享状态、导入就执行交互。保留输入、预期、实际输出和修复后的结果,不能只贴全绿截图。未验证的外部依赖如实记为待验证。
留给下一阶段
今天交付的是行为没变、规则来源更清楚的一版程序。下次修改数量条件时,应知道改哪个函数、跑哪些检查。明天再解决字典字段容易写错,以及退出程序后订单怎样保存的问题。