JEPA4Japan · 教程

函数设计、作用域与调用模式

1,645字 5分钟阅读 #Python

设计职责明确的函数,并理解函数作为值、默认值、关键字参数、作用域、闭包和副作用。

课程进度 课程大纲 已发布 24/24 课

让每个辅助函数只做一件事

第 8 章制作了一份学习报告。很长的一套步骤也能完成任务,但小型辅助函数更容易理解和测试。一个函数应该只有一项明确的工作,并拥有一个简洁的契约:传入什么、返回什么,以及它会改变什么。

def session_label(topic, minutes=30, *, excited=False):
    label = f"{topic}: {minutes} minutes"
    if excited:
        return label.upper() + "!"
    return label


print(session_label("Python"))
print(session_label("Git", minutes=45, excited=True))

输出:

Python: 30 minutes
GIT: 45 MINUTES!

topic 是必需参数,minutes 有默认值,而 * 后面的所有参数都必须按名称传入。excited=True 比一个含义不明的第二个 True 更清楚。

这个契约很简单:接收一个主题和分钟数,返回文本,并且不改变函数外部的任何内容。返回数据可以让计算过程与 print() 产生的可见副作用彼此分离。

函数本身也是值。第 8 章把 minutes_in 作为键函数传给了 sorted()。传递函数时不要加 ();否则,括号会让函数立即被调用。

可变默认值是一个共享的背包

Python 会在执行 def 语句时计算默认值,而不是在每次调用函数时计算。因此,一个列表可能会把旧东西带进下一次调用。

  1. 一个背包 创建默认列表
  2. 放入 Python 背包会保留它
  3. 放入 Git Python 仍然在里面
后续调用会重复使用同一个可变默认对象。

下面的示例是故意写错的:

def collect(topic, topics=[]):
    topics.append(topic)
    return topics


print(collect("Python"))
print(collect("Git"))

输出:

['Python']
['Python', 'Git']

使用 None 作为“请创建一个新列表”的标志:

def collect(topic, topics=None):
    if topics is None:
        topics = []
    topics.append(topic)
    return topics

现在,两次省略列表参数的调用会各自独立开始。需要注意一个边界情况:调用者提供的列表仍然会被改变。如果契约承诺不修改它,就先使用 topics = list(topics) 创建副本。

从内向外查找名称

当 Python 遇到一个名称时,它会按照 LEGB 顺序查找:局部、封闭、全局、内置。

  1. 局部 当前这次函数调用
  2. 封闭 外层函数
  3. 全局 当前文件的顶层
  4. 内置 例如 len 这样的名称
Python 找到第一个匹配的名称后就会停止查找。
course = "Python"                 # Global


def make_reader():
    prefix = "Study"              # Enclosing

    def label(minutes):
        suffix = "minutes"        # Local
        return f"{prefix} {course}: {minutes} {suffix}"

    return label


reader = make_reader()
print(reader(40))                  # print is Built-in

输出:

Study Python: 40 minutes

在函数内部进行赋值通常会创建一个局部名称。不要仅仅为了更新总数而使用 global;应该接收旧总数,再返回新总数。隐藏的共享修改会让测试结果取决于哪个调用先运行。

闭包可以记住一件小事

上面返回的 reader 仍然记得 prefix。一个函数与它记住的封闭作用域名称组合在一起,就称为闭包。只有当内层函数必须替换封闭作用域中的值时,才使用 nonlocal:

def make_counter(start=0):
    total = start

    def add(minutes):
        nonlocal total
        total += minutes
        return total

    return add


counter = make_counter(10)
print(counter(20))
print(counter(15))

输出:

30
45

如果没有 nonlocal,total += minutes 就会尝试在新的局部变量 total 还没有值之前使用它。闭包适合保存少量私有状态,不适合用来隐藏整个程序。

纯转换只使用输入并返回结果,不改变外部状态。打印内容、写入文件和修改调用者的列表都属于副作用。只要可以,就让转换处于程序中间,把副作用放在边缘。

构建一个小型分析流水线

  1. 规范化 创建干净的新记录
  2. 分析 计算总数并排名
  3. 格式化 返回报告文本
  4. 打印 一个可见的副作用
简洁的契约让数据沿着清晰的流水线传递。

将下面这个完整项目保存为 study_pipeline.py:

def normalize_session(session):
    return {
        "topic": session["topic"].strip(),
        "minutes": session["minutes"],
    }


def minutes_by_topic(sessions):
    totals = {}
    for session in sessions:
        topic = session["topic"]
        totals[topic] = totals.get(topic, 0) + session["minutes"]
    return totals


def minutes_in(item):
    return item[1]


def analyze(sessions, target=60):
    clean = [normalize_session(session) for session in sessions]
    totals = minutes_by_topic(clean)
    ranking = sorted(totals.items(), key=minutes_in, reverse=True)
    total = 0
    for session in clean:
        total += session["minutes"]
    return {"total": total, "met": total >= target, "ranking": ranking}


def format_report(analysis, *, heading="Study report"):
    status = "met" if analysis["met"] else "not met"
    lines = [heading, f"Total: {analysis['total']} minutes", f"Target: {status}"]
    for number, (topic, minutes) in enumerate(analysis["ranking"], start=1):
        lines.append(f"{number}. {topic}: {minutes} minutes")
    return "\n".join(lines)


def run_pipeline(sessions, formatter=format_report, *, target=60):
    return formatter(analyze(sessions, target=target))


sessions = [
    {"topic": " Python ", "minutes": 45},
    {"topic": "Git", "minutes": 40},
    {"topic": "Python", "minutes": 30},
]
before = [session.copy() for session in sessions]
report = run_pipeline(sessions, target=100)

assert sessions == before
assert "Python: 75 minutes" in report

print("Checks passed.")
print(report)

运行 python3 study_pipeline.py:

Checks passed.
Study report
Total: 115 minutes
Target: met
1. Python: 75 minutes
2. Git: 40 minutes

formatter 接收一个函数值。所有计算都会返回数据;只有最后两次 print() 调用会产生可见的副作用。

三个小任务

  1. 编写 format_duration(minutes, *, compact=False),返回 45 minutes 或 45m。
  2. 不传入列表,调用安全版本的 collect() 两次,并证明两次结果彼此独立。
  3. 创建一个从 100 开始的闭包,依次加上 20 和 5,并在运行前预测两次的答案。

准备好学习第 10 章了吗?

  • 我能说明一个函数的输入、返回值和副作用。
  • 我知道为什么列表通常不应该用作默认值。
  • 我能按照 LEGB 顺序查找名称。
  • 我能解释闭包会记住什么,以及 nonlocal 会改变什么。
  • 我能把计算与打印分开。
  • 我运行了流水线,并且它的检查全部通过。

接下来,你将通过精确的异常和验证来拒绝错误输入,从而保护这些函数契约。