15.2 同源不同宿主
15.1 跨语言摘要与 digest 已经让三处消费给出同一串摘要,但那是在「语言原语刚好一致」的前提下成立的。本节把前提本身摊开:《引擎实现与体验打磨》 那套在同一个宿主内打磨渲染与手感,换宿主是它不关心的事;本节恰恰只关心换宿主——同一份源码,Python、Node、浏览器三处的语言原语并不一样。把这些差异量清楚,才能说清「哪些量必须逐位一致、哪些只能按声明容差断言」。
宿主差异的三个战场
同一份逻辑,换宿主后会在三处咬人。三者性质完全不同,处理方式也不同:
| 战场 | Python | JavaScript | 后果 |
|---|---|---|---|
| 整数域 | int 任意精度 | number 是 float64,安全整数到 2^53-1 | 越过上界,JS 静默丢位 |
| 大数 | ** 本身就是大整数 | 必须显式用 BigInt | 幂与增长曲线一侧精确一侧丢位 |
| 字符串 | len 数码点,encode 数字节 | .length 数 UTF-16 码元 | 非 BMP 字符下两侧长度不同 |
三条里,只有第一条会静默出错——丢位不抛异常,结果看起来还挺合理。后两条是显式的:BigInt 要手写,字节长度要显式调 API。所以本节的重点放在第一条上。
整数域:2^53 这道墙
JS 的 number 是 IEEE 754 双精度浮点,能精确表示的整数只到 2^53-1(Number.MAX_SAFE_INTEGER)。越过它,相邻整数不再可分辨。实测:
- Python 的
2^53 + 1是精确的9007199254740993; - JS 的
2 ** 53 + 1求值后塌回2^53,(2**53 + 1) === 2**53为真。
这不是「四舍五入到接近」,是丢位——9007199254740993 变成了 9007199254740992,差 1。它不抛异常、不警告,只是安静地少了 1。
3 ** 40 更早越过这道墙:Python 是精确的 12157665459056928801,JS 的 3 ** 40 走 number,换算回来已经丢位。这正是纪律 ③ 补的那一句「幂运算 TS 侧用 BigInt」的由来。
哪些量必须逐位一致,哪些只能按容差
宿主差异摆清楚之后,「一致性」这句话要分两半说。判据只有一条:这个量参不参与胜负判定。
| 量 | 所在层 | 载体 | 断言口径 |
|---|---|---|---|
dealt / enemyHp / turn / energy | 机制层 | 整数(远小于 2^53) | 逐位一致,=== |
hand(卡牌 id 数组) | 机制层 | 字符串数组 | 逐位一致,元素顺序也一致 |
| 种子 / 卡牌计数 | 机制层 | 整数 | 逐位一致 |
幂与增长曲线(如 3^40) | 机制层 | Python 大整数 / TS BigInt | 精确,但不能进摘要 |
| 胜率 / 熵 ×1000 | 机制层派生 | 整数‰ | 按千分比容差(下一节) |
| 像素位移 / 动画时长 | 表现层 | 宿主浮点 | 声明容差内相等 |
机制层的量为什么能用 number 当载体?因为本规格的数都很小——最大是种子 20261014,离 2^53 还差九个数量级。只要机制层量落在安全整数域内,number 就是精确整数,两侧就能逐位一致。这不是巧合,是设计约束:机制层的预算与阈值一律取整数、且取小整数。
S 层:把三处差异各量一遍
脚本核对本规格的机制层量级是否落在安全整数域内,并把整数域、大数、字符串三处的差异各量一遍。
#!/usr/bin/env python3
# file: sim/gd_mech_15_host.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_15_host.py
"""15.2 同源不同宿主(S 层)。
同一份源码换宿主(Python / Node / 浏览器),语言原语的差异在三处咬人:
① 整数域:Python 的 int 任意精度;JS 的 number 是 float64,安全整数只到
2^53-1。越过它,Number 静默丢位:JS 里 (2**53+1)===2**53 为真。
② 大数:幂与增长曲线很快越过 2^53,TS 侧必须走 BigInt;而 BigInt 进不了
JSON.stringify(第 15.1 节反例三),所以机制层的「摘要」永远不能是大数。
③ 字符串:Python len 是码点数,JS .length 是 UTF-16 码元数,UTF-8 字节数又是
第三个数。中文在 BMP 内时前两者相等(「灰烬牌库」都是 4),表情符号不在
BMP 内时 JS .length=2 而码点只有 1。
本脚本把三处差异各量一遍,并核对本规格的机制层量全部落在安全整数域内——
这正是「机制层可要求逐字一致、表现层只能按容差」的宿主侧根据。
"""
from __future__ import annotations
import json
import os
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SAFE = 2 ** 53 - 1 # JS Number.MAX_SAFE_INTEGER
SAMPLES = ["灰烬牌库", "ashdeck", "🎴"]
def spec_numeric_max(spec: dict) -> tuple[int, str]:
"""规格里所有整数字段的最大绝对值(机制层量级)。"""
best, where = 0, "-"
for k, v in spec["params"].items():
if isinstance(v, int) and abs(v) > best:
best, where = abs(v), f"params.{k}"
for c in spec["cards"]:
for k in ("cost", "damage", "block", "count"):
if abs(c[k]) > best:
best, where = abs(c[k]), f"cards.{c['id']}.{k}"
if abs(spec["seed"]) > best:
best, where = abs(spec["seed"]), "seed"
return best, where
def main() -> int:
spec = ashlib.load_spec()
rows = [
["safe_integer_max", str(SAFE)],
["py_2p53_plus_1", str(2 ** 53 + 1)],
["float_2p53_plus_1", str(int(float(2 ** 53 + 1)))],
["float_loses_at_2p53", str(int(float(2 ** 53 + 1)) != 2 ** 53 + 1).lower()],
["py_3p40", str(3 ** 40)],
["float_3p40_lost_bits", str(int(float(3 ** 40)) != 3 ** 40).lower()],
]
for s in SAMPLES:
rows.append([f"codepoints({s})", str(len(s))])
rows.append([f"utf8_bytes({s})", str(len(s.encode("utf-8")))])
rows.append([f"utf16_units({s})", str(len(s.encode("utf-16-le")) // 2)])
csv_path = os.path.join(ROOT, "out", "mech15_host.csv")
ashlib.write_csv(csv_path, ["quantity", "value"], rows)
maxv, where = spec_numeric_max(spec)
in_safe = maxv <= SAFE
report = {
"spec": spec["id"],
"safeIntegerMax": SAFE,
"py2p53plus1": 2 ** 53 + 1,
"float2p53plus1": int(float(2 ** 53 + 1)),
"floatLosesAt2p53": int(float(2 ** 53 + 1)) != 2 ** 53 + 1,
"py3p40": 3 ** 40,
"float3p40LostBits": int(float(3 ** 40)) != 3 ** 40,
"encoding": [
{"s": s, "codepoints": len(s), "utf8Bytes": len(s.encode("utf-8")),
"utf16Units": len(s.encode("utf-16-le")) // 2}
for s in SAMPLES
],
"specNumericMax": maxv,
"specNumericWhere": where,
"specWithinSafeInteger": in_safe,
}
out = os.path.join(ROOT, "out", "gd_mech_15_host.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
lost = int(float(2 ** 53 + 1)) != 2 ** 53 + 1
print(f"safe_integer_max={SAFE} py_2p53_plus_1={2 ** 53 + 1} "
f"float_2p53_plus_1={int(float(2 ** 53 + 1))} float_loses={lost}")
print(f"py_3p40={3 ** 40} float_3p40_lost_bits={int(float(3 ** 40)) != 3 ** 40}")
for s in SAMPLES:
print(f"encoding {s} codepoints={len(s)} utf8_bytes={len(s.encode('utf-8'))} "
f"utf16_units={len(s.encode('utf-16-le')) // 2}")
print(f"spec_numeric_max={maxv} where={where} within_safe_integer={in_safe}")
print(f"wrote out/gd_mech_15_host.json wrote out/mech15_host.csv rows={len(rows)}")
return 0 if in_safe else 1
if __name__ == "__main__":
raise SystemExit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_15_host.py:
safe_integer_max=9007199254740991 py_2p53_plus_1=9007199254740993 float_2p53_plus_1=9007199254740992 float_loses=True
py_3p40=12157665459056928801 float_3p40_lost_bits=True
encoding 灰烬牌库 codepoints=4 utf8_bytes=12 utf16_units=4
encoding ashdeck codepoints=7 utf8_bytes=7 utf16_units=7
encoding 🎴 codepoints=1 utf8_bytes=4 utf16_units=2
spec_numeric_max=20261014 where=seed within_safe_integer=True
wrote out/gd_mech_15_host.json wrote out/mech15_host.csv rows=15
读出来:float_2p53_plus_1=9007199254740992 比精确值少 1,float_loses=True;within_safe_integer=True 说明本规格最大的机制层量(种子 20261014)离 2^53-1 还有极大余量,number 在这里就是精确整数。
字符串那三行更直观:「灰烬牌库」码点 4、UTF-8 12 字节、UTF-16 码元 4——同一个字符串三个长度。中文落在 BMP 内,所以码点与 UTF-16 码元相等;表情符号 🎴 不在 BMP 内,码点 1 而 UTF-16 码元 2。任何把字符串长度写进摘要或哈希的做法,都必须先声明用哪一个。
P 层:把三条边界钉成断言
TS 侧把三处差异各写成可执行断言,与 Python 侧实跑输出逐项对应(完整用例见 core/test/gd_mech_15_host.test.ts):
// snippet: core/test/gd_mech_15_host.test.ts 的核心断言(完整版见工作区)
test("整数域:number 的安全上界是 2^53-1,越过就丢位", () => {
assert.equal(Number.MAX_SAFE_INTEGER, 2 ** 53 - 1);
assert.equal(2 ** 53 + 1 === 2 ** 53, true); // 求值后塌回 2^53
assert.equal(2 ** 53 + 1, 9007199254740992);
assert.equal(Number.isSafeInteger(2 ** 53 - 1), true); // 上界本身安全
assert.equal(Number.isSafeInteger(2 ** 53), false); // 越界,不再安全
});
test("大数:BigInt 精确,换算回 number 就丢 1", () => {
assert.equal(2n ** 53n + 1n, 9007199254740993n);
assert.equal(BigInt(2 ** 53 + 1) !== 2n ** 53n + 1n, true);
assert.equal(2n ** 53n + 1n - BigInt(2 ** 53 + 1), 1n); // 丢位的具体数值
assert.notEqual(BigInt(3 ** 40), 3n ** 40n);
});
test("字符串:码点数 / UTF-16 码元数 / UTF-8 字节数是三个数", () => {
assert.equal("灰烬牌库".length, 4);
assert.equal(Buffer.byteLength("灰烬牌库", "utf8"), 12);
assert.equal("🎴".length, 2); // 代理对
assert.equal([..."🎴"].length, 1); // 码点
assert.equal(Buffer.byteLength("🎴", "utf8"), 4);
});
实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_15_host.test.ts':
✔ 整数域:number 的安全上界是 2^53-1,越过就丢位 (0.870667ms)
✔ 大数:BigInt 精确,换算回 number 就丢 1 (0.131625ms)
✔ 字符串:码点数 / UTF-16 码元数 / UTF-8 字节数是三个数 (0.074083ms)
✔ 机制层量全部落在安全整数域内(这是 number 能当机制层载体的根据) (0.0965ms)
✔ 摘要的值域落在安全整数内(大数被挡在机制层之外) (0.263125ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
判读:三个战场各自的处理
- 整数域:
Number.isSafeInteger(2**53 - 1)为真、2**53为假——上界就是分水岭。本规格的量全部在这条线以下,所以number可用。 - 大数:
2n**53n + 1n精确,BigInt(2**53 + 1)少 1。幂与增长曲线必须走BigInt,但不能进摘要(15.1 反例三:JSON.stringify遇BigInt抛异常)。 - 字符串:
"🎴".length是 2、[...'🎴'].length是 1。字符串长度不是「一个数」,是三个数——写进摘要前必须先声明用哪一个。
| 检查项 | 合格标准 |
|---|---|
| 整数域 | 机制层量全部 Number.isSafeInteger |
| 大数 | 越过 2^53 的幂与增长量走 BigInt,且不进摘要 |
| 字符串 | 涉及长度处显式声明码点 / 码元 / 字节口径 |
| 容差 | 机制层逐位一致,表现层按声明容差(下一节给出可执行判据) |
宿主差异量清楚之后,最后一步是把它变成一个可执行的容差判据:什么量容差为 0、什么量容差是 1‰、什么量只能按浮点容差断言。下一节收口。
阅读导航:上一节:15.1 跨语言摘要与 digest · 下一节:15.3 偏差与容差 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。