3. 函数与作用域面试:闭包、装饰器、lambda表达式、*args与**kwargs、作用域规则(LEGB)

函数这块,说实话是Python面试里最容易被问出深度的部分。很多候选人能写出装饰器,但一问到闭包原理就卡壳了。我个人习惯把函数当作「一等公民」来理解——它跟整数、字符串没什么区别,可以赋值、可以传参、可以当作返回值。这个认知一旦建立,后面那些概念就顺了。

3.1 作用域规则(LEGB)

先聊作用域。Python查找变量时,遵循一个叫LEGB的规则:

  • L — Local,局部作用域(函数内部)
  • E — Enclosing,外层嵌套作用域(外层函数的局部)
  • G — Global,全局作用域(模块级别)
  • B — Built-in,内置作用域(比如len、print)

说白了,Python找变量就像剥洋葱,从里往外一层层找。找到就停,找不到就报错。

面试高频题:下面代码输出什么?

x = 10
def outer():
    x = 20
    def inner():
        print(x)
    inner()
outer()  # 输出20,不是10

为什么会这样?因为inner函数里没有定义x,就往上一层找,找到了outer里的x=20。这就是LEGB里的E(Enclosing)在起作用。

我曾经踩过的坑:在嵌套函数里直接给外层变量赋值,以为能修改它。结果Python把它当成了新的局部变量。后来才知道要用nonlocal声明。

def counter():
    count = 0
    def increment():
        nonlocal count  # 不加这行会报错
        count += 1
        return count
    return increment

3.2 闭包

闭包,说白了就是「函数+它记住的环境」。一个函数如果引用了外层函数的变量,并且外层函数返回了这个内部函数,那就形成了闭包。

我面试时喜欢问:闭包到底有什么用?

两个典型场景:

  • 数据隐藏:用闭包模拟私有变量,避免全局污染
  • 延迟计算:先记住参数,等需要时再执行
def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))  # 10
print(triple(5))  # 15

你看,double和triple各自记住了自己的n值。这就是闭包的核心——函数带着环境一起走

一个小技巧:用闭包实现函数工厂,比用类更轻量。我在写配置解析器时就用过,根据不同文件类型生成不同的解析函数。

3.3 装饰器

装饰器其实就是闭包的一种应用。它接收一个函数,返回一个新函数,在不修改原函数代码的前提下增加功能。

嗯,这里要注意:装饰器在模块导入时就执行了,不是在调用时。这个细节很多人搞混。

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"耗时: {time.time() - start:.2f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(1)

slow_function()  # 输出: 耗时: 1.00s

你想想看,@timer本质上就是执行了 slow_function = timer(slow_function)。所以装饰器返回的wrapper函数,参数必须用*args, **kwargs来兼容所有情况。

带参数的装饰器:再加一层闭包

def repeat(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(n):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}")

greet("Alice")  # 打印3次

三层嵌套看着有点晕?其实逻辑很简单:@repeat(3)先调用repeat(3)返回decorator,再用decorator去装饰greet函数。

我曾经犯过的错:装饰器没有用functools.wraps,导致被装饰函数的元信息(__name__、__doc__)全丢了。调试时函数名显示wrapper,排查问题特别痛苦。

from functools import wraps

def timer(func):
    @wraps(func)  # 保留原函数信息
    def wrapper(*args, **kwargs):
        ...
    return wrapper

3.4 *args 与 **kwargs

这两个符号,说白了就是Python的「万能参数袋」。

  • *args:接收任意数量的位置参数,打包成元组
  • **kwargs:接收任意数量的关键字参数,打包成字典
def log(message, *args, **kwargs):
    print(f"[LOG] {message}")
    if args:
        print(f"位置参数: {args}")
    if kwargs:
        print(f"关键字参数: {kwargs}")

log("用户登录", "admin", "192.168.1.1", level="INFO")
# 输出:
# [LOG] 用户登录
# 位置参数: ('admin', '192.168.1.1')
# 关键字参数: {'level': 'INFO'}

我个人习惯在写装饰器、代理模式、或者需要转发参数的场景里大量使用它们。比如数据库连接池的封装,参数不确定时用**kwargs特别灵活。

解包操作:调用函数时,*和**也可以用来解包序列和字典

def add(a, b, c):
    return a + b + c

nums = [1, 2, 3]
print(add(*nums))  # 6

data = {"a": 1, "b": 2, "c": 3}
print(add(**data))  # 6

3.5 lambda表达式

lambda就是匿名函数,适合写一些简单的一次性逻辑。我面试时发现很多人滥用lambda,写出一堆难以阅读的代码。

什么时候用lambda?

  • 作为高阶函数的参数(sorted的key、map/filter的回调)
  • 逻辑简单到一行能写完

什么时候别用?

  • 逻辑超过一行
  • 需要复用
  • 影响可读性
# 好的用法
students = [("Alice", 85), ("Bob", 92), ("Charlie", 78)]
students.sort(key=lambda s: s[1], reverse=True)

# 糟糕的用法
# 这种就别用lambda了,写个普通函数更清晰
complex_func = lambda x, y: x ** 2 + y ** 2 if x > 0 else y ** 2 - x

闭包陷阱:lambda在循环中容易出问题

funcs = [lambda: i for i in range(5)]
print([f() for f in funcs])  # [4, 4, 4, 4, 4] 不是 [0, 1, 2, 3, 4]

为什么会这样?因为lambda里的i是延迟绑定的,循环结束时i已经变成了4。解决办法是用默认参数绑定当前值:

funcs = [lambda i=i: i for i in range(5)]
print([f() for f in funcs])  # [0, 1, 2, 3, 4]

3.6 面试实战要点

知识点 常见问法 回答要点
LEGB 变量查找顺序 从内到外,逐层查找
闭包 闭包是什么?有什么应用? 函数+环境,装饰器、回调、工厂函数
装饰器 手写一个计时装饰器 闭包结构、*args/**kwargs、functools.wraps
lambda 循环中lambda的陷阱 延迟绑定、默认参数解法
*args/**kwargs 参数传递与解包 打包与解包、装饰器中的转发

我的建议:面试前把装饰器手写三遍,闭包原理用自己的话讲一遍。这两个概念是函数面试的「必考点」,也是区分初级和中级工程师的分水岭。

函数与作用域这块,说白了就是理解Python「一切皆对象」的设计哲学。函数可以像变量一样传递,作用域决定了变量在哪里可见,闭包和装饰器则是这种灵活性的具体体现。把这些搞透了,后面看异步编程、元类这些高级话题会轻松很多。