趣谈Python:def与class本质都是赋值语句
作者:新兰2026.11.07 01:14浏览量:0简介:def 与 class 在语法上像“声明”,在语义上却都是赋值语句。本文沿编译期与运行期两条线索,拆解函数对象与类对象的构建过程:code object 如何被包装、类体如何被当作伪函数就地执行、残余局部变量如何变成类属性,并给出条件定义方法、批量生成方法等实战玩法与经典陷阱分析,帮助读者写出更灵活可靠的 Python 代码。
一、从一个反直觉的事实说起
绝大多数开发者每天都在写 def 和 class,却很少追问这两个关键字在解释器层面究竟做了什么。直观印象里,def 是“声明函数”,class 是“声明类”;但从语言语义上看,它们与 x = 1 并无本质区别——都是“先构造一个对象,再把对象绑定到一个名字上”。
def语句:编译代码块,构建函数对象,把函数对象赋值给函数名;class语句:就地执行类体,收集命名空间,构建类对象,把类对象赋值给类名。
理解了“都是赋值”,很多看似神奇的现象就顺理成章了:函数为什么能像普通值一样被传递、删除、塞进列表;类体里为什么能写 if、for 甚至 try;以及为什么能在运行期“凭空”造出一个类。下面沿“函数”与“类”两条线索逐一拆解。
二、def 的两段式生命周期
2.1 编译期:代码块变成 code object
def power(base, exp=2):"""计算 base 的 exp 次方"""return base ** exp
当代码块被加载时,def 下面的函数体早在编译阶段就已编译成一个 code object:它包含字节码指令、常量表、变量名信息等静态内容。注意,code object 是“纯静态”的,它不知道自己将来会被绑定到哪个名字,也不记录默认值、闭包这类运行期信息。
2.2 运行期:构建 function object 并完成赋值
真正执行到 def 这一行时,解释器会根据这个 code object 构建一个 function object,然后把它赋值给 power 这个名字——没错,函数名就是一个普普通通的变量:
print(power) # <function power at 0x...>print(power.__defaults__) # (2,) 默认值挂在函数对象上print(power.__code__.co_argcount) # 2 参数信息则记录在 code object 里alias = power # 函数可以像普通对象一样赋值del power # 删掉原名,函数对象依然存活print(alias(3)) # 9
2.3 为什么不直接把 code object 赋给名字
既然函数体已经是 code object,为什么还要再包一层?因为 function object 承载了 code object 不具备的动态信息:
__defaults__/__kwdefaults__:位置与关键字参数默认值;__closure__:闭包单元,记录对外层作用域变量的引用;__globals__:函数运行时使用的全局命名空间;__name__/__qualname__/__doc__/__annotations__等元信息。
同一个 code object 可以反复构建出多个互相独立的 function object,最典型的就是工厂函数:
def make_adder(n):def adder(x):return x + n # n 来自外层函数作用域return adderadd1 = make_adder(1)add2 = make_adder(10)print(add1.__closure__, add2.__closure__) # 两个不同的闭包单元print(add1(1), add2(1)) # 2 11
两次调用 make_adder 执行的是同一份 adder 的代码,但每次都会新建一个 function object,各自携带不同的闭包。这就是“声明一次、构造多次”的语义。
2.4 连带陷阱:默认值在 def 执行那一刻求值
“def 是赋值”还推出一个经典结论:默认参数的值在 def 被执行时求值一次,然后存进 __defaults__,而不是每次调用时重新求值。
def append_to(item, target=[]): # 默认列表只在 def 执行时创建一次target.append(item)return targetprint(append_to(1)) # [1]print(append_to(2)) # [1, 2] 两次调用共享了同一个列表
可变默认值因此成为高频事故现场,惯用修正写法是 target=None 再在函数体内判空重建。有趣的是,这个机制在后文“批量生成方法”的场景里还会反过来帮我们大忙。
三、class 的本质:一段就地执行的“伪函数”
3.1 类体的三条特殊规则
class 同样是赋值语句,只是过程更复杂:执行到 class 这一行时,解释器会把类体当作一个“伪函数”来编译和执行。它像函数一样拥有独立的局部命名空间,但在语法和语义上有几条关键差异:
- 不接受输入参数。类体没有形参列表,传给
class语法的元信息(如指定元类、关键字参数)都由元类机制消费,到不了类体; - 不能通过
return向外传值。类体的“产出”不是返回值,而是执行后沉淀下来的命名空间; - 类体中
def出的函数不会把类体当作闭包外层。方法查找名字时会跳过类体作用域——这是与真正的函数嵌套最大的不同。
类体是顺序执行的,可以直接观察:
print("before")class Config:print("class body executing") # 定义类时就会打印mode = "debug"if mode == "debug":def log(self, msg):print("[DEBUG]", msg)else:def log(self, msg):passprint("after")
输出顺序为 before → class body executing → after,说明“定义类”其实是“执行类体 + 构造类对象”的过程,说“构造类”比“定义类”更贴切。
3.2 执行完毕后:命名空间沉淀为类属性
伪函数执行完成后,解释器并不销毁它的局部命名空间,而是把“残余”的局部变量转换为类的属性,再连同类名、基类元组一起交给元类(默认是 type)组装成类对象,最后赋值给类名——所以类名同样只是变量。整个过程大致等价于:
def show(self):return f"dim={self.dim}"ns = {"dim": 2, "show": show}Point = type("Point", (), ns) # class 语句的“手工等价形式”
正因为类体是普通代码,if、for、try 甚至临时变量都可以出现在里面;也正因为残余变量会变成类属性,用 del 清理临时变量就成了类体编程的收尾好习惯。
3.3 经典作用域陷阱:方法看不见类体里的名字
规则 3 值得单独强调,它引出一个大名鼎鼎的坑:
x = "global"def factory():x = "enclosing"class C:x = "class"def get(self):return x # 返回 "enclosing",而不是 "class"!return Cprint(factory().x) # classprint(factory()().get()) # enclosing
方法内查找名字的顺序是:局部 → 外层函数 → 全局 → 内建,类体作用域不在链条上。因此方法想访问类属性,必须显式经由 self.x、类名或 type(self)。同理,在类体里写推导式也要小心:
class C:data = [1, 2, 3]doubled = [v * 2 for v in data] # 正常:最外层可迭代对象在类体作用域求值picked = [doubled[i] for i in (0, 2)] # NameError:推导式内部看不到 doubled
这些“反直觉”行为,本质上都是“类体是一个作用域规则特殊的伪函数”的外在表现。
四、把机制变成生产力:两个典型玩法
4.1 条件定义方法
既然类体是可编程的,就可以在“构造类”的时刻根据环境决定方法形态:
import osclass Reporter:if os.environ.get("VERBOSE"):def report(self, msg):print("[verbose]", msg)else:def report(self, msg):print(msg)
两个分支里定义的都是名为 report 的函数,类体执行结束后,最终生效的那个会沉淀为类属性。相比在 __init__ 或每次调用时做分支判断,这种写法把决策提前到了类构造期,运行期零开销;代价是行为在导入时就固定了,适合按部署环境定制行为的场景。
4.2 批量生成方法
另一个常见需求是为一组相似行为批量生成方法,比如按格式码解析二进制定长整数:
import structclass BinaryReader:"""按大端序读取不同宽度的无符号整数"""def __init__(self, payload):self.payload = payloaddef _factory(fmt):size = struct.calcsize("!%s" % fmt)def reader(self):return struct.unpack("!%s" % fmt, self.payload[:size])[0]return readerfor _fmt in "BHIQ":locals()["read_" + _fmt] = _factory(_fmt)del _fmt, _factory
说明三点:
_factory是定义在类体里的普通工厂函数,它返回的reader通过真正的函数闭包捕获了fmt和size,天然避免了循环变量共享的坑;locals()["read_" + _fmt] = ...依赖“类体命名空间即locals()且可直接修改”这一具体实现的特性,跨解释器环境不保证可移植;- 结尾的
del _fmt, _factory把临时名字清掉,避免它们变成两个无意义的类属性。
如果不想依赖上述细节,更稳妥的写法是在类定义之后用 setattr 补齐,这里还能顺手回收 2.4 节的默认值机制:
for _fmt in "BHIQ":def _reader(self, _fmt=_fmt): # 默认值在 def 执行时“钉住”当前 _fmtsize = struct.calcsize("!%s" % _fmt)return struct.unpack("!%s" % _fmt, self.payload[:size])[0]_reader.__name__ = _reader.__qualname__ = "read_" + _fmtsetattr(BinaryReader, "read_" + _fmt, _reader)del _fmt, _reader
_fmt=_fmt 正是利用“默认值在 def 时求值一次”的特性,把循环变量的当前值固定进每个函数对象——同一个机制,既是陷阱也是工具。
五、Python 3 的重要演进:方法绑定时机变了
在早期版本中,类体里的函数在构造类时会被转换成 unbound method;而 Python 3 取消了这一层包装,函数作为类属性时始终是普通函数,“绑定到实例”被推迟到属性访问那一刻,由描述符协议动态完成:
class A:def f(self):return "f"print(A.f) # <function A.f ...> 普通函数print(A().f) # <bound method A.f of ...> 访问时才生成绑定方法
这也顺带解释了 staticmethod 与 classmethod 的本质:它们是描述符,改变的是函数作为类属性被访问时的行为——前者原样返回函数(不绑定),后者让访问发生时绑定到类而非实例。构造类时并不需要对其做特殊转换,真正起作用的是属性访问时的描述符协议。理解了“函数即描述符”,装饰器、property、类方法、静态方法就统一成了一套机制。
六、克制地使用“类体编程”
类体编程提供了很强的表达力,但它是一把双刃剑:
- 可读性:类体里塞满控制流会让“这个类有哪些属性和方法”变得难以一眼看清,静态分析工具与阅读者的预期都可能失效;
- 可调试性:在类体中用
exec动态拼代码,报错栈、断点、重构工具都不友好,现代工程中应尽量避免; - 可移植性:如前所述,修改类体
locals()属于实现细节。
实践建议:
- 类体内优先做“声明式”的事情:常量、类型注解、简单条件分支;
- 批量生成方法优先考虑“工厂函数 +
setattr”或装饰器;需要拦截子类行为时,可用__init_subclass__、__set_name__等更正规的钩子; - 临时变量要么
del掉,要么用下划线前缀约定,避免污染类命名空间; - 记住两条铁律:方法内看不到类体作用域;默认值在
def执行时求值一次。
七、小结
把 def 和 class 还原为赋值语句,是理解 Python 对象模型的一把钥匙:
- 函数 = code object(静态)+ function object(动态)+ 名字绑定;同一份代码可以构造出任意多个函数对象;
- 类 = 就地执行的伪函数 + 沉淀为属性的命名空间 + 元类组装 + 名字绑定;类名同样只是变量;
- 方法绑定、
staticmethod/classmethod、装饰器、动态建类等特性,都可以在“赋值 + 描述符”这套统一框架下得到自然解释。
下一次写下 def 或 class 时,不妨把它默念成一次赋值——很多“语法糖”的甜味,其实来自对底层机制恰到好处的利用。

登录后可评论,请前往 登录 或 注册