本章

基础篇 / 第 6 章

第 6 章 · 函数与作用域

本章导读:函数是 Python 组织代码的第一等公民。本章从「为什么要用函数」讲起,逐一拆解位置参数、关键字参数、默认参数、仅位置参数、仅关键字参数、变长参数六种参数形式,讲清 return 与多返回值、LEGB 作用域规则、闭包的实现原理、递归的三要素,最后说明「函数即对象」这一核心特性如何支撑装饰器与高阶函数,为第 10、11 章打好基础。

为什么要用函数:抽象与复用

在第 3 章我们用循环处理过一列表数据,但当同样的逻辑要在三个地方重复时,复制粘贴就是灾难。函数(function)把「一段有名字、可重复调用、有明确输入输出的代码块」封装起来,让程序从「命令的堆叠」变成「可组合的积木」。

用 def 关键字定义函数,函数体必须缩进。定义本身只是把函数对象绑定到一个名字上,不执行任何代码:

# def_demo.py —— 函数的基本形态
def greet():                       # 定义函数:只是绑定名字,不执行
    print("你好,欢迎学习 Python!")   # 缩进 4 个空格的函数体


print("【调用前】greet 是什么?")
print(greet)                        # 输出函数对象本身及其内存地址

print("【调用】")
greet()                            # 第一次调用
greet()                            # 第二次调用,行为完全一致

# 预期输出:
# 【调用前】greet 是什么?
# <function greet at 0x7f8e1c0b1234>   (地址每次运行不同)
# 【调用】
# 你好,欢迎学习 Python!
# 你好,欢迎学习 Python!
表 6-1 函数带来的直接收益
收益说明示例
复用(reuse)逻辑写一次,处处调用len()、print() 就是复用函数
抽象(abstraction)隐藏实现细节,只暴露「输入 → 输出」的契约调用者不需要知道 len() 内部如何计数
可测试一个函数就是一个测试单元,便于断言assert is_prime(7) is True
可组合函数的返回值可以作为另一个函数的参数max(map(abs, nums))
降低复杂度主程序读起来像「步骤清单」,而不是几百行细节把 200 行拆成 10 个各 20 行的函数

好函数的三条实践准则:

  1. 短:一个函数最好只做一件事,正文超过 30 行就该考虑拆分(Python 社区经验值,非硬性)。
  2. 纯:同样的输入应得到同样的输出,不依赖外部变量、不修改全局状态、不打印。
  3. 有名字的返回值:用 return 交结果,而不是靠 print() 让外部去猜。
要点函数名用小写加下划线(snake_case)描述动作:calculate_total();不要用 cal()、doIt() 这类含糊名字。
常见坑定义在文件顶层、但在 if __name__ == "__main__": 之外的演示代码,在被 import 时会自动执行。第 23 章的命令行工具会反复用到这一点:
def main():
    print("只有直接运行本文件时才执行")


if __name__ == "__main__":   # 只有作为脚本直接运行才为 True
    main()

参数的五种形式(其实有六种)

Python 函数的参数非常灵活,是本章最需要反复练手的部分。完整清单如下:

表 6-2 Python 参数形式总览
形式语法调用方式典型用途
位置参数(positional)def f(a, b)f(1, 2)最常见的两个及以上参数
仅位置参数(positional-only)def f(a, /, b)f(1, 2),不能 f(a=1, b=2)避免与关键字参数混淆、防误传
默认参数(default)def f(a, b=2)f(1) 或 f(1, 3)可选参数
仅关键字参数(keyword-only)def f(a, *, b)必须写 f(1, b=2)可选参数、可扩展性
变长位置参数def f(*args)f(1, 2, 3)参数个数不定,收成 tuple
变长关键字参数def f(**kwargs)f(a=1, b=2)参数名不定,收成 dict

位置参数与关键字参数

调用函数时,参数可以按位置传,也可以按名字传:

# params_basic.py —— 位置传参与关键字传参
def book_info(title, author, year):
    """打印一本书的基本信息。"""
    print(f"《{title}》 / {author} / {year} 年")


print("1) 位置传参:靠顺序对应")
book_info("Python 编程", "Mark Lutz", 2021)

print("\n2) 关键字传参:靠名字对应,顺序可以乱")
book_info(year=2021, title="Python 编程", author="Mark Lutz")

