CS1602计算导论
第 14 讲Part 6 Pythonic 与现代实践AI Level 1

函数进阶、函数式编程与作用域

Pythonic II: Functions, FP and Scope

函数不只是"一段可以调用的代码"——它是可以传递、可以返回、可以包装的值。理解这一点,你就能读懂 Python 里那些看起来像魔法的写法。

本讲结束后你应当能

  • 说清楚"函数是一等公民"意味着什么
  • 避开可变默认参数和延迟绑定这两个经典陷阱
  • 用闭包和装饰器给已有函数加功能
  • 用 lambda、map、filter、reduce 写函数式风格的代码,并知道什么时候不该用
  • 用 LEGB 规则解释任何一个变量名是从哪来的
本页目录

函数是值

L10 结尾提过一句:Python 里一切都是对象,函数也是。现在展开。

def greet(name: str) -> str:
    return f"你好,{name}"

print(type(greet))
print(greet.__name__)
print(greet.__doc__)

函数是个对象,就意味着它能做任何值能做的事:

def greet(name: str) -> str:
    return f"你好,{name}"

# 1. 赋给变量
f = greet
print(f("张三"))

# 2. 放进容器
funcs = [str.upper, str.lower, str.title]
for fn in funcs:
    print(fn("hello World"))

# 3. 当参数传
def apply_twice(fn, x):
    return fn(fn(x))

print(apply_twice(lambda s: s + "!", "hi"))

# 4. 当返回值
def make_greeter(greeting: str):
    def greeter(name: str) -> str:
        return f"{greeting},{name}"
    return greeter

hello = make_greeter("你好")
morning = make_greeter("早上好")
print(hello("张三"))
print(morning("李四"))

这就是”一等公民”(first-class citizen)的意思:函数和数字、字符串享有同等待遇。

你其实早就在用了——sorted(words, key=len) 里的 len 就是当参数传进去的函数。

两个经典陷阱

在讲高级用法之前,先把两个坑填了。它们都源于同一件事:你以为的求值时机,和实际的求值时机不一样。

陷阱一:可变默认参数

L4 提过一句,现在讲透。

def add_item(item: str, box: list[str] = []) -> list[str]:
    box.append(item)
    return box

print(add_item("苹果"))
print(add_item("香蕉"))          # 期望 ['香蕉'],实际呢?
print(add_item("橘子"))

默认值在函数定义时求值一次,之后所有调用共用同一个对象。

def add_item(item: str, box: list[str] = []) -> list[str]:
    box.append(item)
    return box

print("默认值存在这里:", add_item.__defaults__)
add_item("x")
print("调用之后:", add_item.__defaults__)      # 它被改了

正确写法是用 None 当哨兵:

def add_item(item: str, box: list[str] | None = None) -> list[str]:
    if box is None:
        box = []                  # 每次调用都新建
    box.append(item)
    return box

print(add_item("苹果"))
print(add_item("香蕉"))
print(add_item("橘子", ["已有的"]))

陷阱二:延迟绑定

funcs = []
for i in range(3):
    funcs.append(lambda: i)       # 每个 lambda 都返回 i

print([f() for f in funcs])       # 期望 [0, 1, 2]?

全是 2。因为 lambda 里的 i 是在调用时才去查的,而那时候循环早就结束了,i 停在 2。

funcs = []
for i in range(3):
    funcs.append(lambda: i)

i = 99                            # 改一下外面的 i
print([f() for f in funcs])       # 三个 lambda 都跟着变了

函数使用外部变量时,用的是调用那一刻的值,不是定义那一刻的值。

解决办法是用默认参数在定义时就把值固定下来——正好利用了陷阱一的机制:

funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)   # 默认参数在定义时求值

print([f() for f in funcs])

闭包

def make_counter():
    count = 0                     # 这是 make_counter 的局部变量

    def increment() -> int:
        nonlocal count            # 声明要改外层的 count
        count += 1
        return count

    return increment              # 返回内部函数


c1 = make_counter()
c2 = make_counter()

print(c1(), c1(), c1())
print(c2(), c2())                 # 两个计数器互不干扰

make_counter 已经执行完了,它的局部变量 count 按理说应该消失。但 increment 还记得它。

这种”内部函数记住了外层函数的变量”的结构叫闭包(closure)。

def make_counter():
    count = 0
    def increment() -> int:
        nonlocal count
        count += 1
        return count
    return increment

c = make_counter()
c(); c()
print("闭包捕获的变量:", c.__closure__)
print("当前的值:", c.__closure__[0].cell_contents)
def outer():
    x = 1
    def read_only():
        return x              # 只读,不用声明
    def modify():
        nonlocal x            # 要改,必须声明
        x = 100
    print("读:", read_only())
    modify()
    print("改后:", x)

outer()

装饰器

闭包最常见的用途,是给一个已有函数加功能,而不改它的代码。

从原理讲起

