Skip to content

三值逻辑

tujue edited this page Oct 1, 2026 · 1 revision

三值逻辑

这是 pyPython 唯一一个不是"套壳 CPython"的功能, 也是唯一一个"正常语言没有、而它有"的东西。


三个值

布尔有三个值,不是两个:

值 含义 输出
TRUE 已观测,为真 1 ⟨TRUE⟩ 0b1
FALSE 已观测,为假 0 ⟨FALSE⟩ 0b0
MAYBE 尚未观测 MAYBE ⟨MAYBE⟩

MAYBE 不是 None,不是错误,不是随机数。

它是还没被观测的状态。类比(不进代码注释):

约人出来。TRUE 是约到了,FALSE 是约不到, MAYBE 是消息发出去还没回。 还没回不等于失败,也不等于成功——它就是还没被观测。

这个状态必须被语言保留,而不是用异常和空值去糊弄。


怎么写出来

x「未知」           赋值给变量
$(未知)            直接输出

未知 是关键字,跟 self 同类——它是个值,不是语句、不是运算符。

为什么必须是关键字:如果它只是个内置变量名,用户一句 未知「5」 就能把"未观测"这个状态覆盖掉。用户要求"这个状态必须被语言保留", 而能被覆盖的名字保留不住任何东西。

未知「5」           炑报错:覆盖不掉

Kleene 真值表

AND(且)—— 假能压过未知

右:T 右:F 右:M
左:T T F M
左:F F F F
左:M M F M
$(1 and 1)         → 1 ⟨TRUE⟩
$(1 and 0)         → 0 ⟨FALSE⟩
$(1 and 未知)       → MAYBE ⟨MAYBE⟩     不确定的一侧压不过去
$(0 and 未知)       → 0 ⟨FALSE⟩          ← 确定的假能压过未知
$(未知 and 0)       → 0 ⟨FALSE⟩          ← 顺序无关,结果相同
$(未知 and 未知)     → MAYBE

OR(或)—— 真能压过未知

右:T 右:F 右:M
左:T T T T
左:F F F M
左:M T M M
$(1 or 未知)        → 1 ⟨TRUE⟩          ← 确定的真能压过未知
$(0 or 未知)        → MAYBE
$(未知 or 1)        → 1 ⟨TRUE⟩
$(未知 or 未知)      → MAYBE

NOT(非)—— 取反不产生信息

输入 输出
T F
F T
M M
$(not 1)           → 0 ⟨FALSE⟩
$(not 0)           → 1 ⟨TRUE⟩
$(not 未知)         → MAYBE ⟨MAYBE⟩

这一格是整个设计里最要紧的一格。 如果 not 未知 返回了确定值, 就等于语言替用户猜了一次(猜"未知当成假"或"当成真")。 取反不产生任何新信息,所以未知取反仍是未知。


输出格式

$(未知)            → MAYBE ⟨MAYBE⟩

不套引号(它不是字符串),直接印状态本身。真值部分印 ⟨MAYBE⟩, 不经过真值密室——密室只能判"真/假",而未知的意义正是"密室还没法判"。


未知会传染

未知参与任何运算,结果都还是未知:

运算 结果
未知 + 1 MAYBE
未知 - 1 MAYBE
未知 * 2 MAYBE
未知 > 1 MAYBE
未知 ~ 1 MAYBE
-未知 MAYBE
[未知][0] MAYBE(存进容器再取出来还是未知)
{"a": 未知}["a"] MAYBE
f(未知) MAYBE(跨函数也传得过去)
(未知 + 1) > 0 MAYBE(传几层都是未知)

为什么不猜:未知 + 1 是几?猜 0 就是 1,猜 1 就是 2。 哪个都不是用户能确认的答案。传递未知才是诚实的做法。


绝不使用随机

底层是二进制机器,任何"随机"都是伪随机。

用伪随机冒充未知,等于把"我不知道"偷偷换成"我掷了个骰子"—— 那是撒谎。未知就是未知,一路传下去,不替用户做判断。

验证方式:连续 8 次求值同一个表达式,结果必须完全一致。 这一点写在回归测试里(test_v4_three_valued.py 第 5 组)。


控制流:静默观测

if 碰到未知

x「未知」
if x
    $(1)           ← 不执行
else
    $(2)           ← 也不执行

两个分支都不执行。 不报错、不随机、不跳过,只往账本记一笔。