print("\n3) 混合:位置参数必须在关键字参数左边")
book_info("流畅的 Python", year=2022, author="Luciano Ramalho")
# 预期输出:
# 1) 位置传参:靠顺序对应
# 《Python 编程》 / Mark Lutz / 2021 年
#
# 2) 关键字传参:靠名字对应,顺序可以乱
# 《Python 编程》 / Mark Lutz / 2021 年
#
# 3) 混合:位置参数必须在关键字参数左边
# 《流畅的 Python》 / Luciano Ramalho / 2022 年
常见坑「位置参数必须在关键字参数左边」是语法错误:
book_info(year=2021, "Python 编程", "Mark Lutz")   # SyntaxError
规则是:调用时「位置在前、关键字在后」;定义时「仅位置在前、默认/仅关键字在后」。

默认参数

带默认值的参数必须在无默认值的参数之后,否则解释器无法判断省略哪个:

# params_default.py —— 默认参数与「求值一次」陷阱
def connect(host="localhost", port=8080, timeout=5.0):
    return f"{host}:{port} 超时={timeout}s"


print(connect())                             # 全用默认值
print(connect("192.168.1.10"))               # 只覆盖第一个
print(connect(port=443, timeout=30))         # 用关键字跳过中间的

# 预期输出:
# localhost:8080 超时=5.0s
# 192.168.1.10:8080 超时=5.0s
# localhost:443 超时=30s

print("\n--- 默认值的经典陷阱 ---")
def add_item(item, bucket=[]):               # 危险写法!
    bucket.append(item)
    return bucket


print(add_item("a"))    # ['a']
print(add_item("b"))    # ['a', 'b']  <-- 上次的结果被保留!
# 正确写法:用 None 作为哨兵
def add_item_ok(item, bucket=None):
    if bucket is None:      # 注意是「是 None」,不是「非空」
        bucket = []
    bucket.append(item)
    return bucket


print(add_item_ok("a"))   # ['a']
print(add_item_ok("b"))   # ['b']   <-- 干净
常见坑可变对象(list、dict、set)作默认值只在函数定义时求值一次,之后每次调用都复用同一个对象,导致数据越积越多。永远用 None 做默认值,在函数体内替换。
表 6-3 判断默认值是否该用 None
默认值类型能否直接作默认原因
int / float / str / tuple / frozenset / None可以不可变,重复使用没有副作用
list / dict / set / bytearray不可以可变,会被跨调用共享

仅位置参数与仅关键字参数

Python 3.8 引入了 /(斜杠左边的参数只能按位置传),3.0 起就有 *(星号右边的参数只能按关键字传)。两者配合可以写出接口极其清晰的函数:

# params_slash_star.py —— / 与 *
def create_user(name, /, *, email=None, role="user"):
    """创建用户。

    name: 仅位置,避免被误写成 name=... 从而混淆
    email / role: 仅关键字,让调用者明确意图
    """
    return {"name": name, "email": email, "role": role}


u = create_user("张三", email="zhangsan@example.com", role="admin")
print(u)
# 预期输出:
# {'name': '张三', 'email': 'zhangsan@example.com', 'role': 'admin'}

# 下列调用会报错:
# create_user(name="张三")            TypeError: name 是仅位置参数
# create_user("张三", "a@b.com")      TypeError: email 是仅关键字参数

这条能力极为实用:位置参数数量不定(*args)时,用 * 之后的仅关键字参数承载可选配置,这是标准库大量采用的模式:

# flexible_api.py —— *args 配合仅关键字参数
def log(level, *messages, sep=" | ", end="\n"):
    """level 按位置传,messages 数量任意,格式选项必须写名字。"""
    print(sep.join(str(m) for m in messages), end=end, flush=True)


log("INFO", "服务启动", "端口 8080")
log("ERROR", "磁盘已满", "剩余 1%", sep=" -> ", end="\n")
# 预期输出:
# INFO | 服务启动 | 端口 8080
# ERROR -> 磁盘已满 -> 剩余 1%

变长参数:*args 与 **kwargs

# params_var.py —— 收集与展开
def avg(*numbers):
    """接收任意多个位置参数,收集成 tuple。"""
    print("收集到:", numbers, type(numbers).__name__)
    return sum(numbers) / len(numbers)


print("三个数:", avg(10, 20, 30))
print("五个数:", avg(1, 2, 3, 4, 5))
# 预期输出:
# 收集到: (10, 20, 30) tuple
# 三个数: 20.0
# 收集到: (1, 2, 3, 4, 5) tuple
# 五个数: 3.0


def build_profile(**info):
    """接收任意多个关键字参数,收集成 dict。"""
    return info


print(build_profile(name="李四", age=30, city="杭州"))
# 预期输出:{'name': '李四', 'age': 30, 'city': '杭州'}


