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} 元")

从实参到参数、局部计算、返回值与调用处

调用发生时:

  1. 调用处先计算实参;
  2. 实参分别交给形参;
  3. 函数体在一次新的调用环境中执行;
  4. 遇到 return 把结果交回调用处;
  5. 调用处继续执行。

只写 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"])

字典是可变对象。函数拿到它后修改字段,调用者会看到变化。这不是一定错误,但必须写进合同:

  • 哪个函数允许修改状态?
  • 修改前检查哪些条件?
  • 失败是否保持原值?
  • 返回什么,让调用者知道动作成功或失败?

金额计算和查询适合纯规则:相同输入得到可预测结果,不修改传入对象。支付/取消是受控副作用:先检查,成功路径只更新一次。

函数收到订单字典后直接修改它,调用者会看到什么?怎样区分计算和修改?

字典是可变对象,调用者可能看到原数据被改动。金额计算优先返回新值;确实要更新状态的函数应明确副作用、前置条件和失败行为。

从第一个函数继续形成函数合同

合同项要回答的问题
职责这个函数只解决什么业务问题?
参数调用者必须提供哪些值?单位和类型是什么?
返回成功时返回什么?失败怎样表达?
异常/边界哪些输入拒绝?是否抛异常或返回失败结果?
副作用会不会修改传入对象?哪个字段?
非职责明确不读取键盘、不打印或不访问什么

输入/输出(Input/Output,I/O)、规则函数与回归检查的职责合同

“处理订单”太宽。更可审的职责可能是:

  • 计算一条明细金额;
  • 校验商品与数量;
  • 按 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 组织测试。

至少覆盖:

  • 正常金额;
  • 未知商品或数量边界;
  • 合法支付与非法取消;
  • 失败状态前后相同。

断言不是给最终用户看的输入处理,也不替代明确业务错误。它用于开发与检查,帮助我们证明重构前后行为一致。

重构循环:一次只提取一个职责

冻结基线、提取职责、看 diff 与回归的循环

每次遵循同一节奏:

选一个职责
→ 写合同
→ 写一个能调用它的检查
→ 提取最小代码
→ 看 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 一次只修改负责该行为的文件;修改前预测正常与失败结果,检查差异,再运行回归。每人需要解释一处接受或拒绝的建议。

错误与边界怎样验收

今天重点检查:打印代替返回、隐式共享状态、导入就执行交互。保留输入、预期、实际输出和修复后的结果,不能只贴全绿截图。未验证的外部依赖如实记为待验证。

留给下一阶段

今天交付的是行为没变、规则来源更清楚的一版程序。下次修改数量条件时,应知道改哪个函数、跑哪些检查。明天再解决字典字段容易写错,以及退出程序后订单怎样保存的问题。

官方资料