函数进阶、函数式编程与作用域
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,或者干脆写循环 |
| 排序的 key | lambda(这是 lambda 最自然的场合) |
编程范式
Python 是多范式的——它不强迫你选一种。实践中你会混着用:用类组织大结构,用推导式处理数据,用循环写主流程。
LEGB:一个名字是从哪来的
现在回答一个你一直在用但没深究的问题:Python 看到一个变量名,去哪里找它?
答案是按顺序找四个地方,缩写为 LEGB:
| 层 | 全称 | 是什么 |
|---|---|---|
| L | Local | 当前函数内部 |
| E | Enclosing | 外层函数(闭包那一层) |
| G | Global | 当前模块的顶层 |
| B | Built-in | Python 内置的名字 |
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;别遮蔽内置名
练习
- 解释下面每段的输出,不要运行:
def f(x, acc=[]): acc.append(x) return acc print(f(1)); print(f(2)); print(f(3)) - 写一个装饰器
retry(n),让被装饰的函数失败时最多重试 n 次。(提示:这是带参数的装饰器,需要三层嵌套) - 写一个装饰器
count_calls,记录函数被调用了多少次,并提供func.call_count查询。 - 下面的代码想生成三个不同的乘法函数,但都变成了乘 3。改对它:
multipliers = [lambda x: x * i for i in range(1, 4)] print([m(10) for m in multipliers]) - 用
sorted加lambda把一个学生列表按”成绩降序、成绩相同按姓名升序”排列。 - 把下面的函数式代码改写成推导式,比较可读性:
result = list(map(lambda x: x[0].upper(), filter(lambda x: len(x) > 3, words))) - 画出下面代码里每个
x属于 LEGB 的哪一层:
然后运行验证你的判断。x = 1 def outer(): x = 2 def middle(): def inner(): print(x) inner() middle() outer()
本讲的配套上机题在 Lab 14,团队项目也在本周启动。