# ---- 反向:把序列「展开」成多个参数,用 * 或 ** ----
def show(a, b, c):
    return f"a={a}, b={b}, c={c}"


data = (7, 8, 9)
print(show(*data))                       # 用 * 展开 tuple/list
print(show(**{"a": 1, "b": 2, "c": 3}))   # 用 ** 展开 dict(键必须匹配形参名)
# 预期输出:
# a=7, b=8, c=9
# a=1, b=2, c=3
技巧解包调用 f(*data, **conf) 是编写「灵活包装函数」的标准手法,第 12 章装饰器、第 23 章命令行参数转发都会这样写。

参数传递方式:值传递 vs 对象引用

Python 不存在 Java 那样的值传递(value passing)或引用传递(reference passing)之争,准确的说法是:对象按引用传递,但函数参数一律按值传递。所谓「值」是内存地址的副本。

# passing.py —— 传参语义
def try_to_modify(n, lst):
    n = 999          # 只改本地名字的指向,调用方看不到
    lst.append("新增") # 改动的是同一个对象的内容,调用方能看到


num = 10
items = ["原有"]
try_to_modify(num, items)
print(num, items)
# 预期输出:10 ['原有', '新增']
# num 没变(int 不可变 + 重新绑定只是改本地名字);items 变了(同一个 list 对象)


def really_replace(lst):
    lst[0] = "改写"   # 修改元素而不是重新绑定名字


arr = ["原值"]
really_replace(arr)
print(arr)            # ['改写']


def shadow(lst):
    lst = ["全新"]    # 只是让本地名字指向新对象


shadow(arr)
print(arr)            # ['改写'] <-- 没变
表 6-4 可变 vs 不可变对象的传参后果
对象类型函数内操作调用方可见
不可变(int/str/tuple)n = 999否
可变(list/dict/set)lst.append(1)是
可变lst = [1](重新绑定)否
可变lst[:] = [1](原地改内容)是
要点如果希望函数不影响调用方传入的可变对象,做一个浅拷贝即可:lst = list(lst);写库函数时这几乎是必须的习惯。

返回值:return、多返回值与元组

return 的行为

# return_demo.py —— return 的四种情况
def with_return():
    return "有返回值"


def without_return():
    return           # 只写 return,或干脆不写


def early_exit(n):
    if n < 0:
        return "负数"
    if n == 0:
        return "零"
    return "正数"


print(repr(with_return()))    # '有返回值'
print(repr(without_return())) # None <-- 隐式返回 None
print(early_exit(-5), early_exit(0), early_exit(3))
# 负数 零 正数

# return 立刻结束函数(像 break 一样),常用于「提前退出」保护
def safe_divide(a, b):
    if b == 0:
        return None            # 不能除,返回 None
    return a / b


print(safe_divide(10, 2), safe_divide(10, 0))
# 5.0 None

多返回值:用元组解包

Python 没有 C/C++ 那样的指针出参。返回多个值的方式是返回一个元组,再解包:

# multi_return.py —— 多返回值
def min_max(nums):
    """返回最小值、最大值和总和。"""
    return min(nums), max(nums), sum(nums)   # 逗号即构造元组


lo, hi, total = min_max([8, 3, 19, 42, 7])
print(f"最小={lo} 最大={hi} 总和={total}")
# 最小=3 最大=42 总和=79

# 只想取一部分:用下标或 _ 丢弃
smallest = min_max([1, 2, 3])[0]
print(smallest)                       # 1

a, _, c = min_max([5, 6, 7])
print(a, c)                           # 5 7   (中间的 6 用 _ 丢弃)

# 元组可比较、可作字典键,适合当复合返回值的封装
print(min_max([3, 1, 2]) == (1, 3, 6))   # True


# ---- 真实场景:解析一行日志 ----
def parse_log(line):
    """把 '2024-05-01 12:30 INFO 服务已启动' 拆成结构化数据。"""
    date, time, level, *rest = line.split()
    message = " ".join(rest)
    return {"date": date, "time": time, "level": level, "message": message}


log = parse_log("2024-05-01 12:30 INFO 服务已启动")
for k, v in log.items():
    print(f"{k:8s} = {v}")
# 预期输出:
# date     = 2024-05-01
# time     = 12:30
# level    = INFO
# message  = 服务已启动
常见坑返回元组时,如果调用者写 x = f() 而不是 a, b = f(),拿到的就是一个元组。元组长度固定且语义清晰,不建议返回长度为 6 的元组;元素超过 3 个时应改用 dataclass 或 NamedTuple(第 12、13 章详述)。

