logo

趣谈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

  1. def power(base, exp=2):
  2. """计算 base 的 exp 次方"""
  3. return base ** exp

当代码块被加载时,def 下面的函数体早在编译阶段就已编译成一个 code object:它包含字节码指令、常量表、变量名信息等静态内容。注意,code object 是“纯静态”的,它不知道自己将来会被绑定到哪个名字,也不记录默认值、闭包这类运行期信息。

2.2 运行期:构建 function object 并完成赋值

真正执行到 def 这一行时,解释器会根据这个 code object 构建一个 function object,然后把它赋值给 power 这个名字——没错,函数名就是一个普普通通的变量:

  1. print(power) # <function power at 0x...>
  2. print(power.__defaults__) # (2,) 默认值挂在函数对象上
  3. print(power.__code__.co_argcount) # 2 参数信息则记录在 code object 里
  4. alias = power # 函数可以像普通对象一样赋值
  5. del power # 删掉原名,函数对象依然存活
  6. 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,最典型的就是工厂函数:

  1. def make_adder(n):
  2. def adder(x):
  3. return x + n # n 来自外层函数作用域
  4. return adder
  5. add1 = make_adder(1)
  6. add2 = make_adder(10)
  7. print(add1.__closure__, add2.__closure__) # 两个不同的闭包单元
  8. print(add1(1), add2(1)) # 2 11

两次调用 make_adder 执行的是同一份 adder 的代码,但每次都会新建一个 function object,各自携带不同的闭包。这就是“声明一次、构造多次”的语义。

2.4 连带陷阱:默认值在 def 执行那一刻求值

“def 是赋值”还推出一个经典结论:默认参数的值在 def 被执行时求值一次,然后存进 __defaults__,而不是每次调用时重新求值。

  1. def append_to(item, target=[]): # 默认列表只在 def 执行时创建一次
  2. target.append(item)
  3. return target
  4. print(append_to(1)) # [1]
  5. print(append_to(2)) # [1, 2] 两次调用共享了同一个列表

可变默认值因此成为高频事故现场,惯用修正写法是 target=None 再在函数体内判空重建。有趣的是,这个机制在后文“批量生成方法”的场景里还会反过来帮我们大忙。

三、class 的本质:一段就地执行的“伪函数”

3.1 类体的三条特殊规则

class 同样是赋值语句,只是过程更复杂:执行到 class 这一行时,解释器会把类体当作一个“伪函数”来编译和执行。它像函数一样拥有独立的局部命名空间,但在语法和语义上有几条关键差异:

  1. 不接受输入参数。类体没有形参列表,传给 class 语法的元信息(如指定元类、关键字参数)都由元类机制消费,到不了类体;
  2. 不能通过 return 向外传值。类体的“产出”不是返回值,而是执行后沉淀下来的命名空间;
  3. 类体中 def 出的函数不会把类体当作闭包外层。方法查找名字时会跳过类体作用域——这是与真正的函数嵌套最大的不同。

类体是顺序执行的,可以直接观察:

  1. print("before")
  2. class Config:
  3. print("class body executing") # 定义类时就会打印
  4. mode = "debug"
  5. if mode == "debug":
  6. def log(self, msg):
  7. print("[DEBUG]", msg)
  8. else:
  9. def log(self, msg):
  10. pass
  11. print("after")

输出顺序为 before → class body executing → after,说明“定义类”其实是“执行类体 + 构造类对象”的过程,说“构造类”比“定义类”更贴切。

3.2 执行完毕后:命名空间沉淀为类属性

伪函数执行完成后,解释器并不销毁它的局部命名空间,而是把“残余”的局部变量转换为类的属性,再连同类名、基类元组一起交给元类(默认是 type)组装成类对象,最后赋值给类名——所以类名同样只是变量。整个过程大致等价于:

  1. def show(self):
  2. return f"dim={self.dim}"
  3. ns = {"dim": 2, "show": show}
  4. Point = type("Point", (), ns) # class 语句的“手工等价形式”

正因为类体是普通代码,if、for、try 甚至临时变量都可以出现在里面;也正因为残余变量会变成类属性,用 del 清理临时变量就成了类体编程的收尾好习惯。

3.3 经典作用域陷阱:方法看不见类体里的名字

规则 3 值得单独强调,它引出一个大名鼎鼎的坑:

  1. x = "global"
  2. def factory():
  3. x = "enclosing"
  4. class C:
  5. x = "class"
  6. def get(self):
  7. return x # 返回 "enclosing",而不是 "class"!
  8. return C
  9. print(factory().x) # class
  10. print(factory()().get()) # enclosing

方法内查找名字的顺序是:局部 → 外层函数 → 全局 → 内建,类体作用域不在链条上。因此方法想访问类属性,必须显式经由 self.x、类名或 type(self)。同理,在类体里写推导式也要小心:

  1. class C:
  2. data = [1, 2, 3]
  3. doubled = [v * 2 for v in data] # 正常:最外层可迭代对象在类体作用域求值
  4. picked = [doubled[i] for i in (0, 2)] # NameError:推导式内部看不到 doubled

这些“反直觉”行为,本质上都是“类体是一个作用域规则特殊的伪函数”的外在表现。