def logged(func):
    """接收一个函数,返回一个包装过的新函数。"""
    def wrapper(*args, **kwargs):
        print(f"  调用 {func.__name__},参数 {args}")
        result = func(*args, **kwargs)
        print(f"  返回 {result}")
        return result
    return wrapper


def add(a: int, b: int) -> int:
    return a + b

add = logged(add)             # 用包装版替换原来的
print(add(2, 3))

add = logged(add) 这个模式太常用了,Python 给了它专门的语法:

def logged(func):
    def wrapper(*args, **kwargs):
        print(f"  调用 {func.__name__},参数 {args}")
        result = func(*args, **kwargs)
        print(f"  返回 {result}")
        return result
    return wrapper


@logged                       # 完全等价于 add = logged(add)
def add(a: int, b: int) -> int:
    return a + b

print(add(2, 3))

@decorator 就是 func = decorator(func) 的简写。 没有别的魔法。

一个有用的例子:计时

import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"  {func.__name__} 耗时 {time.time() - start:.4f} 秒")
        return result
    return wrapper


@timed
def slow_sum(n: int) -> int:
    total = 0
    for i in range(n):
        total += i
    return total

print(slow_sum(1_000_000))

functools.wraps:保住原函数的身份

def logged(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    """把两个数相加。"""
    return a + b

print("名字:", add.__name__)      # 变成 wrapper 了
print("文档:", add.__doc__)       # 也丢了
import functools

def logged(func):
    @functools.wraps(func)        # 把原函数的元信息复制过来
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    """把两个数相加。"""
    return a + b

print("名字:", add.__name__)
print("文档:", add.__doc__)

写装饰器时永远加上 @functools.wraps(func)。 不加的话,调试信息和文档都会丢失。

你已经用过的装饰器

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n: int) -> int:
    return n if n <= 1 else fib(n - 1) + fib(n - 2)

print(fib(100))

L11 用它把指数变线性时说”L14 会讲装饰器是什么”。现在你知道了:lru_cache 就是一个把函数包起来、加了缓存查找的闭包。

@property

class Circle:
    def __init__(self, radius: float) -> None:
        self._radius = radius

    @property
    def radius(self) -> float:
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value <= 0:
            raise ValueError("半径必须为正")
        self._radius = value

    @property
    def area(self) -> float:
        """看起来像属性,其实是算出来的。"""
        return 3.14159 * self._radius ** 2


c = Circle(2)
print(c.radius, f"{c.area:.4f}")

c.radius = 3                      # 看起来是赋值,实际调用了 setter
print(c.radius, f"{c.area:.4f}")

c.radius = -1                     # 被 setter 拦住

@property 让方法用起来像属性。好处是:你可以先写一个普通属性,将来需要加校验或计算时改成 property,调用方的代码一个字都不用改。

这是 L9 封装思想的延续——只是 Python 不需要一开始就写 getter/setter。

函数式编程

lambda:一次性的小函数

square = lambda x: x ** 2         # 不推荐这么写
print(square(5))

def square2(x): return x ** 2     # 有名字就用 def
print(square2(5))
words = ["banana", "kiwi", "apple", "fig"]

print(sorted(words, key=len))                       # 按长度
print(sorted(words, key=lambda w: w[-1]))           # 按最后一个字母
print(sorted(words, key=lambda w: (len(w), w)))     # 先长度再字母序

people = [("张三", 18), ("李四", 22), ("王五", 20)]
print(sorted(people, key=lambda p: p[1], reverse=True))

lambda 只能写一个表达式,不能有语句:

f = lambda x: 
    if x > 0: return "正"

map / filter / reduce

nums = [1, 2, 3, 4, 5]

print(list(map(lambda x: x ** 2, nums)))            # 映射
print(list(filter(lambda x: x % 2 == 0, nums)))     # 过滤

from functools import reduce
print(reduce(lambda a, b: a + b, nums))             # 折叠
print(reduce(lambda a, b: a * b, nums))

map 和 filter 返回的是迭代器(L13),所以要 list() 才看得到。

reduce 把序列”折叠”成一个值,可以给初始值:

from functools import reduce

print(reduce(lambda a, b: a + b, [1, 2, 3, 4], 100))   # 从 100 开始加
print(reduce(lambda a, b: a + b, [], 0))               # 空序列必须给初始值
from functools import reduce
print(reduce(lambda a, b: a + b, []))                  # 不给会报错

但 Python 更喜欢推导式

nums = [1, 2, 3, 4, 5]

# 函数式写法
print(list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, nums))))

# Pythonic 写法
print([x ** 2 for x in nums if x % 2 == 0])

两行做的事一样。第二行更短、更好读,而且不用从里往外读。

需求首选
映射列表推导 [f(x) for x in xs]
过滤列表推导 [x for x in xs if cond]
求和/最值/计数sum()、max()、len()
累积成一个值functools.reduce,或者干脆写循环
排序的 keylambda(这是 lambda 最自然的场合)