作用域与 LEGB 规则

「这个名字在哪里能查到」由 LEGB 规则决定,按顺序查找四个位置,找到即停:

表 6-5 LEGB 查找顺序
字母名称含义典型例子
LLocal局部作用域:函数内部、类内部赋值的名字函数体里的 total = 0
EEnclosing外层函数作用域(仅对嵌套函数有意义)闭包里的 rate
BGlobal模块顶层作用域模块里的 TAX_RATE
GBuilt-in内置作用域print、len、max
# legb.py —— 四层作用域
TAX_RATE = 0.06          # G:Global,模块级常量


def outer(rate):          # 参数 rate 属于 Enclosing 层
    rate = rate           # 注意:这行只是给参数重新绑定,仍在 L 层

    def inner(amount):
        """L 层没有 rate,于是依次查 E(函数参数)、G。"""
        return amount * (1 + rate)

    return inner


def demo_legb():
    """演示 global 与 nonlocal 的差别。"""
    counter = 0           # 外层函数的局部变量

    def bump():
        nonlocal counter   # 声明「我要改外层的 counter」
        counter += 1
        return counter

    print("bump 前:", counter)
    print("bump 后:", bump())
    return counter


c = outer(0.1)(100)       # outer 返回 inner,立即用 100 调用
print("含税价格:", c)      # 110.0
print("内置作用域:", len("Python"))   # 6
print(demo_legb())          # 1

global 与 nonlocal

# scope_kw.py —— global 修改全局,nonlocal 修改外层局部
counter = 0


def use_global():
    global counter        # 声明为全局
    counter += 1          # 不写 global 会报 UnboundLocalError


def make_accumulator():
    """返回一个能不断累加的闭包函数。"""
    total = 0

    def add(n):
        nonlocal total    # 指向外层函数的 total,而不是新建同名局部变量
        total += n
        return total

    return add


use_global()
use_global()
print("全局 counter =", counter)          # 2

acc = make_accumulator()
print(acc(10), acc(20), acc(30))          # 10 30 60
常见坑在函数内给一个全局变量赋值时,Python 会先把它当成本地变量,因此读到未赋值版本会抛 UnboundLocalError:
count = 0
def bad():
    print(count)     # 报 UnboundLocalError
    count = 1        # 这行让 count 成了局部变量

def good():
    global count
    print(count)     # 正常读到 0
    count = 1
技巧能用「返回值 + 参数」就别用 global。函数式风格(返回新状态)比修改外部状态更容易测试和并行,第 15 章并发编程时会再次印证这一点。

闭包:函数记住它的出生环境

闭包(closure)是一个函数加上它定义时所引用的外层变量。Python 用「函数对象里存一个 __closure__ 元组」来实现它,内存中的引用让外层函数返回后,那些变量依然存活。

# closure_demo.py —— 闭包保存状态
def make_counter(start=0):
    """返回一个闭包函数,每次调用把内部计数 +1。"""
    n = start

    def increment(step=1):
        nonlocal n
        n += step
        return n

    return increment            # 只返回函数本身,不返回 n


c1 = make_counter()        # 第一台独立机器
c2 = make_counter(100)     # 第二台机器,起点 100

print(c1(), c1(), c1())     # 1 2 3
print(c2(), c2())           # 101 102
print("互不影响:c1 再调一次 ->", c1())   # 4

print("\n--- 闭包的内部结构 ---")
print("__closure__ 长度:", len(c1.__closure__))
cell = c1.__closure__[0]
print("cell 里存的值:", cell.cell_contents)
# __closure__ 长度: 1
# cell 里存的值: 4

闭包的三大用途:

  1. 封装私有状态:外部无法直接改动内部变量,只能通过函数操作。
  2. 带状态的装饰器:functools.lru_cache、functools.cached_property 都基于闭包。
  3. 函数工厂(function factory):一次生成一批参数固定的函数。
# closure_uses.py —— 真实场景
import time

def make_timer(unit="秒"):
    """生成一个「距上次报时多少时间已过」的闭包。"""
    last = time.time()

    def since(tag="计时"):
        nonlocal last
        now = time.time()
        delta = now - last
        last = now
        return f"[{tag}] {delta:.6f} {unit}"

    return since


t = make_timer()
busy_loop = sum(i * i for i in range(300000))
print(t("计算完成"))
print(t("计算完成"))


# ---- 闭包 + 装饰器:带参数的装饰器模板 ----
def repeat(times):
    """返回一个装饰器;装饰器本身是「接收装饰器的函数」。"""
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator


@repeat(3)
def greet(name):
    """打招呼。"""
    print(f"你好,{name}!")
    return "done"


print(greet("王小明"))
# 预期输出(问候三次):
# 你好,王小明!
# 你好,王小明!
# 你好,王小明!
# done
常见坑在循环里定义闭包时,所有闭包会共享同一个循环变量。用「默认参数拍快照」或工厂函数解决:
# 错误写法:三个函数都用 n=2
funcs = [lambda: n for n in range(3)]
print([f() for f in funcs])        # [2, 2, 2]

# 正确写法 1:默认参数在定义时求值
funcs = [lambda n=n: n for n in range(3)]
print([f() for f in funcs])        # [0, 1, 2]

# 正确写法 2:工厂函数(推荐)
def make(n):
    return lambda: n
funcs = [make(n) for n in range(3)]
print([f() for f in funcs])        # [0, 1, 2]
要点闭包的代价:每个闭包都会让被捕获的变量存活到闭包被回收,内存不释放;模块级大量创建闭包可能造成内存增长。此外闭包内的变量不能被垃圾回收机制追踪到真实引用路径,调试时用 inspect.getclosurevars(func) 查看。

递归:函数调用自己

递归(recursion)是函数直接或间接调用自身。它把「大问题」逐步拆成「同类小问题」,直到可以直接求解的基本情况(base case)。

递归三要素

  1. 基本情况:递归终止的条件,必须存在且能被触发。
  2. 递归步骤:把问题规约成更小的同类问题。
  3. 递归出口:保证参数最终朝着基本情况收敛(每次都更接近终点)。
# recursion_factorial.py —— 阶乘
def factorial(n):
    """n 的阶乘:n! = n × (n-1) × ... × 1。"""
    if n < 0:
        raise ValueError("阶乘不接受负数")
    if n <= 1:              # (1) 基本情况
        return 1              #     0! = 1, 1! = 1
    return n * factorial(n - 1)   # (2) 递归步骤(规约到更小的 n)


print(factorial(5))            # 120
print(factorial(0))            # 1


# 等价的迭代写法(不会爆栈、更快)
def factorial_iter(n):
    result = 1
    while n > 1:
        result *= n
        n -= 1
    return result


print(factorial_iter(5))       # 120


# 尾递归形式(Python 不会自动优化,需要靠手写 while)
def factorial_tail(n, acc=1):
    while n > 1:
        n, acc = n - 1, acc * n
    return acc


print(factorial_tail(5))       # 120

递归的经典应用

# recursion_apps.py —— 斐波那契、汉诺塔、树遍历
def fib(n, memo=None):
    """带记忆化的斐波那契,避免指数级重复计算。"""
    if memo is None:
        memo = {}
    if n <= 1:
        return n
    if n not in memo:
        memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
    return memo[n]


print("fib(30) =", fib(30))         # 832040(朴素递归需 100 万+ 次调用,记忆化后仅 30 次)


def hanoi(n, src, dst, aux):
    """汉诺塔:n 个盘子从 src 经 aux 移到 dst,返回步骤列表。"""
    if n == 0:
        return []
    steps = hanoi(n - 1, src, aux, dst)
    steps.append(f"{src} -> {dst} ({n})")
    steps.extend(hanoi(n - 1, aux, dst, src))
    return steps


for step in hanoi(3, "A", "C", "B"):
    print(step)
# 预期输出:
# A -> B (1)
# A -> C (2)
# B -> C (1)
# A -> C (3)
# B -> A (1)
# C -> A (2)
# B -> A (3)


def flatten(nested):
    """递归展开任意层级的嵌套列表。"""
    out = []
    for item in nested:
        if isinstance(item, list):
            out.extend(flatten(item))
        else:
            out.append(item)
    return out


print(flatten([1, [2, [3, [4, [5, 6]]]], 7]))
# 预期输出:[1, 2, 3, 4, 5, 6, 7]

递归的限制与 Python 的递归上限

# recursion_limit.py —— 查看递归深度限制
import sys

print("默认递归上限:", sys.getrecursionlimit())     # 通常为 1000


def deep(n):
    return 1 if n == 0 else 1 + deep(n - 1)


print(deep(500))                                     # 正常
# print(deep(2000))                                  # RecursionError