四、把机制变成生产力:两个典型玩法

4.1 条件定义方法

既然类体是可编程的,就可以在“构造类”的时刻根据环境决定方法形态:

  1. import os
  2. class Reporter:
  3. if os.environ.get("VERBOSE"):
  4. def report(self, msg):
  5. print("[verbose]", msg)
  6. else:
  7. def report(self, msg):
  8. print(msg)

两个分支里定义的都是名为 report 的函数,类体执行结束后,最终生效的那个会沉淀为类属性。相比在 __init__ 或每次调用时做分支判断,这种写法把决策提前到了类构造期,运行期零开销;代价是行为在导入时就固定了,适合按部署环境定制行为的场景。

4.2 批量生成方法

另一个常见需求是为一组相似行为批量生成方法,比如按格式码解析二进制定长整数:

  1. import struct
  2. class BinaryReader:
  3. """按大端序读取不同宽度的无符号整数"""
  4. def __init__(self, payload):
  5. self.payload = payload
  6. def _factory(fmt):
  7. size = struct.calcsize("!%s" % fmt)
  8. def reader(self):
  9. return struct.unpack("!%s" % fmt, self.payload[:size])[0]
  10. return reader
  11. for _fmt in "BHIQ":
  12. locals()["read_" + _fmt] = _factory(_fmt)
  13. del _fmt, _factory

说明三点:

  • _factory 是定义在类体里的普通工厂函数,它返回的 reader 通过真正的函数闭包捕获了 fmt 和 size,天然避免了循环变量共享的坑;
  • locals()["read_" + _fmt] = ... 依赖“类体命名空间即 locals() 且可直接修改”这一具体实现的特性,跨解释器环境不保证可移植;
  • 结尾的 del _fmt, _factory 把临时名字清掉,避免它们变成两个无意义的类属性。

如果不想依赖上述细节,更稳妥的写法是在类定义之后用 setattr 补齐,这里还能顺手回收 2.4 节的默认值机制:

  1. for _fmt in "BHIQ":
  2. def _reader(self, _fmt=_fmt): # 默认值在 def 执行时“钉住”当前 _fmt
  3. size = struct.calcsize("!%s" % _fmt)
  4. return struct.unpack("!%s" % _fmt, self.payload[:size])[0]
  5. _reader.__name__ = _reader.__qualname__ = "read_" + _fmt
  6. setattr(BinaryReader, "read_" + _fmt, _reader)
  7. del _fmt, _reader

_fmt=_fmt 正是利用“默认值在 def 时求值一次”的特性,把循环变量的当前值固定进每个函数对象——同一个机制,既是陷阱也是工具。

五、Python 3 的重要演进:方法绑定时机变了

在早期版本中,类体里的函数在构造类时会被转换成 unbound method;而 Python 3 取消了这一层包装,函数作为类属性时始终是普通函数,“绑定到实例”被推迟到属性访问那一刻,由描述符协议动态完成:

  1. class A:
  2. def f(self):
  3. return "f"
  4. print(A.f) # <function A.f ...> 普通函数
  5. print(A().f) # <bound method A.f of ...> 访问时才生成绑定方法

这也顺带解释了 staticmethod 与 classmethod 的本质:它们是描述符,改变的是函数作为类属性被访问时的行为——前者原样返回函数(不绑定),后者让访问发生时绑定到类而非实例。构造类时并不需要对其做特殊转换,真正起作用的是属性访问时的描述符协议。理解了“函数即描述符”,装饰器、property、类方法、静态方法就统一成了一套机制。

六、克制地使用“类体编程”

类体编程提供了很强的表达力,但它是一把双刃剑:

  • 可读性:类体里塞满控制流会让“这个类有哪些属性和方法”变得难以一眼看清,静态分析工具与阅读者的预期都可能失效;
  • 可调试性:在类体中用 exec 动态拼代码,报错栈、断点、重构工具都不友好,现代工程中应尽量避免;
  • 可移植性:如前所述,修改类体 locals() 属于实现细节。

实践建议:

  1. 类体内优先做“声明式”的事情:常量、类型注解、简单条件分支;
  2. 批量生成方法优先考虑“工厂函数 + setattr”或装饰器;需要拦截子类行为时,可用 __init_subclass__、__set_name__ 等更正规的钩子;
  3. 临时变量要么 del 掉,要么用下划线前缀约定,避免污染类命名空间;
  4. 记住两条铁律:方法内看不到类体作用域;默认值在 def 执行时求值一次。

七、小结

把 def 和 class 还原为赋值语句,是理解 Python 对象模型的一把钥匙:

  • 函数 = code object(静态)+ function object(动态)+ 名字绑定;同一份代码可以构造出任意多个函数对象;
  • 类 = 就地执行的伪函数 + 沉淀为属性的命名空间 + 元类组装 + 名字绑定;类名同样只是变量;
  • 方法绑定、staticmethod/classmethod、装饰器、动态建类等特性,都可以在“赋值 + 描述符”这套统一框架下得到自然解释。

下一次写下 def 或 class 时,不妨把它默念成一次赋值——很多“语法糖”的甜味,其实来自对底层机制恰到好处的利用。

发表评论

活动