Repository navigation
三值逻辑
这是 pyPython 唯一一个不是"套壳 CPython"的功能, 也是唯一一个"正常语言没有、而它有"的东西。
布尔有三个值,不是两个:
| 值 | 含义 | 输出 |
|---|---|---|
TRUE |
已观测,为真 | 1 ⟨TRUE⟩ 0b1 |
FALSE |
已观测,为假 | 0 ⟨FALSE⟩ 0b0 |
MAYBE |
尚未观测 | MAYBE ⟨MAYBE⟩ |
MAYBE 不是 None,不是错误,不是随机数。
它是还没被观测的状态。类比(不进代码注释):
约人出来。
TRUE是约到了,FALSE是约不到,MAYBE是消息发出去还没回。 还没回不等于失败,也不等于成功——它就是还没被观测。
这个状态必须被语言保留,而不是用异常和空值去糊弄。
x「未知」 赋值给变量
$(未知) 直接输出
未知 是关键字,跟 self 同类——它是个值,不是语句、不是运算符。
为什么必须是关键字:如果它只是个内置变量名,用户一句
未知「5」 就能把"未观测"这个状态覆盖掉。用户要求"这个状态必须被语言保留",
而能被覆盖的名字保留不住任何东西。
未知「5」 炑报错:覆盖不掉
| 右: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
| 右: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
| 输入 | 输出 |
|---|---|
| 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 组)。
x「未知」
if x
$(1) ← 不执行
else
$(2) ← 也不执行
两个分支都不执行。 不报错、不随机、不跳过,只往账本记一笔。
这不是 bug,是三值逻辑的应有之义:未知时不该替用户选任何一边。
x「未知」
while x
$("循环体") ← 一次都不跑
$("循环已退出") ← 这一行会执行
不进循环体,记一笔账后退出。
为什么不"先跑一圈看看":循环体可能改变条件依赖的变量, 而"未知"的意思正是我们不知道它会不会变。替用户赌一把, 就是拿伪随机冒充未知。
如果两边都不跑、又什么都不说,用户只会看到一个不动的程序, 分不清"遇到未知"和"程序坏了"。
账本让未知可见,但不让它被处理。
每次静默观测,账本记录:
- 哪个变量是未知
- 哪一行代码碰到了它
- 这个未知有没有传染到后面的运算
输出格式:
炑:静默观测已触发(第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。
三个最容易踩的坑:
-
未知在输出分派里必须排在字符串分支前面。MAYBE底层是普通字符串, 不拦的话会掉进字符串分支、按长度(5 ≥ 1)判成TRUE—— 等于把"未知"印成"真"。 -
未知的算术拦截必须排在
left_is_text判断前面。 排后面的话未知 + 1会被当成"字符串和数字相加", 报出一条跟真实原因毫无关系的错误。 -
合并污染标记时不能拆开已有的
>路径。拆了会把"传递"降级成 "并列",三层以上的链全断。