# 危险操作:把上限调得太大可能让进程直接崩溃(栈溢出,不可捕获)
# sys.setrecursionlimit(100000)   # 一般只在框架内部使用
常见坑递归深度默认约 1000 层,写深递归(如深度优先遍历超大树)时会抛 RecursionError: maximum recursion depth exceeded。正确解法是改写成显式栈的迭代版本,而不是盲目调大上限。
表 6-6 递归 vs 迭代选型
维度递归迭代
可读性树、分治、回溯类问题更直观线性流程更自然
性能函数调用开销大通常更快
内存占调用栈,受递归上限限制可精确控制栈大小
深度上限约 1000受内存限制,深度更大

函数即对象:把函数当值传递

在 Python 里,函数是对象(first-class object):可以赋值给变量、放进列表或字典、作参数传入、从另一个函数返回、存入对象属性。这意味着你可以编写「接收函数」的高阶函数(higher-order function)。

# first_class.py —— 函数是一等公民
def double(x):
    return x * 2


def triple(x):
    return x * 3


# 1) 存进容器
ops = {"double": double, "triple": triple}
print(ops["triple"](5))          # 15

# 2) 存进列表,按需选择
funcs = [double, triple]
print([f(4) for f in funcs])     # [8, 12]

# 3) 函数可以作参数
def apply_all(func, values):
    return [func(v) for v in values]


print(apply_all(double, [1, 2, 3]))    # [2, 4, 6]
print(apply_all(triple, [1, 2, 3]))    # [3, 6, 9]

# 4) 函数可以作返回值(工厂)
def get_adder(n):
    def adder(x):
        return x + n
    return adder


add10 = get_adder(10)
print(add10(5))                  # 15

# 5) 交换函数的行为 —— 不改函数本身,只改引用
def square(x):
    return x ** 2


func = square
print(func(5))                   # 25
func = triple
print(func(5))                   # 15   (square 本身没变,只是名字指向了 triple)
print(square(5))                 # 25


# 6) 匿名函数 + sorted 的 key(最常见的函数传递)
names = ["李四", "王", "张三丰", "陈小明"]
print(sorted(names, key=len))                       # ['王', '李四', '张三丰', '陈小明']
print(sorted(names, key=len, reverse=True))         # ['张三丰', '陈小明', '李四', '王']
print(sorted(["banana", "kiwi", "apple"], key=str.upper))   # ['apple', 'banana', 'kiwi']

# 7) 统计函数自身的属性
print(double.__name__)            # double
print(double.__doc__)             # None(本例未写文档字符串)
print(apply_all.__doc__ is not None)   # True

实战:用「函数作为数据」重写业务逻辑

# strategy.py —— 策略模式的最简形态(第 12 章会系统展开)
ORDERS = [
    {"item": "手机壳", "price": 30.0, "member": "gold", "qty": 2},
    {"item": "数据线", "price": 25.0, "member": "normal", "qty": 3},
    {"item": "耳机", "price": 199.0, "member": "silver", "qty": 1},
]

DISCOUNTS = {          # 策略表:会员等级 -> 折扣函数
    "gold": lambda p: p * 0.80,
    "silver": lambda p: p * 0.90,
    "normal": lambda p: p * 1.00,
}


def settle(order, discount_fn):
    """通用结算:折扣策略由调用方注入。"""
    unit = discount_fn(order["price"])
    return round(unit * order["qty"], 2)


def total_payable(order, strategy="tiered"):
    """按名调用策略,可动态切换。"""
    if strategy == "tiered":          # 满 500 减 50
        amount = order["price"] * order["qty"]
        return round(amount - 50 if amount >= 500 else amount, 2)
    return settle(order, DISCOUNTS[order["member"]])


for o in ORDERS:
    t1 = total_payable(o, "tiered")
    t2 = total_payable(o)
    print(f"{o['item']:4s} 满减={t1:8.2f}  会员折扣={t2:8.2f}")

print("\n汇总会员折扣:", round(sum(total_payable(o) for o in ORDERS), 2))
# 预期输出:
# 手机壳 满减=  60.00  会员折扣=  48.00
# 数据线 满减=  75.00  会员折扣=  75.00
# 耳机   满减= 149.00  会员折扣= 179.10
#
# 汇总会员折扣:302.1
技巧functools.partial 可以「预填」参数,把任意函数适配成另一个签名,常用于回调和性能敏感的循环:
from functools import partial

def send(url, payload, timeout=5, retries=3):
    return f"POST {url} payload={payload} timeout={timeout} retries={retries}"

# 先绑定 url 与 payload
send_api = partial(send, "https://api.example.com/v1/items")
print(send_api({"id": 1}))
print(send_api({"id": 2}, timeout=10))     # 后面仍可继续覆盖
要点函数也是对象,意味着它有属性和方法:func.__name__、func.__doc__、func.__module__、func.__qualname__,还能赋值自定义属性 func.tag = "v2"。这是 Flask 路由注册、日志库挂标记的常用手法。

