Chapter 4: Evaluation Semantics
表达式求值语义回答一个直接问题:一行 Python 表达式从左到右经过哪些 runtime 动作,在哪些位置创建对象、读取属性、判断 truth value、调用 callable、绑定临时名字,并把哪个对象作为结果返回。读完本章后,读者应能追踪一个表达式的执行顺序,判断副作用发生位置,并区分语法优先级、truth testing、call protocol 和 lazy boundary 各自控制的范围。
本章按 Python 3.14 的 Expressions 与 Truth Value Testing 整理语言层规则,同时把这些规则放回 object / type / frame / namespace / protocol / runtime state 中解释。这里讨论的是 Python 语言语义;CPython 的 bytecode 形态和 specialization 属于后续编译与解释器章节。
贯穿材料是一段带日志的对象。它把“求值”拆成可观察事件:属性兜底、truth testing、参数求值、调用、切片对象创建和 __getitem__ 分发。正文后面的每个小节都会回到这段代码,说明同一行表达式中各个动作的先后关系。
class Probe:
def __init__(self, label):
self.label = label
self.log = []
def __getattr__(self, name):
self.log.append(f"getattr:{name}")
return self
def __bool__(self):
self.log.append(f"bool:{self.label}")
return True
def __call__(self, value):
self.log.append(f"call:{value}")
return value
def __getitem__(self, key):
self.log.append(f"getitem:{key!r}")
return key
def mark(log, name, value):
log.append(f"eval:{name}")
return value
p = Probe("p")
result = p.ready and p(mark(p.log, "arg", 10)) or "fallback"
part = p[mark(p.log, "start", 1):mark(p.log, "stop", 3)]
这段代码的关键结论先给出:p.ready 先触发属性访问,and 对它做 truth testing;只有 truth testing 通过,右侧调用表达式才继续求值;调用前先求值参数;切片表达式先求值 start 和 stop,再构造 slice(1, 3, None),最后把这个对象传给 p.__getitem__。表达式的结果对象和中间判断用到的 truth value 是两层事情,调试时需要分开看。
4.1 Evaluation order and expression sequencing
求值顺序决定表达式中每个子表达式何时进入当前 frame。Python 的基础规则是从左到右求值;赋值语句中右侧先求值,随后才把结果绑定到左侧目标。这个规则让副作用有了稳定位置:函数参数、容器元素、字典键值、切片边界和二元运算的操作数都会按照语言规定的顺序进入 runtime。
在贯穿代码中,result = p.ready and p(mark(p.log, "arg", 10)) or "fallback" 的左侧名字 result 是赋值目标。解释器先处理右侧表达式,拿到最终对象后,才把 result 绑定到当前 local namespace。这里的右侧表达式又由 or、and、属性访问和调用表达式组成,求值顺序需要按照语法结构逐层展开。
下面的图只描述贯穿表达式的 runtime 顺序。图中“返回对象”表示表达式把某个对象交给上一层语法节点;“truth test”表示当前对象被用于真假判断。
这张图强调两个边界。语法树决定谁是 and 的左操作数和右操作数,求值顺序决定这些操作数何时产生对象。and 和 or 还会把对象送入 truth testing,这一步可能调用对象的 __bool__ 或 __len__,也可能直接使用内置对象的真假规则。
运算符优先级会影响表达式如何分组,但它不改写“子表达式从左到右进入 runtime”的基本顺序。表达式 a() + b() * c() 会先求值 a(),再求值 b() 和 c(),随后按照乘法优先于加法的语法结构完成运算。换成对象模型来讲,a() 的副作用位置由 evaluation order 决定,乘法节点先被组合进语法树则由 operator precedence 决定。
字典显示也有版本边界。Python 3.8 起,dict comprehension 中 key 在 value 之前求值;这与 assignment expression 的引入相关。普通字典显示中的键值项按从左到右处理,同一个 key 多次出现时,后写入的 value 成为最终映射值。调试带副作用的字典构造时,先确认 Python 版本,再追踪每个 key/value 表达式的求值点。
对源码阅读来说,本节形成一个稳定检查顺序:先按语法确定表达式树,再按左到右顺序标记每个子表达式的进入点,然后单独标出赋值目标、truth testing、call、attribute、subscription 这些会触发协议分发的位置。这样可以把“一行代码发生了什么”拆成可复核的 runtime 事件。
4.2 Truth testing and short-circuit evaluation
Truth testing 是 Python 把任意对象解释成真假分支的规则。任意对象都可以出现在 if、while、and、or、not 等上下文中;默认情况下对象为真,类可以通过 __bool__ 返回假值,缺少 __bool__ 时可以通过 __len__ 返回零来表示假值。内置假值包括 False、None、数值零和空容器。
贯穿代码里的 p.ready 返回 p 自身。and 需要判断左操作数的 truth value,于是调用 p.__bool__() 并记录 bool:p。__bool__ 返回 True 后,右操作数 p(mark(...)) 才进入求值。这个顺序解释了短路的核心:右侧表达式的对象创建、函数调用和副作用受左侧 truth value 控制。
and 与 or 的返回值是被选中的操作数对象。x and y 在 x 为假时返回 x,在 x 为真时返回 y;x or y 在 x 为真时返回 x,在 x 为假时返回 y。not x 返回布尔对象,因为它需要构造“真假取反”的结果。这个差异会影响工程代码:value = user_input or default 返回的可能是原始字符串,也可能是 default 对象;它的类型不固定为 bool。
短路表达式的调试要分两层看。第一层是“哪个对象被拿来做 truth testing”,第二层是“整个表达式返回哪个对象”。在 result = p.ready and p(mark(...)) or "fallback" 中,p.ready 只决定右侧调用是否继续;p(mark(...)) 返回的 10 又成为 or 的左操作数。10 为真时,or 直接返回 10,字符串 "fallback" 没有进入运行路径。
用户自定义对象的 truth testing 也会产生异常路径。__bool__ 必须返回布尔对象,__len__ 必须返回非负整数;这些方法抛出的异常会沿当前表达式传播。此时表达式没有正常结果,后续短路分支也不会继续求值。阅读业务代码时,看到 obj and obj.action(),需要确认 obj 的 truth testing 是否只是内置真假规则,还是会进入用户自定义方法。
短路表达式常被用于默认值、缓存读取和守卫调用。稳定写法的判断顺序是:先确定左操作数的对象身份,再确认该对象的 truth testing 路径,然后判断右操作数是否可能产生副作用,最后确认表达式结果是否被当作普通对象继续使用。这个顺序能解释“为什么某个函数没有被调用”以及“为什么变量拿到的值不固定为 True 或 False”。
4.3 Attribute reference and call expression evaluation
Attribute reference 的输入是一个 primary 对象和一个属性名。表达式 obj.attr 会先求值 obj,然后向这个对象请求名为 attr 的属性;返回值由对象类型、实例状态、descriptor、__getattribute__ 和 __getattr__ 等路径共同决定。本章只关注求值时机,完整 lookup 链路会在属性系统章节展开。
贯穿代码中,p.ready 先求值名字 p,当前 frame 的 local namespace 找到 Probe 实例;随后属性 ready 在实例中找不到,__getattr__ 接管并返回 p。这个返回对象继续交给 and 做 truth testing。这里的关键点是,属性访问本身已经是一次 runtime 分发,它可以记录日志、构造新对象、返回 bound method,也可以抛出 AttributeError。
Call expression 的输入是一个 callable 对象和一组已经求值的实参对象。表达式 p(mark(p.log, "arg", 10)) 进入调用前,解释器先求值 callable 位置的 p,再求值参数表达式 mark(p.log, "arg", 10)。mark 记录 eval:arg 并返回 10 后,p.__call__(10) 才进入执行。
调用表达式包含两个容易混淆的阶段。参数表达式求值阶段负责产生对象,参数绑定阶段负责把这些对象放入函数形参槽位。重复关键字、缺少必要参数、额外位置参数等错误发生在绑定阶段;参数表达式中的副作用已经可能完成。对于 f(mark(...), x=mark(...)) 这类代码,调试时先看每个参数表达式的求值顺序,再看函数签名如何接收这些对象。
属性访问和调用连在一起时,obj.method(arg) 可以拆成三步:求值 obj,求值 obj.method 得到一个 callable,然后求值 arg 并调用该 callable。对普通 Python 实例来说,method 往往是由函数 descriptor 生成的 bound method,它已经携带实例对象。这个携带行为解释了为什么 obj.method(x) 中的函数体会看到 self,也解释了把 obj.method 暂存到变量后仍能调用同一个实例方法。
源码阅读中,遇到 target.attr(factory()) 应按固定顺序拆解:target 所在 namespace 先给出对象,attribute reference 再给出 callable,factory() 产生实参对象,最后进入 call protocol。任何一步都可以改变状态或抛出异常;把这几步混在一起,会让日志顺序、错误位置和性能成本变得难以判断。
4.4 Slicing, lambda, walrus, and operator precedence
Slicing 是 subscription 的一种特殊形式。表达式 obj[start:stop:step] 会先求值被订阅对象 obj,再求值切片边界表达式;缺失的边界使用 None;随后解释器构造一个 slice(start, stop, step) 对象,并把它传给 obj.__getitem__ 或类型订阅相关路径。切片的结果由被订阅对象决定,list 会复制片段,用户对象可以返回任意对象。
贯穿代码中的 part = p[mark(p.log, "start", 1):mark(p.log, "stop", 3)] 先得到 p,再依次求值 start 与 stop 两个边界表达式,最后把 slice(1, 3, None) 传给 __getitem__。日志中会出现 eval:start、eval:stop 和 getitem:slice(1, 3, None)。这说明切片语法不能直接等同于复制容器;它先创建一个描述边界的对象,再交给目标类型解释。
Lambda expression 会创建函数对象。表达式 lambda x: x + mark(log, "body", 1) 在求值时生成一个 function object;函数体表达式在这个函数被调用时才进入新的 frame。若 lambda 带默认参数,例如 lambda x=mark(log, "default", 1): x,默认值表达式在 lambda 被求值、函数对象创建时执行,因为默认值需要挂到 function object 上。
Assignment expression,也就是 walrus :=,会先求值右侧表达式,把结果绑定到当前表达式语境中的名字,再把同一个结果作为表达式值返回。while chunk := file.read(9000): 中,file.read(9000) 的返回对象同时承担两个角色:绑定到 chunk,并参与循环条件的 truth testing。Python 3.8 引入了这个语法;在切片、lambda、关键字参数、条件表达式、comprehension filter 等位置使用时,括号规则会影响能否通过语法解析。
Operator precedence 决定表达式如何组合成语法树。Primary 级别的属性访问、调用和下标绑定最紧;随后是幂运算、一元运算、乘除、加减、比较、not、and、or、条件表达式、lambda 和 walrus。这个顺序解释了 p.ready and p(mark(...)) or "fallback" 为什么先把 p(mark(...)) 作为 and 的右操作数,再把整个 and 结果作为 or 的左操作数。
优先级分析的稳定方法是先加括号复原语法结构,再按 evaluation order 标出每个子表达式的求值点。a + b * c 的乘法节点优先组合,但 a 仍然先于 b 和 c 求值;obj.attr(arg)[i] 由 primary 连续绑定成链,每一步的返回对象都会成为下一步的输入。这个方法比记忆单个运算符强,因为它能直接解释对象返回、协议调用和副作用顺序。
4.5 Lazy evaluation and side effects
Lazy evaluation 指表达式创建了一个延后执行边界,部分代码在当前求值点只保存上下文或构造对象,真正的取值动作发生在后续调用、迭代或 truth testing 中。Python 常见 lazy boundary 包括 short-circuit、conditional expression、lambda body、generator expression、iterator、all() 和 any() 消耗 iterator 的过程。
短路是最小的 lazy boundary。left and right 中,right 的求值取决于 left 的 truth value;left or right 中,right 的求值也取决于 left 的 truth value。这里延后的对象是右操作数整个表达式进入当前 frame 的时机,而非某个对象内部计算。副作用因此也被延后或跳过:函数调用、属性读取、容器修改、日志写入都跟随求值点发生。
Generator expression 提供另一种延后边界。(mark(log, "body", x) for x in range(3)) 在创建 generator object 时建立迭代状态;body 表达式在 next() 推进时执行。左侧第一个 for 的 iterable 表达式会在 generator 创建时求值,这使得 iterable 构造失败可以在定义点暴露。后续 filter 和 body 副作用则跟随迭代推进。
Lambda body 的延后也需要和默认参数区分。fn = lambda: mark(log, "body", 1) 创建函数对象时不会记录 body,调用 fn() 时才记录;fn = lambda x=mark(log, "default", 1): x 创建函数对象时已经记录 default。这条边界来自 function object 需要保存 defaults,而函数体需要在调用 frame 中执行。
副作用排查的关键是给表达式做“求值点标注”。先画出语法树,确认 short-circuit、conditional、lambda、generator 这些延后边界;再标出属性访问、调用、subscription、truth testing 这些会触发协议分发的位置;最后检查每个副作用函数是否位于一定会执行的路径上。这样能解释日志缺失、缓存没有刷新、数据库写入次数不符合预期等问题。
Lazy evaluation 也会影响对象状态观察。一个 generator 捕获了外层对象引用后,后续 next() 看到的是推进时的对象状态;一个 lambda 捕获名字时,函数体读取的是调用时该名字能解析到的对象;一个 short-circuit 表达式返回的是最后被求值的操作数对象。工程代码中,想让状态固定下来,需要在进入 lazy boundary 前显式创建稳定对象,例如拷贝列表、绑定局部变量或把必要值传入默认参数。
本章的最终判断模型可以压缩成一条顺序:先看语法树,再看左到右求值,再看协议分发,再看 lazy boundary,最后看表达式返回的对象。这个模型让 evaluation semantics 从“语法细节”变成 runtime 追踪工具:读者看到复杂表达式时,可以直接定位对象创建、名字绑定、状态改变和异常传播的发生点。
最小自检任务
阅读下面代码,判断 log 的记录顺序、value 的最终结果,以及哪些表达式被 short-circuit 跳过。
log = []
class Box:
def __init__(self, truth):
self.truth = truth
def __bool__(self):
log.append(f"bool:{self.truth}")
return self.truth
def __call__(self, x):
log.append(f"call:{x}")
return x
def mark(name, value):
log.append(f"eval:{name}")
return value
left = Box(False)
right = Box(True)
value = left and right(mark("arg", 7)) or mark("fallback", 9)
答案要点
left and right(mark("arg", 7)) 先对 left 做 truth testing,记录 bool:False。由于 left 为假,and 直接返回 left,右操作数 right(mark("arg", 7)) 没有进入求值路径,因此不会记录 eval:arg,也不会调用 right.__call__。
随后 or 对 and 的结果 left 再做 truth testing,因此再次记录 bool:False。or 的左操作数为假,右操作数 mark("fallback", 9) 进入求值,记录 eval:fallback 并返回 9。最终 value 绑定到整数对象 9,log 的顺序是 ['bool:False', 'bool:False', 'eval:fallback']。
这个任务验证三件事:and 和 or 会返回操作数对象,truth testing 可以多次发生在同一个对象上,短路会让右侧调用表达式连同参数表达式一起跳过。
本章知识点总结
- 求值顺序:Python 表达式按语法结构从左到右进入 runtime,赋值语句先求值右侧再绑定左侧目标。
- 语法树:Operator precedence 决定表达式如何分组,evaluation order 决定子表达式何时产生对象和副作用。
- 对象返回:表达式的结果是对象,truth testing 只是部分语法节点为了分支判断临时执行的动作。
- 真假判断:对象默认按真值参与条件判断,类可以通过
__bool__或__len__改变 truth value。 - 短路路径:
and和or根据左操作数 truth value 决定右操作数是否求值,并返回最后被选中的操作数对象。 - 属性访问:
obj.attr先求值obj,再由对象和类型路径产生属性结果,返回对象会继续参与后续表达式。 - 调用表达式:调用前先求值 callable 和所有参数表达式,随后才进入参数绑定与函数体执行。
- 切片对象:切片语法会求值边界表达式并构造
slice对象,再把它交给目标对象的 subscription 协议解释。 - Lambda 边界:Lambda 求值时创建 function object,函数体表达式在调用时执行,默认参数表达式在函数对象创建时执行。
- Walrus 绑定:Assignment expression 先求值右侧对象,再完成名字绑定,并把同一对象作为表达式结果返回。
- 延迟边界:Short-circuit、lambda body 和 generator expression 会把部分执行推迟到后续 truth testing、调用或迭代阶段。
- 排查顺序:复杂表达式应先复原语法树,再标注求值点、协议分发、lazy boundary 和最终返回对象。