执行语义与编译实现
行一:求值状态
运行环境 ρ 把局部变量映射到值,存储 σ 把可变位置映射到值。普通求值写作 ⟨e,ρ,σ⟩ ⇓ ⟨v,σ′⟩,另有异常转移、显式退出和不终止。静态类型检查不执行一般用户函数。
常量直接得到值;名字取其词法绑定或模块已初始化全局。函数形成得到“代码+词法捕获环境”,不执行函数体。普通函数参数按值传递;引用值复制位置身份,不复制其内容。
调用先求函数,再求首个运行时实参并施于函数,得到中间结果;随后求下一个实参并施于中间函数,依此进行。类型检查已保证源码参数给足,运行时仍按逐参应用执行;不可把所有实参提前求完再执行中间函数体。只有中间施用都只是新建闭包、没有可观察效果时(被调的是已知顶层定义,其签名元数内的参数都是连续拉姆达),实现才可先按次序求完这些实参再一次调用直接入口,见编三。隐式类型参数在静态阶段完成,运行时不执行、也不占槽位。标准库「且」「或」等普通函数照常先求全部实参,不短路;普通布尔函数调用不能自动得到特殊条件求值规则。要短路就用关键词 且、或:甲 且 乙 先求甲,甲为阴即得阴、不求乙;甲 或 乙 先求甲,甲为阳即得阳、不求乙(提案 00022)。
局部绑定先求定义,再把结果加入后式环境;顺序式先求前式,保留其存储变化、丢弃前值,再求后式。元组按项次序求值、一次存储各项。条件先求爻,只求所选分支。模式分析对象只求一次;测试模式不执行任意用户函数;仅选中分支求值。字符串拼接语法糖先左后右,即使它在语法上右结合,也不可反转副作用顺序。
普通源文件(名。豫)的顶层只有定义,没有计算:每个顶层定义的值都是数据——函数、字面、别名、构造器作用于数据(含列与元组)、以数据为初值的「置匣」「置匣列」(静态格,每个程序实例一份),以及把数据绑定到局部名的 令。算术、字符串拼接与任何函数调用都不能放在顶层,由类型检查报错;值要在运行时算的,写成函数 得「无」而 …,或用标准库的「一算」在首次取值时算一次。编译器把顶层数据写成全局段里的常量初值,程序一开始全局就已就绪;超过一万字节的字符串常量受引擎常量数组上限所限,在启动时从数据段拷入字节。
测试(名。测试。豫)、脚本(名。脚本。豫)、设计源(名。设计。豫,提案 00018)与黄金样例(名其一。豫,编号逐位写成汉字)的顶层就是程序体:顶层语句按次序执行,被导入的测试与设计源的程序体先于导入者执行。设计源可以被任何源文件导入。
可执行程序从入口函数开始:入口文件(不是测试、脚本、黄金样例)定义「入口」,类型 化「有」为「有」,包描述可以用「入口函数」另指一个名字。程序启动时依次执行测试与脚本的程序体,最后调用入口函数一次。
行二:递归和状态
局部/顶层递归函数的通用实现:分配单槽递归单元,初值单位;把定义中的自引用改为读该槽;求定义得到闭包;写回递归单元;返回闭包。闭包捕获的是递归单元,不能在初始化前把单位拷贝为最终自身。其他捕获值按词法环境取得;捕获引用时保留引用身份。
若一般递归值在填槽前读取自身,会观察到未初始化占位或触发后续错误;不能赋予惰性无限数据结构语义。源语言没有一般递归终止检查,也不能承诺每个通过类型检查的表达式终止。
引用操作分别分配、读、写位置。写返回单位;别名观察到同一次写。GC 搬迁后引用身份保持,原始机器地址可变,所以不以裸地址作为跨安全点稳定身份。数组边界与不安全原语不由类型系统自动证明。
行三:内建函数与异常展开
以下完整名字写在 「」 中。∀a 对应 凡,箭头与元组均使用类型篇定义。
| 内建函数完整名字 | 类型 |
|---|---|
《《内建函数:整数:相等》》 |
整数→整数→爻 |
《《内建函数:整数:大于》》 |
整数→整数→爻 |
《《内建函数:整数:减》》 |
整数→整数→整数 |
《《内建函数:抛出异常字符串》》 |
∀a. 字符串→a |
《《内建函数:尝试运行字符串》》 |
∀a. ((有→a) 合 (字符串→a))→a |
《《内建函数:以当前续延调用》》 |
∀a. ((∀b. a→b)→a)→a |
注意原始“尝试运行”接收一个二元组,标准库可再提供二参数包装。有 是单位类型,元 是其值;不是可容纳任意对象的顶类型。
字符串异常用 Wasm 原生异常实现。抛出字符串就是抛出携带该字符串的异常(WasmGC 的 throw $error),它沿调用栈向外传,由最近一个仍在进行的尝试运行接住。尝试运行 (操作,处理):
- 在捕获范围内调用
操作(元);正常完成时,其结果就是尝试运行的结果,处理不被调用。 - 操作执行期间(含它调用的函数)抛出的字符串异常在这里被接住;抛出点之后的语句不再执行。
- 接住后先离开捕获范围,再调用
处理(s),其结果就是尝试运行的结果;处理自己再抛的异常由外层处理。 - 正常路径不分配对象、不写全局状态。
一路没被接住的字符串异常到达程序入口,交给顶层未捕获处理器。处理器的初值是标准库的「常处祸」:把“未捕捉的豫言异常:”加消息写到标准错误,并以退出码 1 结束进程。程序没链入「祸福」时,编译器生成的程序入口照样把“未捕捉的豫言异常:”加消息写到标准错误,并以退出码 1 结束进程。
以当前续延调用把“提前返回”包装为可传函数;调用它时向捕获的动态返回点送值。每次进入以当前续延调用都新建一个动态标识,提前返回按标识找到自己的返回点,途经的尝试运行不拦截它,进入次数不受限制。WasmGC 用异常标签和动态标识实现,不提供可任意保存并多次恢复已销毁调用栈的通用多次续延契约。
模式缺支抛出与抛出字符串相同的可捕获异常,消息固定为“模式匹配错误,缺少分支:鉴表达式”,以保证各次编译的诊断常量一致。系统原语的退出与中止不经过语言的异常处理。
编一:独立实现的阶段与不变量
| 阶段 | 输入→输出 | 必须成立的不变量 |
|---|---|---|
| 词法/语法 | 字节→令牌→表达式及声明 | 定界符正确;候选解析唯一;糖和绑定范围明确 |
| 名称解析 | 声明→文件接口引用与唯一局部引用 | 模块投影可静态消除 |
| 检查 | 名称树→已检查类型与对象 | 隐参已定;不残留未解类型;错误不得延到运行时 |
| 模式展开 | 模式→单次分析、标签测试及字段投影 | 分支结果及失败轨迹不变 |
| 类型擦除 | 已检查对象→无类型对象 | 去标注、类型值、隐式参数及抽象;源显参顺序不变 |
| 局部规范化 | 无类型对象→显式参数组 | 不改变饱和组与函数值的入口协议 |
| 递归转换 | 递归→可变单槽单元 | 填槽前闭包捕获单元,允许环 |
| 闭包转换 | 局部函数→代码与捕获元组 | 除明确直接函数外,调用都传闭包自身 |
| 全局存储/提升 | 定义→全局槽和提升代码 | 公开名与定序稳定,静态根及时登记 |
| ANF | 复杂表达式→命名中间值 | 函数先于参数;实参逐个求值;控制支不提前执行 |
| 代码生成 | 显式存储/控制→WasmGC | 遵守目标 ABI;无未解析模块或自由变量 |
内部可以合并阶段。类型检查结果应能交给一个简单不优化后端;优化器不是使非法/不完整树勉强可执行的必要条件。跨模块优化可跳过,但旧模块公开直接函数已被他方引用时必须提供相同入口或桥接包装。
编二:可直接实现的 ANF
定义 正规化(e,继续):若 e 为直接值则交给继续;否则递归正规化其子表达式,把结果绑定为新鲜临时值,再调用继续。先将表层显参脊左折为一参数应用树,再逐节点执行 正规化(函数, λf.正规化(实参, λa.令r=调用(f,a);继续(r)))。只对已经明确分组的内部 IR 才一次传其全部参数。条件将继续接至合并点,分别生成两支;不能先计算两个分支再选择值。
尾位置包括函数体末尾、条件两支末尾、令x=调用;返回x,不能因 ANF 增加临时绑定而丢失尾位置。WasmGC 用 return_call_indirect 保证尾调用不增长调用栈。
编三:闭包与直接函数桥接
对 λx1…xn.e,按固定次序枚举自由值 v1…vk,分配 [代码,v1,…,vk]。对内部多参数节点,新代码接收 self,x1,…,xn,将 vi 引用改为 self[i](i 从 1 起)。间接调用为:求一次闭包 c;读 c[0];以 [c,实参…] 调代码。捕获字段排列是该闭包私有协议,不能跨闭包类型推断。
普通源码的嵌套 λ 保留逐参闭包链;不能将它擅自合并后用同一全局值替换旧闭包。跨模块直接入口按明确导出的内部参数组接收实参,不含 self。需要提供可传函数值时,创建包装闭包:包装代码丢弃 self,以原实参调用直接入口。反向桥接则直接入口取保存的闭包值,插入 self 调用它。以此保持“值全局”和“公开直接函数”两种符号都可用。
现行实现:顶层定义按类型检查过的签名取元数 N(右嵌套箭头展平后的显式参数个数,不计隐式参数);定义开头至少有 N 层一元拉姆达时生成 N 参数的直接入口(工函数),全局槽里存逐参包装,包装满参时调用直接入口。直接入口的名字、定义序数与元数写进阶段产物“顶层元数”,他模块据此对满参调用直接调用,余下实参按逐参应用;拉姆达层数不足 N 的定义不生成直接入口,照旧经全局槽。详见 库/译场/文档/函数应用形状分析.汉语.md。
优化可能移除未使用值、合并包装或直接调用已知代码。跨模块测试应禁用相应跨模块假设或显式保留待测试入口,不得把已经被内联的函数当作跨模块接口测试成功。
编四:数值和平台指令
整数是 64 位补码。内建减法按 64 位环绕,比较为 64 位有符号比较,结果为爻。由运行时实现的加减乘除在溢出、除零和最小整数除负一时没有统一稳定承诺。必须分清内建路径与运行时路径,不把两者统一为未验证的“全环绕整数”。
小数是 binary64,常量按位模式精确生成。普通打印与中间产物的精确表示(17 位有效数字)不同,不得通过普通打印再解析生成浮点常量,否则会丢失精度。字符串常量按实际 UTF-8 字节生成;源码长度、码点长度与运行时字节长度分别处理。
现行实现(WasmGC 后端):标准库的整数加、减、乘、除以及小数加、减、乘、除、整数转小数、小数转整数都由编译器就地生成 Wasm 指令。整数加减乘按 64 位环绕;整数除以向零截断,除数为零或最小整数除以负一时陷阱。小数四则按 IEEE binary64 就近取偶,非数的符号与载荷不作承诺;整数转小数就近取偶;小数转整数向零截断,遇非数、无穷或截断后超出 64 位整数范围时陷阱。陷阱即程序终止,尝试运行接不住,退出码由宿主决定。旧编译器编出的产物仍经宿主计算这些原语,越界时结果可能不同(如诺驿宿主的小数转整数按 2^64 环绕)。
第二批数值原语(名单见网页汇编接口)也就地生成。整数取余是截断余数,符号随被除数,除数为零时陷阱,最小整数余负一得零;无符号除与无符号余把两数都当作 u64,除数为零时陷阱;左移、右移、无符号右移、循环移位的位数取模 64,右移补符号位;前导零数、末尾零数、置位数得零至六十四;小等、大等按有符号比较,无符号小于、大于按 u64 比较。小数比较按 IEEE,遇非数时只有“不等”为真,负零等于正零;最小、最大传播非数,负零小于正零;绝对值、取负、复号只改符号位;向上取整、向下取整、截断、就近取整(取偶)的结果仍是小数;开方正确舍入。小数饱和转整数向零截断,非数得零,越界取 64 位整数的最大或最小值;无符号整数转小数把整数当作 u64,就近取偶;小数位、位转小数按位重解释,不改任何位。
标准库的理解整数(原语字符串转整数)也在客体内完成:跳过开头的空白(空格与制表、换行、纵向制表、换页、回车),认一个可选的正负号与其后的十进制数字,遇到别的字符就停,不检查尾部;超出 64 位时按 2^64 回绕,没有数字时得零。
编五:中间产物和外部可见性
独立编译器不必复现 JSON 缓存格式才具有 ABI 兼容性。如果要消费原编译器的阶段文件,则须另遵守 ["豫言中间单元一", 阶段名, 有序定义数组],每定义 [名,公开性,表达式],并按对应阶段解释绑定索引、节点形状、文件身份及版本。阶段文本只是数据,不应执行其中任意函数。
模块互操作所依赖的是 Wasm 模块的导入、导出与类型身份。语言服务器协议、源码排版、包管理网站、缓存命中方式、调试注释及本地临时名不在 ABI 验收内;不得以这些不相同否定已通过的模块调用兼容,也不得以它们相同代替 ABI 验证。
编六:脚本运行
豫言程序有两种运行方式(提案 00003)。
- 编译执行:整个程序先过前端,无错才生成并运行;运行时的次序按行一。
- 脚本运行:各模块编成装载单元(一个模块的豫汇模块与机器码,见《文件后缀》与《豫行格式》“装载单元”),装入正在运行的豫构进程,链接后执行。被导入的模块以单元装入,装入时只写入顶层数据:普通模块的顶层只有定义(提案 00006),顶层数据由单元的
_start写入,各单元按装入次序运行_start,后装的单元读得到先装单元的顶层数据;「一算」的值在第一次取用时计算,与编译执行相同。带入口函数的普通源文件装入后再调用入口函数一次。 豫构 运行 路径 参数…把整份脚本连同依赖的模块编成单元再装入运行,前端错误在运行前报出,与编译执行相同。豫构 交互逐组处理:一组是一句,或「乃」加随后的同名定义。每组过前端、编成组单元、装入执行,一组的效果在下一组开始前已经发生。出错的组(编译出错,或运行时有未接住的异常)不记,它的定义后面看不到,先前的定义照旧可用。最后一句是裸式时按类型印值。- 两者的关系:接受与拒绝的程序相同,求值轨迹与输出相同(《总纲》总〇);差别在出错的时机(交互执行到那一组才报)、优化的范围(内联与整程序分析只在单元内部做)与速度。
- 同名再定义:沿用编译器的规则,后定义遮住前定义,已经编好的引用命中较早的定义(《类型与模块》);要随执行改变的量用「置匣」「写匣」。
- 首行
#!:源文件首行以#!起头时,两种运行方式都把这一行当作空白(《词法与语法》)。 - 链接:单元导入别的模块的函数与全局,字段名是与位置无关、全进程唯一的名字;各单元共用一张零号表与异常标签,闭包、对象与异常在单元之间直接通用。原生宿主把单元的类型按结构规范化后并入主程序的实例,机器码按重定位装入(执行器一侧见客体二号说明“装载单元”);诺驿宿主按网页汇编的导入导出逐个实例化单元。
- 后续版本:
豫构 运行改在豫构进程里编译(现在用编译器子进程做前端);交互里同一类型的构造器分在几组定义。