实战:用函数组织一个「文本统计器」

把前面所有知识串起来,写一个完整可运行的小工具:

text_stats.py —— 组合闭包 + 高阶函数 + 多返回值

"""文本统计器:演示参数五种形式、多返回值、闭包与函数即对象。"""

import re
from collections import Counter


# ---------- 1. 参数形式全家桶 ----------
def analyze(text, *, strip_punct=True, ignore_case=True, top=5):
    """分析一段文本。

    text: 位置参数
    strip_punct / ignore_case / top: 仅关键字参数(强制调用者写清意图)
    """
    result = {"raw_len": len(text)}

    content = text
    if strip_punct:
        content = re.sub(r"[^\w\s]", "", content, flags=re.UNICODE)
        result["punct_removed"] = len(text) - len(content)

    if ignore_case:
        content = content.lower()

    words = content.split()
    result["word_count"] = len(words)
    result["uniq_count"] = len(set(words))
    result["char_count"] = len(content.replace(" ", ""))

    # 多返回值 + 元组解包
    longest, shortest = max(words, key=len), min(words, key=len)
    result["longest_word"] = longest
    result["shortest_word"] = shortest

    # 闭包:把「取 top N」做成内部函数,携带 top 参数
    def top_n():
        return Counter(words).most_common(top)

    result["top_words"] = top_n()
    return result


# ---------- 2. 变长参数:接收任意多段文本 ----------
def merge(*texts, separator="\n\n"):
    return separator.join(t.strip() for t in texts if t.strip())


def report(**options):
    """接收任意关键字参数,返回配置摘要。"""
    defaults = {"strip_punct": True, "ignore_case": True, "top": 3}
    cfg = {**defaults, **options}      # ** 解包合并字典
    return cfg


# ---------- 3. 递归:统计嵌套结构深度 ----------
def depth(obj):
    """递归计算嵌套结构的深度。"""
    if isinstance(obj, dict):
        return 1 + max((depth(v) for v in obj.values()), default=0)
    if isinstance(obj, (list, tuple, set)):
        return 1 + max((depth(v) for v in obj), default=0)
    return 0


# ---------- 4. 高阶函数:对每个值应用处理函数 ----------
def pipeline(value, *funcs):
    """把若干函数串成管道,依次施加。"""
    for f in funcs:
        value = f(value)
    return value


SAMPLE = """Python is a programming language.
Python 入门,简单又强大!Python rocks.
Do you like Python? Python, Python, Python!"""

if __name__ == "__main__":
    stats = analyze(SAMPLE, top=3)
    print("=== 文本统计 ===")
    for k, v in stats.items():
        print(f"{k:16s}: {v}")

    print("\n=== 配置合并(**kwargs)===")
    print(report(top=5, ignore_case=False))

    print("\n=== 合并多段(*args)===")
    print(repr(merge("第一段", "第二段", separator=" | ")))

    print("\n=== 嵌套深度(递归)===")
    nested = {"a": [1, {"b": {"c": {"d": 1}}}]}
    print(depth(nested))

    print("\n=== 函数管道(高阶函数)===")
    print(pipeline(SAMPLE, str.strip, len))

预期输出(文本部分因源码中的换行略有差异):

=== 文本统计 ===
raw_len        : 118
punct_removed  : 5
word_count     : 20
uniq_count     : 13
char_count     : 84
longest_word   : programming
shortest_word  : a
top_words      : [('python', 8), ('is', 2), ('like', 2)]

=== 配置合并(**kwargs)===
{'strip_punct': True, 'ignore_case': False, 'top': 5}

=== 合并多段(*args)===
'第一段 | 第二段'

=== 嵌套深度(递归)===
4

=== 函数管道(高阶函数)===
115

本章小结

  • 函数定义用 def,缩进体是函数体;定义不执行,调用才执行;用 if __name__ == "__main__": 保护脚本入口。
  • 参数六形态:位置参数、仅位置(/)、默认参数、仅关键字(*)、*args、**kwargs。调用顺序规则是「位置在前、关键字在后」。
  • 可变对象禁止作默认值,用 None 哨兵 + 函数体内初始化。
  • 传参语义:对象按引用传递,参数按值传递;重新绑定名字不影响调用方,修改内容会影响。
  • return 立即结束函数;无 return 返回 None;多返回值本质是元组,调用方解包。
  • LEGB 决定名字查找:局部 → 外层 → 全局 → 内置。global 改全局,nonlocal 改外层局部。
  • 闭包= 函数 + 外层环境,能保存私有状态、制造函数工厂;循环里建闭包要用默认参数或工厂函数快照变量。
  • 递归三要素:基本情况、递归步骤、递归出口;默认深度上限约 1000,超限改写为显式栈的迭代版。
  • 函数是一等对象:可赋值、入容器、作参数、返回值、带属性;由此可得高阶函数、装饰器、策略模式与 functools.partial。