这不是 bug,是三值逻辑的应有之义:未知时不该替用户选任何一边。

while 碰到未知

x「未知」
while x
    $("循环体")     ← 一次都不跑
$("循环已退出")     ← 这一行会执行

不进循环体,记一笔账后退出。

为什么不"先跑一圈看看":循环体可能改变条件依赖的变量, 而"未知"的意思正是我们不知道它会不会变。替用户赌一把, 就是拿伪随机冒充未知。

为什么一定要记账

如果两边都不跑、又什么都不说,用户只会看到一个不动的程序, 分不清"遇到未知"和"程序坏了"。

账本让未知可见,但不让它被处理。


账本:记三件事

每次静默观测,账本记录:

  1. 哪个变量是未知
  2. 哪一行代码碰到了它
  3. 这个未知有没有传染到后面的运算

输出格式:

炑:静默观测已触发(第2行),状态未知,未执行任何分支。
      未知来源:x
      传播路径:x
      传染情况:未传染(未知到此为止)
      提示:若要确定分支,先给 x 赋一个确切的值;不确定就让它保持未知,语言不会替你猜。

(第一行格式是固定的,一个字都不能改。后面三行是明细。)


真溯源:未知是怎么传过来的

未知身上带污染标记(taint),标记跟着值一路走。

x「未知」
y「x + 1」
z「y * 2」
w「z - 3」
if w
    $(1)          不执行
else
    $(2)          也不执行

账本输出:

炑:静默观测已触发(第5行),状态未知,未执行任何分支。
      未知来源:w
      传播路径:x → y → z → w
      传染情况:未传染(未知到此为止)
      提示:若要确定分支,先给 x 赋一个确切的值;……

注意两件事:

  • 传播路径是完整的:x → y → z → w,四层都在
  • 提示指向源头 x,不是中间环节 w——w 是算出来的, 给它赋值盖不住 x

跨容器、跨函数都追得到:

x「未知」
盒「[x]」
y「盒[0] + 1」      传播路径:x → y

两个独立来源不会被画成因果链

a「未知」
b「未知」
c「a + b」
if c
    ...
传播路径:a → c 与 b → c

不是 a → b → c。因为 a 和 b 毫无关系, 画成一条链就是在撒谎说"a 引起了 b"。

实现上用两个连接符区分:

符号 含义
> 流过去:值从左边算出来,存进了右边(x>y = "x 传给了 y")
+ 两个独立来源汇合(a+b = "a 和 b 各自被污染过")

存储:标记放在哪

标记存在编码里,不在对象上。

为什么:变量注册表存的是 encode() 出来的字符串,不是对象本身。 所以任何挂在对象属性上的标记过一次赋值就没了。

编码格式加了一个可选前缀:

Tx>y|N:5          值 5,污染路径 x→y
a+b|S3:abc        字符串,两个独立来源
N:5               没污染——和以前一模一样

没污染的值编码完全不变。 这是向后兼容的关键, 也是所有旧测试、旧输出格式不受影响的原因。


已知取舍

  • x ~ x 返回 MAYBE,不是 TRUE。理由:不确定的东西不能声称 自己等于自己。副作用是用户没办法写"这个值到底观没观测"的自反判断。
  • 污染标记不会自动清除。值被污染过就带着标记(除非被赋了新值)。 真正的"污点清除"需要显式机制,本版没做。
  • 溯源粒度是变量,不是表达式。y「x + 1」 记成 x>y, 中间那个 + 1 不体现。
  • 比较不参与真值表:未知 ~ 未知 返回 MAYBE。 这超出 Kleene 标准表(标准表只定义逻辑连接词),是本项目自己定的。

→ 完整清单见 已知限制


实现笔记

想改这部分代码的话,先读 ADR-0009。

三个最容易踩的坑:

  1. 未知 在输出分派里必须排在字符串分支前面。MAYBE 底层是普通字符串, 不拦的话会掉进字符串分支、按长度(5 ≥ 1)判成 TRUE—— 等于把"未知"印成"真"。
  2. 未知的算术拦截必须排在 left_is_text 判断前面。 排后面的话 未知 + 1 会被当成"字符串和数字相加", 报出一条跟真实原因毫无关系的错误。
  3. 合并污染标记时不能拆开已有的 > 路径。拆了会把"传递"降级成 "并列",三层以上的链全断。

Clone this wiki locally