3.2 函数对象与作用域


3.2 函数对象与作用域

本节摘要:函数在 Python 里是一等对象:可赋值、可传参、可存容器、可当返回值。它还随身携带一个环境——闭包。本节讲函数对象的内部构成、LEGB 名字查找规则、参数系统的全部形态(默认、可变、仅位置/仅关键字、*args/**kwargs),并解剖一个闭包计数器。

先看函数的"体检报告"

1.2 节说过函数是对象,现在看看它身上挂着什么:

>>> def greet(name, prefix="Hi"): ... """向对方问好""" ... return f"{prefix}, {name}!" >>> type(greet) <class 'function'> >>> greet.__name__, greet.__doc__, greet.__defaults__ ('greet', '向对方问好', ('Hi',)) >>> print(greet.__code__.co_varnames) # 函数体内出现的全部局部名 ('name', 'prefix')

函数对象自带三样行李:代码对象(编译后的字节码与局部名表)、全局命名空间引用、默认值元组。__defaults__ 尤其眼熟——1.3 节可变默认参数坑的案发现场正是这里。

一等公民的直接好处是函数可以当数据用。一个把"策略"参数化的排序例子:

records = [("ann", 92), ("bob", 85), ("cy", 91)] by_score = sorted(records, key=lambda kv: kv[1], reverse=True) # [('ann', 92), ('cy', 91), ('bob', 85)]

key 接收的不是"结果",是"怎么算结果"的函数——把行为作为参数传递,这是从"面向过程"跨向高阶抽象的第一步。

LEGB:一个名字的查找路径

赋值创建名字,但名字住在哪里?Python 的命名空间从内到外四层,查找严格按 LEGB 顺序:

  • Local:当前函数体内;
  • Enclosing:外层函数体(闭包层);
  • Global:模块顶层;
  • Builtins:内置名字(print、len、True……)。

关键规则只有一条:函数内出现赋值语句,该名字就默认为局部名——哪怕赋值发生在使用之后:

>>> counter = 10 >>> def bump(): ... counter = counter + 1 # 赋值使 counter 成为局部名 ... return counter >>> bump() UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value

报错不是"找不到全局变量",而是"局部变量尚未赋值就被引用"——解释器在编译期就决定了 counter 是局部的。想改全局(或外层)变量,必须显式声明 global counternonlocal counter。而没有赋值、只读取的名字,自然沿 LEGB 向外找:

>>> threshold = 0.5 >>> def classify(x): ... return "high" if x > threshold else "low" # 读全局,合法 >>> classify(0.7) 'high'

LEGB 查找路径示意

LEGB 查找路径示意

工程建议:全局变量能少用就少用,跨模块共享状态用配置对象或第 5 章的模块级约定;真的需要写全局时,把 global 声明写在函数第一行并写注释说明理由。

参数系统的全部形态

现代 Python 的参数排序规则:**仅位置 → 位置或关键字 → *args(或裸 *)→ 仅关键字 → kwargs

def connect(host, port=5432, *, timeout=3.0, pool=True, **extra): ... connect("db.local") # 位置 + 默认 connect("db.local", 6432, timeout=5) # timeout 传位置会报错——在 * 之后 # TypeError: connect() takes 2 positional arguments

仅关键字参数(* 之后的)是 API 设计的自觉:调用点必须写出参数名,可读性与防错同时到手。标准库里 sorted(items, key=..., reverse=...) 就是这么设计的。/ 标记则反方向划出"仅位置"区,适合参数名无语义的场合(如 pow(x, y, z))。

*args**kwargs 的本质是调用侧的收集与解包,成对出现:

>>> def audit(*args, **kwargs): ... print(args, kwargs) >>> audit(1, 2, mode="fast") (1, 2) {'mode': 'fast'} >>> parts, opts = (1, 2), {"mode": "fast"} >>> audit(*parts, **opts) # 解包再传入,还原上一次调用 (1, 2) {'mode': 'fast'}

定义侧收集、调用侧解包,是同一枚硬币的两面。装饰器(第 7 章)之所以能"透传任意签名",全靠这对搭档。

闭包:函数随身携带的环境

函数不仅能传出去,还能记住定义处的名字。这种"函数 + 环境"的打包就是闭包:

def make_counter(start=0, step=1): count = start def bump(): nonlocal count # 改写外层函数的变量 count += step return count return bump # 把内层函数连同环境一起返回 c1 = make_counter() c2 = make_counter(start=100) c1(), c1(), c2() # (1, 2, 101) —— 两个计数器互不干扰

c1c2 各自"闭"住了一份独立的 count。证据存在函数对象的 __closure__ 里:

>>> c1.__closure__[0].cell_contents 2

闭包是 Python 实现"轻量状态对象"的手段——不想为一个计数器定义一个类时,闭包就是答案。JS 出身的读者会倍感亲切。

经典的延迟绑定坑也来自闭包:循环里创建的函数共享同一个循环变量,调用时才查它的终值:

>>> fns = [lambda: i for i in range(3)] >>> [f() for f in fns] [2, 2, 2] # 不是 0,1,2! >>> fns = [lambda i=i: i for i in range(3)] # 默认参数在定义时求值,钉住当前值 >>> [f() for f in fns] [0, 1, 2]

记住模型就不再踩坑:闭包捕获的是变量本身,不是创建那一刻的值。

匿名函数与可调用性的边界

lambda 是"表达式版的单行函数",限制(只能是表达式)正是它的用途说明——给 key、回调这类微型策略用;逻辑超过一行就命名成 def 函数,别硬撑 lambda。最后,任何实现 __call__ 的对象都能像函数一样调用,类的实例也可以"当函数用"——可调用性的统一抽象在第 7 章装饰器里还会回来。

⚠️ 常见坑:可变默认参数(1.3 节,配合 __defaults__ 复查);忘记 nonlocal 导致 UnboundLocalError;闭包延迟绑定;**kwargs 键与显式关键字参数重名报 multiple values

💡 关键直觉:函数 = 代码对象 + 环境引用。参数系统的一切形态,只是"收集"与"解包"在定义侧与调用侧的组合。

本节要点回顾

  • 一等对象:函数有 __code____defaults____closure__,可当值传递。
  • LEGB 与编译期判局部:函数内有赋值即局部,读外层沿四层向外找。
  • 参数排序:仅位置 → 普通 → */*args → 仅关键字 → **kwargs;仅关键字参数是好 API 的标志。
  • 收集与解包成对:定义侧 *args/**kwargs 收,调用侧 */** 展开。
  • 闭包捕获变量nonlocal 可改写;延迟绑定坑用默认参数钉值。
  • lambda 边界:单表达式策略专用,复杂逻辑上 def。

第 4 章把"携带环境的可调用"升级为完整的对象体系——类,本质上是"生产实例的 callable"。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U