练习题

  1. 参数形式综合。定义函数 make_profile(name, /, *, age=18, city="北京", **extra),其中 name 只能按位置传,age/city 必须写关键字,其余任意关键字收集进 extra。调用它传入 "李雷"、age=20、hobby="摄影",验证返回字典内容。
  2. 修 bug。下面代码第二次调用返回的是 ['x', 'y'] 而不是 ['y'],请说明原因并给出两种修正方式:
    def push(item, lst=[]):
        lst.append(item)
        return lst
    参考答案:默认列表在函数定义时创建一次,之后所有调用共享同一对象。修正一:默认 None,函数体 if lst is None: lst = []。修正二:def push(item, lst=()) 后 return lst + (item,)(不可变默认值,但写法不同)。
  3. 多返回值。实现 stats(nums),返回 (总和, 平均值, 最大值, 最小值, 方差) 五个结果,方差用总体公式 Σ(x-μ)²/n。对 [2, 4, 4, 4, 5, 5, 7, 9] 测试,并用解包接收;再故意只取其中两个值。
  4. 闭包状态机。写 make_bank(balance=0),返回一个闭包 op(cmd, amount),支持 deposit(存)、withdraw(取,余额不足返回 False)、balance(查询)三条命令,并演示两个不同的「银行账户」余额互不干扰。
    参考答案:
    def make_bank(balance=0):
        def op(cmd, amount=0):
            nonlocal balance
            if cmd == "deposit":
                balance += amount
            elif cmd == "withdraw":
                if balance < amount:
                    return False
                balance -= amount
            elif cmd == "balance":
                pass
            else:
                raise ValueError(cmd)
            return balance
        return op
    
    acc = make_bank(100)
    print(acc("withdraw", 200))   # False
    print(acc("deposit", 50))     # 150
    print(acc("balance"))         # 150
  5. 递归改造。把 def total(n): return n if n <= 0 else n + total(n - 1) 改写为迭代版本,并再写一个「尾递归模拟」版本(用 while + 累加器)。三者在 total(1000) 上结果应一致。
    参考答案:迭代版 while n > 0: s += n; n -= 1;尾递归版 while n > 0: n, s = n - 1, s + n。注意:total(1000) 用朴素递归在部分解释器上会触及递归上限。
  6. 高阶函数重写。把「列表去重并保持顺序」分别用三种方式实现:① set;② for + list.append;③ 用 sorted 配合 key 与 set 去重。比较三者在顺序是否保留上的差异。
  7. LEGB 辨析。下面代码打印什么?为什么?
    x = "全局"
    def outer():
        x = "外层"
        def middle():
            def inner():
                print(x)
            inner()
        middle()
    outer()
    参考答案:打印「外层」。inner 的 L 层没有 x,E 层找到 middle 的作用域(其父是 outer),于是取到 outer 的 x,不会继续查到全局。
  8. 参数陷阱。解释下面两行输出的差异,并写出「如何只给 100 传参而让其余用默认值」的写法:
    def f(a, b, c=3, *, d=4): return (a, b, c, d)
    print(f(1, 2, 3, 5))          # 第 1 行
    print(f(1, 2, d=9))          # 第 2 行
    参考答案:第 1 行传了 4 个实参,其中第 4 个位置值落到仅关键字参数 d,结果 (1, 2, 3, 5)。第 2 行 (1, 2, 3, 9)。
  9. 综合实战。写一个 validate(validator_dict, *values, strict=True, **options):按 validator_dict 中「字段名 → 校验函数」的顺序校验 values,strict=True 时任一失败立即抛异常,否则收集所有错误并返回。至少写 3 个校验函数(not_empty、is_int、in_range(min,max) 闭包工厂)。
    参考答案要点:校验函数统一签名 f(value) -> bool 或 抛异常;用 try/except 捕获逐项失败;in_range 用闭包捕获上下界:
    def in_range(lo, hi):
        def check(v):
            if not lo <= v <= hi:
                raise ValueError(f"{v} 不在 [{lo}, {hi}]")
            return True
        return check
Python 完全教程 · 第 6 章