编程范式

范式核心思想Python 里
命令式一步步告诉机器怎么做for 循环、赋值
面向对象把数据和行为打包成对象L9、L10 的类
函数式用函数变换数据,避免修改状态本讲的内容

Python 是多范式的——它不强迫你选一种。实践中你会混着用:用类组织大结构,用推导式处理数据,用循环写主流程。

LEGB:一个名字是从哪来的

现在回答一个你一直在用但没深究的问题:Python 看到一个变量名,去哪里找它?

答案是按顺序找四个地方,缩写为 LEGB:

层全称是什么
LLocal当前函数内部
EEnclosing外层函数(闭包那一层)
GGlobal当前模块的顶层
BBuilt-inPython 内置的名字
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print("inner 看到:", x)

    inner()
    print("outer 看到:", x)

outer()
print("模块级看到:", x)

一层层去掉,看它往外找:

x = "global"

def outer():
    x = "enclosing"
    def inner():
        print("没有 local,找到:", x)      # 找到 enclosing
    inner()
outer()
x = "global"

def outer():
    def inner():
        print("没有 local 和 enclosing,找到:", x)   # 找到 global
    inner()
outer()
def f():
    print(len)                # local、enclosing、global 都没有 → built-in
    print(len([1, 2, 3]))
f()

Enclosing 这一层只有在闭包里才存在——这就是为什么本讲先讲闭包再讲 LEGB。没有嵌套函数,就没有 E。

赋值会改变作用域判定

x = 10

def read():
    print("读取:", x)         # 没赋值,往外找,读到 global

read()
x = 10

def write():
    print(x)                  # 报错
    x = 20                    # 因为这一行,x 被判定为局部变量

write()

只要函数里有对某个名字的赋值,那个名字在整个函数里都算局部变量——哪怕赋值在后面。所以第一行的 print(x) 读的是”还没赋值的局部变量”。

要改外层的变量,必须声明:

x = 10

def write_global():
    global x
    x = 20

write_global()
print("模块级的 x 变成了:", x)

遮蔽内置名

def f():
    list = [1, 2, 3]          # 局部变量遮蔽了内置的 list
    return list

print(f())
print(list("abc"))            # 外面的 list 还在,因为遮蔽只在函数内
list = [1, 2, 3]              # 在模块级遮蔽,就麻烦了
print(list("abc"))

L3 和 L8 都提过”别用内置名当变量”,LEGB 解释了为什么:你的名字在 G 层,内置在 B 层,G 先被找到。

小结

函数是值

  • 函数可以赋给变量、放进容器、当参数、当返回值——一等公民
  • 默认参数在定义时求值一次:不要用列表、字典作默认值,用 None
  • 自由变量在调用时求值:循环里造闭包要用默认参数固定住

闭包与装饰器

  • 闭包 = 内部函数记住了外层函数的变量
  • nonlocal 改外层函数的变量,global 改模块级的;只读不用声明
  • @decorator 就是 func = decorator(func),没有别的魔法
  • 写装饰器永远加 @functools.wraps(func)
  • @property 让方法用起来像属性,且不影响调用方代码

函数式

  • lambda 只用在”当场传进去”的场合,要起名字就用 def
  • map/filter/reduce 能用,但推导式更 Pythonic
  • 函数式最有价值的是避免修改外部状态,不是那三个函数

作用域

  • 名字按 LEGB 顺序查找:Local → Enclosing → Global → Built-in
  • Enclosing 只在闭包里存在
  • 函数里有赋值,那个名字整个函数都算局部
  • 少用 global;别遮蔽内置名

练习

  1. 解释下面每段的输出,不要运行:
    def f(x, acc=[]):
        acc.append(x)
        return acc
    print(f(1)); print(f(2)); print(f(3))
  2. 写一个装饰器 retry(n),让被装饰的函数失败时最多重试 n 次。(提示:这是带参数的装饰器,需要三层嵌套)
  3. 写一个装饰器 count_calls,记录函数被调用了多少次,并提供 func.call_count 查询。
  4. 下面的代码想生成三个不同的乘法函数,但都变成了乘 3。改对它:
    multipliers = [lambda x: x * i for i in range(1, 4)]
    print([m(10) for m in multipliers])
  5. 用 sorted 加 lambda 把一个学生列表按”成绩降序、成绩相同按姓名升序”排列。
  6. 把下面的函数式代码改写成推导式,比较可读性:
    result = list(map(lambda x: x[0].upper(), filter(lambda x: len(x) > 3, words)))
  7. 画出下面代码里每个 x 属于 LEGB 的哪一层:
    x = 1
    def outer():
        x = 2
        def middle():
            def inner():
                print(x)
            inner()
        middle()
    outer()
    然后运行验证你的判断。

本讲的配套上机题在 Lab 14,团队项目也在本周启动。