状态:normative(权威)。适用版本:0.90.0(
selfhost/src/version.dawn的VERSION)。 实现与本文冲突时,以本文为准并把实现当 bug——除非本文某条被显式标注为「已被 X 取代」。标题曾长期写「v0.1 草案」,改掉它那天工具链已经到 0.11:一份自称草案的文档没法充当裁判, 而这是仓库里唯一有资格裁判语义争议的文档。版本号跟
VERSION走,不再单独编号。
本文是语法与语义的权威定义。设计动机见 design.md。
仓库不另设形式化语法文件:文法有争议时以本文与 selfhost/src/front/parser.dawn 为准,
可执行的那份期望在 scripts/grammar-corpus/。
规范用词:必须(违反即编译错误)、保证(实现承诺的行为)、 未定义(本规范不承诺,勿依赖)。
1.源文件与词法#
1.1源文件#
- 编码 UTF-8,扩展名
.dawn。 - 一个文件即一个模块(见 §10)。
1.2注释#
# 行注释,到行尾
## 文档注释,附着于紧随其后的声明(工具链提取)
没有块注释。 注释只有 # 开头、到行尾为止的这一种;要注释掉一段代码,每行各加 #。
/* 是词法错误(Dawn has no block comments; use `#` on each line),报在 /* 处,
其后到 */(没有则到文件尾)的文本整段跳过,不再连带报别的错。
理由:Dawn 的行结构有语义(§1.7 换行分隔语句、行尾 token 决定是否续行),插值也规定单行(§1.6);
没有块注释,任何一行都能脱离上文独立切词,「这一行最后一个 token 是什么」不依赖前文,
dawn fmt 也不必为跨行注释定缩进规则。Gleam、Zig 同样只有行注释。
文档注释。 以 ## 开头的整行注释是文档注释(### 也是,第三个 # 属于正文);
只以一个 # 开头的注释对工具链没有意义。
- 整行:文档注释所在的行除缩进(空格、制表符)外只有这条注释。写在代码后面的
##(行尾注释) 不是文档注释,这一行对附着规则而言是代码行。 - 正文:去掉
##与其后至多一个空格,余下即该行正文;更多的前导空格保留(用于缩进代码与列表)。 只有##的行是空正文行。连续若干行的正文以换行连接。工具链不解析正文,原样交给消费者; 站点与编辑器把它当 Markdown 渲染。 - 附着到声明:声明首行正上方、连续的文档注释行组成该声明的文档。空行、普通
#行或代码行 (含带行尾注释的代码行)都会断开;断开之上的##不属于这个声明。 - 可附着的声明:顶层
fn、type、const、trait、effect;trait 的关联类型、关联效果与方法; effect 的操作;类型的构造器与字段(记录字段与构造器的具名字段);impl 内的方法。成员与顶层声明同一条 行规则,读成员自己那一行之上;但构造器或字段与其所属声明(type头、构造器头)写在同一行时没有自己的 文档:那一行之上的块写的是所属声明,不是成员,dawn doc对它给null。编辑器 hover 在成员没有自己的 文档时可以改显示所属声明的文档,那是显示层的选择,不是成员的文档。 impl 头、局部声明(let、局部 fn、lambda)与test块不接受文档,写在它们上面的##只是注释,不报错。 - 模块文档:文件第 1 行起连续的文档注释,若其后的那一行不是某个顶层声明(
fn、type、const、trait、effect或impl)的首行,则它是模块的文档;否则它是那个声明的文档(impl头不发布文档, 但紧接impl的块同样不算模块文档)。其后是空行、普通#行、use行或test块时都算模块文档。 要给第一个声明写文档又给模块写文档,用空行把两块隔开。第 1 行不是文档注释(例如维护者的#头注释) 的文件没有模块文档。 - 发布范围:
dawn doc发布模块文档与pub声明及其成员的文档:fn、type(含构造器与字段)、const、trait(含关联类型、关联效果与方法)、effect(含操作)。impl 不发布文档:dawn doc的 impl 列表只记Trait[Type],impl 头与 impl 方法的文档只由编辑器 hover 显示;pub(pkg)与私有声明的 文档只供编辑器使用(§10.4)。 链接:正文里的
[`name`]是对声明的引用。只认这一种写法:普通的代码 span 不是链接; 后面紧跟(或[的是普通 Markdown 链接;围栏代码块里的不算。name在文档所在模块的 作用域解析,即该模块的代码能怎样写出这个名字:- 本模块的顶层声明(私有的也算),裸写,或以模块路径的末段限定(写在 std/io 里的
io.exit); use绑定的模块别名加点:list.map,只看得到那个模块导出给本模块的名字;别名本身(list)指模块;- 选择性导入的名字,
as改名的按本地名; - prelude:builtin 函数、prelude trait 及其方法、
Option/Result/ForeignError及其构造器、builtin 类型; - 成员:
Type.Ctor、Type.field、Trait.method、Trait.Item、Effect.op,Owner按上面几条解析, 也可以写成module.Owner.member;构造器、effect 操作与 trait 方法也能像代码里一样裸写。
首字母大小写决定先找类型还是先找值(记录类型与它同名的构造器,指类型),没有消歧前缀。没有导入的模块 链接不到,而只为文档写的
use是未使用的导入(编译错误),所以链接看得到的就是代码看得到的。dawn doc解析它发布的每段文档里的链接:解析不到时逐条报模块:行与原因,不输出 JSON,以非零状态 退出;解析到的写在那段文档旁的links里(目标的模块路径、名字与种类)。编译(check、run、build) 不读链接。- 本模块的顶层声明(私有的也算),裸写,或以模块路径的末段限定(写在 std/io 里的
- 示例:声明的可运行示例不写在文档注释里,写成以该声明命名的测试(
test f { ... },§3.4);dawn doc把pub声明的示例发布在它的文档旁(examples)。正文里的```dawn围栏只是插图, 不编译也不运行。
理由:文档注释仍是注释。一条注释是不是文档只看它自己那一行(开头是不是 ##、前面有没有代码),
与上文一样每一行都能独立切词,dawn fmt 也不必为它另立规则;附着靠「正上方连续」这一条行规则判定,
不需要语法树里多一个节点。行尾 ## 若也算,它会被下一行的声明认领,说明的却是本行的代码。
Rust、Zig、Gleam 的 /// 都是同一个形状。链接的记号取 Rust 与 KDoc 的 [`name`]:在 CommonMark 里它是未定义的引用,按字面渲染,
不认识它的渲染器照样能读。不把所有反引号里的名字自动当链接:仓里写在反引号里的大多是代码片段,
哪些算链接会变得无法预测。解析放在工具层而不是编译器:注释不影响语义,dawn run 不该因注释里的
错字拒绝编译,也不该为读注释付费。
1.3标识符与命名约定#
强制的部分——首字符决定语法类别,parser 靠它消歧(模式匹配中 x 是绑定、
X 是构造器;TYPEIDENT 是独立 token):
- 值、函数、模块名:首字符是小写字母或
_ - 类型与构造器:首字符是大写字母
- 效果变量:
!后接一个值标识符(io为保留效果名)
风格约定——工具链不强制,但全仓遵守:值用 lower_snake_case
([a-z][a-z0-9_]*),类型用 UpperCamelCase([A-Z][A-Za-z0-9]*)。
关于非 ASCII:「大写 / 小写」按 Java 的
Character.isUpperCase判定,后续字符按Character.isLetterOrDigit或_。于是fn 中文()、fn _hidden()都能编译——汉字既非大写也非小写,走「不是大写」这一支,即值标识符。这是实现定义的,不是设计过的:Unicode 的 XID_Start/XID_Continue、 normalization(
é的两种写法算不算同一个名字)、同形字符,本规范全部未定义, 依赖它们的代码不可移植。无大小写文字(汉字、阿拉伯文、假名)因此只能作值名, 作类型名没有自然写法。收敛到明确定义的 Unicode 标识符语法是一项未决工作 (docs/codebase-audit.md 的 SYN-01)。
1.4关键字#
fn let var type alias const use pub
match if else for while
return break continue
comptime
trait impl effect
true false not
关键字不可用作标识符。panic、todo 是预置函数而非关键字。上下文关键字另有十二个,
它们只在各自唯一的语法位置有特殊含义,在别处仍是普通标识符(let in = 1、字段名 test 都合法):
| 词 | 唯一的语法位置 |
|---|---|
java | use java "a.B":use 之后且后面跟类名字符串(§9.1) |
test | 顶层声明首:test "名字" { … }(§3.4) |
assert | 语句首且后面跟一个表达式(§3.4);assert = 1、assert.x 里是名字 |
with | 语句首且后面跟一个名字或 <-(§4.10) |
in | for 头里 pattern 之后(§4.7) |
derive | type 声明尾部(§3.5) |
as | use 的重命名位(§10.2) |
handle | with 之后且下一个 token 不是 <-(§6.5) |
opaque | 紧接 type,引入不透明类型(§2.7) |
ctl | 紧接 effect,声明可挂起的效果(§6.5) |
resume | handler 臂的参数表之后:op(a) resume k => …(§6.5) |
pkg | 只在 pub( 之后:pub(pkg)(§10.4) |
语句首的判据取「作为普通名字它后面不可能合法地跟什么」:名字后面紧跟一个表达式或另一个名字
从来不是合法程序,所以把这种串分给关键字不夺走任何合法写法。唯一的代价是:名叫 assert 的
函数不能在语句首写成 assert(x) 调用(那是断言)。
上面两份清单由机器对账:硬关键字即 selfhost/src/front/token.dawn 的 keyword 表,上下文关键字即
selfhost/src/front/parser.dawn 里 is_word(…, "<词>") 的判定点;scripts/doc-check.py 与编辑器语法的
契约测试(editors/vscode/test/scope-contract.js)都从这两处读,本节或语法文件漏一个就红。
符号 token 取最长匹配(同 >>>/>=/->/|>)。with 语句的绑定箭头
<-(§4.10)也照这条:a<-b 读作 a <- b,不是 a < (-b)。写了空格的
a < -b 不受影响,且 dawn fmt 给每个二元运算符两侧加空格(§1.8),
所以格式化过的源码到不了第二种读法。
1.5字面量#
| 形式 | 类型 | 说明 |
|---|---|---|
42, 1_000_000, 0xFF, 0b1010 | 无期望时 Int | 64 位有符号;下划线可作分隔;随期望定型(见下) |
3.14, 1.0e-9 | 无期望时 Float | IEEE 754 double;随期望定型(见下) |
true / false | Bool | |
"hello" | String | 见 §1.6 |
() | Unit | 唯一值 |
[1, 2, 3] | List[Int] | 允许尾逗号;分隔符与元素形式见 §4.11 |
(1, "a") | (Int, String) | 元组,2 至 8 元 |
'a', '\n', '世', '\u{1F600}' | Char | 字符字面量(见下) |
整数 token 的非负 magnitude 通常必须在 0..9223372036854775807。唯一例外是精确的
2^63:它只可直接作一元 - 的操作数,所以十进制 -9223372036854775808、十六进制
-0x8000000000000000 和对应的 64 位二进制写法都表示 Int 最小值;表达式与字面量 pattern
同此规则。括号包住整个负值仍合法((-9223372036854775808)),但把 magnitude 与负号隔开
不合法(-(9223372036854775808)),裸 2^63 也不合法。大于 2^63 报范围错误;某进制
不允许的数字(如 0b2)报非法字面量,不与范围错误混为一类。
数字字面量没有自己的类型,由期望类型定型(2026-10-06 起,
literal-system-design.md)。一个字面量原子是整数字面量、浮点
字面量,或直接跟在一元 - 后的二者之一;检查它时若有期望类型 T:
T是Float:整数字面量就是同值的Float,不能精确表示(|n| > 2^53且没有恰好等于它的 double)是编译错误。-0先在整数上取负,得+0.0;负零写-0.0。T是在此处看不穿的类型,且有FromInt[T](整数字面量)或FromFloat[T](浮点字面量)的 impl:字面量就是该 impl 的from_int/from_float作用于字面量值的结果(§3.5)。浮点字面量先 舍入到Float再交给from_float,所以与bf16(x)一类的显式转换逐位相同;十七位以上有效数字、 恰落在窄格式中点上的字面量因此可能与「十进制直接舍入」差一个 ulp。整数字面量不经FromFloat, 浮点字面量也永远不进整数类型。- 声明模块里看得穿的不透明类型按其目标定型(§2.7),字面量在那里与从前完全相同。
- 其余情况,包括没有期望类型:整数字面量是
Int、浮点字面量是Float,与期望的不符照常报错。
impl 纯时,编译器在编译期求出字面量的值(与 comptime 同一个解释器,§7),impl 的 panic 是指向
该字面量的编译错误:U8 的范围写在它的 FromInt impl 里,所以 let b: U8 = 300 不编译,而编译器
并不知道 255。impl 带效果时,字面量是一次运行期调用,效果计入外层签名(§6);它不能出现在 const
与 comptime 里。字面量的定型从不反过来影响别的表达式的类型:没有推断变量,没有缺省规则。
期望怎样到达字面量:二元运算见 §4.3(向有类型的一侧让步);泛型调用的实参里,形参还含未定类型参数的
字面量等到其余实参定型之后、需要期望才能定型的实参之前再检查,同一个类型参数上浮点字面量先于整数
字面量(g(1, 2.5) 中 T 是 Float,g(s, 1) 中 1 以 s 的类型定型)。
模式里的字面量不在此列,照旧按 §5.1。
字符是自己的类型 Char:一个 Unicode 标量值(0..0x10FFFF,不含 D800..DFFF
的代理半区)。它是 Int 上的 opaque type(§2.7),owner 是 std/char——表示就是
码点,故零开销,且 ==、<、哈希、match 里的字面量模式全部沿用 Int 的那一份。
渲染分两层(§4.3),std/char 两层各写了一份:impl Display[Char] 是顶层,
to_string(c) 与 "${c}" 出的是那个字符;impl Show[Char] 是嵌套那层,嵌在结构里的
Char([c]、记录字段、元组)渲染成源码字面量 'a',转义集合就是上面那一套,
一如嵌套的 String 渲染成 "a"。要一个字符的字符串也可以直接问 str.from_char(c),
那是顶层那个字符串,只是不依赖 impl。但它不是
Int:'a' + 1 不成立,两者互转经 std/char——char.code(c) -> Int 拿码点、
char.of(n) -> Option[Char] 从码点造字符(不是标量值就 None)。
单引号内是单个码点:转义与字符串相同(\n \t \r \\ \u{...})另加 \';
空字面量、含多个码点、或 \u{...} 不是标量值 → 词法错误。
按码点处理字符串的函数见 §11(code_points -> List[Char]/from_code_points/
len/str.slice/str.at)。
注意:运行期存储是 java.lang.String(UTF-16 码元下标),故「按码点下标随机访问」需要
O(n) 换算——实测与设计取舍见 `seq6-research.md` 附录。逐字符遍历请用
§11 的游标(std/cursor),它每步恒定开销;下标版留给单次调用。
cursor.char是例外,回Int不回Char:它到尾答-1(§4.8 具名例外), 而一个不是码点的哨兵住不进「每个值都是码点」的类型里。Char 面是str.at、str.chars、code_points;cursor.char是它们下面的原语。沿革:
'a'在 v0.56.0 及以前是Int(Go 的 rune 路线)。类型名Char在 v0.54.0、std/char模块在 v0.56.0 各提前一版落地成Int的透明拼法,只为满足种子 纪律(docs/bootstrap.md):编译翻转那一版的编译器必须已经认识这个名字和那个模块。 翻转在 v0.57.0 一次做完。分期、实测与被否掉的几条出路见 `audit/nominal-types-design.md` §7。
1.6字符串与插值#
双引号字符串,支持转义 \n \t \r \\ \" \$ 与 Unicode \u{1F600}。
花括号 { } 是普通字符,无需转义——写 JSON、CSS、代码生成很方便。
插值只有一种写法:${expr} 插入任意表达式(一个名字也写 ${name});被插值的类型必须有 Show 见证——预置标量、写了 impl Show 或
derive Show 的用户类型、以及元素可渲染的容器与元组(见 §4.3):
let n = 3
println("got ${n} items, first = ${list.get(0)}")
$ 后紧跟可以开始名字的字符(字母或 _)是词法错误:诊断给出两种改法,
${name} 插值或 \$name 字面。$ 后接其它字符(数字、空格、标点、字符串结尾)仍是字面
美元号("$5" 无需转义);要在 { 前写字面 $ 用 \$。
(v0.78.0 之前还有 $name 短形,以词法最长匹配吞名字,"$obj_x" 与 "$obj.name" 会静默取到
作者没写的值;它被删除而不是改读成字面,是因为改读会让按旧写法写的程序静默输出另一段文本,
报错才能让每一处都被看见。v0.78.0 到 v0.79.0 间的 dawn fmt 曾把 $name 改写成 ${name},
v0.80.0 起只报错,见 `syntax-window-design.md` §2。)
插值内的表达式效果并入整个字符串表达式的效果。
${...} 必须写在一行内(跨行报 interpolation cannot span lines)——这条限制对
三引号字符串同样成立:可跨行的是字符串,不是它里面的某个 ${...}。除此之外插值里
写什么都可以:结束它的 } 是跳过完整字面量之后遇到的那个 },所以字符串、
三引号、raw string 与字符字面量里的 }("${'}'}"、"${`}`}")都不算数。
多行字符串用三引号 """,首尾换行与公共缩进被剥除;插值规则相同。
raw string 用反引号:`...` 之间的一切都是字面量——无转义、无插值、
可跨行、不剥缩进,所见即值。唯一限制是内容里不能出现反引号本身(也没有逃生舱,
要写反引号就用普通字符串)。三种形式死角互补:含反引号 → "...";
要插值的模板 → """;含引号与 $ 的原样文本(正则、代码样本、HTML)→ 反引号。
1.7换行与分号#
语句由换行分隔,没有分号——写了 ; 会被专门诊断(提示删掉它),并当作换行恢复,
不至于把后文全带崩。行尾是二元运算符、|>、逗号、开括号时自动续行;
此外 |>、. 与二元运算符都允许出现在下一行行首(竖排管道 / 竖排方法链 /
竖排布尔或算术,惯用写法——Java builder 链 x\n .uri(u)!\n .build()! 可断行,
长条件 a\n && b\n && c 也可断行)。唯一例外是 + 与 -:行首 - 与一元负号
有歧义(x\n - y 究竟是 x - y 还是新语句 -y?无法判),故这对算术运算符不作行首续行。
惯例上一行一条语句;dawn fmt 负责统一。
行尾是 =、->/<-/=>、.、not 或 : 时同样续行(let a: 换行再写类型是一条语句),
这张表只在词法器里写一份(continues_line),dawn fmt 的续行缩进由它派生,两边不会各说各的。
换行还在两处兼作分隔符:match 的臂之间(§5)、列表字面量的元素之间(§4.11)。 两处都是先按上面的规则把表达式解析完,剩下的那个换行才是分隔符,所以续行规则优先, 分隔靠后。
1.8dawn fmt#
dawn fmt <文件>... 就地格式化;dawn fmt --check <文件>... 只报告未格式化的文件
(有则退出码 1,供 CI)。实现是基于 token 流的重排器:逐 token 按原样重印
(字符串与插值按源码区间原样保留,一字不改),调整 token 间的空白——行内间距、
2 空格缩进、折叠连续空行(保留作者的物理换行)。唯一的 token 规范化是省略单行
和类型声明的首个可选 |(§2.3);其余 token 与注释均保留,格式化幂等,
且只需词法成功(不需能解析),语法错误的文件也能格式化。
词法成功是前置条件,不成立就拒绝:词法失败的字符不产生 token,而重印的只有 token,
所以格式化它等于删掉它。故 dawn fmt 对词法失败的文件不写回——渲染诊断、退出 1、
文件一字不动(--check 同);同一批里词法成功的文件照常格式化。
直接点名的非 .dawn 文件同样拒绝(退出 2):目录模式本就按扩展名过滤,
点名却不过滤,于是一次路径笔误就能让 Markdown 进 Dawn 格式化器并被覆盖。
要点:缩进 2 空格;
二元运算符/->/=>/=/|> 两侧各 1 空格;,/: 后 1 前 0;(/[ 内侧贴紧;
./? 贴紧;.. 两侧贴紧(a..b;它作前缀时——记录展开 { ..base }、
列表/构造器/记录的剩余 [x, ..rest]——左侧的空格归开括号或逗号那条规则);
不折超长行(作者断的行保留)。
2.类型#
2.1基础类型#
用户可直接命名、由编译器拥有的非泛型基础类型是:Int(64 位)、Char(Unicode
scalar value)、Loc(调用点位置,§8.4)、Float(double)、Bool、String、Bytes(不可变字节序列)与 Unit。
这八个名字与下节三个公开泛型名字构成完整的 public builtin type surface。
Unit 是一等值(唯一值 ()),可出现在任何值能出现的位置:形参、局部变量、
闭包捕获、元组元素、返回值,以及实例化类型参数(Result[Unit, E]、List[Unit]、
catch_fault(() => <void 调用>))。运行期它有真实表示——一个单例对象
(dawn/rt/Unit,与 None 及无字段构造子同一表示),占一个引用槽位,
C 后端给它一个字节。
只有一条例外,它不是表示上的限制:
- 构造子字段不能是
Unit——没有载荷的分支写成裸构造子就是了,这是建模的说法。
Unit 与其他标量一样有 Eq/Hash/Ord/Show 四个 impl(§3.5),答案都是常量:
() == ()、cmp((), ()) == 0、hash(()) == 1(合成哈希的种子,一个部分都没有)、
渲染为 ()。常量本身没有用处,用处在组合上:Result[Unit, E] 因此可比较、可打印、
可作键。(2026-09-24 之前这三个 impl 是故意不给的,理由正是「答案会是常量」;
builtin-privileges-design.md §2 记了为什么那笔账算错了。)
没有 null。 所有类型的值都必然有效;可缺失用 Option[T] 表达。
没有隐式转换。 Int → Float 必须显式 to_float(n)。数字字面量不是 Int 或 Float 的值,
它按 §1.5 定型:let f: Float = 1 合法,let f: Float = n 不合法。
2.2复合类型与命名层次#
用户可直接命名、由编译器拥有的泛型类型恰为 List[T]、Map[K, V] 与 Set[T]:
List[T]— 不可变持久列表。实现是32 叉 trie + 尾块的持久向量(std/pvec,纯 Dawn 源, 建在Array原语之上):索引 O(log32 n)——长度 ≤32 时全在尾块、即一次数组读;++逐元素追加,尾块未满时只复制尾块(≤32 槽),每 32 次把尾块压进 trie、复制一条 根到叶的路径,故acc = acc ++ [x]的累积循环是线性的、O(1) 摊还。 结构共享,已发布的列表永不改变;追加到旧版本不复制整表,只复制它自己的那一小块。Map[K, V]— 不可变映射(见下)Set[T]— 不可变集合(见下)
其余复合类型不属于这张 builtin 名字表:
- 元组
(A, B, ...)与函数类型fn(A, B) -> C !e是结构类型,没有名义类型名;!e可省略,表示纯。 Option[T](Some(T) | None)、Result[T, E](Ok(T) | Err(E))与ForeignError是 prelude 注入的普通名义 ADT;它们不是 compiler-owned builtin type。Array[T]是 bundled std 可命名的 compiler-owned 表示原语。用户模块不能借这个名字 访问该原语;在 std 外声明的Array只是用户自己的名义类型。Never是 compiler-owned 的底类型与硬保留名,没有值或 constructor。它只能直接写在 顶层函数、局部函数、trait/impl 方法、effect operation,以及任意函数类型的返回位。 因此fn() -> Never与alias F = fn() -> Never合法;形参、字段、const/let 标注、 generic/collection/tuple 等存储位、associated binding,以及直接别名alias N = Never都非法。type、alias、trait、effect、constructor 名与 type parameter 都不能遮蔽Never。声明返回Never的函数体本身必须发散。推断守同一条:元组、列表字面量里类型为Never的 分量与 constructor 实参一样只作底类型,取上下文给出的分量类型,上下文没给就报 无法推断,不会推出(Never, Int)或List[Never];字符串插值里的Never部分照常 求值,其后不可达,不需要Show。LSP 只在函数返回上下文补全该名;dawn doc --builtins以"use": "return"标明它不是全局可用的 public builtin。io.exit仍返回Unit,本规则不改变其既有 API。
类型位置的 (T) 只做分组,解析后仍是 T,不会产生额外类型节点。元组仍至少有
两个元素:(A, B) 是元组,(T,) 不是单元素元组,空的 () 也不是类型(单位类型写
Unit)。
函数类型的箭头保持右结合;后缀效果属于它紧跟的那一层函数。返回值本身也是函数时, 括号用来把外层效果隔开:
fn() -> fn() -> Int !io # 外层纯,返回的函数是 !io
fn() -> (fn() -> Int) !io # 外层 !io,返回的函数纯
fn() -> (fn() -> Int !io) !io # 两层都是 !io
Option 与 Result 就是 prelude 的普通 ADT,无特殊地位
(? 运算符对它们有语法支持,见 §8.1)。
Map[K, V] / Set[T] 是与 List 同级的内建持久容器,无字面量语法,全部经
内建函数操作(清单见 §11)。语义要点:
- 持久(不可变)接口:
map.insert/map.remove返回新映射,原值不变。 键类型 = 有
Hash的类型,没有第二条规则。每个会摸到键的操作(map.insert/map.get/map.has/map.remove/m[k]与set的同名者)都带[K: Eq + Hash]bound,键合法性就是这条 bound 解得开——Int/String/Bool/Bytes、元组、ADT、 record 都可以(没写 impl 的按自身结构合成一份,§4.3),Float不行(NaN/-0.0给了它两套相等,§4.3 因此没给它Hash),Array不行(按同一性持有,非按内容)。 报错落在用到键的那个操作上,不在类型标注上:Map[Float, Int]这个类型本身 拼得出来,只是没有任何操作能往里放键。2026-07-27 之前另有一条独立的结构行走(
invalid_key_part)走在类型标注与泛型 实例化点上。它与 bound 平行且打架:它禁Bytes(理由「哈希是引用同一性」, 而Bytes早已改成内容哈希),而 bound 放行;它只在键位置拦Float, 于是hash(1.5)在键位置之外照样能过。删掉的是那条行走,不是规则。- 迭代顺序 = 插入顺序,确定且 JVM/native 一致(
map.keys/map.entries/set.to_list按插入序)。map.insert遇已存在的键替换值、保留原插入位置。 - 相等与顺序无关:两个键值对相同的
Map相等,无论插入次序。 - 实现是持久 HAMT(
std/hamt,纯 Dawn 源,建在Array原语之上):map.insert/map.removeO(log32 n),只复制根到叶的路径,其余结构与原映射共享(插入序由逐键序号维持)。 键的相等与哈希取自键类型的Eq/Hash(以字典传入),不是宿主的对象同一性。
2.3和类型(ADT)#
首个 | 可以省略;格式约定是单行声明不写首竖线,多行声明在每个构造器行首写竖线。
type Shape =
| Circle(r: Float)
| Rect(w: Float, h: Float)
| Point # 无载荷构造器
- 构造器字段必须带名字;构造调用可按位置或按名:
Rect(2.0, h: 3.0)。 - 和类型构造器只用圆括号,构造与模式同规:
Rect { w: 2.0, h: 3.0 }是错误 (「constructorRectis built with parentheses」),模式里的Rect { w, .. }同样是错误 (「constructorRectis matched with parentheses」)。花括号属于记录(§2.4):声明用什么括号, 构造和匹配就用什么括号,一种东西一种拼写(v0.78.0 起,此前两种都接受; `syntax-window-design.md` §3)。 - 构造器(含字段的)裸名在函数位置是普通函数值:
map(xs, Some)等价于map(xs, x => Some(x))。类型参数从期望的函数类型推导(Some的元素类型来自 上下文);仅由字段无法确定的类型参数(如Ok的错误类型E)需上下文补足, 否则报「无法推导类型参数」。无载荷构造器(Point)本身是值不是函数;记录构造用 花括号语法,不参与此规则。 - 泛型:
type Tree[T] = Leaf | Node(left: Tree[T], value: T, right: Tree[T])
2.4记录(record)#
type Point = { x: Float, y: Float }
let p = Point { x: 1.0, y: 2.0 }
let q = Point { ..p, x: 3.0 } # 函数式更新
let d = p.x # 字段访问
记录是单构造器积类型的糖,同样支持模式匹配。字段不可变——"修改"即函数式更新。
记录只能用花括号构造,也只能用花括号匹配。 Point(1.0, 2.0)、Point(x: 1.0, y: 2.0)、Point()
都是错误(「record Point must be built with braces」),无论括号是直接写的还是
由管道(§4.4)生成的;模式里的 Point(x: a) 同样是错误(「record Point is matched with braces」,
v0.78.0 起)。理由是拼写与含义一一对应:花括号形式的字段必须带名,
按位置填一个记录会让字段顺序变成 ABI;而记录的裸名(§2.3 末条)也不是构造函数。
x |> Point { ... } 不是「填字段」,它是在调用一个记录值,报「不可调用」。
fn 类型的字段可以直接调用:r.f(x) 调用字段里存的函数值,等价于
let g = r.f 后 g(x);字段的效果照常并入调用方。作用域里同时存在
名为 f 的函数时,方法写法 r.f(x) 是编译错误(歧义)——静默优先级会让远处新增
一个同名函数悄悄改变既有调用的含义(§10.3 对模块别名同名早已拒绝同类歧义,
此处同规)。消歧:取字段写 (r.f)(x)(或先绑定 let g = r.f);调函数写直呼 f(r, x)。
歧义只属于 r.f(x) 这一个写法:它是方法调用的语法形(§4.3)。其余把 r.f 当作值
再应用的写法——括号 (r.f)(x)、管道 x |> r.f(§4.4,右侧不是调用时整体应用到左值)、
with v <- r.f——callee 就是字段读,所以记录有 fn 类型的字段 f 时一律取字段,
不看作用域里有没有同名函数;新增同名函数改变不了它们的含义,上面那条理由不适用。
(记录没有字段 f 时,这几种写法今天仍按 UFCS 调用函数 f;是否收紧另议。)
2.5泛型#
- 类型参数用
[T, U]声明在名字后:fn map[T, U](xs: List[T], f: fn(T) -> U !e) -> List[U] !e - 单态性:类型参数在每个调用点必须能完全推导,不支持高阶类型(HKT)。
- 没有调用点类型实参:类型参数只由实参与期望类型定下。
f[Int](x)(Go 的显式实例化 拼写)在 Dawn 里是「以表达式Int为下标去索引函数f」,编译器认出这个形状并只报一条 「Dawn has no call-site type arguments」(主体是泛型函数或内建、下标拼写一个类型时); 要定下类型就去标注接收结果的绑定或实参的来处。 - 实现为擦除 + 装箱;单态化是可选的后续优化,不影响语义。
- 没有子类型、没有继承、没有变型(variance)。类型要么相等要么不同。
- 函数体内的局部标注可以引用签名的类型参数(
let acc: List[T] = [])—— 刚性类型参数在推断里视同已知的具体类型。
2.6类型别名#
alias Meters = Float # 内建标量
alias Pair = (Int, String) # 元组
alias Names = List[String] # 泛型应用
alias Handler = fn(Request) -> Result[Response, HttpError] !io # 函数类型
alias Lookup[T] = fn(String) -> Option[T] # 可带类型参数
alias Paint = Color # 用户类型(ADT/record)也可别名
别名有自己的关键字 alias;type 只声明名义类型(ADT 或 record)。
别名是透明的(不是 newtype):解析期展开,与被指的类型完全互换。
alias Meters = Float不提供任何单位安全。 它与Float完全互换:Meters和Seconds可以相加,函数收Meters时传裸Float也照过。 上面第一行只是「别名可以指内建标量」的语法示例,别把它读成领域建模的推荐做法—— 单位类型是 alias 最容易误导的用法。别名的真实价值在下面几行:给长类型起短名 (Handler、Lookup[T])。想要单位安全,用的是下一节的
opaque type:pub opaque type Meters = Float在模块外与Float、与Seconds都不互换,出入写两个一行函数(§2.7)。 这句曾写着「Dawn 目前没有」——那是 §2.7 落地前的话,忘了回来改(LANG-05 的病根 正是示例与指路不同步,这句自己也犯了一次)。
历史注记:两者曾共用
type,靠「右侧形状」启发式区分——同形不同义,且用户类型 无法被别名(裸大写名恒被读作构造器)。现在type X = <fn 类型/元组/Name[...]/内建标量>是编译错误,hint 指向alias;type Color = Red维持 ADT 语义不变。
限制:别名不可递归(环报错);效果变量必须由参数表绑定(alias Mapper[T, U, !e] = fn(T) -> U !e),
写一个没绑定的变量是错误,具名标签与 !io 则直接写(§6.3);pub 后可跨模块导入(use m.{Handler})。
2.7不透明类型#
pub opaque type UserId = Int # 只有本模块知道它是 Int
pub opaque type Env = Map[String, Int]
pub opaque type Pair[T] = (T, T) # 可带类型参数
opaque type N[..] = T 与 alias 的语法一样,差别只有一条:谁被允许看穿它。
声明它的模块里,N 与 T 可以互相转换;模块之外,两者是不同的类型。
# 在 ids 模块内
pub fn wrap(n: Int) -> UserId = n # ✅ 本模块可转换
pub fn unwrap(u: UserId) -> Int = u # ✅
# 在别的模块
let bad: Int = wrap(7) # ❌ annotated type is Int but the
# initializer is UserId
转换发生在赋值、传参与返回位置(即类型可赋值性判定处),不在表达式内部:
本模块里 u + 1 仍是错的,写 let n: Int = u 再算,结果回到 UserId 位置时自动转回。
这是 newtype 的纪律,也让「不透明」在实现上只是一条判定,而不是散落各处的特判。
不透明只挡视线,不改语义:运行期一个不透明类型就是它的目标类型——同样的表示、
同样的相等、哈希与序(Eq/Hash/Ord,自己没写就用目标的),
两个后端都如此,零开销。opaque 是软关键字,只有 opaque type 有意义。
继承的只有关系:一张封闭白名单。不透明类型自己没写 impl 时,只有 Eq、Hash、Ord
落到目标;其余一切 trait——Show/Display、算术、FromInt/FromFloat、
HasDtype 这类「格式身份」trait,以及用户或库声明的任何 trait——只问类型自己的 impl,
没写就是编译错误:no impl of … / … has no such impl。判据只有一条:关系只回答真假或符号,
不暴露表示,也不铸造值;其余 trait 描述的是类型的身份、格式或行为,借目标的答案就是把
目标的身份白送给一个声明过「我不是它」的类型。(2026-10-07 起;此前是「默认落回、黑名单
排除 Show、算术、FromInt/FromFloat」,每出现一个新 trait 就要多加一处特例,
HasDtype[U16] 因此静默解析成 Int 的 "i64",与 std/dtype 声明的「应是类型错误」相反。
Haskell newtype、Rust newtype、Scala 3 opaque type、Kotlin value class 默认都不继承;
GHC 的 GeneralizedNewtypeDeriving 复用表示的字典,也唯独 Show/Read 例外。要继承就在
声明模块写两行转发 impl。)
下面两段是这条规则在渲染与算术上的具体后果。
渲染。不透明类型不继承目标的 Show(也就不继承 Display)。
关系只回答真假或符号,不暴露表示;渲染把表示原样印出来,而那正是这个类型要藏的东西。
要打印就在声明模块写 impl Show[N](需要时再写 impl Display[N]);没写,to_string、
${…}、derive Show 的字段、[T: Show] 约束对它都是编译错误。
(2026-09-24 起;此前继承,std 与 packages 的十四个句柄类型因此会把句柄号或内部状态
印出来。GHC 的 GeneralizedNewtypeDeriving 是同一条切分:复用表示的字典,唯独
Show/Read 不看穿。审计与逐个类型的处置见
builtin-privileges-design.md §4。)
不透明类型也不继承目标的算术(Add/Sub/Mul/Div/Rem/Neg,§3.5、§4.3):
算术产出该类型的新值,而新值怎样满足类型的不变量只有声明模块知道(句柄号相加、
窄格式浮点跳过重新舍入都会静默算错)。要 + 就在声明模块写 impl Add[N];没写,
u + u 与 [T: Add] 约束对它都是编译错误,Char 也一样(char.code 先转成 Int)。
(2026-10-06 起;此前 opaque 上的算术同样是错误,只是因为算术只认 Int/Float。
GHC 同一条切分:Num 不随 newtype 白给,要显式 deriving newtype。见
arith-operator-traits-design.md D4。)
泛型不透明类型的实例身份由声明 identity 与实例化实参共同组成,target 只回答运行期表示。
即使某个类型参数根本不出现在 target 里,Phantom[Int] 与 Phantom[String] 仍是两个类型;
替换、相等、统一、显示与公开面校验都带着这些实参走。「表示不公开」不蕴含「类型参数也隐藏」。
给实现者的判据(别名替换法):把
opaque type N = T原地换成alias N = T, 若某个函数的答案变了,它要么是下面六件事之一,要么就是 bug。只有六件事允许看见TyOpaque:可赋值性与统一判定(谁能转换)、impl 选择(head_of/impl_at)、 符号命名(ty_key/dict_key/impl 方法名)、诊断里的类型名、公开面可见性校验 (§3.3:查 identity 与显式实参,不查 representation),以及白名单之外的见证解析 (只有Eq/Hash/Ord落回目标)。 其余每一个吃Ty的函数——宽度、描述符、槽位、装箱、哪条指令、能不能当常量、 某个 trait 有没有答案——都取目标的答案。 次序也是定的:先问身份再问表示,impl Eq[UserId]必须先于「按 Int 比较」, 否则声明它就没意义了。 机器化在scripts/opaque-twin/:每个语料跑两遍,一遍原样一遍换成alias, 输出必须一致(编译错误也算输出)。2026-07-27 用手工做这件事一次抓出 12 处。 白名单之外的 trait(渲染、算术等)因上面的规则不在这个性质里,语料经目标显式渲染、显式运算; 「别名侧能编译、opaque 侧必须被拒」由twin-refused-alias-ok判决单独钉住。
可以给不透明类型写自己的 impl(impl Show[UserId]、impl Display[UserId]),它优先于
目标类型的;孤儿规则把不透明类型算作声明模块的本地类型。Char 两层都写了
(impl Display[Char] 与 impl Show[Char],§1.5),所以它可以渲染,且两层都不是 Int 的,
而 ==、<、哈希仍然是。scripts/opaque-twin/char.dawn
把这四件事逐条钉住,两个渲染各钉两向:等于 impl 该出的那个串,且不等于 Int 的。
为什么需要它:在此之前,每要隐藏一次表示就得现搓一套机制——
Cursor是编译器铸造的 不透明标量,集合纯 Dawn 化的 HAMT 节点会是下一个。这是第三次之前把机制立出来。
Cursor已经迁完(§11):编译器那一套删干净,由std/cursor用本节的机制重新 立起来,是这个特性的第一个真实用户。
Array不是同类——这里原先写着它「随集合纯 Dawn 化一并迁」,实测是错的: 本机制要一个目标类型,而Array没有目标,它就是表示;且两者方向相反, 本机制公开名字、隐藏表示,Array的is_std_module门控隐藏名字、对 std 公开表示。 那道门控留着,理由见 `trait-v2-design.md` §8.3。
3.声明#
模块顶层只允许:use、type(含 opaque type)、alias、const、fn、test、trait、impl、effect。
没有顶层可变状态。
3.1函数#
fn add(a: Int, b: Int) -> Int = a + b
fn greet(name: String) -> Unit !io = {
println("hi, ${name}")
}
- 参数类型必须写全(所有函数);
pub fn还必须写返回类型——公开签名是 API 契约。 - 私有函数可以省略返回类型:
fn double(x: Int) = x * 2。返回类型省略时由函数体 推断;如果同时完全省略效果注记,base effect 也在同一条推断路径中确定。三类函数 必须标注返回类型: ①pub;② 递归/互递归(编译器按调用图拓扑序推导,环上无法推导); ③ 体内用了return或?(二者需要已知的返回类型)。 - base effect 只在双省略形态中推断:只有私有函数同时省略返回类型和全部效果注记,
checker 才从函数体推断返回类型与 base effect(pure /
!io)。显式写出-> T却省略 效果注记,表示pure承诺而不是请求推断;函数体做 IO 会报错。如果省略返回类型但写出 效果行,则只推断返回类型,效果行按所写内容固定。具名效果从不推断,必须显式声明;一旦 写出任一具名效果,整条效果行就是固定承诺,不会再自动补 io。例如一个!Ask函数同时 做 IO,必须写!(io | Ask);只写!Ask会报错。 - 函数体是
=后的单个表达式;块{ }也是表达式(§4.2)。 - 有默认参数(见下);没有变长参数、没有按名字的重载:一个名字在一个作用域里恰有一个签名。 运算符背后的 trait(§3.5、§4.3)不是重载:每个「trait × 类型」全程序至多一个 impl, 选择是一次查表。
默认参数(2026-08-08,#207):形参可写 name: Type = expr,调用省掉该实参时
在每次调用求值一次(不做 Python 那种「定义时求一次共享」):
fn column(kids: List[Int], align: Int = 0, gap: Int = 0) -> Int = align + gap * len(kids)
column(kids)
column(kids, gap: 12)
column(kids, align: 1, gap: 12)
- 默认值表达式必须纯(
!io与具名效果都不行):否则函数的效果行会随「调用方 有没有传这个实参」而变——同一个签名两个效果行,类型系统不能要。前面的形参若是函数, 默认值可以调用它,前提同样是这次调用纯:fn app(g: fn(Int) -> Int, x: Int = g(1))合法,fn run(g: fn() -> Int !e, x: Int = g())被拒(调用g带上效果变量e)。 在声明处的作用域加上排在它前面的形参里求值(2026-10-03,K6):可以引用本模块的私有 函数与
const,也可以引用声明在它前面的形参,包括前面那个形参自己的默认值 (Kotlin、JavaScript 同规):fn find(s: String, sub: String, from: Cursor = start(s)) -> Option[Cursor] fn span(lo: Int, hi: Int = lo + 10, mid: Int = (lo + hi) / 2) -> Int引用本形参自己或声明在它后面的形参是检查期错误,各有专门的报错: 「the default of parameter
arefers toaitself」、「the default of parameterlorefers tohi, which is declared after it」。理由是求值顺序:默认值就是省掉实参时这个形参的值, 它求值时本形参还没有值,后面的形参也还没求。默认值里的 lambda 可以捕获前面的形参(按值, 同 lambda 规则);默认值里自己let出来的同名局部量照常遮蔽,不算引用形参。 本条之前(#207 至 K6)默认值引用不到任何形参,放宽不是破坏性变更:旧程序的每个默认值 原样合法、含义不变。- 求值顺序:一次调用里,写出来的实参先按书写顺序全部求值(§4.3,具名实参乱序也一样),
然后省掉的默认值按声明序逐个求值,每个默认值看到的是它前面各形参的值——写出来的
实参或更早的默认值,与具名实参写成什么顺序无关。尾块是写出来的实参,所以也在默认值之前
求值;它填最后一个形参,排在任何默认形参之后,默认值看不见它。默认值是纯的,这个顺序
只在默认值 panic 时看得见(两后端对拍:
scripts/spike-native/default_sees_params.dawn)。 - 形参类型可以提及本函数的类型参数(
fn join[T](xs: List[T] = []) -> T):合成出来的f$default$k原样带上本函数的类型参数与它们的 bound,省掉实参的调用点按该次调用自己的 实例化去调它——类型参数替换与见证字典都取调用点已经解出来的那一份。于是默认值表达式里 可以用 bound 提供的 trait 方法(fn pick[T: Zero](v: T = zero())),得到哪个实现由调用点 的实例化决定。 - 默认值不必在参数表尾部:
fn f(a: Int = 1, b: Int)合法,只是a只能具名跳过 (位置前缀从左到右占槽,跳不过去);漏掉必填形参报 「missing argument(s) forf: …」,实参个数出区间报「ftakes 1 to 3 argument(s), got 0」。 - 实现形状:每个带默认值的形参在声明模块合成一个纯函数
f$default$k($不在标识符词法里,源码拼不出来),它的形参就是f的前 k 个形参——不论默认值 读没读它们,元数只由签名决定,所以别的模块的调用点只看f的签名就知道怎么调。 省掉实参的调用点先把写出来的实参按书写顺序绑到局部量,再按声明序把每个省掉的默认值 绑到局部量(f$default$k吃前 k 个局部量),最后按形参序调f;只省掉第一个形参时f$default$0零元,调用不加绑定。两个后端与 comptime 解释器都把f$default$k当普通 顶层函数、把绑定当普通let,没有专门的处理。 caller()是唯一一个不在声明处求值的默认值(2026-10-05,L4):形参的默认值整个就是caller()时,省掉实参的那次调用得到的是这次调用自己的位置(Loc,§8.4),不合成f$default$k。caller()写在别处(默认值里套一层、函数体、const)都是编译错误。 它是常量,满足「默认值必须纯」。- 把函数当值用则默认值丢失:
let g = f之后g的类型是全参的fn(A, B, C) -> R——与「构造器裸名当函数值时字段名丢失」(§2.3)是同一条纪律。 - 范围:只有顶层函数(含私有、模块函数)。局部 fn(其调用走函数值路)、lambda、
构造器字段(record 已有
{ ..base, x: v })、trait 方法(Dawn 已有一个叫「default」 的东西——默认方法体;且字典传递下 impl 无法覆盖值默认)、effect operation (operation 调用是读证据字段 + 调闭包,没有可挂符号)、Java 方法都不收,各自报错说明理由。
局部命名函数:块内可写 fn name(params) -> T [!Row] = body 语句——本质是
「名字在自身 body 内可见的 lambda」,因此可递归(自尾调用编译为循环,§12.4)、
可捕获外围绑定(按值,同 lambda 规则)、可当值传递。参数类型与返回类型必须写全;
效果行与写出来的函数类型同规则(§6.2 第 8 条):可以写 !io、具名效果、外层签名已绑定的效果变量与
关联效果投影,但不能引入新的效果变量;不可声明类型参数(外围函数的类型参数天然在作用域内)。
fn sum(xs: List[Int]) -> Int = {
fn go(i: Int, acc: Int) -> Int =
if i == len(xs) { acc } else { go(i + 1, acc + xs[i]) }
go(0, 0)
}
3.2常量#
const MAX_DEPTH: Int = 64
const SIN_TABLE: List[Float] = comptime {
range(0, 360) |> map(d => sin(to_radians(d)))
}
顶层 const 的右侧隐式处于 comptime 上下文(§7),必须是纯的、可常量化的。
3.3可见性#
所有声明默认模块私有;pub 导出。pub 可用于 fn、type、alias、const、trait、effect。
pub type 同时导出其构造器与字段。
一个 pub 声明的完整已解析导出面都必须能由模块外命名。只给最外层加 pub,不会把它
引用到的私有名义身份一并公开:
pub fn检查类型参数的 bound、参数、返回值与完整效果行;pub const检查声明类型, initializer 仍是实现细节。pub type/ record 检查全部构造器字段;pub trait检查全部方法签名、约束与效果行, default body 仍是私有实现细节;pub effect检查每个操作的参数与返回值。- transparent alias 在这个边界完整展开:私有 alias 若最终只指向公开类型,可以出现在公开面;
展开后触及私有名义类型仍是错误。
pub opaque type相反,停在自己的公开 identity 上—— representation 可以用私有类型,但公开拼写里的每个实例化实参仍须公开可名,而私有 opaque identity 本身不得出现在公开面。 - 类型投影同时检查 trait identity 与 subject;具名效果必须是
pub effect。 - impl 没有
pub拼写:只有「公开 trait + 模块外可命名的 subject」的 impl 属于导出面, 它的泛型约束与全部 associated type binding 也必须公开可名。私有 trait 或私有 subject 的 impl 对外不可达,因而不受此约束——这也是dawn doc不列它的原因。
普通 pub fn 可以声明公开具名效果(见 §6.5):调用方能导入、传播或安装 handler。
私有效果出现在公开面才是错误。
3.4测试块#
test "precedence" {
assert eval("2+3*4") == Ok(14)
}
test eval {
assert eval("1") == Ok(1)
}
test块只被dawn test编译执行,dawn build剥除。- 块内允许
!io。 assert expr:expr为Bool;失败时报告源文本与两侧子表达式的值 (编译器对==、比较运算符做拆解以给出好的失败信息),末尾是断言的位置(§8.2)。
以声明命名的测试。 test 后面除了字符串,还可以写本模块一个顶层声明的名字,这个测试就是
该声明的示例:
- 写法只有三种:
test f(函数)、test T(类型、类型别名、trait、effect;常量名同样以大写开头, 写作test LIMIT)、test T.Ctor(类型T的构造器Ctor)。不接受路径(test a.b、test T.C.D),裸写的大写名字总是指顶层声明、不指构造器(记录与它的构造器同名,裸写会有两种读法)。 - 名字只能指向本模块的顶层声明(私有的也算)或其构造器。解析不到是编译错误,报在
test后的名字上 并说明原因:本模块没有这样声明的名字、这个名字是从别的模块导入的、类型没有这个构造器、点号前的 名字不是类型、或这个名字同时是本模块的两个声明。导入的名字不行:示例属于声明它的模块。 - 一个声明可以有多个示例。
dawn test把它们报作example f,同一个名字的第二个、第三个按出现顺序 报作example f #2、example f #3。 - 除此之外与字符串名的测试相同:同一作用域(看得见本模块的私有声明),
dawn test照跑,dawn build剥除。 dawn doc发布pub声明及其构造器的示例:该对象在doc(与links)之后多一个examples数组, 按出现顺序是每个测试体的源码,不含外层花括号。写在一行的体去掉首尾空白;跨行的体去掉{所在行与}所在行中除花括号外为空的那一行、去掉所有非空行共有的缩进与每行行尾空白,空行保留为空行,注释 原样保留。没有示例的对象不写这个键;私有声明的示例不发布。
理由:示例应当是代码,而不是注释里的文本。写成测试,dawn fmt、编辑器与诊断都照常作用于它,
被它命名的声明改名或删除立即是编译错误,发布出去的示例因此总是编得过、跑得过。写法取 Zig 的
decltest,而不是 Rust 那样在 /// 里写代码块再由工具抽出来跑:后者要另立一套块属性(ignore、
no_run、should_panic),要把诊断从注释正文映射回源码位置,还要工具跨行重建代码,与 §1.2「注释
每一行都能独立切词」相悖。名字只许指本模块:test 块本来就属于所在模块,指向别处的声明说明示例放错了
地方;导入的名字若也算,同一个示例会随 use 的增删换主人。构造器取 T.Ctor 而不是裸 Ctor,
是为了让「裸写的名字就是顶层声明」这条规则没有例外。
3.5trait 与 impl#
单参数、名义式的 typeclass,字典传递实现(完整设计与判定规则见 trait.md):
trait Ord2[T] {
fn cmp2(a: T, b: T) -> Int
fn max_of(a: T, b: T) -> T = if cmp2(a, b) >= 0 { a } else { b } # 默认体
}
impl Ord2[Point] {
fn cmp2(a: Point, b: Point) -> Int = a.x - b.x
}
fn sort2[T: Ord2](xs: List[T]) -> List[T] = ... # 约束:[T: Trait (+ Trait)*]
- trait 恰有一个类型参数;方法进入模块函数命名空间(可直呼、可 UFCS、可管道)。
- 注入是逐 trait 的属性:一个 trait 的方法名是否占据函数命名空间由该 trait 决定。
今天
trait声明恒注入,Ord/Eq/Hash/Show/Iter/Len六个预置 trait 也注入; 方法名由语言代用户消费掉的十二个不注入,它们的方法名只在 impl 体、文档与错误消息里 出现:Index([]消费它,§4.8)、Display(to_string与${...}消费它,§4.3)、 六个算术 traitAdd/Sub/Mul/Div/Rem/Neg(运算符消费它们,§4.3)、FromInt/FromFloat(数字字面量消费它们,§1.5)与StagedIter/StagedVar(stagedfor消费它们,§4.7)。 - 预置 trait 十八个:
Ord(cmp,背后是</<=之外的排序)、Eq(eq, 背后是==/!=)、Hash(hash)、Show(show,嵌套渲染,也是to_string要的 bound)、Iter(背后是for..in,§4.7)、Len(len,容器的元素个数)、Index(背后是[],§4.8)、Display(display,顶层渲染,背后是to_string与${...},§4.3), 背后是+ - * / %与一元-的六个算术 trait(见下面那条)、背后是数字字面量的FromInt/FromFloat(再下一条),以及背后是 stagedfor的StagedIter/StagedVar(再下一条)。 标量的 impl 随语言提供;derive Ord/derive Show铸的是普通 impl,泛型类型上铸的是条件 impl;Display不可 derive(理由见下面Display那条)。 元组没有 head,写不出 impl,前四者对元组由编译器按结构合成。 derive是一张封闭表:可 derive 的只有Ord与Show两项,表在编译器里只写一处 (types.derivable_traits),derive列表里任何别的名字都是编译错误,诊断列出表内各项。Eq/Hash不在表里,因为它们不需要请求:每个类型都有结构化的Eq/Hash(§4.3、 下面Eq/Hash那条),写 impl 是覆盖(Go 的结构体==同此);Show需要请求, 因为渲染把表示印出来,是一个呈现决定,相等与哈希不是(opaque 类型同理,§2.7)。 用户 trait 不能 derive:derive 要编译器知道该 trait 的结构规则,Dawn 没有让库陈述它的 编译期元编程(Haskell 2010 的 deriving 同样只收 Prelude 的封闭集合)。Iter声明两个关联类型与四个方法(关联类型见本节下方):trait Iter[C] { type Cur type Item fn iter_start(it: C) -> C.Cur fn iter_done(it: C, c: C.Cur) -> Bool fn iter_next(it: C, c: C.Cur) -> C.Cur fn iter_get(it: C, c: C.Cur) -> C.Item }——游标式四方法而非返回配对的next, 一步不强制任何分配(游标形状由 impl 定)。std 为List/String/Bytes/Map/Set各提供一个 impl(元素分别是T/单字符String/Int字节/(K, V)/T);用户类型实现Iter即可被for迭代。四个方法名随 prelude 注入函数命名空间,与其余 prelude 名同待遇——可被本模块的声明遮蔽(§10.3)。 形参名取自这份声明(具名实参,§4.3):接收者it、游标c,所以写xs.iter_done(c: k)。Len声明一个方法、无关联类型:trait Len[C] { fn len(x: C) -> Int }。len(c)与c.len()按接收者类型找 impl;List的 impl 随 std 提供,用户类型 写impl Len[T]即得len。len这个名字随 prelude 注入(上条),但 impl 体里的fn len不是模块顶层声明,不遮蔽任何东西,这正是它存在的理由:一个模块可以给自己的 类型写len,同一模块里对List的len(xs)照旧。Len在List上就是语言的len原语,不是一次对 impl 方法的调用;impl 体只服务[C: Len]的字典槽,并且只调内部名(调自己是无条件自递归,被检查器拒绝)。Index声明两个关联类型与一个方法:trait Index[C] { type Idx type Item fn index(c: C, i: C.Idx) -> C.Item }。 语言为List(Idx = Int)与Map(Idx = 键类型)提供 impl;用户类型实现Index即可用[](示例见 §4.8)。方法名index不进入函数命名空间 (见上条注入规则),只在 impl 体、文档与错误消息里出现。一个类型只有一个索引 类型——Index是单参数 trait,写不出同一容器的第二种下标(如切片)。Display声明一个方法、无关联类型:trait Display[T] { fn display(x: T) -> String }。它是顶层渲染那一层:to_string(x)与"${x}"有 impl 就用它,没有就照 §4.3 的既有规则渲染。方法名display不进入函数命名空间(见上条注入规则)。三条边界:Show[T]仍是to_string要的 bound。Display只改一个已经存在的答案, 不让任何原本渲染不出来的类型渲染得出来。- 不可 derive。
derive Show说的是「按我的结构渲染」,而一份Display是一个 呈现决定:一个类型一份,手写。 - 只问类型自己(§4.3):
opaque type A = B上没写Display时,A用自己的Show,不借B的Display(不透明类型不继承任何渲染,§2.7)。
语言自带的唯一一份是
impl Display[Char](在std/char,§1.5;同一个模块也写了 那一层对应的impl Show[Char])。Add/Sub/Mul/Div/Rem/Neg(背后是+、二元-、*、/、%、一元-, §4.3):同质、单参数,trait Add[T] { effect Add = !() fn add(a: T, b: T) -> T !T.Add },其余同形 (Neg一元:fn neg(a: T) -> T !T.Neg)。语言为Int/Float提供 impl;方法名 不进入函数命名空间。每个 trait 带一个与 trait 同名的关联效果,默认纯;impl 可绑定一个 具名效果(effect Add = !Dev),运算符的效果就是该绑定,记在外层签名里(§6),泛型代码以!T.Add投影它。impl 的效果绑定只能是 ground 行,所以「把算术转发给类型参数」的条件 impl (impl[T: Add] Add[Pair[T]]体内写p.a + q.a)写不出;不转发的条件 impl 不受影响。 六个名字从此在 trait 与效果位置被占用:用户不能再声明同名的trait或effect(同名type仍可)。不提供Num/Zero/One。FromInt/FromFloat(背后是数字字面量,§1.5):trait FromInt[T] { effect FromInt = !() fn from_int(n: Int) -> T !T.FromInt },FromFloat同形(fn from_float(x: Float) -> T)。主体只出现在返回位,按名字不可调用也无妨: 方法名不注入,编译器在已知的T处插入调用、直接解见证。语言提供FromInt[Int]、FromInt[Float](不精确即 panic,与 §1.5 的编译期判据相同)与FromFloat[Float], 不提供FromFloat[Int]。泛型代码里[T: FromInt]下的0就是from_int(0)。 两个名字同样从此在 trait 与效果位置被占用。StagedIter与StagedVar(背后是 stagedfor,§4.7):trait StagedIter[R] { type Item effect StagedIter = !() fn staged_for(r: R, body: fn(R.Item) -> Unit !R.StagedIter) -> Unit !R.StagedIter },trait StagedVar[T] { type Cell effect StagedVar = !() fn var_open(v: T) -> T.Cell !T.StagedVar fn var_get(c: T.Cell) -> T !T.StagedVar fn var_set(c: T.Cell, v: T) -> Unit !T.StagedVar }。 两者的方法名由语言消费,不进入函数命名空间;不可 derive;各带一个与 trait 同名的关联效果, 默认纯(与算术、字面量 trait 同),所以同时实现两者的类型在泛型代码里写!T.StagedIter、!T.StagedVar不会混淆;语言不为任何类型提供 impl。两个名字同样从此在 trait 与效果位置被占用。- 一致性:全程序每个「trait × 类型」至多一个 impl;孤儿规则:impl 只能
写在 trait 或主体类型的声明模块。impl 全局生效,不需要
use。 - 主体形状:一个类型构造器,作用在互不相同的类型变量上,而那些变量恰好是
这个 impl 自己的参数——
impl Eq[Money]、impl[T: Eq] Eq[List[T]]、impl[K: Eq, V: Eq] Eq[Map[K, V]]都合法;impl Eq[List[Int]](具体实参)与impl[T] Eq[Map[T, T]](重复变量)不合法。这一条同时决定了「哪个 impl 匹配」 (head 相等)、「两个 impl 是否重叠」(同上)与「递归求解是否终止」(子目标是 父目标的真子项)。 - 条件 impl:
impl[T: Eq] Eq[List[T]]的方法是泛型函数,按约束接收字典;Eq[List[Int]]这样的具体目标在编译期解成常量字典,Eq[List[T]](T刚性) 则在运行期由Eq[T]构造。无 dyn(异构集合的写法见本节末「异构集合」;裁决理由与 重开条件见 trait.md §10)、无 supertrait、无特化(不问「哪个更特化」)。 trait 方法的效果行是完整的行:纯、!io、效果变量、具名标签、关联效果投影都可以 (§6.5 边界);impl 的效果 ⊑ trait 声明(经该 impl 的关联效果绑定归约后),效果变量 按位置配对,标签两边必须恰相等——每个标签是一格隐藏证据参数,是方法形状的一部分。 关联类型(设计与裁决见 assoc-types-design.md):trait 体可声明裸
type Item成员(无 bound、无默认),impl 对每个声明恰绑定一次type Item = X(X 在 impl 自己的参数作用域解析,且不得又是投影);漏绑、多绑、绑 trait 没有的名字,各是编译错误。方法签名以T.Item投影它——主体必须是带该 trait bound 的类型参数(恰一个 bound 声明该名字;零个与歧义都是错误),List[Int].Item这样的具体主体不支持。- 归约是急切的:调用点实例化让主体落到具体头时,投影立即经该主体的
impl(与字典选择同一扇门)换成绑定值——具体类型里不存在未归约投影。
刚性主体(泛型体内的
T)上的投影保持原样,只与逐字相同的投影相互统一。 - 关联类型名只活在 trait 作用域内,不进模块类型命名空间;
T.Item是 唯一到达方式。opaque 主体只查自己的 impl,不落到 target 的(与 witness 同规则:只有Eq/Hash/Ord落回,关系没有关联类型)。运行期零表示:类型照常擦除,字典不多一个槽位。 - 投影出现在参数位时不参与推断:类型参数由非投影的参数位定下,投影随后
按该主体的 impl 绑定归约、再与实参比对。推论:类型参数只出现在投影里的方法
按名字不可调用(报「无法推断」);trait 作者应把主体放进参数表——
Iter四个方法都把c: C放在第 0 位,正是这个原因。
- 归约是急切的:调用点实例化让主体落到具体头时,投影立即经该主体的
impl(与字典选择同一扇门)换成绑定值——具体类型里不存在未归约投影。
刚性主体(泛型体内的
trait Head[C] {
type Item
fn first(c: C) -> Option[C.Item]
}
impl[T] Head[List[T]] {
type Item = T
fn first(c: List[T]) -> Option[T] = get(c, 0)
}
fn head_or[C: Head](c: C, d: C.Item) -> C.Item = # 投影随实例化归约
match first(c) { Some(x) -> x, None -> d } # head_or([1], 9) 是 Int
关联效果(设计与裁决见 effect-params-design.md 刀 5;默认值为 2026-08-26 裁决): trait 体可声明
effect E成员(无 bound),并可给它默认行effect E = !X;impl 对 每个声明至多绑定一次effect E = !X,右侧是具体行——一个具名效果、!io或空行!(),效果变量与投影都不可绑,默认行的右侧同此纪律。impl 省略有默认的成员即取默认, 写了绑定即覆盖;省略无默认的成员、多绑、绑 trait 没有的名字,各是编译错误。注册期把 默认物化成该 impl 的绑定,此后归约、证据与后端对「默认来的」与「写出来的」绑定不可分辨 (tea 的五个纯 impl 删绑定前后 Core 逐字节相同,是这句话的可执行判词)。方法的行以!T.E投影它 ——主体必须是带该 trait bound 的类型参数(恰一个 bound 声明该名字;零个与歧义都是 错误),许可位置与类型投影相同:方法签名内,含参数位与返回位的函数类型行。- 归约是急切的,与类型投影同一扇门:调用点实例化让主体落到具体头时,投影立即经该 主体的 impl(与字典选择同一个答案)换成绑定的行——具体行里不存在未归约投影。
- 证据按参数结算,恰一格:行里每个写出的投影给函数追加一格擦除的隐藏证据参数,
排在写出的标签所合成的证据之后。调用点归约出标签,就交该标签的证据;归约到调用方
自己的刚性投影,就转发自己那一格;归约成纯或
!io,交占位值。字典本身不携带任何 证据,槽位形状只取决于 trait 声明。v1 边界:一个关联效果成员至多绑一个标签, 要两个效果就写两个成员。 - bound 是检查,不是定义:投影读出的永远是 impl 的实际绑定(每个主体唯一); 消费者在 bound 上写下的行只是上界(impl 侧 ⊑ 它),两者并存。
预置
trait Ord[T] { fn cmp(a: T, b: T) -> Int }及Int/Bool/String/Bytes/Unit的 impl(Unit的cmp恒为0,§2.1;Float没有,NaN 下无全序可给——拒绝理由见 §4.3 数值边缘语义,2026-07-26 撤销了此前照Double.compare给的那份)。Ord[Bool]:false < true,即两个构造器按声明序(与derive Ord对和类型的规则同一条; Rust、Haskell、OCaml 同此序)。Ord[Bytes]:无符号逐字节字典序,首个不同的字节定大小,公共前缀相等时短者小 (与 Rust[u8]、HaskellByteString同)。这正是Ord[String]在 UTF-8 上的算法。- 这两条 2026-09-24 补上(builtin-privileges-design.md §1);
此前
Bool/Bytes有==而无<,也进不了sort,且用户无处写这两条 impl(孤儿规则)。 cmp只承诺符号:小于返回-1、相等返回0、大于返回1, 不承诺其它幅值——此前cmp("a", "z")会把宿主的码元差-25漏出来, 2026-07-31 起全类型统一 −1/0/1(把结果当差值缩放本就可能溢出,std 一直只用符号)。Ord[String]是码点序:逐码点比较,首个不同的码点定大小,前缀相同时短者小。 UTF-8 的字节序与码点序等价(编码的设计属性);哈希的货币仍是 UTF-16 码元 (见下文哈希叶子),有意不跟走——哈希只须与==一致,不必与序同货币。 语言其余各处(len/slice/chars/code_points/游标)本就以码点为货币, 序是最后一处对齐的(2026-07-31,此前是 UTF-16 码元序,只在增补平面字符上可观测)。
derive Ord生成字段字典序比较 (和类型先比构造器声明顺序,构造器不同时只产生-1/1),字段须为 有Ord的标量、自身具 Ord impl 的类型,或该类型自己的类型参数 (此时生成的是条件 impl:type Box[T] = { v: T } derive Ord得到impl[T: Ord] Ord[Box[T]])。List[T]有 std 写的词典序 impl。预置
trait Eq[T] { fn eq(a: T, b: T) -> Bool }与trait Hash[T] { fn hash(x: T) -> Int }。Eq的标量 impl 是Int/Float/Bool/String/Bytes/Unit;Hash的没有Float(拒绝理由见 §4.3 数值边缘语义,同Ord[Float]),故是五个。 没有derive Eq:==本来就对每个类型结构化(§4.3),等于每个类型 隐式实现 Eq;写 impl 是为了覆盖它。- 覆盖后
==即该 impl,容器与嵌套比较一并跟随。 impl Eq与impl Hash必须成对出现(编译错误):相等的值必须哈希相同, 否则该类型作Map/Set键即失效。- 覆盖体内不能用
==比较主体类型本身——那就是这个 impl,会无限递归;比字段。 Eq/Hash约束实例化到没有 impl 的类型(ADT、元组)时,编译器按这个类型 自己的结构合成一份实现——等价于一条隐式的条件 impl,主体含类型参数时要求 该参数有对应 bound。主体静态已知时直接塌缩成原语,不建字典。- 合成的哈希是可观测的数(
hash(x)可以打印),故定义在此:种子1, 逐部分h = 31*h + hash(part),32 位环绕算术;元组按元素序,构造器按字段序, 有一个以上构造器时构造器序号作为第一个部分先折进去(一个构造器时没有可分辨的 标签,与==/cmp同一条规则)。 五个标量叶子的算法同样定义在此(全部 32 位环绕算术,结果为 32 位数):
Unit:1,即上面复合规则的种子(一个部分都没有)。Int:v ^ (v >>> 32),取低 32 位。Bool:true得1231,false得1237。String:种子0,逐 UTF-16 码元h = 31*h + unit。哈希的货币是 UTF-16 码元,与序的货币是两回事,这是有意的:哈希只须与==一致 (内容相同 ⟹ 哈希相同),不必与任何序同货币,序的货币变时哈希不跟走。Bytes:种子1,逐字节h = 31*h + b,b取有符号 byte(−128..127)。 恰与上面的复合规则同形——把每个字节当一个 part 折。
List不在此列:std 写了impl[T: Eq] Eq[List[T]]与对应的Hash/Ord。Map/Set/元组仍走合成。
- 覆盖后
- trait 方法与带约束的函数可以直接当函数值:期望的函数类型定型主体之后,编译器
写出 eta 展开,约束在展开处解析成字典。约束落在外层函数的类型参数上时,合成的
闭包捕获外层那个字典参数,与手写 lambda 同形。主体没有定型(
let f = to_string) 仍报「无法推断类型参数」,写出期望类型或手写带标注的 lambda 即可。带效果标签的 函数照样可以当值:标签进入值的类型,证据由调用点供(§6.5 边界)。效果操作不行, 它没有可取的函数符号。 - 限制:comptime 中不允许 trait 约束的调用与 impl 排序。
异构集合#
没有 dyn,异构集合的惯用法是一等函数 + 不透明类型:在类型还具体的地方把方法调用 包成闭包,装进一条记录,把记录声明成 opaque 类型,再给这个 opaque 类型写 impl。
type ShownRepr = { render: fn() -> String }
pub opaque type Shown = ShownRepr
pub fn shown[T: Show](x: T) -> Shown = {
let r: ShownRepr = ShownRepr { render: () => show(x) }
r
}
impl Show[Shown] {
fn show(s: Shown) -> String = {
let r: ShownRepr = s
r.render()
}
}
pub fn main() -> Unit !io = {
let xs: List[Shown] = [shown(1), shown("two"), shown(true)]
for x in xs { println("${x}") }
}
1
"two"
true
四步各有依据,都在本文件内:
shown里Show[T]消解出的字典是普通值,被 lambda 按值捕获(本节「trait 方法与 带约束的函数可以直接当函数值」一条:与手写 lambda 同形;§4.5:闭包按值捕获绑定)。T到此为止,Shown里不再有它。Shown是不透明类型,运行期就是ShownRepr,零开销(§2.7)。impl Show[Shown]合法且优先于目标类型的 impl:孤儿规则把不透明类型算作声明模块的 本地类型(§2.7)。let r: ShownRepr = s不能省:不透明类型的转换只发生在赋值、传参与返回位置,不在 表达式内部(§2.7),s.render()是编译错误。
适用范围:
- 单方法 trait:完整替代。
- 多方法 trait:每个方法写一个字段,即手写一张函数表;语言不代劳。
Eq/Ord写不出来:二元方法(fn eq(a: T, b: T))要求两个实参是同一类型, 而两个Shown的真实类型可以不同——「同类型」这件事在Shown的类型里无处安放。 推论:Map[Shown, V]这样的东西做不出来。
4.表达式#
Dawn 是表达式导向的:if、match、块都产生值。
4.1绑定#
let x = 42 # 不可变绑定,类型推导
let y: Float = 1.0 # 可选标注
var acc = 0 # 可变局部变量
acc = acc + 1 # 赋值,仅对 var 合法
let不可变、不可遮蔽(同一作用域重复绑定同名是错误;嵌套作用域允许遮蔽)。var仅限函数体内的局部变量;record 字段、参数、顶层均不可变。- 赋值是语句(类型
Unit),不是表达式——if (x = 1)这类错误不存在。
4.2块#
let area = {
let w = 3.0
let h = 4.0
w * h # 最后一个表达式是块的值
}
块引入新作用域;最后一个表达式是块的值,其余语句必须是 Unit 类型
(防止悄悄丢弃 Result——丢弃非 Unit 值必须显式 let _ = ...)。
4.3运算符与优先级#
自低到高:
| 优先级 | 运算符 | 结合性 | 说明 |
|---|---|---|---|
| 1 | |> | 左 | 管道,见 §4.4 |
| 2 | || | 左 | 逻辑或,短路 |
| 3 | && | 左 | 逻辑与,短路 |
| 4 | == != < <= > >= | 不结合 | 比较;链式比较是语法错误 |
| 5 | | | 左 | 按位或;仅 Int |
| 6 | ^ | 左 | 按位异或;仅 Int |
| 7 | & | 左 | 按位与;仅 Int |
| 8 | << >> >>> | 左 | 移位;仅 Int。>> 算术(补符号)、>>> 逻辑(补零) |
| 9 | ++ | 右 | String/List 连接 |
| 10 | + - | 左 | 两侧同类型;Int/Float 原生,其余类型经 Add/Sub(§3.5) |
| 11 | * / % | 左 | 两侧同类型;Int/Float 原生,其余经 Mul/Div/Rem;Int 除零 panic |
| 12 | not、一元 -、~ | 前缀 | 一元 - 在 Int/Float 外经 Neg;~ 仅 Int 按位取反 |
| 13 | ? . () []调用 | 后缀 | ? 见 §8.1;() 自 2026-07-30 起是一般后缀(见下) |
- 调用是一般后缀(SYN-02):任何后缀表达式后同一行紧跟
(即为应用——make()(1)、(if c { f } else { g })(1)、get_handler()(req)都合法。f(x)与Circle(1.0)走的是同一条后缀路径(2026-07-31 起没有各自的特判), 是哪种调用由 callee 是什么决定,而不是由写法决定:构造器名→构造、 函数名→静态调用(名字绑的是函数值则动态调用)、Java 成员→Java 调用、 其余表达式→应用一个函数值。 跨行的(不吃:换行结束后缀链(只有.可跨行续链,§1.7), 故let x = f换行(1 + 2)仍是两条语句——与一元-不续行同一条纪律。 尾块:同一行紧跟的裸
{ ... }是最后一个实参(2026-08-08,#206)。f(a) { e }就是f(a, () => e);带参数写花括号头——items(xs) { item => e }就是items(xs, item => e),多参/带注解用括号:fold(xs, 0) { (acc, x) => e }、{ (x: Int) => e }、{ () => e }。落点与(完全相同(所以xs.each { e }出方法调用、裸名column { e }出应用),糖只换拼写不换节点。 历史裁决「裸{}与无括号if头的体无法区分,永久不做」已推翻(2026-08-08): 区分机制就是头部禁记录字面量的那个开关(nb,见下),当年裁决写在它落地之前。 边条件四则:- 同一行才吃,与
(同一条纪律:let y = g(1)换行{ ... }是两条语句, 独立块语句不会被上一行的调用吞掉。 if/while/for的头部与match被检者不开尾块(with的 callee 同, §4.10):if f(x) { ... } { ... }里第一对花括号是if的体。 加括号即恢复——if (f(x) { ... }) { ... }。match 臂的 guard 不禁: guard 后面跟->不跟块,没有可被吃掉的体。- 前一个记号是
}时拒绝:if c { 1 } else { 2 } { 3 }、match x { ... } { ... }、P { x: 1 } { ... }、f(a) { ... } { ... }都不是把块应用到值上的意思;顺带「一次调用至多一个尾块」不需要另立规则。 要把块应用到一个值,先加括号:(...) { ... }。 - 大写名的花括号归记录字面量(§2.3):
Column { ... }是构造, 尾块永远落不到裸 TYPEIDENT 上;组件函数用小写名。Column(align: c) { ... }不受此限——括号在前,块照常附加。
尾块在尾块位绑定形参:
{ x => ... }的头是独立产生式,不是「块被重新解释」 (如同Point { x }用花括号但不是块)。要写「值是一个 lambda 的块」, 加括号即可:f(a) { (x => e) }——括号组不再以=>收尾,头就读不出来。 尾块填最后一个声明的形参/字段(Kotlin 同规),与它前面有没有具名实参无关, 「位置实参不得跟在具名实参后」对它豁免;默认值填剩下的洞(#207):column(gap: 12) { text("hi") }里块填body、align走默认。 那个形参已被填过则报「the last parameterbodyis already given; the tail block is what fills it」。- 同一行才吃,与
- 尾块是唯一的尾实参形式(#206 期 2):过渡期接受的
f(a) fn(x) => e已退役,现行拼写只有f(a) { x => e }。fn在表达式中没有位置,只用于具名函数 声明(§3.1)和函数类型(§2.2);旧尾闭包拼写会得到迁移到尾块的专有诊断。 裸箭头 lambda 仍进不了尾位:f(a) (x) => e里(x)已是柯里化调用f(a)(x), 两种读法在=>之前无法区分——这条与花括号无关,维持原判。 - 具名实参(
f(a: 1))语法上在每个调用位置都合法,包括点调用与应用函数值; 能不能用由 callee 是什么决定(2026-08-08,#207)。带签名的 callee 接受: 顶层函数、模块函数、UFCS/点调用、管道、trait 方法(名字取 trait 声明的形参名, impl 的形参名纯属局部)、effect operation、构造器(字段名,见 §2.3)。 函数值不接受——函数类型不携带参数名,let g = f之后g(a: 1)是类型错误 (「gis a function value, and a function type carries no parameter names」); Java 成员不接受——Java 元数据里没有形参名。 分派规则与构造器是同一份:位置前缀从左到右占槽,具名按名占槽、具名之间可任意重排, 位置实参不得跟在具名实参之后;点调用的接收者已按位置占掉第一个槽,x.f(self: y)报的是「parameterselfis given twice」。f(1)(2)能解析,语义仍是「f(1)的结果必须是函数」——Dawn 没有自动柯里化; 第二次应用的 callee 是函数值,故不可具名。 - 实参求值顺序 = 写序(2026-08-08,#207 裁决):调用与构造的实参一律按书写顺序
求值(与 Python / C# / Scala / Swift / Kotlin 相同);具名实参重排的是落槽,
不是求值顺序——
R { b: f(), a: g() }先跑f()后跑g(),值各归各的字段。 省掉的默认值在全部写出的实参之后、按声明序求值(§3.1)。..base在语法上只能写在最前,也最先求值。二元运算符从左到右(&&/||短路照旧)。 此前构造器按声明序求值、C 后端把实参顺序留给 C(gcc 从右到左)——两者都已按本条修正, 两后端对拍钉住(scripts/spike-native/named_args.dawn、eval_order.dawn)。 - 按位
& ^ \|与移位仅作用于Int(无Float位模式);它们紧于比较, 故a & b == c是(a & b) == c,无 C 家族那个坑。移位计数取低 6 位(同 JVMLSHL)。
数值边缘语义(全部为保证——自举后两套实现以此对拍,不允许「碰巧一致」):
Int溢出环绕(二补码,同 JVM):MAX + 1 == MIN。唯一可能溢出的除法MIN / -1同样环绕(结果MIN),MIN % -1 == 0。/向零取整(-7 / 2 == -3,不是 floor);%的符号随被除数 (-7 % 2 == -1、7 % -2 == 1),恒满足a == (a / b) * b + a % b。/与%除零是 panic(消息Int division by zero/Int modulo by zero)—— panic 而非 Java 异常,故catch_fault不拦、只有catch_panic拦(§9.8)。Float算术遵循 IEEE 754:除零不 panic(得±Inf/NaN);==与< <= > >=是 IEEE 比较——NaN 与任何值(含自身)比较均为 false,-0.0 == 0.0为 true。Float没有Ord(2026-07-26 改;此前照 JavaDouble.compare给了一个 全序)。NaN 与任何值(含自身)都不可比,所以 Float 上没有全序可给,而Ord正是sort/max/min/max_by/min_by/derive Ord与[T: Ord]所依赖的东西。「降成偏序」在 Dawn 里是少一条 impl,不是多一个 trait:</<=/>/>=对标量本就不解见证,照旧可用、照旧是 IEEE 语义。 变的只有一条:[T: Ord]不再收 Float,cmp(1.5, 2.5)不再编译。 (Rust 的total_cmp是f64的固有方法,f64同样没有Ordimpl。)Float没有Hash,因此不能作 Map/Set 的键:-0.0 == 0.0为真而两者 位模式不同,没有哈希能同时与这个相等和自身一致(同 Rust)。Float有Eq,而它不自反(nan == nan为 false)。这是 IEEE 的事实, 不是漏洞,Dawn 不为此拆出PartialEq/Eq两个 trait(2026-07-26 明确 裁定不拆):Dawn 只有一个Eq,拆开会让1.5 == 2.5这种日常写法多背一层 概念,代价远大于收益。用到自反性的地方(容器查找)另有 Float 不能作键这条挡着。Float的to_string/Show渲染是语言自己写下的规则(实现是std/fmt.dtoa一份 Dawn 源码,两后端与 comptime 折叠共用;2026-07-31 起不再引用宿主方法—— 规则的形状取自当日 JVMDouble.toString(JDK 19+ 的 Schubfach 最短往返), 故已发布的 JVM 字节一个不变,但此后宿主换算法也不跟):- 最短往返:输出是能唯一读回原 double 的最短十进制数字串;同长候选取 最接近真值者,仍平局取偶数尾数。
- 两种形式,按数值大小切换:
10^-3 ≤ |v| < 10^7用普通小数形式(0.001、9999999.0),此外用科学计数法d.dddEe——首位恰一个非零数字、小数点后至少 一位、大写E、指数无前导零、负指数带-、正指数不带+(1.0E7、9.999999999999998E-4、4.9E-324)。 - 整值保留
.0(1.0不是1);小数部分至少一位;-0.0保留符号。 - 三个特殊拼写:
NaN(无符号)、Infinity、-Infinity——与parse_float认的三个拼写相同,故parse_float(to_string(x))对每个x闭合(§11)。
to_int(x)向零截断且饱和(同 JVMD2L):to_int(2.7) == 2、to_int(-2.7) == -2;NaN得0,超出Int范围的(含±Inf)得较近的那一端 (to_int(1.0 / 0.0) == Int的最大值)。写下来是因为 C 的强转对这三种输入全是未定义、 且在 x86-64 上三者都答Int的最小值——2026-07-30 之前 native 就是这么答的。==/!=默认是结构相等,对任意类型可用(函数类型除外——比较函数是编译错误); 类型可用impl Eq覆盖这个默认(§3.5)。排序比较
</<=/>/>=有两套机制,不是一套:- 在有
Ord的标量(Int/Bool/String/Bytes/Unit,§3.5)与Float上它们是 不解见证的原生运算——不查 impl、不建字典。这两组是同一张表(Ord的标量表) 加上唯一的例外Float:Float有原生<而无全序。Float上是 IEEE 语义(NaN 参与的比较一律 false,见上文数值边缘语义)。 其他具体类型桥接到预置 traitOrd的cmp(见 §3.5)——有 impl (手写或derive Ord)即可比较。 [T: Ord]是 trait bound,走字典:受它约束的类型参数上,<与cmp/sort一样解Ord的见证。Float没有 impl(拒绝理由见上文「Float没有Ord」, 不在此重复),故cmp(1.5, 2.5)不编译,而1.5 < 2.5照旧合法。- 两套机制在
Ord的每个标量上答案相同(String上<与cmp是同一条 比较、同一个序——码点序,见 §3.5),在Float上有意不同:原生<是 IEEE 偏序,Ord要的是全序,Float 只有前者。
- 在有
- 算术同样是两套机制:具体的
Int/Float上+ - * / %与一元-是不解见证的 原生运算,不查 impl、不建字典;其余类型解对应 trait 的见证,a + b就是Add的add(a, b),效果是该 impl 绑定的行。[T: Add]是普通 bound,Int/Float满足它。 左操作数无期望地检查,右操作数以左操作数的类型为期望类型检查,从不反向(lit(2.0) * t推不出lit的类型参数,写t * lit(2.0))。例外:恰一侧是数字字面量时,先检查另一侧, 字面量以它的类型定型(§1.5;0.5 * s与s * 0.5同义,r * 2与2 * r在r: Float时 都成立);这条对算术、比较与==/!=都成立,按位与移位只有Int,不适用。两侧都是字面量时, 整个运算的期望只在它是经FromInt/FromFloat收字面量的类型时给两侧(let b: U8 = 200 + 100);Float期望不穿过运算符:let f: Float = 1 / 2里1 / 2仍是Int的0,是类型错误,写1.0 / 2.0(同一段文字不随注解在 0 与 0.5 之间变;库类型的字面量没有别的种类可退,所以照流)。其余情况下,一个整数字面量挨着一个浮点字面量就取Float(3.14159 * 2 * r、1 + 2.0都成立),反向不成立,同种类则各取缺省(1 / 2仍是Int的0)。 这是在检查之前从语法认出字面量,一遍完成,不是回填。缺 impl 先于两侧类型比较报出:true + 1说的是Bool没有+。检查次序不改变求值次序:求值仍是左到右。 用户类型的打印:
type声明后加derive Show获得to_string与字符串插值支持 (可 derive 的还有Ord,见 §3.5;多个用逗号:derive Show, Ord)。Show是预置 trait,derive Show铸的就是一条 impl,所以也可以手写impl Show[T] { fn show(x: T) -> String }自定义渲染,泛型类型则用条件 impl (impl[T: Show] Show[Box[T]])。to_string的签名是[T: Show]。 渲染形如合法 Dawn 源码:- 无载荷构造器 →
Red;带位置字段的构造器 →Circle(1.5); - 记录 →
Point { x: 0.0, y: 2.5 }(带字段名); String字段带双引号并转义("a\nb");Int/Float/Bool同各自to_string;- 容器递归渲染:
List→[a, b]、元组 →(a, b)、Option/Result随载荷(Some(Red))。Map/Set没有字面量语法,渲染成重建它的那个调用:map.from([(k, v), ...])、set.from([e0, e1])——写的是公开拼写,因为「读起来像源码」的意义在于能粘回去 (曾输出已退役的平铺名map_from,粘回去报 undefined function;审计 RD-14)。 - 每个字段类型必须可打印(函数字段、未
derive Show的嵌套用户类型 → 声明处报错); 泛型类型可打印 当且仅当 其类型实参都可打印(Box[Int]可,Box[fn(...)→...]不可)——derive Show在泛型类型上铸的是impl[T: Show] Show[Box[T]],这条就是它的 bound。 - 顶层的
String不加引号,嵌套的加。to_string("a")是a,而["a"]是["a"]——引号是「这里是一个值,不是周围的标点」的记号。Char同理,定界符换成单引号:to_string('a')是a,而['a']是['a'](§1.5)。于是List[Char]、List[String]、List[Int]三者的渲染互不相同。 trait 方法show是嵌套那一份,所以经[T: Show]约束渲染一个字符串 会带引号;to_string/${}只在静态类型就是String时去掉它。 这条规则由静态类型定,不由值定:to_string("hi")是hi,而同一个调用写在fn f[T: Show](x: T) = "${x}"里面出的是"hi",因为那里静态类型是T,渲染走Show见证。这一点与下条的Display无关,两层分开的那天起就是这样。 顶层这一层类型可以自己接管,写法是
impl Display(§3.5)。to_string(x)/${x}先问x的静态类型有没有Displayimpl:有就用它, 没有才落到本节其余各条(含上一条的String恒等)。两条边界:- 只问类型自己,不往下剥。 不透明类型不继承目标的渲染(§2.7),能进
to_string的不透明类型必有自己的Show;opaque type A = B上没写Display时A走自己的Show,不借B的Display。(2026-09-24 之前渲染可继承, 这里是「逐层剥、每层重问」;前提没了,这条也就删了。) Show那一层不动。 写了Display的类型嵌在结构里仍走Show,[T: Show]约束下的类型变量仍走见证(同上条)。
Show是嵌套那一份、Display是顶层那一份,两个名字对上 Rust 的Debug与Display;这里曾经只有一个 trait 兼二职,这两条就是它拆开的位置。- 只问类型自己,不往下剥。 不透明类型不继承目标的渲染(§2.7),能进
- 无载荷构造器 →
点调用(UFCS)#
x.f(a, b) 是 f(x, a, b) 的另一种写法:接收者 x 占 f 的第一个参数,
括号里的实参依次填其余形参。这是纯语法糖,不引入新节点;与管道 x |> f(a, b)(§4.4)
是同一次调用,三种拼写(f(x, a, b)、x.f(a, b)、x |> f(a, b))的类型、效果、
求值顺序与诊断全部相同。点是后缀(§4.3 表第 13 级),比一元运算符和 |> 都紧:
a + b |> f 是 f(a + b),而 (a + b).f() 才把 f 作用在和上。
recv.name(args) 的 recv 是什么,决定这是哪一种调用,按下列顺序认:
- 模块别名:
recv是整模块引入的别名,则是限定调用alias.name(args)(§10.3)。 别名与本模块任何绑定同名是编译错误,所以这一步不会与下面几步争。 - Java 静态:
recv是use java引入的类名(Math.floor(x)、StringBuilder.new())。 - Java 实例:
recv是一个值,其类型是 Java 类,且该类有名为name的实例方法。 - fn 类型的记录字段:
recv是记录值且有名为name的 fn 类型字段,则调用字段里存的函数值 (§2.4)。作用域里同时有名为name的函数时,这个写法是编译错误(歧义), 消歧写(r.name)(a)或直呼name(r, a)。 - UFCS:其余情形,
name按裸名在当前作用域找一个函数,把recv作为第一个实参。 找到的可以是本模块的 fn、选择性引入的名字(use std/str.{split})、prelude 的 builtin, 以及 trait 方法(trait 方法进入模块函数命名空间)。
第 5 步只看无限定地在作用域内的名字。整模块引入 use std/str 只绑定别名 str,
不把 split 带进作用域,所以 s.split(",") 不成立,编译器报:
error: undefined function: split
= hint: `split` is not in scope unqualified, but an import exports it: write
`str.split(...)`, or add `use std/str.{split}` to keep the dot call
要么写限定调用 str.split(s, ",")(或管道 s |> str.split(","),限定名不需要引入),
要么选择性引入后用点写法。
具名实参照常可用,接收者已按位置占掉第一个槽:x.f(self: y) 报
「parameter self is given twice」(§4.3 具名实参一条)。尾块也落在点调用上:
xs.each { x => e } 就是 each(xs, x => e)。
4.4管道#
x |> f(a, b) 等价于 f(x, a, b)——把左侧塞进第一个参数。
x |> f 等价于 f(x)。标准库 API 均按"主数据是第一参"设计以配合管道。
|> 是普通调用的参数插入,不是别的东西。 右侧照 §4.3 的后缀规则解析成一个
表达式;若它最外层已经是调用(f(a)、m.f(a)、r.m(a)、make()(a)),
左侧插到那次调用写下来的参数表首位;否则整个右侧被应用到左侧。
x |> f(a) # f(x, a)
x |> f # f(x)
x |> m.f(a) # m.f(x, a) 模块别名不是接收者
x |> r.m(a) # r.m(x, a) 接收者原地不动,不重复插入
x |> make()(a) # make()(x, a) 不是 make()(a)(x)
x |> One(a) # One(x, a) 构造器也只是被调用的东西
管道不引入任何新节点,因此:
- 求值顺序就是把调用写开的顺序:先求 target(动态 callee / 接收者),
再按写序求各参数,而左侧只是第一个参数。
lhs |> make()(arg)先跑make(), 再跑lhs,最后跑arg。这条是规定的,不是实现巧合——静态 callee 没有求值 效应,所以常见管道看起来仍是左侧先跑。不为「视觉上左侧在前」引入隐藏临时变量。 - 可调用性、元数、重名参数与记录限制全部按普通调用判。
x |> One(1, 2)报的是 元数错误,x |> k(k: Int)报的是「不是函数」,x |> Point { ... }报的是 「不可调用」,x |> Point(1)报的是「记录必须用花括号」(§2.4)。 - callee 位置的模块函数是具名调用(§10.3):模块别名不是值,所以
x |> m.f与x |> f同义,就是m.f(x)。默认值、具名实参、按调用点推断泛型与效果、效果操作都照常, 报错也与裸名同文。(m.f)(x)与with v <- m.f同理;这与 §2.4 记录值的x |> r.f不同,模块别名不会与任何绑定同名,没有歧义可言。只有作为值使用(let g = m.f、map(xs, m.f))时m.f才是函数值,和裸名f作值一样不带默认值与形参名。 - 记录里存的函数值字段照常动态调用:
x |> r.callback就是r.callback(x)。
4.5Lambda#
函数值最多接收 8 个显式参数;函数类型、lambda、命名函数或构造器作为值使用时,
超界在检查期报错。命名函数直接调用、直接构造记录不受这个函数值限制。
普通效果操作最多 8 个参数;ctl effect 操作最多 7 个,因为处理器还接收续延。
效果操作在声明处检查;这些边界在 JVM 与 native 后端一致。
let double = (x: Int) => x * 2
let add = (a, b) => a + b # 参数类型可推导时可省略
let now = () => 0 # 零参数
xs |> map(x => x * x) # 单参数可省括号
- 写法只有一种:
params => expr。params是x(单参数且不带注解时括号可省)、(a, b)、(),每个参数可带: Type。=>独占「一个匿名函数从这里开始」这个职责。 fn前缀已彻底退休:fn(x) => e不再解析成 lambda,原先仅在尾位保留的f(a) fn(x) => e也已于 #206 期 2 退役。fn现在只用于具名函数声明和函数类型; 两种旧表达式拼写都有专有迁移诊断。- body 要多条语句就用块:
(x) => { ... }。 - 写在调用后面(同一行)时它是那次调用的最后一个实参——尾块,见 §4.3:
f(a) { x => e }(花括号头绑形参);这是唯一的尾实参拼写。 裸箭头进不了尾位:f(a) (x) => e里的(x)已经是柯里化调用f(a)(x), 两种读法在读到=>之前无法区分。诊断会点名这件事。 (开头的形式先看)之后是不是=>再决定怎么解析:(a: Int)是合法的 参数表而不是合法的表达式,覆盖文法(先当表达式解析、见到=>再重解释)在这里 不成立。判定失败时报「invalid parameter list before=>」并点名出错的那个记号 ((a + 1) => e)。- lambda 不能直接当
if/while的条件:if x => { ... }会把本该是循环体的 花括号吃成 lambda 的 body。这是一条专门的诊断,不是「expected{」。 - 闭包按值捕获绑定(捕获
var是编译错误——想共享可变状态请显式传递)。 - 例外只有两处,且都是格子:handler 的格子(§6.5)与 staged
for的格子(§4.7)。 它们以格子身份穿过各自被授权的闭包,读写经过格子;其余闭包照旧。 - 箭头分工(设计裁决,2026-07-31,刻意不统一):
->是子句箭头,只出现在 「声明形状」的位置——函数类型(§2.2)与 match 臂(§5.1);=>是表达式箭头, 唯一职责是标记一个匿名函数的起点。两者不互相客串:臂体是 lambda 时 (Some(f) -> x => f(x)),两种箭头正是读者辨认层级边界的信号—— 这与 Rust 保持->/=>分立同理,与 Scala 的统一相反。
4.6if#
let sign = if x > 0 { 1 } else if x < 0 { -1 } else { 0 }
- 条件必须是
Bool,分支体必须是块。 - 作为值使用时
else必须存在且分支同类型; 作为语句(值被丢弃)时可省else,此时分支必须是Unit。
4.7循环#
for x in [1, 2, 3] { println("${x}") }
for (key, value) in entries { println("${key}=${value}") }
while queue.non_empty() { ... }
for/while是Unit类型的语句,体内可用var累积。for pattern in source与for pattern in from..to复用完整递归 pattern 文法,包括 tuple、list、constructor、qualified constructor、or-pattern 与行首|continuation。 pattern 必须对元素类型不可反驳;可反驳 pattern 是编译错误,不会被解释成 filter 或静默跳过不匹配元素。- pattern binding 只在循环体内可见。source 与 range 两端都在外层 scope 检查和求值, 看不到这些 binding;循环后的代码也看不到 pattern binding 或循环体局部变量。
- 若 iterable source 不返回(类型为
Never),它仍恰好求值一次,并作为整个语句的 bottom 路径;不解析迭代 witness,也不进入 loop、pattern binding 或 body。 break退出最内层循环,continue跳到其下一轮。二者是类型Never的表达式 (同return,可出现在 match 臂等表达式位置);只在循环体内合法,且不可穿越 lambda/局部函数边界去够外面的循环(lambda 是独立函数,想退出它用return)。 无标签形式——需要多层跳出请提取函数用return。comptime 循环同样支持。with之后的语句也是一个闭包的体(§4.10),所以那里的break同样够不到外面的 循环——诊断会点名with,而不是说一个作者没写的 lambda。for pattern in e迭代任何实现了Iter的类型(预置 trait,§3.5):e在进入 循环前恰好求值一次;迭代脱糖为对iter_start/iter_done/iter_next/iter_get的普通 trait 调用,元素类型是该 impl 的Item。std 的五个容器(List/String/Bytes/Map/Set)开箱可迭代; 泛型函数里[C: Iter]的参数同样可for(字典转发)。迭代序即 impl 的游标序 (String按码点、Map/Set同entries/to_list)。staged
for:e的类型实现了StagedIter时,for pattern in e { body }不是宿主 循环,而是交给该 impl 的staged_for:body成为一个闭包,由 impl 决定怎么跑(tileir 用它记录设备循环)。e仍在外层恰好求值一次,先于一切格子。体中提到的每个外层var(读或写,按作用域,不含体内自己声明的)在进循环前按声明序各开一个格子 (StagedVar.var_open),体内对它的读写经过格子,循环后写回;嵌套的 staged 循环复用外层 已开的格子。这样的var的类型必须实现StagedVar,否则是编译错误(宿主值在被记录的体里 没有意义);体内的普通 lambda 不能捕获格子名;break/continue/return/?不能离开 staged 体;体只能执行StagedIter[R]声明的效果。区间a..b永远是宿主循环;同时实现Iter与StagedIter的类型不能被for迭代。var acc = zeros(o) # Tile[Float],tileir 提供 StagedVar[Tile[D]] for k in d_range(0, K / T) { # tileir 提供 StagedIter[DRange] acc = mmaf(load_at(a, [k]), load_at(b, [k]), acc) } store_cell(o, acc)for pattern in a..b支持右开区间的整数范围(不经Iter,元素类型是Int)。a先于b求值; 两端各恰好求值一次,且都在进入循环前求值。即使区间为空,这些保证仍成立。
惯用风格优先 map/filter/fold;循环是给性能敏感处和口味用的。
4.8下标#
let x = xs[i] # List[T] -> T;越界 panic(含负数)
let v = m["key"] # Map[K, V] -> V;缺键 panic(消息含键值)
let c = rows[1][0] # 可链式、可与 ?/./() 组合
[]是断言,get 家族是问询:xs[i]用于「下标必然合法,越界是 bug」的场合 (panic 语义,同 Rust);越界/缺键是正常分支时用get(xs, i)/map.get(m, k)(返回Option)。- 下标由预置 trait
Index求解(§3.5):List(Idx = Int)与Map(Idx = 键类型)的 impl 随语言提供,用户类型写一个impl Index即可支持[]; 没有 impl 的类型是编译错误。opaque type不沿用其目标类型的Index:下标把元素交出模块外, 与渲染同构,要[]就在声明模块写自己的impl Index(§2.7)。 - comptime 中支持
List下标(越界为编译错误)。 - 只读——没有
xs[i] = v,Index也没有对应的写方法。列表与映射不可变; 用户类型即使可变也不经[]写入。 - 比较运算符
</==不经 trait 路由到标量的 native 实现(见 §4.3)——Index只管[]。 算术同,见 §4.3。
用户类型的 impl:
type Grid = { w: Int, cells: List[Int] }
impl Index[Grid] {
type Idx = (Int, Int)
type Item = Int
fn index(g: Grid, p: (Int, Int)) -> Int = {
let (x, y) = p
g.cells[y * g.w + x]
}
}
let g = Grid { w: 3, cells: [1, 2, 3, 4, 5, 6] }
let v = g[(2, 1)] # 6
fn first[C: Index](c: C, i: C.Idx) -> C.Item = c[i] # 泛型消费者
越界三判据。语言只承诺三条越界策略,每个带下标/区间参数的操作(本节的 [] 与
§11 的库函数)恰属其一,按参数的含义归类而不是逐函数各定各的:
- 断言——参数是一个位置,调用方声称它合法,越界是 bug:
xs[i]、m[k]、bytes.at、str.at、pvec.index/nth、cursor.slice的非法区间 → panic(含负下标)。 - 问询——越界/缺键是调用方要区分的正常分支:
get族(list.get、map.get、set.has的缺席、index_of族(str/bytes/list)的未命中、str.strip_prefix/strip_suffix的不匹配、bytes.from_hex/from_base64与fspath.extension(包)的 格式不合)→Option(或Bool)。 - 钳位——参数是一个区间,说的是「要这一段里有的部分」,不断言端点存在:
list.take/drop/slice、bytes.slice、str.slice/take/drop、cursor.next/prev/back/seek→ 两端各自夹进[0, len](负数取0、超长取len),from > to得空序列,永不 panic。
唯一具名例外:cursor.char(s, c) 到尾返回哨兵 -1 而非 Option(§11「std/cursor」)。
它是逐字符前进的原语——每步包一层 Option 就是每步一次分配;同一个哨兵在 bytes.at
那里被 std 包装成 panic(判据 1),在 cursor.char 这里则是公开契约的一部分
(packages/json 的 lexer 依赖它)。除此之外,任何库函数不得以哨兵值表达越界。
4.9return#
fn classify(n: Int) -> String = {
if n < 0 { return "negative" } # guard 子句
if n == 0 { return "zero" }
"positive"
}
return expr/ 裸return(仅Unit函数)从最内层函数提前返回—— 在 lambda 内则退出该 lambda(同?的作用域规则)。return是类型为Never的表达式,可出现在任意表达式位置(如 match 分支)。- 所在函数(或 lambda 的期望类型)必须已声明返回类型——省略返回类型的推导函数内不可用。
- 糖区内不可用:
with之后的语句是一个闭包的体(§4.10),return到不了外面, 编译器在那里直接报错并点名with。
4.10with 语句#
fn write_all(path: String, bytes: Bytes) -> Unit !io = {
with f <- bracket(FileOutputStream.new(path), s => s.close())
f.write(bytes)!
f.flush()!
}
with x <- f(a, b) 是 parser 级的糖:块里它之后的所有语句被打包成
x => { ... },作为 f 的最后一个实参附加上去,整个调用成为块在这一点的值。
上面那段等价于
bracket(FileOutputStream.new(path), s => s.close(), f => {
f.write(bytes)!
f.flush()!
})
- 只在块里,且只能是语句:它要吃「块剩下的部分」,没有块就没有可吃的
(
fn f() = with x <- g()是语法错误,提示加大括号)。 - 一个块里多个
with自然嵌套——第二个吃的是它自己之后的部分。配合bracket时释放顺序因此天然是由内向外(§9.8.2)。 - 落点与尾块同一处(§4.3):
f(a, b)长一个实参、r.m(a)长一个实参、 裸名字g变成g(x => ...)。所以with与尾块组合起来只是普通调用, 没有第三条规则。 with的 callee 上不再开尾块(#206):with x <- f(a) { ... }被拒——with已经要把块的剩余部分作为f的最后一个实参,{会是同一次调用的 第二个闭包。诊断点名这件事;要附加块就加括号,或者不用with。?透明穿过:闭包交回的就是with调用的值,而那就是块的值,所以糖区内的expr?传播出去的Err会成为外层函数的返回值——bracket的 release 照跑 (对拍见scripts/spike-native/with_sugar.dawn)。return/break/continue在糖区内被拒,诊断点名with:它们要穿的是一个 作者没有写出来的闭包边界,而「穿出去」只在这个块恰好处于尾位时才等于读者以为的 意思。糖区内自己的循环不受影响(for在with之后,break属于它)。糖区内碰不到
with之前的var,诊断点名with引入的那个闭包:糖区是闭包体, 而闭包按值捕获、拒绝捕获var(§4.5)。所以with之前声明的每个var, 在它之后的块剩余里既不能赋值也不能读;with之后声明的var属于糖区自己, 不受影响。要把糖区之前的值带进来,先let一个快照,或者把它作为参数传进去。 stagedfor(§4.7)是另一个作者没写出来的闭包;那里能碰到的外层var只有能进格子的那些。var n = 1 let base = n # 先 let 化;此后再读 n 会被拒 with x <- g var acc = base + x # 糖区里自己声明的 var 正常- 绑定处写一个名字,规则同 lambda 形参;没有
_形式(lambda 形参也没有)。
为什么是
bracket而不是defer:受保护的区间恒为一次闭包调用,return/break在语言层面就跨不出去,编译器因此不欠一套逃逸改写。 判据见docs/core-move2-design.md§2.6 与 §6。
4.11列表字面量与元素形式#
list_lit = "[" [ list_elem { list_sep list_elem } [ list_sep ] ] "]"
list_sep = "," | NL (* 换行与逗号是同一个分隔符,相邻时算一个 *)
list_elem = expr (* 普通元素:贡献一个 *)
| ".." expr (* 展开:贡献 0..n 个 *)
| if_no_else (* 条件元素:贡献 0 或 1 个 *)
if_no_else = "if" expr block { "else" "if" expr block } (* 末尾没有 else *)
column([
header,
..body, # body: List[Widget[Msg]],整段接进来
if m.note != "" { text(m.note) }, # 有话说才占一行
help,
])
分隔符:换行与逗号等价。 多行的列表字面量里,元素之间的逗号可以省略, 一个换行就是一个分隔符:
column([
header
..list.map(m.todos, t => item_row(t))
if m.note != "" { text(m.note) }
dim(text("commands"))
])
两种分隔符可以在同一个字面量里混用,尾随的逗号或换行照旧允许,空字面量 [] 与
跨行的 [\n] 都还是零个元素。这是纯语法加法:[1, 2, 3]、多行带逗号、尾逗号
这些既有写法的解析结果一字未变。dawn fmt 也不在两者之间改写,作者选的分隔符保留
(对列表只调整行内空白与缩进,不动作者断的行)。
省的只是换行处的逗号。同一行上的两个元素仍然要逗号:[a b] 是语法错误,
诊断仍是「expected ], found b」,位置不变。
哪个换行是分隔符,由 §1.7 决定,不由这条规则决定。元素先按普通表达式规则解析完,
可以领起行首的续行运算符(|>、.、二元运算符)照旧被上一行的元素吃掉,
剩下的那个换行才是分隔符:
[
xs
|> f # 一个元素:`|>` 领起续行,与列表无关
a
-1 # 两个元素:`+`/`-` 不作行首续行(§1.7),`-1` 是新元素的前缀负号
]
+ 没有前缀含义,于是行首的 + 落在元素位置上被拒,诊断是
「expected an expression, found +」。这是 +/- 那条例外在元素位置上的自然结果,
不是列表字面量的额外规则。
行首的 .. 一律读作展开,不是 range 的续行。.. 从来不是表达式运算符
(它的四处含义都是位置,见下),range a..b 只出现在 for 头部(§4.7),
所以下面左边是两个元素、而不是右边那个 range:
[
a
..xs # 展开元素
]
[0..3] # 仍然被拒:`..` 夹在两个值之间,又不在元素开头,两种含义都不是
三种元素形式只在列表字面量里成立。元组、记录、构造器实参不认识它们;
pattern 里的 [x, ..rest](§5.1)是另一件事——那是解构的余项,与这里的展开
拼法对称、语义相反。
展开 ..xs:操作数必须是 List[T],它的每个元素按位置铺开。不是列表报
「.. spreads a list, but this is …」。.. 在表达式位置永远不合法,只在这三处:
for 头的范围(§4.7)、record 展开 P { ..p }(§2.4)、pattern 余项(§5.1),
再加上这里的元素展开。
条件元素 if c { x }:条件为真贡献一个元素 x,为假贡献零个。
x 的类型参与元素类型合流,它不必是 Unit——普通表达式位置上
「没有 else 的 if 必须是 Unit」那条规则(§4.6)在这个位置不适用,
因为这里不需要它凑出一个值。
else if 链算一整个元素形式:if a { x } else if b { y } 贡献 0 或 1 个,
取第一个条件为真的那一臂;全不真则零个。写多深都是一个元素形式。
带 else 的链不是元素形式,它是一个普通元素,语义与本特性存在之前完全相同:
[a, if c { x } else { y }, b] 永远是三个元素。于是有一处刻意的不对称——
不带 else 能省略,带 else 不能展开:
[a, if c { x }] # 0 或 1 个:条件元素
[a, if c { x } else { y }] # 恒 1 个:普通元素,二选一
[a, if c { ..xs } else { ..ys }] # 不合法:`..` 不在元素位置上
理由是取最小的那一刀。要让带 else 的形式也能展开,就得让 if 的两个分支
各自是「一串元素」而不是一个值,也就是 Dart 的 collection-if/collection-else 那套
——一个只在集合字面量内部成立的第二套 if 文法,还要回答 else 分支能不能再套
for、能不能嵌套之类的一串问题。而不带 else 的形式不需要任何这些:它在普通
表达式位置本来就只能是 Unit,把它挪到元素位置上没有和任何既有含义冲突。
同样的理由否掉了 collection-for([for x in xs { f(x) }]):list.map 已经写得下,
而加了 for 立刻要回答「为什么没有 while」。
求值顺序是从左到右,元素形式不改变这一点。条件元素的 body 只在它那一臂 被选中时求值。
类型规则:所有元素形式的贡献汇成同一个元素类型 T,字面量的类型是 List[T]。
普通元素贡献自身类型,展开贡献 List[T] 的 T,条件元素贡献各臂 body 的共同类型。
期望类型向下传,这是元素形式真正的用处:一旦 T 定下来(从字面量的期望类型,
或从任何一个已检查的元素),后面每个元素都以 T 作为期望被检查——展开的操作数
拿到的是 List[T],条件元素的每个 body 拿到的是 T。于是
# text: fn text[M](s: String) -> Widget[M],M 只出现在返回类型里
[text(s)] # 报错:cannot infer type parameter(s) M
[header, text(s)] # 可以:header 先把 M 定成 Msg
[header, if c { text(s) }] # 同样可以,期望穿过条件元素进到 body
第三行是 let note: List[Widget[Msg]] = if … 这类中间变量消失的原因:
一个单独的 [text(s)] 绑定没有兄弟可问,只能靠标注;同一串东西写成一个字面量的
元素,标注就不需要了。需要期望才能检查的元素(裸 None、嵌套 [])仍然排在
最后一轮检查,所以它们和源序无关。
破坏性语义改变(v0.67.0 之后):
[if c { <Unit 表达式> }]以前是合法的, 类型List[Unit],长度恒为 1——里面那个if是普通元素,作为语句求值出()。 现在同一份源码长度是 0 或 1,且条件为假时 body 根本不求值。不报任何错,静默改行为。受影响的程序恰好是「条件元素的 body 类型为
Unit」这一族,一个不多一个不少: 元素形式之前,没有 else 的if在表达式位置只能是Unit(§4.6)。 落地时对 dawn-lang(566 个.dawn)与 dawnop-site 后端(84 个)逐文件用新 parser 扫过,既有出现 0 处。没有给过渡期诊断,理由是它只能是错误:Dawn 的诊断没有严重级别,
Diag只有msg/lo/hi/hint,全部诊断都是错误。而把「body 是Unit的条件元素」定成错误, 等于为了保护一类实测不存在的程序,在新形式的类型规则上永久挖一个洞——List[Unit]长度 0 或 1 在新规则下是自洽的。过渡期会过去,类型规则里的特例不会。
5.模式匹配#
match shape {
Circle(r) if r > 100.0 -> "big circle"
Circle(r) -> "circle ${r}"
Rect(w, h) -> "rect ${w}x${h}"
Point -> "point"
}
相邻 match 臂必须由物理换行或 , 分隔;, 后可以换行,最后一臂后也允许
尾逗号。空格不是分隔符:match x { 0 -> 1 1 -> 2 } 会在第二个 1 处明确报缺少
换行或逗号,而不会把“下一 token 看起来像 pattern”当作隐式边界。该规则不改变
§1.7 的换行续接:嵌套在 ()、[]、{} 内的换行仍属于臂体表达式。
5.1模式形式#
| 模式 | 例 | 匹配 |
|---|---|---|
| 字面量 | 0, "yes", true | 相等则匹配 |
| 绑定 | x | 恒匹配并绑定 |
| 通配 | _ | 恒匹配不绑定 |
| 构造器 | Some(x), Rect(w, h), Rect(w: w, ..) | 按位置或按名解构,.. 忽略其余字段;只用圆括号 |
| 记录 | Point { x, .. } | 字段解构;只用花括号 |
| 元组 | (a, b) | |
| 列表 | [], [x, ..rest] | 空表 / 头与余下 |
| 或 | 0 | 1 | 2 | 任一匹配(各分支绑定必须一致) |
| 守卫 | pat if cond | 模式匹配且守卫为真 |
| 是 pattern 的最低优先级,递归出现在构造器、记录、元组与列表 pattern 内,并按
源码顺序收集为扁平的 n-ary or-pattern。(pat) 只用于分组,tuple pattern 仍需要逗号。
| 可以放在续行行首,例如 A\n| B;换行后若没有 |,pattern 仍开始下一个 match 臂。
pattern 的首个 | 可写可不写,与和类型声明(§2.3)同一条规则:| A | B -> 1 与 A | B -> 1 相同,
多行或-模式因此可以每支一行、各带竖线,dawn fmt 把这些竖线与臂对齐:
fn light(c: Color) -> Int =
match c {
| Red
| Amber -> 0
Green -> 1
}
行首的 | 只属于 pattern:它不续接上一行的按位或(0 -> x 换行 | A -> 2 是两个臂,不是 x | A),
按位或要跨行时把 | 留在行尾,与行首 - 是前缀而不续接减法同理(v0.78.0 起)。
运行时选择第一个匹配的 alternative,选中后不在同一 or-pattern 内回溯。match 臂的 guard 作用于整个 or-pattern,最多执行一次;guard 为 false 时进入下一臂,body 也最多执行一次。
每个 alternative 必须绑定完全相同的名字集合,且同名绑定的类型必须一致。外围的 let
或 var 为整个 pattern 一次性决定可变性,各 alternative 不可能在这点上不同。第一支的
绑定是共享环境中的 canonical binding,后续支只为它提供另一个取值路径。
5.2穷尽性#
match 必须穷尽。编译器对 ADT/Bool/Option/Result/元组做穷尽性检查,
缺分支报错并列出缺失构造器。Int/String/Float 上的 match 必须有
_ 或绑定兜底分支。
let 也接受不可反驳模式:let (a, b) = pair、let Point { x, y } = p,以及导入的单构造器
类型上的 let m.Only(x) = value。or-pattern 使用同一份 usefulness 判定,所以
let true | false = flag 合法,而 let true = flag 仍然可反驳并被拒绝。
编译器在 usefulness 搜索前归约类型已知的完备 alternatives,包括 true | false。剩余搜索
受确定性工作预算约束。如果预算耗尽,编译器会拒绝原本语法合法的 match 或结构性 let,
报告 pattern analysis exceeded its complexity budget,而不会猜测是否穷尽。诊断会建议简化
嵌套 alternatives,或把 pattern 拆成较小的 matches。
6.效果系统#
6.1模型#
效果行有两条轴。
基轴的两个已定点是 pure(默认、不写)与 io。
!io 覆盖一切可观测副作用:文件、网络、时钟、随机数、打印、可变全局态、
以及全部 Java 互操作。基轴上还住着两类未定原子,与 io 并列而不是 io 的子情形:
效果变量(§6.3)与关联效果投影(§6.5)。
标签轴是用户用 effect 声明的具名效果的有限集合(§6.5)。它独立于基轴:
!io 不覆盖具名效果。所以一个 !io 的函数发出 !Ask 仍然要在签名里写出 !Ask。
时钟。 读钟是 !io,原语有两个:墙钟 io_clock_wall_ns(1970-01-01T00:00:00Z 起的纳秒,
宿主可以拨它,所以后一次读数可以更小、读数可以为负)与单调钟 io_clock_mono_ns(原点任意、
只有两次读数之差有意义;两后端读同一个钟,JVM System.nanoTime、native 与 wasi
CLOCK_MONOTONIC)。单位都是纳秒,一个 Int,可表示到约 2262 年;分辨率随宿主,不作承诺。
两者都只给 std 用(§10.6),编译期求值拒绝(§7.2):读数会让同一份源码在不同时刻编出不同的字节。
程序经 std/io 的具名效果 Clock(§6.5)读钟,生产 handler 是 io.with_clock_real:
io.now_wall_ns() 回墙钟 Int,io.now() 回单调读数 io.Instant(opaque,§2.7),
io.elapsed_ns(a, b) 是 b - a,为负时取 0。
io 的两副面孔要分开读,这是本节的主条文:在包含关系里 io 是上界,
一条签名承诺 !io 就覆盖得住更纯的实现(包括只发效果变量的实现);在并运算里
io 不是吸收元,!io !e 不得消去 !e,两个原子都留在规范形里(印作 !(e|io))。
完整规则见 §6.6。
两条轴都平凡可判定:并是有限集合按分量取并加一位布尔或,包含是逐轴的集合判定。
环境效果#
io 是唯一的环境效果。 环境效果是一档,不是「一个小写的原子」:它不用声明就能写进任何行,
不占证据槽,没有 handler 能应答它、也没有 handler 能把它从行里减掉,任何签名写了它就容得下它。
用户不能声明环境效果(effect 只声明标签轴上的具名效果),所以这一档今天只有 io 一个成员。
一个原子是什么由表决定,不由拼写决定,查的次序是:环境效果表 → 作用域内的具名效果
(含选择性引入与改名,§10.2)→ 模块别名的导出表(!dep.Ask)→ 类型参数的关联效果(!T.E)→
本签名的效果变量。哪张表都不认识的名字,拼写只决定报哪条诊断:大写名本意是一个效果,
报「unknown effect」(它若是个类型,报「is a type, not an effect」);小写名是效果变量,
按 §6.3 出现即引入。两者对合法程序总是一致,因为大小写在声明点已被强制:effect
只收大写名,效果参数只收小写名,改名不许改变大小写类别。
pure 同样只在效果位保留:它不是关键字,在效果位之外是普通标识符;在效果位的任何地方都被拒绝(§6.3)。
6.2规则#
- 函数体的效果 = 体内所有调用效果的并。
- 签名未标
!io的函数,体内出现 io 效果 → 编译错误 (报错指出哪个调用引入了 io,并建议在签名加!io或消除该调用)。 - 标了
!io但体是纯的 → 允许(预留演化空间)。Dawn 只有一种诊断等级,没有 lint; 「多余!io」将来要么做成错误,要么不做。 - 纯函数保证:给定相同参数返回相同值、无可观测副作用。
编译器可据此折叠、消重、在 comptime 调用。唯一的例外是
dbg(§8.3):它写的 stderr 行 定义为诊断旁路、不算程序的副作用,所以它的类型是纯的。具名效果也在这条保证之内: 函数值的类型带着完整的效果行,能从行里减去一个标签的只有真正应答了它的那个with handle(§6.5),所以签名为纯的函数跑不出别人的 handler 臂。 - 函数值的效果在调用点应答,不在写下它的地方。闭包的行就是它体内发出的效果, 创建点不做改写;一次调用实例化出的行里若有没人应答的具名效果,错误报在这次调用上 (§6.5)。
panic/todo/assert不算 io——它们不返回(发散不是效果)。- 吸收禁令:效果行不得在无 handler 的前提下丢掉标签、效果变量或关联效果投影。
并不丢(§6.6);把一个函数值送进某个位置时,目标位置的行必须逐原子容得下它的行,
丢一个原子就是丢一格证据。唯一的减法点是
with handle(§6.5),它只减自己应答的 那个标签。签名承诺!io覆盖住自己绑定的变量不算丢弃:那是包含关系(§6.6), 变量在这条签名的绑定者列表里仍有证据槽。 - 局部
fn的行与 lambda、写出来的函数类型同规则:fn g() -> Int !Ask = ask()合法,!e、!T.E只要外层签名绑定了也合法;同一个体写成 lambda 与写成局部fn能力相同。 区别只有一处:lambda 的行由体推出,局部fn的行是写出来的固定承诺(同顶层签名,§3.1), 体里用到行外的标签、变量或投影是错误;局部fn不能引入新的效果变量(它也不能声明类型参数)。 v0.78.0 之前局部fn的行只能是!io或纯(SPC-19)。
6.3效果多态#
高阶函数用效果变量转发参数的效果:
fn map[T, U](xs: List[T], f: fn(T) -> U !e) -> List[U] !e
fn compose[A, B, C](f: fn(A) -> B !e1, g: fn(B) -> C !e2) -> fn(A) -> C !(e1 | e2)
- 效果变量
!e无需声明,在签名中出现即引入;作用域是整条签名。 也可以在参数表里写出显式绑定者:fn map[T, U, !e](xs: List[T], f: fn(T) -> U !e) -> List[U] !e。 两种写法等价。签名自带显式绑定者时,该名字在这条签名内只解析到它;没写的名字照旧出现即引入, 同一条签名里两者可以混用。绑定者不带约束([!e: X]是错误),名字必须小写(!E是具名效果, 不是变量),!io与pure都不能当绑定者名,同名绑定者不能写两次。
pure 在行的任何位置都被拒绝,不只是绑定者位置:它读起来是「只许纯」,
当变量用却是「什么行都行」,两者相反。空行有自己的拼法 !()(§6.6)。
绑定者列表也是顺序的来源:它按写下的次序绑定,未写绑定者的名字仍按首次出现的次序引入。
- 类型声明也能绑定效果参数:
alias Mapper[T, U, !e] = fn(T) -> U !e、type Box[!e] = { f: fn(Int) -> Int !e }。只能靠绑定者:类型声明不是签名, 「出现即引入」不适用,写一个本声明没绑定的变量是编译错误——加进参数表,或写!io,或留纯。 同一声明里的多处!e是同一个变量,不同声明的!e互不相干。 具名效果不受这条限制:alias A = fn() -> Int !Ask、type Boxed = { f: fn() -> Int !Ask }都合法。标签不是变量,没有绑定者可言;它的含义是「调用这个函数值的人供 handler」, 而这正是今天证据的走向(§6.5)。 - 名义类型绑定的效果参数只走空证据。 限制的是实例化,不是书写:
type Box[!e]、type Chain[!e]、opaque type Hidden[!e]的!e只能解成不欠证据的行, 即纯、!io,或它自己(原样转发,fn store(b: Box) -> Box = b)。带具名标签、 带关联效果投影、或带别处绑定的变量的行不得经这个通道流进消费方。 理由是通道不存在:签名绑的变量有一格隐藏证据参数可走(§6.5「实现」),名义类型没有, 记录不是帧,而名义类型的身份只记名字与类型实参(下下条),没有位置逐使用点记住一条行。 要让一条真欠证据的行流动,就把变量绑在运行这个闭包的函数上 (fn f(g: fn() -> Int !e) -> Int !e),或者改用透明alias并在使用点写出效果实参(下一条)。 - 使用点可以写效果实参,只有透明
alias收:Thunk[!io]、Thunk[!Ask]、Thunk[!e]、Mapper[Int, String, !(Ask | e)]。实参位收的就是行位置能写的一切,一条也不少: 透明别名就是它的展开,Thunk[!Ask]与fn() -> Int !Ask是同一个类型的两种拼法, 内联写得出的行不会因为经过别名而写不出。 实参在使用点自己的作用域里解析,与内联写下同一条行的读法逐字相同:!e指的是消费方 签名绑的那个变量(首次出现即引入),!Ask是这里可见的那个具名效果,!T.E是这里的投影。 代换按声明顺序逐位进行,别名自己的变量被替换掉,结果按集合语义并进展开式里原有的行 (拍平、去重、io不吸收变量,见下两条):alias IoThunk[!e] = fn() -> Int !io !e上IoThunk[!Ask]是fn() -> Int !(io | Ask)。 实参与类型实参共用同一对方括号,各按自己那条列表定位(声明侧的[T, U, !e]也是这样分的), 所以效果实参写在哪两个类型实参之间都不动类型实参的对齐。 记录、变体、opaque type不收:它们的身份是「名字 + 类型实参」,没有位置记住这个行, 收下来要么两个类型不可分辨,要么可分辨却印成同一个名字。透明别名没有身份可记,它就地展开, 实参落在展开式里。 - 省略效果实参仍然合法,不是元数错误:
Mapper[Int, String]只给类型实参。 省略掉的那一格仍是声明自己的那个效果变量,由上下文求解: 把一个纯闭包存进去,它在那里就解成纯;把一个!io闭包存进去,它在那里就解成!io。 消费方要在自己的行里容得下它,而一条只由本签名绑定的!e容不下别处绑的变量。 别名的出路是把实参写出来(Mapper[Int, String, !e],!e由本签名绑定); 名义类型没有这条出路,消费方写!io(!io容得下任何行)。 !(e1 | e2)为并,规范化后存储:并按分量取(io 位布尔或,变量、投影、标签三个 集合各自取并),pure是幺元(可省去),能收成更小形状的就收成更小形状 (!(e|e)即!e)。io不吸收任何东西:!(io | Ask)就是!(io | Ask),!io !e就是!(e|io),都不会塌成!io。完整规则与它的封闭性见 §6.6。- 效果变量横跨两轴:
!e可以实例化成带标签的行。map(xs, x => ask())里list.map的!e实例化成「pure + {Ask}」,于是for循环体里发具名效果照样通得过。 - 实例化不限原子个数:一个
!e可以解成单个标签、若干标签的并(!e := !(Ask | Tell))、 另一个变量、或标签与变量的混合(!e := !Ask !e2)。没有「一个变量至多站一个标签」这条规则。 - 调用点实例化:
compose(inc, tag)若inc纯、tag为!io,则结果类型的效果并 规范化为!io;两者皆纯则规范化为纯,结果可在纯上下文调用。 - 调用点实例化:
map(xs, println)中e = io,故整个调用是 io。
6.4(已删除)unsafe_pure#
本节曾定义 unsafe_pure { <表达式> }:一个只在 std 内合法、把 !io 屏蔽为纯的表达式戳子,
以及随它而来的编译期 Java 折叠(route C)与 --comptime-ffi 开关。2026-09-24 连同关键字一起删除
(裁决 5):它在整个生态里零使用点,而它承担的「一个宿主调用支撑一个纯函数」已经由 intrinsic 契约
在声明侧承担(§11)——一个原语的效果行由编译器随自身发布,受自举与差分守护,不由调用点逐个作保。
unsafe_pure 从此是普通标识符。设计与删除理由见 `docs/pure-ffi-design.md`
与 `docs/effects-window-design.md` §6。节号保留,免得全仓的 §6.5、§6.6 引用顺延。
6.5具名效果与 with handle#
限定名与改名#
效果是普通的模块成员,到达它的路径与类型、函数相同:use library as dep 之后,!dep.Ask
指 library 声明的公开效果 Ask;use std/io 之后 !io.Fs 同理(这里的 io 是模块别名,
不是环境效果 io——环境效果是不带点的原子)。小写首段是模块别名,不是效果变量;
!T.E 仍是大写首段的关联效果投影。限定名在一切效果行里可用,包括函数类型、效果实参与并
(!(io | dep.Ask | e));也可以在 trait 默认与 impl 绑定里命名单个地面效果
(effect E = !dep.Ask),以及当 handler 选择子(with handle dep.Ask { ask() => 42 })。
臂里写操作在所选效果内的非限定名。
选择性引入可以给效果改名:use std/io.{Fs as Files} 之后行里写 !Files、handler 写
with handle Files { ... }(改名的通用规则见 §10.2)。改名只改本模块的名字表,效果的身份、
它带进来的操作名、诊断里打印的名字都不变。
限定名、选择性引入与改名解析到同一个由提供方拥有的效果身份。不同模块的同名效果仍是不同的效果; 整模块引入不把效果或它的操作带进非限定命名空间。两个库导出同名效果时,两个都用就写限定名 或改名,两个都裸引入是冲突错误。缺失的别名、缺失或私有的效果、种类不对的导出成员都是错误, 绝不隐式成为效果变量的绑定者。只支持两段。地面绑定仍拒绝变量、关联投影与并,限定名不放松这条规则。
effect 声明一组操作签名,调用处直接调操作,由调用处词法上最近的 with handle 应答。
臂有两档。尾恢复(tail-resumptive)臂是「就地调用、返回值即操作结果」的普通闭包,
没有延续捕获。控制臂多一个绑定子句(op(参数…) resume k => 表达式),k 绑定这次
操作的续延,臂的值是整个 with handle 块的值;控制臂只写得在声明为 ctl 的效果之下,
规则集中在下面的「控制臂」一节。绑定子句在不在,就是臂的种类记号。
设计与裁决见 `docs/effects-design.md` 与
`docs/oneshot-design.md`。
声明#
effect Ask {
## 向上下文要一个 Int。
fn ask() -> Int
}
effect State {
fn get() -> Int
fn put(v: Int) -> Unit
}
ctl effect Fetch {
fn fetch(url: String) -> String
}
- 效果名是 UpperCamelCase,与类型、trait 共享一个命名空间。
- 声明前可以写一个
ctl前缀,意思是「这个效果的操作可以被控制臂挂起」。ctl是上下文关键字(与handle、resume同例),不保留,照旧可以当标识符、 参数名与函数名。带可见性时的顺序是pub ctl effect E:ctl站在opaque站的位置, 在可见性与它修饰的关键字之间。 - 操作是普通函数签名:无体、不得再带效果注记(操作的效果就是它所属的效果本身), 当前也不得带类型参数。
- 操作名进入模块的函数命名空间:与本模块的任何顶层声明(普通函数 / trait 方法 /
另一个效果的操作)同名是重定义错误;与另一处引入的名字(含另一个效果带进来的
同名操作)同名也是错误。
pub effect的操作随use m.{Ask}一起进来, 也可以写m.ask(…)。 - 效果本身不带类型参数;
effect Yield[T]当前不支持。
拼写与传播#
fn sum_three() -> Int !Ask = ask() + ask() + ask()
fn logged(x: Int) -> Int !Ask !io = {
io.println("asking")
ask() + x
}
- 具名效果写在效果位,与
!io、效果变量并列;多个注记可以叠写(!Ask !io), 也可以写成并(!(io | Ask))。 !name的判别是查表,次序与落空时的诊断见 §6.1「环境效果」:命中作用域内的effect声明 → 具名效果;哪张表都不认识时,小写名是效果变量,大写名报「unknown effect」 (是个类型时报「is a type, not an effect」)。- 发出了却没人应答(既没在签名里声明、也没有 handler)→ 在没人应答的那次调用上报错,
并给出两条出路:签名加注记,或就地
with handle。直接调操作时那次调用就是操作调用; 标签是经函数值或效果多态调用带进来的,报错点就是那次调用,不是被调函数的定义处。 - 只有入口模块的
pub fn main的标签必须为空——它没有调用者提供证据,「没人应答」的错误落在main的签名上。普通pub fn可以携带pub effect的标签,由调用方继续传播或安装 handler; 出现在公开面的是私有效果时,才由导出面校验拒绝(§3.3)。
with handle#
fn demo() -> Unit !io = {
with handle Ask { ask() => 42 }
io.println("${sum_three()}") # 126
}
with handle E { 臂… } 是 with 语句的一种子句形态(§4.10):块的剩余部分在该
handler 的作用域内,与 with x <- f(…) 完全同构,并继承它的全部纪律——只在块内合法、
剩余部分是真闭包、return/break/continue 被拒(诊断点名 with handle)、
? 透明穿过。
- 臂形如
op(参数…) => 表达式(尾恢复臂)或op(参数…) resume k => 表达式(控制臂, 见下面的「控制臂」一节),=>与 lambda 同记号(臂本来就是闭包),臂之间换行分隔。 每个声明的操作恰好一臂:少臂、多臂、臂名不属于该效果都是编译错误。 一个 handler 里两种臂可以并存。 - 一个
handle一个效果;同一效果重复handle是内层遮蔽外层,合法。 - 允许 handle 一个块里实际没发出的效果(无害的死证据)。
handler 局部状态(格子):臂表的开头可以声明若干
var,每个是这次安装私有的 一格可变状态。格子从本次安装的臂与with handle之后的块剩余可达:臂读它写它, 块剩余读它。这是带状态的 handler 把攒下来的东西交回安装点的唯一表面,没有return臂 一类的第二条路。三条附带规定:格子必须全在臂之前(混在臂之间是编译错误,有专门诊断, 免得读者去猜声明顺序有没有意义);类型标注强制(臂表的花括号里没有可推的上下文); 这里声明的是位置而不是新写法(var与赋值语句都是 §4.1 那两条,格子只是给它们添了一个 合法的声明位置)。格子本身没有可拼写的类型,用户拼不出它。with handle Emit { var acc: List[Int] = [] # 格子:注解必须写,且必须排在臂之前 emit(n) => { acc = acc ++ [n] } } body() acc # 块剩余读它,这就是交回表面安装点之前的
var在其后碰不到(§4.10 的按值捕获纪律在这里的实例):安装点 之前声明的每个var,在with handle之后的块剩余里既不能赋值也不能读; 安装点之后声明的var完全正常。诊断点名with引入的那个闭包,并给两条出路: 先let一个快照,或者把它作为参数传进来。臂本身也是闭包,同样拒绝捕获外层的普通var、 拒绝赋值给它。例外只有本次安装的格子:上一条声明的那几个var,臂可读可写, 它们不是被捕获进来的外层绑定,而是这次安装自己的状态。var n = 1 let base = n # 先 let 化;此后再读 n 会被拒 with handle Ask { ask() => 42 } var acc = base + ask() # 安装点之后声明的 var 正常- 类型规则:设剩余闭包的效果是
(base, L)、各臂身体的效果是(base_i, L_i), 则装 handler 的这个块记(base ∪ ⋃base_i, (L ∖ {E}) ∪ ⋃L_i)。 减法就在这个节点上:剩余部分是闭包,供给它证据的就是这个节点,所以能把E从行里 减掉的只有它。减法跟着供给走,不跟着执行走:控制臂之下,剩余可以由一条跑到别处去的 续延来运行(见「控制臂」),而它要的那份证据仍是这个节点在安装点建好交出去的那一份。 一个没供过E的 handler 因此拿不掉任何东西。基轴不减:!io不是handle应答得了的。 臂身体自己发的效果(含 io、含别的标签)全部并回块头上—— 臂在装 handler 的地方运行,不在发出操作的地方。 「行失去一个成员」只发生在with handle这一个语法节点上,不进 unification。 ?早退丢状态:?照旧透明地穿过with handle(§4.10),带格子也不例外。 早退丢的是本次激活的状态,那次激活的格子随之销毁;块剩余里读格子的那行代码本来 就不会执行,所以没有「看起来一定会跑、实际被跳过」的交回表面在骗人。 在早退之前被某个控制臂存下来的续延不受影响:它带着自己那次激活的格子,恢复它就是 恢复另一条状态谱系。这不是本语言发明的口径, 它是文献里的 local state interpretation(Wu / Schrijvers / Hinze, Effect Handlers in Scope, Haskell 2014),也就是状态 handler 装在错误边界内侧时的标准答案。
use std/io
effect Emit {
fn emit(n: Int) -> Unit
}
fn body() -> Unit !Emit = {
emit(1)
emit(2)
}
pub fn main() -> Unit !io = {
with handle Emit {
var acc: List[Int] = []
emit(n) => { acc = acc ++ [n] }
}
body()
io.println("${acc}")
}
[1, 2]
这就是格子存在的形状:body() 里散落的 emit 按发出顺序攒进 acc,而 body 的签名只说
它发 !Emit,不说攒到哪里去。
词法作用域与供给点#
证据(handler 的臂)按词法解析:操作调用绑定到词法上最近的 with handle。
证据随调用进入被调方,闭包在创建点不捕获任何 handler。推论:
- handler 是调用点的,不是创建点的。在 handle 块内建的闭包逃出块外再被调用,
不带着原 handler:它的行里仍有
!E,由调用点在场的 handler 应答,那里没有就报错。 类型说的是「谁得供 handler」,与臂做什么无关;纯臂的 handler 造出的逃逸闭包也是!E。 - 格子逃不出词法域。状态格子只从本次安装的臂与块剩余可达,被任何不属于这次安装的
闭包捕获是编译错误,三种写法各有诊断:臂里手写的 lambda(被许可的是臂,不是臂里再造的
闭包)、
with x <- f(…)之后的那段块剩余(with引入的是另一个闭包,它在声明格子的with handle之外,出路是在with之前把格子读出来)、另一次安装的臂。格子本体也不能 作为值传出词法域,它没有可拼写的类型。与它相对的是普通let:handler 区域里let绑的值 照旧可以被逃逸闭包捕获,这道禁令收紧的只有格子。 续延是唯一能把这次激活的状态带出块外的东西,而它带出去的不是一个新名字:块剩余闭包 本身仍然只被原地施用一次,逃出去的是它跑到一半的那条续延,续延带着的是它自己那次激活 的那一份格子。所以「没有第二个名字够得着这一格」在名字层面原样成立。 - 臂身体里发本效果,绑定到外层的同效果 handler(自己不应答自己);没有外层就是 臂欠标签,照常报错。
- 重入:臂拿到的证据是安装点之前那一层的环境,所以一次安装的格子只被这次安装的臂
访问。嵌套安装各有各的格子,内层攒的东西不混进外层。
「读格子、调用、写格子」那个经典的丢更新形状在尾恢复这一档构造不出来:中间那次调用
无论怎么绕都回不到同一个格子。控制臂之下它构造得出来:臂读格子、调
k、剩余再发一次 同一个效果、同一个臂再进来。答案是格子的单位是一次激活,重进来的臂读到的是本次激活 的那一份;同一次安装被递归激活多次时各次激活各有一份,恢复一条续延回到的是它自己那次激活 的那一份。(此前这条只对尾恢复档承诺,并留了一句「放开之后格子须随延续复制」的逃生门; 本节就是行使它的地方。) - 臂身体里发别的效果,由安装点在场的 handler 应答,不是发出操作的那处在场的: 臂在安装点运行。
use std/io
effect Ask {
fn ask() -> Int
}
fn escaping() -> fn() -> Int !Ask = {
with handle Ask { ask() => 7 }
() => ask() + 1
}
pub fn main() -> Unit !io = {
let f = escaping()
with handle Ask { ask() => 2 }
io.println("${f()}")
}
3
ask() => 7 应答的是它自己那段区域里发出的操作,而 f() 发生在区域之外,
所以 f 的行留着 !Ask,由 main 这里的 handler 应答。
控制臂:resume 绑定与一次性续延#
控制臂形如 op(参数…) resume k => 表达式,k 由作者命名。它只写得在 ctl 效果之下,
非 ctl 效果之下的臂写绑定子句是编译错误,诊断点名那个臂并要求去改声明:可挂起性是
声明上的性质,因为一条行点名的是效果,而这是 Java 边界读得到的全部。ctl 是许可不是
要求,ctl 效果之下写不带绑定子句的尾恢复臂合法。
- 应答类型:设
with handle块剩余的类型是A,则块的类型是A,每个控制臂的体 必须也是A,k的类型是fn(R) -> A(行见下一条),R是该操作声明的返回类型。A不是新的类型变量,它就是块剩余的类型;一个控制臂都没有的 handler 里,这条规则什么都不说。 - 臂的值就是块的值,与操作声明的返回类型无关。一次都不恢复是合法的,臂的值直接成为 块的答案,块剩余里挂起点之后的部分不再运行。
k是一个普通函数值,没有属于自己的类型:它进得了 ADT 字段、格子与列表,也可以在 安装 handler 的那个帧已经返回之后再被调用。它的行是块剩余的行在基轴上的 io 位:剩余 含 io,或含效果变量、关联效果投影(两者都可能被实例化成 io,保守并入),则k是fn(R) -> A !io;否则是纯的fn(R) -> A。标签一律不带。不带标签不是宽容,是供给点 纪律的直接推论(见下面的「实现」):剩余要的那份证据包在安装点就建好并交给了续延,调用 它的人不必也不能再供一次。io 位带着,是因为它不占证据槽、对调用者一无所求;它禁止的只有 一件事:从一个签名说纯的帧里恢复或丢弃一条会做 io 的剩余。于是 §9.8.1 的「fault 只从 io 来」不需要为续延开例外。k是深的:调用它会重装本次安装,所以恢复之后的剩余再发同一个效果,仍由这次安装 应答,读到的仍是这次激活的格子。- 一次性是动态的:一条续延至多用掉一次。类型不说这件事,也不试图说;第二次用掉它是 panic(消息见下表),不是可捕获的 fault。
- 控制臂里的
?:按A判,因而要求A是Option/Result,意思是放弃续延、 用None/Err当整个块的答案。在调用k之前?,放弃的是一条从未恢复的续延; 在调用k之后?,丢掉的是剩余已经交回来的值,不是状态。 - 不跨 Java 边界:行里点名了某个
ctl效果的函数值不能转成 Java 函数式接口, 在 SAM 转换点报编译错误(§9.4),因为 Java 帧下面没有 Dawn 帧可以承接一次挂起。 效果变量与关联效果投影两条轴不静态判,转换点不知道调用方会把!e实例化成什么; 它们由运行期接住,一次接不住的挂起是 panic 而不是错答案。
use std/io
ctl effect Ask {
fn ask(n: Int) -> Int
}
fn release(tag: String) -> Unit !io = io.println("release ${tag}")
fn resumed() -> Int = {
with handle Ask { ask(n) resume k => k(n + 1) }
ask(1) + ask(10)
}
fn bare() -> String !io = {
with handle Ask { ask(n) resume k => "dropped at ${to_string(n)}" }
with r <- bracket("bare", release)
"held ${r}, then ${to_string(ask(1))}"
}
fn explicit() -> String !io = {
with handle Ask {
ask(n) resume k => {
discard(k)
"discarded at ${to_string(n)}"
}
}
with a <- bracket("outer", release)
with b <- bracket("inner", release)
"held ${a} and ${b}, then ${to_string(ask(1))}"
}
pub fn main() -> Unit !io = {
io.println(to_string(resumed()))
io.println(bare())
io.println(explicit())
}
13
dropped at 1
release inner
release outer
discarded at 1
resumed 是一次往返:k(n + 1) 拿这个值当那次 ask 的结果去跑块剩余,剩余产出什么就是
臂的值,也就是块的值。bare 与 explicit 是下面那条裁决的两半。
放弃一条续延#
捕获了却不恢复的续延有两种结局,而语言只承认一个显式的触发器。
- 裸丢弃什么都不跑。 把最后一个引用丢掉,被冻住的那段计算里的
bracket一个release都不跑(上面的bare:没有release bare那一行,这就是全部断言)。这是裁决而不是疏漏: 按回收触发清理会让同一份源程序在两个后端上有两种可观察行为,因为 JVM 的 GC 时机不是 native 的引用计数时机;而析构器那条路也不通,release是带词法环境的任意用户代码, 运行时独立调用不了它。被丢弃的续延占用的内存由实现回收,本规范不承诺回收的时机。 discard是被认可的那条路。fn discard[T, U, !e](k: fn(T) -> U !e) -> Unit !e它用一剂毒药恢复被放弃的计算,让展开在挂起点与安装点之间的每一个
bracket上落地、 由内向外跑一遍release,然后答Unit。参数类型就是续延自己的类型,不是更窄的 东西:上面已经裁过k是一个普通函数值,这里不把它收回去。所以任何函数值都过类型, 是不是一条续延在运行期判。!e就是续延自己的行:纯剩余的续延丢弃起来是纯的, 带 io 的续延丢弃起来是!io,丢弃与恢复欠的是同一条行。discard与恢复花的是同一张票:丢弃过的续延不能再恢复,恢复过的也不能再丢弃。 comptime 拒绝它。
四种误用各有一条逐字的消息,它们只在下面这段程序的输出里写一次,按这个顺序:恢复一条
已经用掉的续延;丢弃一条已经用掉的续延(丢弃后恢复、恢复后丢弃、丢弃两次是同一张票,
所以是同一条消息);对一个不是续延的函数值 discard;在丢弃的展开途中挂起。
(写第二遍就是一份没人核对的抄件:output 那一栏由这份文档的门禁跑出来比对,
散文里的表格不会。)
use std/io
ctl effect Ask {
fn ask(n: Int) -> Int
}
type Held =
| Ready(v: Int)
| Waiting(k: fn(Int) -> Held)
fn plain(n: Int) -> Int = n
fn twice() -> Int = {
with handle Ask { ask(n) resume k => k(n) + k(n) }
ask(1)
}
fn escaping() -> Held = {
with handle Ask { ask(n) resume k => Waiting(k) }
Ready(ask(1))
}
fn spent() -> String !io =
match escaping() {
Ready(v) -> "resumed ${to_string(v)}"
Waiting(k) -> {
discard(k)
match catch_panic(() => discard(k)) {
Ok(_) -> "discarded twice"
Err(e) -> e.message
}
}
}
fn suspending() -> String !io = {
with handle Ask {
ask(n) resume k => {
discard(k)
"unreachable"
}
}
with r <- bracket("r", tag => {
let _ = ask(99)
()
})
"unreachable ${r} ${to_string(ask(1))}"
}
pub fn main() -> Unit !io = {
match catch_panic(() => twice()) {
Ok(v) -> io.println(to_string(v))
Err(e) -> io.println(e.message)
}
io.println(spent())
match catch_panic(() => discard(plain)) {
Ok(_) -> io.println("discarded a plain function")
Err(e) -> io.println(e.message)
}
match catch_panic(() => suspending()) {
Ok(s) -> io.println(s)
Err(e) -> io.println(e.message)
}
}
dawn: continuation resumed twice
dawn: continuation discarded after it was already used
dawn: `discard` expects a continuation
dawn: a `ctl` operation was raised while a continuation was being discarded
另外四条规定:
- 毒药不是失败:
catch_panic与catch_fault都拦不住它。这条是必须的而不是方便: 拦得住就意味着被放弃的计算可以从自己的清理里复活,从屏障那里接着往下跑完。bracket照旧释放,它护而不拦(§9.8.2)。 - 冻结跨度里的内层激活一起丢:一次冒泡越过的内层 handler 激活也在被丢弃的那一段里,
它底下的
release同样跑,由内向外。 - 展开途中不许挂起:
release里做 io、失败、开自己的bracket都可以,唯独不能发ctl操作。展开下面的帧已经注定要走,没有任何东西能恢复进去,所以一次会挂起的展开 就是一次可能永远不完成的展开。 - 展开途中
release逃逸的失败不按 §9.8.2 的替换规则:剩下的release照跑,第一条 失败是被交付的那一条,在展开跑完之后交给调用discard的人。理由记在 §9.8.2 的补注里。
两个后端只承诺可观察的一致。 冻结与恢复的机制不写进本规范:JVM 上是把栈冻起来,
native 上是停下那条栈、换另一条来跑,两者不同是刻意的。本规范承诺的是同一份源程序在两个
后端上给出同一串可观察行为:答案、release 的次数与顺序、panic 的消息与它落在哪里。
逐字节比对这三样的语料是 scripts/spike-native/ctl_resume.dawn、ctl_nested.dawn 与
ctl_discard.dawn。
边界(v1)#
以下是 v1 的边界。「写出来的具名效果」不再是其中任何一条的理由,各条各有各的判据。
- trait / impl 方法:方法的行是完整的行——效果变量、具名标签、关联效果投影
都可以。变量由这条签名自己引入(§6.3 的隐式引入在这里就算
「已绑定」),不合成证据,字典槽位一字不动;标签在槽位上就是一格类型精确的证据参数;
投影是一格擦除的——哪个证据记录由 impl 决定,边界上只知道有这么一格。
trait Container[C] { fn wrap(c: C, body: fn() -> Int !e) -> Int !e }里!e是 调用者的:同一个wrap一处收纯闭包、一处收!io闭包。 impl 与 trait 的效果变量按位置配对(各自签名里的引入序:行、参数从左到右、返回类型), 拼写不必相同,个数不等是错误。配对、并经这个 impl 的关联效果绑定归约之后,impl 的行必须 被 trait 的行覆盖:纯总是可以(做得比行少不算食言),无关的标签或!io不行;写出来的 标签例外,两边必须恰相等——每个标签是一格隐藏证据参数,是方法的形状而不只是承诺, 少写一个就换了方法的元数。 trait 方法的默认体照普通函数体检查:它能沿变量或擦除的证据格转发(调那个闭包), 但装不了 handler——刚性效果没有名字可供with handle点名。 - comptime / const 初始化:编译期求值不发具名效果,也装不了 handler。
- 局部函数:规则见 §6.2 第 8 条。局部
fn是闭包,函数值恒带一格证据包,调用点按它写出的行建包, 所以行里写了的标签、变量、投影都由调用点供给;行里没写的,体内就拿不到(词法上够得着外层 binding 不等于运行时拿得到那格证据),这正是它的行必须写全的原因。 - 写出来的函数类型:任何
TypeRef位置的fn(…) -> T !E都合法,包括参数、返回、let注解、alias目标、record/variant 字段、泛型实参、元组元素、trait / impl 方法的 参数类型。写出来的标签读作「调用我的人得供 handler」,这正是它的运行时含义。 效果变量(fn() -> Int !e)是另一种写法,不是迁移写法:它把行转发给自己的调用者, 收任何行的闭包。!io、关联效果投影(fn() -> Int !C.E)同样合法。 记录字段存得进带标签的闭包,字段的行就是取出来调用时要供的行。 - 函数值:带标签的具名函数可以当值传,行进入值的类型,调用点供证据。
效果操作本身不能:操作调用是「读证据字段 + 调闭包」,没有可取的函数符号,
诊断也是这么说的。写成 lambda(
() => ask())即可。 - 效果多态代码能转发、不能装 handler:
with handle E在语法上就点名一个具体效果, 所以 handler 的安装点永远是单态的。签名里的!e只能把行传下去,装 handler 要写出 效果的名字;同一堵墙的另一面是pub fn main的 labels 必须为空。
实现(信息性)#
每个 effect E 让 lowering 合成一个普通记录类型(用户拼不出它的名字),字段是各操作的
闭包,末尾多一格 env:臂运行时所在的证据环境,在安装点填好。with handle 构造这个
记录并绑成一个局部,操作调用 = 取字段 + 调闭包,被调臂的证据格取自 env 而不是发出点。
证据有两套约定,翻译点唯一。
具名调用按行逐原子给槽。 签名里写出的每个标签给函数追加一格类型精确的隐藏证据参数, 排在字典参数之后、按效果 id 升序;每个签名内引入的效果变量追加一格擦除的; 每个关联效果投影再追加恰好一格擦除的,排在标签的证据之后、按(主体、trait、成员名) 排序,由 impl 侧的桥接还原成具体证据记录。
函数值恒一格。 不论行写了什么,函数值的运行期元数是「写出的参数 + 一格证据包」,
纯函数值也背一格空包。证据包是不可变链,节点为 (key, evidence, outer),按原子键寻址:
标签、效果变量、关联投影三条轴各自分段取键,所以两条行在一格里相遇是一次 cons、查询是按键
走链,不必重排也不会串位。调用点建包的规则:行里已定的原子(写出的标签、已归约成标签的
投影)各 cons 一个节点,压在本帧已经持有的未定环境之上;行里没有已定原子、只剩一格未定原子
时,包就是那一格,不分配。超集无害:查询按键,多一个节点只多走一步;缺键才是错误。
两套约定在 lift_fn_value 合成的 wrapper 上换手:把具名侧要的每一格按键从函数值那一格包里
取出来。高阶库函数(list.map 之流)走的是「只剩一格未定原子」那条,原样转发,不建包。
边界条款:值离开语言时带的是创建点的快照。 上面两条都把供给点定在调用处,因为只有调用处 能确定 handler 帧还活着。函数值转成 Java 函数式接口(§9.4)之后没有这样的地方:adapter 由 Java 帧进入,底下没有 Dawn 栈帧,调用的时机与线程都不由语言决定。所以这一处,也只有这一处, 证据由值自己带:转换点照常用同一个建包规则把当时在作用域里的包建好,存进 adapter 对象的 一格字段,Java 每次进入都把它交给闭包。行在转换点按普通调用点记账,作用域答不上的原子照常 报错,所以快照不是把标签丢掉的办法,而是把它的应答提前到唯一还有应答的地方。语言内部的规则 一个字没改:调用点供证据,跨界点取快照,两者只在值离开语言的那一步接触。
这就是并不设吸收的原因(§6.6)。 上面两套约定都从效果行读形状:具名侧读出槽的有无 与个数,函数值侧读出包里该有哪些键。于是任何删掉一个标签、一个变量、一个投影的行等式, 都会让静态的行与运行时的载体失配:签名少开一格,或者建包时少 cons 一个键,而读的那一侧 照旧按键去取,取空就是「effect evidence missing」这条编译器不变式的 panic,落在一份检查 全绿的程序上。§6.6 的吸收禁令是同一件事的静态一侧。
一个备选方案在此显式否决:让运行时在槽位缺失时降级为按标签动态查找,吸收于是只损失 性能、不损失正确性。不采用。它把「行说有的原子,载体里必有它的位置」这条不变式换成一条 回退路径,而回退路径只在恰好缺槽的程序上跑,测不到的那部分正是不健全的那部分;查找也会 从「按键走链」一套语义变成两套。不变式保持原样。
6.6效果行的等价与规范形#
一条效果行由四类原子构成:基轴的 io、效果变量(§6.3)、关联效果投影(§6.5),
以及标签轴的具名效果。pure 是没有原子的行,写作 !();凡是能写 !io 的位置都写得下它
(签名的行、函数类型的行、效果实参、impl 的关联效果绑定),与整条注记省略不写等价。
pure 不是这条行的拼法:它不是关键字,写进行里只会成为一个普通的效果变量,
而效果变量任何行都解得开,所以 !pure 是错误(§6.3)。
行的等价由且仅由以下五条规则生成。
- 并按分量取:两条行的并 = io 位取布尔或,效果变量、关联效果投影、具名标签三个集合
各自取并。于是
|交换、结合、幂等,pure是幺元。多个注记叠写与写成一个并等价:!Ask !io就是!(io | Ask)。 - 规范形取最小形状:基轴分量全空即
pure,只剩 io 即!io,无 io 且只剩一个基轴 原子即该原子本身,否则是这些基轴分量的并;标签集合为空就不带标签层,非空则是这条基轴 行加上这些标签。 - 规范形有确定次序:效果变量按引入序(签名内首次出现的次序,显式绑定者按写下的次序
排在前面),关联效果投影按(主体类型参数、trait、成员名),具名标签按效果 id。这个次序
同时是证据槽的布局次序(§6.5「实现」)。渲染是另一回事:印出来的原子按名字字典序排,
所以
!io !e印作!(e|io)。 - 投影按 impl 归约:主体定下来时,关联效果投影换成该 impl 绑定的那条行,换完按上面 重新规范化。一条 ground 的行里不再有投影。
- 两条行等价当且仅当规范形相等。
没写下的等式就是没有。 尤其是:并里没有吸收律。!(io | Ask)、!(e | io) 都是
不动的规范形,任何把它们等同于 !io 的读法都不是这五条的推论,而是新增了一条规则。
并不区分「占证据槽的效果」与「环境效果」。 io 是环境效果,自己不占证据槽
(§6.5「实现」),这不构成它可以吸收 !e 的理由:!e 可以实例化成占槽的具名效果,
而槽的有无要到实例化之后才知道,并却是在实例化之前算的。这条最容易被反向推翻成
「io 又不占槽,吸掉有什么关系」,所以写成条文而不是注释:吸收的对象是原子,不是槽。
包含是另一条关系,不参与等价。 !io 覆盖 pure、io 与效果变量(含它们的并);
具名标签与关联效果投影则按精确的集合包含判,!io 既不覆盖 !Ask 也不覆盖 !C.E。
两条关系分工明确:
- 签名按包含判。体比签名纯是允许的(§6.2 规则 3),一条签名可以承诺
!io而站在 它绑定的变量上面,证据照样到得了,因为槽由绑定者列表决定而不是由行决定。 - 函数值不按包含判。它没有绑定者列表,它的行就是调用点用来建包的全部依据,所以送进 某个位置时,目标位置的行必须逐原子容得下它的行。向上放宽仍然免费:纯闭包进任何 位置都行,多出来的键没人读;被拒的是「行里有个原子,目标没有」。
行相减是「逐原子」的唯一例外。把一个函数值送进形如 !(e | R) 的参数位时,若 e 是
被调方签名绑定的唯一效果变量,R 是这条行里具体原子(具名标签、io、关联效果投影)
的集合,则把 e 定为 S \ R 并接受,其中 S 是实参的行。写成 !(e | io) 的
参数因此收得下一个既做 io 又抛具名效果的闭包,而在此之前唯一能收下它的拼法是光秃的 !e。
这一步有一条共现前提:被调方签名里提到 e 的每一条行,都必须写下 R 的全部原子。
理由是 !(e | R) ~ !S 的所有解在含 R 的行上取值一致,e 究竟取了哪条,只可能从一条
「提到 e 却不提 R」的行上看出来。签名里没有这样的行时,e := S \ R 是唯一可观察的
答案,取它不损失主型。有这样的行时,取哪条都是替调用者做了一个它没写下的选择,故仍然拒绝,
并在诊断里点名是哪一处出现让这个选择变得可观察。
一次调用把多个函数值实参穿过同一个 e 时,各实参的余量 S_i \ R 取并(这条格上
的最小上界)并进 e 的绑定:既不是第一个实参说了算,也不是最后一个。这与光秃的 !e 参数
一直以来的做法相同,一条参数行的两种拼法不给出不同答案。
这一步有一条配套条件:e 要在每个参数未代换的声明行上找。前一个实参给 e 落下
绑定之后,代换后的行里已经没有变量可找了。
实参的行不必含有 R。S 短于 R 时,S \ R 就是 S 减去它本来就没有的原子,e
照样定为它并接受。这一步安全的理由与「向上放宽仍然免费」是同一条:调用点是按实例化后的
行建包的,不是按实参自己的行;包按键沿链查找,不按下标定位。所以短一截的闭包拿到的是个超集,
多出来的键没人读。
其余形状不变。反过来仍然拒绝:实参行里有个原子而目标位置没有,那是上面「函数值逐原子」 的那一条,本节不动它。行里有两个及以上效果变量也仍然拒绝:余量在它们之间没有主型划分,本 规范不给一条。
7.comptime#
7.1形式#
const CRC_TABLE: List[Int] = comptime { crc32_table() }
fn lookup(d: Int) -> Float =
SIN_TABLE.get(d % 360).expect("table covers 0..360")
comptime { ... } 是表达式:编译期由编译器内置解释器求值,
结果作为常量嵌入产物。顶层 const 的右侧隐式处于 comptime 上下文。
7.2约束#
- comptime 代码必须是纯的(可调用任何纯函数,包括本模块与依赖模块的)。
- 结果类型必须常量可序列化——即编译器能在类初始化时把这个值重新造出来:
Int/Float/Bool/String/Unit,以及仅由这些组成的List/元组/record/ADT。 函数值不行。不透明类型与它的目标一样可序列化(§2.7),只有一个例外:std/cursor的Cursor不可以。它的Int是后端各自的字符串表示里的偏移 (JVM 是 UTF-16 码元、native 是 UTF-8 字节),而常量只折叠一次、写进两个后端 共读的 Core,所以折出来的位置至多对一个后端成立。要在编译期算位置,就持有字符串、 在用它的地方走过去。这条例外是 §11「度量永不成为可观察的值」的一个推论,不是它的 全部:渲染那条路同样被堵上了,否则const S: String = to_string(c)会绕过本条、 把度量以字符串形式折进产物(解释器按码点计数,于是那还是第三个答案)。 等游标统一度量后这条例外撤销。Map/Set暂不可以:它们是Array之上的 HAMT,而 comptime 解释器没有Array原语;List能行是因为解释器自带列表表示,不是因为它跑得动std/pvec。Bytes也不可以,理由同源:解释器的值类型里没有字节串这一种,一个Bytes值在编译期 无处存放,于是std/bytes的原语在 comptime 一律被拒绝。它有Eq/Hash/Show不改变这一点;能不能作常量问的是解释器能不能持有它,不是它支持哪些 trait。 要在编译期准备字节数据,就算出List[Int]或String,在用它的地方再转成Bytes。 - 求值有步数预算(默认 10⁸ 步,
--comptime-fuel调整),超限报错—— 保证编译必然终止。产出与输入等长的原语按产出大小计步,在分配之前扣: String 的++按结果的码点数,List 的++按右操作数的元素数(左边是共享的, 见 §2.2),range、列表切片、join、code_points/from_code_points、 大小写转换、字符串切片与引号渲染按产出的元素数或码点数。于是预算同时界住 内存与单步耗时:s = s ++ s翻倍一次付一次结果的长度,而不是几步。 产出个数大到Int装不下时(例如range两端之差溢出)按超出任何预算处理,不按零计。 - comptime 里没有 Java 互操作、没有 io(由约束 1 自动保证)。
- 纯不等于能折叠:有些纯的 builtin 在 comptime 同样被拒绝,因为它们操作的值解释器表示
不了(上面提到的
Array、Bytes、Map/Set),或者折出来的答案属于构建而不属于程序。 哪些被拒绝逐函数查,不在这里枚举:dawn doc --builtins给每个编译器 builtin 的条目 带一个"comptime"字段,值是"ok"或"refused"。这份标记取自解释器实际拒绝的 名单,门禁双向核对它与解释器、与selfhost/builtins.dawn的# comptime: rejected标记一致。用 Dawn 写成的 std 函数没有这个字段:它能不能折叠取决于函数体走到哪些 builtin,常量求值失败时报错会沿调用链指出是哪一个(dawn check即可看到)。
7.3明确不做#
comptime 不能生成类型、不能生成声明、不能内省 AST。 它只是“提前跑一段纯 Dawn 代码”。本规范不提供元编程能力。
8.错误处理#
8.1可恢复:Result / Option + ?#
fn parse_config(path: String) -> Result[Config, String] !io = {
let text = read_file(path)? # Err 时提前返回该 Err
let json = json.parse(text)?
Config.from_json(json)
}
- 后缀
?作用于Result[T, E]:Ok(v)解出v,Err(e)使当前函数 立即返回Err(e)。对Option[T]同理(None提前返回None)。 ?所在函数的返回类型必须是相容的Result/Option(E类型必须一致, 没有自动错误类型转换)。?是Option/Result的表达式级传播简写:它把当前表达式的None/Err分支传播为当前函数的返回。return、break、continue是独立的显式 jump,不属于这套简写。
跨错误类型:写个本地 helper,别等语言给。 ? 要求 E 一致,所以返回
Result[_, HttpError] 的函数不能直接 ? 一个 Result[_, String]。解法是
在边界处放一个 4 行函数:
fn as_http[T](r: Result[T, String], status: Int) -> Result[T, HttpError] =
match r { Ok(v) -> Ok(v)
Err(m) -> Err(http_error(status, m)) }
之后 let rows = as_http(repo_call(...), 500)? 即可,? 接管其余部分。
曾为此加过一个 std 的
map_err,2026-07-19 撤销:dawnop-site 的 102 处跨层match里,94 处靠「?+ 上面这个 helper」就能拆(另有 8 处错误类型本就相同, 连 helper 都不需要),而map_err的全部作用只是把这个 helper 从 4 行缩成 1 行, 一个项目一次。真正解开那 102 处的是?和局部 helper,不是新增的库函数。
? 在 lambda 内从该 lambda 返回,故闭包里的桥同样可以塌。
8.2不可恢复:panic#
panic(msg, at: Loc = caller()):向 stderr 打印 panic: <msg> at <at>,进程以非零退出。
不打印栈。at 省掉就是这次调用的位置;替调用者报错的函数把自己的 at 传下去(§8.4)。
todo(at: Loc = caller()) 等价于在同一处写 panic("not yet implemented"),且能通过任意类型检查
(返回类型为底类型 Never)。
位置后缀:panic、todo、expect、后缀 ! 与 assert(§3.4)失败时,消息末尾是
调用点的位置 at <path>:<line>:<col>,由编译器在编译期按调用点填入:
<path>只取决于源码树,不取决于命令行与工作目录:项目里相对项目根(src/main.dawn), 单文件相对入口文件所在目录(p.dawn),[deps]包是<包名>/<包内路径>。<line>、<col>从 1 起,列按码点计,与诊断的--> path:line:col同一把尺子。 指向失败表达式的起点:panic/todo的名字、o.expect(..)与o!的o、assert关键字。- 位置是消息的一部分,
catch_panic拿到的message同样带它(§9.8)。 - std 没有位置路径:std 里省掉
at的失败不带位置。把panic当函数值用时at是普通形参 (fn(String, Loc) -> Never),调用方必须给出一个Loc。 - 没有运行期栈迹:Core 不带 span,位置只在编译期知道的调用点上
(理由见
docs/source-location-design.md)。
后缀 !:o! 把 Option[T] 解成 T,None 则 panic。语义同
expect(o, msg),唯一区别是消息由编译器生成——含产生 None 的调用与源位置
(unwrapped None from URI.create() at src/http.dawn:23:13),故不必为它编造占位串。
let uri = URI.create(url)! # 而不是 .expect("uri")
let base = HttpRequest.newBuilder()!.uri(uri)! # 而不是 .expect("b") / .expect("b-uri")
! 存在的理由就是 §9.2:Java 把引用返回一律包成 Option,而绝大多数 JDK 方法
其实永不返回 null,于是解包是常态。
- 只作用于
Option。Result用?传播(§8.1)或match。 - 与
./[]同级、左结合:a()!.b()!即((a()!).b())!。 - 行尾可以是
!——它不是二元运算符,故不触发续行(§1.7)。x! != v中!=仍是一个 token(先按最长匹配切分),比较的是解包后的值。 - 确有话要说时仍用
expect(o, "原因")——它就是为此存在。
get/map.get 返回 Option(问询);下标 c[i] 越界/缺键 panic(断言,§4.8;语义由该类型的 Index impl 定,List/Map 如上);
Int 除零(/ 与 %)panic——是 panic 故 catch_fault 不拦(§4.3 数值边缘语义)。
8.3调试输出:dbg#
dbg[T: Show](x: T) -> T 原样返回 x,并向 stderr 写一行:
[<path>:<line>:<col>] <实参的源码文本> = <show(x)>
let n = dbg(a * 2) # stderr: [src/main.dawn:4:11] a * 2 = 8
let m = xs |> len |> dbg # stderr: [src/main.dawn:5:11] xs |> len = 3
- 类型为纯,语义为恒等。
dbg(x)的值就是x;那一行是诊断旁路,不属于程序语义: 不进效果行(不要求!io,纯函数里也能写),handler 截不到,不保证在将来的优化下条数与顺序不变。 这是全语言唯一一个类型说纯、实际写输出的特例(§6.2 规则 4 的例外即此)。 <path>:<line>:<col>与 §8.2 的位置后缀同一规则同一把尺子,指向调用的起点(dbg这个名字; 管道e |> dbg是e的起点)。- 实参文本取自调用点的原文,不是反打印。跨行的实参折成一行:跨过换行的一段空白变成一个空格,
紧挨括号内侧的则去掉(
twice(\n a +\n 1)写作twice(a + 1))。 - 渲染用
Show(嵌套渲染,String带引号),类型没有Show是编译错误,与插值同一条纪律。 - comptime 中它就是恒等:常量照常折叠,编译期什么也不打印(§7)。
- 只属于正在调试的程序。 由
[deps]加载的模块里、std 里出现dbg是编译错误; 同一个包作为独立项目检查时可以用。没有剥离dbg的构建模式:语义恒等,剥不剥结果不变。 - 只能调用,不能当函数值(
let f = dbg是编译错误):函数值没有调用点,也没有实参文本。 要传就包一层 lambda:(x) => dbg(x)。 - 只有一个实参。要看几个值,传一个元组:
dbg((a, b))。
8.4调用者位置:caller() 与 Loc#
替调用者报错的函数用一个默认值为 caller() 的形参拿到调用它的那一行:
fn assert_eq[T: Eq + Show](a: T, b: T, at: Loc = caller()) -> Unit =
if a != b { panic("${show(a)} != ${show(b)}", at: at) }
fn sums() -> Unit = assert_eq(1 + 1, 3) # panic: 2 != 3 at src/t.dawn:4:21
Loc是编译器提供的不透明类型(拥有者std/loc),值就是<path>:<line>:<col>, 路径、行、列的规则同 §8.2 的位置后缀。"${l}"与show(l)都是这串本身;loc.path、loc.line、loc.col取三段(从右切,路径里有:也对)。只有caller()能造出Loc。caller()只能是一个顶层函数形参的完整默认值。 省掉这个实参时,它的值是这次调用 的位置:调用表达式的起点(f这个名字;方法式与管道是左边的起点),路径是调用点所在 模块的位置路径。写了实参就用写的。caller()出现在别处、当函数值用,都是编译错误; 本模块自己声明的fn caller遮蔽它,那时= caller()是普通默认值。- 转发是显式的。 每一跳写
at: at;没有at形参的函数报它自己体内的那一行。 不做隐式调用者位置(理由见docs/caller-location-design.md)。 - 要一个当前位置的值(测试里比对、经函数值调用时补实参):
loc.here(),它本身就是fn here(at: Loc = caller()) -> Loc = at。 panic、todo、expect的最后一个形参就是at: Loc = caller()。省掉它时消息与 §8.2 逐字相同。- std 没有位置路径,std 里省掉的
caller()是不指向任何地方的Loc(渲染为空串,panic对它不加后缀); std 只从调用方接收Loc,不把自己的交还给程序。 - comptime 中照常:位置在求值之前已是常量。
9.Java 互操作#
9.1引入与调用#
use java "java.nio.file.Files"
use java "java.nio.file.Path"
use java "java.lang.StringBuilder"
fn slurp(p: String) -> Option[String] !io =
Files.readString(Path.of(p).expect("valid path"))
fn build() -> String !io = {
let sb = StringBuilder.new() # 构造器统一拼写为 .new
sb.append("a")
sb.append("b")
sb.toString().expect("non-null")
}
use java "全限定名"把类引入为:一个不透明类型 + 一个静态方法命名空间。 只读静态字段可访问:Class.FIELD(常量惯例大写,如Integer.MAX_VALUE、Math.PI、枚举常量TimeUnit.SECONDS;小写字段名同样可读,如System.out)读取该字段—— 值按 §9.2 映射(引用类型包Option需!解包,int/double等基本类型加宽), 效果同一切互操作为!io。只读(无Class.FIELD = v);实例字段与非pub静态字段不可访问。.后的成员名可以是任意「词」,包括与 Dawn 关键字同形的名字(in/type/match等): Java 有名为这些的成员,而.之后关键字无歧义、一律按成员名收。故System.in(字段名in是关键字)与obj.type()(方法名type)都能直接写,无需反射绕道。- 成员名按 JVM 声明逐字匹配;大小写不决定成员种类。
Class.member读公开静态字段,Class.member(args)调公开静态方法,value.member(args)调公开实例方法——同名的字段与方法 (Java 的两个命名空间允许重名)由有无(...)后缀消歧。故Math.IEEEremainder(a, b)是 方法调用而非字段读。写错大小写不做折叠(Math.ieeeremainder(a, b)报「没有静态方法」), 静态/实例同样是匹配的一部分:静态方法不能经实例调用,实例方法也不能经类调用。 Dawn 自己的限定拼写优先于 Java 查找:m.C(args)是限定构造器,m是模块别名时整条 按 §10.3 解析(限定常量加(...)仍报不可调用)。 - 构造器统一为
Type.new(args),返回T本身(构造器不会返回 null,不包 Option); 实例方法用.method(args)。 - 所有 Java 调用的效果都是
!io,无例外(理由见 design.md D5)。 - Java 调用的返回值允许直接丢弃(语句位置或 Unit 块的尾位置)——Java API 常返回
this或状态码;「不得悄悄丢弃」规则只保护 Dawn 值。 - 返回值里出现未导入的引用类(如
Path.of的Path)时,值仍可用(自动成为 不透明类型、可继续链式调用);只有要在签名里写出类型名才需要use java导入。 - 类解析发生在编译期反射:JDK 类恒可见;第三方类可由项目
[java-deps]提供 (dawn check/doc/run/test/build),或由--cp <jars>提供 (dawn run/test/build通用,§12.1);需要运行程序的命令在编译与运行时共用同一份 classpath。 LSP 目前仅解析 JDK 类,第三方类在编辑器里报未找到但命令行可编译。 - 嵌套类用点号写:
use java "java.net.http.HttpResponse.BodyHandlers"(不是$——$在字符串里会被当插值)。解析时先整体反射,失败则从右往左把.逐个换成$重试,故任意嵌套深度都能用点号书写;绑定名取最后一段 (BodyHandlers)。嵌套类的泛型方法仍按擦除返回不透明Object(§9.2)—— 如HttpResponse.body()返回Object,取字符串体用String.valueOf(...)桥回。
9.2类型映射#
| Dawn | Java | 方向 |
|---|---|---|
Int | long(接收 int 自动加宽) | 双向 |
Float | double | 双向 |
Bool | boolean | 双向 |
String | java.lang.String | 双向 |
Unit | void | 返回 |
引入的类 T | 该类引用 | 双向 |
Java 方法返回引用类型一律为 Option[T]——null 在边界处被拦下。用 ! 解包(§8.2)
或 match 处理:
use java "java.net.URI"
use java "java.lang.StringBuilder"
pub fn main() -> Unit !io = {
let uri = URI.create("https://dawn-lang.org/spec")! # 方法:包 Option,解包
let sb = StringBuilder.new() # 构造子:不包,直接是对象
sb.append(uri.getHost()!)
println(sb.toString()!)
}
dawn-lang.org
为什么方法包、构造子不包——这不是两套随意的规则,而是各有依据:
- 构造子不包:JLS 保证
new表达式永不返回 null,包成Option是纯噪音。 - 方法包:方法可以返回 null,且编译器无从静态区分。JDK 类不携带运行期可见的
可空性注解——
URI.create(永不 null)与Map.get(真可空)反射出来的注解都是空。 既然分不出,就只能一律包:宁可让!显式承担风险,也不把 null 放进 Dawn。
基本类型返回值不包 Option;short/byte/int 返回自动加宽为 Int,float 加宽为
Float。char 出入参当前不支持;数组走不透明直通(§9.5)。Option 实参传 null
同样不支持(Dawn 侧目前不能把 null 传给 Java)。
9.3重载消解#
按"实参个数 + 静态类型"打分选唯一候选(精确匹配 long/double 优于收窄到
int/float,String 优于 CharSequence/Object);并列最高分或无候选都是
编译错误(错误信息列出候选签名)。函数值实参只匹配函数式接口形参(§9.4);
Dawn List 实参可匹配 List/Collection/Iterable 形参(§9.6);数组实参精确匹配
优于宽化到 Object(§9.5)。
变长参数照 JLS 分两相:先不打包试一轮(相位 1),全部失败才按变参打包 (相位 2);相位优先于分数——分数是逐参求和、随实参个数增长,不分相位会让打包 候选反超精确匹配。可变部分内联铺开,与 Java 写法一致:
use java "java.nio.file.Path"
use java "java.util.List"
use java "java.lang.String"
pub fn main() -> Unit !io = {
let p = Path.of("a", "b")! # 可变部分 1 段:打包成 String[1]
let q = Path.of("a", "b", "c")! # 同一相位,String[2]
let l = List.of("a", "b")! # 相位 1 胜:选 of(E, E)
let m = List.of("a", "b", "c")! # 相位 2:打包成 E[]
let e = List.of()! # 不传可变部分 = 打包 0 个 = 空数组
println("${p.toString()!} ${q.toString()!}")
println("${String.join(",", l)!} ${String.join(",", m)!} ${to_string(e.size())}")
}
a/b a/b/c
a,b a,b,c 0
可变部分同样收 Java 引用(BodyPublishers.concat(head, file, tail)!)。尾部实参逐个对
数组分量类型打分,规则同普通形参,故 SAM 转换(§9.4)与 List 桥
(§9.6)在可变部分内同样可用。传一个现成数组当可变部分(如 String[])走相位 1,
原样传入不重新打包。注意标量不装箱(§9.2),故 Object... 收 String 与 Java 引用,
收不了 Int/Float/Bool;char 出入参不支持,char... 随之不支持。
9.4SAM 转换:函数值跨边界#
use java "java.lang.Thread"
fn spawn_hello(msg: String) -> Unit !io = {
let t = Thread.new(() => println(msg)) # Dawn lambda → java.lang.Runnable
t.start()
t.join()
}
- Java 形参是函数式接口(interface 且恰有一个抽象方法,
Object的公共方法不计)时, 实参可传 Dawn 函数值——lambda、命名函数、构造器值均可。仅限接口;单抽象方法的 抽象类不支持。实现会生成一个持有 DawnFnN的普通 adapter class,并在构建期 写入 classfile;native-image 不需要运行时动态类生成配置。 - 匹配:SAM 方法签名按 §9.2 映射成 Dawn 函数类型后做常规匹配;lambda 的参数类型
可从形参播种(与泛型实参推导同一机制)。重载打分时函数值只匹配函数式接口形参。
Dawn 不追踪 Java 泛型实参,泛型 SAM(
Predicate/Function这类)的参数按擦除后 的类型进入 Dawn(通常是不透明Object,只能原样传递);具体类型的 SAM (Runnable、HttpHandler)才有完整体验。 - 除
ctl之外任何行都跨得过去,证据取自创建点:带具名效果标签、含效果变量、含关联 效果投影的行都可以传出。转换点把当时在作用域里的证据包快照进 adapter 对象,Java 调用时 把它交给闭包,这是 §6.5 的边界条款。理由也在那里:Java 从没有 Dawn 栈帧的地方进入回调, 没有调用点可交证据,创建点是最后一个有证据的地方。 ctl是那一个例外:行里点名了某个ctl效果(§6.5)的函数值不跨边界,在转换点 报编译错误,因为ctl说的正是「这个值的调用者可能被一条臂挂起」,而 Java 帧下面没有 Dawn 帧可以承接。出路是把转换挪到别处,或者在没有任何臂绑定续延时把声明上的ctl去掉。 只有写出来的标签这样静态判:效果变量与关联效果投影两条轴,转换点不知道调用方会把!e实例化成什么,由运行期接住,一次接不住的挂起是 panic 而不是错答案。- 行在转换点记账:跨界的行按普通调用点算进本函数的行。作用域里没人应答的标签照常报 「没有 handler」;本函数的签名把它带出去时,快照读的就是本帧的隐藏证据参数。所以边界条款 改的是证据从哪里来,不是它可不可以不存在。
快照是创建时的那个 handler:Java 在安装 handler 的帧返回之后调用,读到的仍是转换时 在作用域里的那一个。把
with handle E { ... }写进函数值体内是另一件事,那是每次运行都 装一个新 handler,压过外面的快照。with handle Log { log(m) => println(m) } Thread.new(() => log("tick")) # 快照带着这个 handler Thread.new(() => { # 每次运行装一个自己的 with handle Log { log(m) => println(m) } log("tick") })- 调用时机仍不受追踪:效果系统不管 Java 侧何时调用,Java 可能在任意线程、任意时刻
(包括本次调用返回之后)调用该函数值。这不破坏纯度契约:Java 代码只可能在 Dawn 的
!io调用之下、或无 Dawn 栈的 Java 线程上运行,纯函数与!io函数的签名承诺都未被违反。 快照使带标签的行也落进这条:不论何时被调,应答它的都是转换时选定的那个 handler。 - 参数的 null 边界:回调的引用类型参数不包
Option,以T直达;桥接层逐参 检查,Java 传入 null 立即 panic(消息指明回调边界)。与返回值包Option(§9.2) 互补:返回位置的 null 是常态故进类型,回调参数的 null 属病态故 fail-fast。 - 返回值收窄:SAM 方法要
byte、short或int而 Dawn 函数返回Int时做 检查性收窄,超出目标有符号 8、16 或 32 位范围 panic,不静默截断; 要float时按 IEEE 规则收窄Float(可能损失精度, 这是浮点语义而非溢出)。 - Dawn 函数在回调中 panic,以
dawn.rt.PanicError(Error的子类)传给 Java 调用方,不捕获不包装。
9.5数组:不透明直通;byte[] = 一等 Bytes#
数组值与未导入的引用类同待遇(§9.1):可接收、持有、传参——重载打分按数组类型
精确匹配,或宽化到 Object;返回位置照 §9.2 包 Option。但数组(除 byte[],见下)
不可命名(签名里写不出该类型)、不可创建、不可索引;要长度用
use java "java.lang.reflect.Array" 的 Array.getLength(a)。
byte[] 是唯一例外:它就是一等类型 Bytes(§9.5.1)。Java 方法返回的具体 byte[]
(如 readAllBytes/toByteArray/Base64.decode/MessageDigest.digest)照 §9.2 落成
Option[Bytes];Bytes 可写进签名、存进 record、切片/索引/拼接/按内容比较;反向传给
Java byte[] 形参(OutputStream.write、MessageDigest.isEqual 等)直接匹配。
use java "java.nio.file.Files"
use java "java.nio.file.Path"
fn slurp(p: String) -> String !io = {
let bytes: Bytes = Files.readAllBytes(Path.of(p).expect("path")).expect("readable")
decode(bytes, "UTF-8")
}
9.5.1Bytes:一等不可变字节序列#
Bytes 是不可变的字节序列,运行期就是裸 byte[]。库函数(§11「bytes」组):
utf8(s) -> Bytes(字符串的 UTF-8 字节)、decode_utf8_lossy(b) -> String /
decode_latin1(b) -> String(解码,见 §11)、
decode_utf8_checked(b) -> Result[String, Utf8Error](严格解码,见 §11)、
len(b)、bytes.at(b, i) -> Int(0..255,越界 panic)、bytes.slice(b, start, end)
([start,end),下标 clamp 进范围)、bytes.index_of(b, needle, from = 0) -> Option[Int]。
index_of 把负的 from 钳到 0;非空 needle 从该字节下标起找首次完整命中。
空 needle 在合法位置 [0, len(b)] 命中(所以 from == len(b) 返回 Some(len(b))),
但 from > len(b) 即使对空 needle 也返回 None。
Bytes ++ Bytes 拼接、==/!= 按内容比较(Show 渲染为 <N bytes> 摘要)。
Bytes 的哈希是内容哈希(种子 1,逐字节 h = 31*h + 有符号 byte,32 位环绕,
见 §3.5——与那里的复合规则同形),与内容 == 一致,故 Bytes 可以
作 Map/Set 键。(曾因 byte[] 的 JVM hashCode 是引用同一性而禁,两头都改成内容之后
禁令没跟着撤,2026-07-27 撤掉。)Bytes 不参与 comptime 常量折叠,也不能作 bare 一等函数值
(用 lambda 包一层)。
擦除的 Object 只可显式认领。 擦除泛型的返回(§9.2)落成不透明
Object;重载消解只按普通 Java assignability 使用这个静态类型,因此它可传给
Object 形参,却不能反向匹配 Path、InputStream、byte[] 等具体引用
形参。编译器不会在参数桥接处插隐藏 CHECKCAST,也不会让同一次失败在显式写法中
成为值、在隐式写法中却穿透宿主异常。
若确知某个擦除泛型的不透明 Object 运行期是某个具体引用类型(如 HttpResponse.body() 配
BodyHandlers.ofByteArray() 时是 byte[]),用泛型内建
cast[T](x: Object) -> Result[T, ForeignError] 把它认领成该类型
(T 取自调用点的期望类型,如 let b: Result[Bytes, ForeignError] = cast(...))——
认领处做一次运行期检查,类型不符是 Err 而不是穿透的异常,载荷见 §9.8.1
(JVM 上 kind 是 java.lang.ClassCastException)。T 须是引用类型
(编译期拒绝 primitive / 无期望类型)。当前表面无法命名的 Java array 或参数化类型也
不获得隐式例外;真实用例需要它们时,应先补可书写的目标类型。
签名为纯的函数不该能用宿主异常退出(LANG-02)——
cast从前抛ClassCastException, 那正是纯签名本该排除的那条出口。现在失败是值。迁移经过的三期(含一个只活一个 版本的过渡拼法)记在 `error-model-design.md` §6.10–§6.12,已收尾。
9.6List 桥接:Dawn List 直达集合形参#
Java 形参声明为 java.util.List / java.util.Collection / java.lang.Iterable 时,
实参可传 Dawn List[T]。桥接是拷贝,不是视图:Dawn List 是纯 Dawn 的持久向量
(std/pvec),Java 侧无从命名,所以每次跨界都按序把元素拷进一个新的 java.util.ArrayList,
再套一层不可变包装(Collections.unmodifiableList)交给 Java。Java 侧的变异方法抛
UnsupportedOperationException;Java 保留这个引用也看不到 Dawn 侧此后的任何新版本。
- 代价:每次跨界 O(n) 时间与 O(n) 分配,与元素个数成正比,不随调用次数摊销: 同一个 Dawn 列表传两次就拷两次。JVM 上实测约 8 ns/元素(10⁶ 元素约 8.4 ms 一次)。 在循环里反复把同一个大列表交给 Java 时,应在 Java 侧持有一次桥接的结果再复用。
- 元素类型
T限:Int/Float/Bool/String/ 已导入或不透明的引用类。 元素是List/Map/Set/ADT/元组/record/函数值时拒绝(编译错误)。拷贝只转最外层, 内层值在 Java 侧仍是 Dawn 自己的表示(如std/pvec的向量节点),Java 无从读取; 当前不做深转换。 - 元素按 §9.2 的装箱表示直达(
Int→java.lang.Long)。泛型擦除意味着期待List<Integer>的 API 会在取用时ClassCastException;当前不额外修复,选 API 时留意。 - 方向仅 Dawn → Java;Java 返回的集合仍是不透明引用 +
Option(§9.2),可链式调用, 但不会转换回 DawnList(把它标注成List[T]是类型错误)。Map/Set桥接当前未提供。
9.7限制#
不能继承 Java 类;不能以命名类形式实现 Java 接口——函数值经 SAM 转换(§9.4)
传出是唯一路径。只读静态字段可访问(Class.FIELD,§9.1)——枚举常量与静态常量直接读
(TimeUnit.SECONDS、Integer.MAX_VALUE);写静态字段、实例字段仍不支持。数组不可创建/索引/命名(§9.5);
Map/Set 不桥接、Java 集合不反向转换为 Dawn 值(§9.6);Option 实参传 null
不支持(§9.2)。
9.8外部失败屏障:catch_fault#
这个内建到 v0.30.0 为止叫
java_try,v0.31.0 改名。它拦的是 fault——外部世界 造成的失败——而这个分类自 native 有了失败种类之后就是两个后端共用的 (`native-backend-plan.md` §14.9),和 Java 无关了;名字比理由多活了一阵。 旧名字的迁移提示已随std/moved.txt机制一并撤回(`std-moved-design.md`), 今天写java_try得到的是普通的未定义函数诊断。
Dawn 无异常:Java 调用抛出的异常默认原样穿透并终止程序(等同 panic 语义)。
但预期中的外部失败(网络断开、SQL 约束冲突、解析失败)在 Java 世界以异常表达,
它们不是 bug,应进 Result。内建 catch_fault 是唯一的转换点:
use java "java.lang.Long"
fn parse(s: String) -> Result[Int, ForeignError] !io =
catch_fault(() => Long.parseLong(s))
# Err(ForeignError { kind: "java.lang.NumberFormatException", message: ..., cause: None })
- 签名
catch_fault[T, !e](f: fn() -> T !e) -> Result[T, ForeignError] !e。被护闭包的行 是一个效果参数!e:闭包可为纯函数,可为!io,也可带标签或效果变量,只要那些 效果在屏障之外有 handler 答。整个调用的行就是那一行——屏障自己不往里加任何东西。 配套的catch_panic与它同形(2026-09-01 到 2026-09-24 之间两者不同形),理由见 §9.8.1 末与 `docs/effects-window-design.md` §4。 - 只拦
java.lang.Exception及其子类;Error不拦——Dawn 的 panic (dawn.rt.PanicError是Error子类)原样穿透,panic 仍然是 bug、不可恢复。 Err载荷是ForeignError——一个 prelude record,字段与取值见 §9.8.1。它到 v0.32.0 为止是一句渲染好的字符串(Throwable.toString()),本节也曾建议 「需要区分异常种类时按前缀匹配字符串」;那条建议已被撤销,kind是它的替代物。- 边界之内失败照常传播:
catch_fault包住整段复合调用即可,无需逐调用包裹。
配套的 catch_panic[T, !e](f: fn() -> T !e) -> Result[T, ForeignError] !e 与它同形:
被护闭包的行就是整个调用的行,屏障自己不加任何效果(§9.8.1 末把三个屏障排在一起)。
它拦的是Dawn panic(PanicError)与 Exception 两类——不是任意 Throwable:
VirtualMachineError(堆耗尽、栈溢出)穿透,资源耗尽不是一个值。它用于监督边界——
服务器的单个请求、任务 runner 的单次执行:一个请求 panic 应变成 500 并记录,而非掀翻
整条连接或进程。它与 catch_fault 分工明确:catch_fault 处理预期外部失败、放
panic 穿透;catch_panic 是隔离点。普通业务失败仍走 Result,别拿 catch_panic
当常规错误处理。
这条分工与后端无关。 JVM 从类层次白拿它(
Error对Exception);native 没有异常,一切失败走同一条longjmp,所以失败带一个种类、handler 记住自己 收不收 panic。判据是同一条:语言自己定义的失败是 panic(panic、expect、 越界下标、除零、非法码点),外部世界造成的失败不是(io 原语——量下来也只有 这一类)。两个后端的实测比对在scripts/spike-native/catch_kinds.dawn; 在它写出来之前 native 的catch_fault拦下了本该穿透的每一个 panic。
9.8.1载荷 ForeignError#
「按前缀匹配字符串」曾是本节的建议,现已撤销:它把控制流建在一句可以被重构、被 本地化、被换一版 JDK 改掉的文本上。载荷是一个 prelude record:
type ForeignError = { kind: String, message: String, cause: Option[String] }
kind是后端自己给这类失败起的名字,而且是个名字不是一句渲染:JVM 上是 二进制名(getClass().getName(),如java.lang.NumberFormatException、dawn.rt.PanicError),native 上是运行时的失败种类("panic"/"fault")。- 按
kind分流的代码是后端相关的代码。 可移植的匹配只有Ok/Err这一层。 取值表与理由在docs/runtime-intrinsics-design.md§12.4。 message是失败自己说的话(JVM 的getMessage(),无则空串);cause是底下那层 失败的渲染,没有则None。不带栈:渲染栈的代价要付在每一次屏障上。
载荷契约(对每个后端):
message没有长度上限,逐字节等于失败发出时的那个 String;语言自己发出的失败, 消息两后端逐字节相同——panic(m)、assert、下标越界、除零、非法码点、expect, 没有例外(scripts/spike-native/的divzero_messages、index_panic_text、catch_kinds等语料两后端对拍钉住)。设上限就得选一个数字,而任何数字都会被某条合法消息 越过;截断还得处理字符边界——所以是「没有截断条款」而不是「上限较大」。message是良构 UTF-8。这是 String 不变式(§4.8)的直接推论,单独写出来是因为 它曾被违反:按字节截断的载荷把切点落在字符中间。- 一个失败的载荷属于将要接住它的那个屏障:从 raise 到该屏障造出
ForeignError(或bracket交给下一个屏障)为止,其它任何屏障读不到也写不到它。嵌套捕获因此 不影响正在传播的失败(§9.8.2 保证 2 的展开)。 - panic/fault 这个二分是可观察的——由哪个屏障收得下体现,不由
kind字符串体现 (kind的取值仍是后端自有)。
屏障只有这一对(拦失败的只有这一对;§9.8.2 的 bracket 什么都不拦),
载荷只有 ForeignError,不保留 String 版本。
这一对的效果行是同一个:被护闭包的行,屏障自己什么都不加。纯函数的承诺是 同一程序、同一构建里,相同实参得相同值(§6.2 第 4 条),按观察的是哪一种失败逐个对:
- fault 是外部世界造成的失败,而通往外部世界的每一条路在能失败之前就已经记过账
(io 原语与
use java调用无条件记!io)。所以拿得到这个Err的人本来就不纯, 屏障再记一笔是记了一笔没人欠的账。 - panic 是语言自己定义的失败,由实参决定:求值是严格的、次序是规定的,一个纯闭包
在给定实参上 panic 与否、消息是什么都已确定,所以折叠与消重纯调用不改变捕获的结果,
捕获几次也不可观察。消息里的源码文本(
assert的表达式)与调用点位置(§8.2,只取决于 源码树)是程序的一部分,与字符串字面量同理;消息里的后端成分由上面的载荷契约排除(两后端逐字节相同)。comptime 仍无条件拒绝catch_panic(§7),编译器不会把构建机上的消息折进产物。
于是三个屏障共用一条行:§9.8.2 的 bracket 什么都不观察,catch_fault 观察的失败
已经被 io 记过账,catch_panic 观察的失败由实参决定。三个的行都是那个变量。
这条线依赖一条不变式:fault 只从 io 来。 它曾带一个具名例外:续延的行一度 无条件是纯的,于是一个纯签名的函数恢复或丢弃一条续延,就能把 remainder 里的 io 和 它的 fault 带回自己的帧(两个后端都实测过)。自 2026-09-02 起续延带着 remainder 的 io 位(§6.5「控制臂」),会 fault 的 remainder 交出的续延是
!io的,纯帧调不动它, 例外随之取消;k仍是普通函数值、仍没有自己的类型,变的只是行里 io 这一格 (`docs/oneshot-design.md` §11.2 后记)。剩下的一种形状是 remainder 只带具名标签、而标签的臂在自己的安装点做 io:那是供给点记账的既有教义(§6.5 「实现」的边界条款,SAM 快照同款),臂的 io 记在安装它的帧上,不是本条的例外。
完整论证与重开条件见 `docs/audit/error-model-design.md` §七。
历史:把载荷从 String 换成
ForeignError花了三个 release,因为一个内建的 签名和它的名字一样受种子纪律约束,而且更紧——改名可以一期内让两张表都认识两个 拼法,改载荷类型不行:编译器自己的调用点没法同时满足Result[T, String]和Result[T, ForeignError]两张表。所以新形状先以零调用点的过渡拼法catch_fault_e/catch_panic_e落地(v0.32.0),发一次 release 教会上一代编译器, 迁调用点并翻转原名的表项(v0.33.0),再把调用点迁回原名、删掉过渡拼法(本版)。 那对名字在 v0.32.0/v0.33.0 的dawn doc --builtins里出现过,此后不再存在。 分期见 `error-model-design.md` §六。
9.8.2释放,而不是拦截:bracket#
Dawn 没有 try/finally,也不打算有(§9.8 那对屏障是拦,不是放)。
「无论怎么退出都要把资源还回去」这件事由第三个内建承担:
fn bracket[A, B, !e](resource: A, release: fn(A) -> Unit !e, body: fn(A) -> B !e) -> B !e
第三个参数叫 body:use 是关键字,bracket(r, close, use: f) 这个调用写不出来,
所以那个拼写从来没有调用方,只在渲染出的签名里出现。
let f = FileOutputStream.new(path) # 取资源:普通代码,在调用之前
bracket(f, s => s.close(), s => write_all(s, bytes))
资源是个值,不是 acquire 闭包。 Haskell 的 bracket 收 thunk 是为了堵住
asynchronous exception 的窗口——别的线程或定时器可能在「取到」和「装上 handler」之间打断它。
Dawn 没有这种东西:失败只从程序自己调用的代码里发出,而实参求值与本内建装 handler 之间
不跑调用方的任何代码,所以 thunk 堵不到任何窗口(Koka 的 finally 同理,也是直接收资源)。
取资源因此是调用之前的普通代码——那里失败无需释放,因为还没取到。
它在最后是给 with 留的路(§4.10,2026-07-31 落地):那个糖把「剩下的块」当
最后一个实参附加,把它放在中间的原语就得改拼写。资源提前取之后,那种站点一个
lambda 都不用写:
with f <- bracket(open(path), close)
...块剩下的部分就是 body...
三条保证:
release每条路径恰好跑一次——body正常返回、panic、fault,三条都跑,且只跑一次。原失败原样继续传播:
kind/message逐字不变,panic 仍是 panic、fault 仍是 fault。 所以catch_fault依然不拦一个穿过 bracket 的 panic(native 侧靠 re-raise 复原那个 种类位,不是重新推断;两个后端的实测比对在scripts/spike-native/bracket.dawn与bracket_fatal.dawn)。release内部自己接住的失败不影响正在传播的那个—— release 里 raise 并咽下一个新失败是合法代码,穿越中的失败原样出去 (scripts/spike-native/bracket_release_fails.dawn)。release自己逃逸一个 失败(raise 而不接)则顶掉原失败、无 suppressed 链:这是两后端的现状,写在这里 使它成为裁决而非巧合。这条替换规则有且只有一个例外:丢弃一条续延时的那次展开(§6.5 的
discard)。 那条路径上被穿过的不是一个失败而是一剂毒药,而毒药按定义拦不住;让release的失败 顶掉它,就等于把它换成一次普通失败,于是冻结跨度里的catch_panic会接住它, 被放弃的计算从自己的清理里复活、接着跑完。所以那里改用另一条规则(Lua__close的那条): 走查不停,剩下的release照跑;第一条失败是被交付的那一条,在展开跑完之后 交给调用discard的人。语料是scripts/spike-native/ctl_discard.dawn的release_failure。bracket不拦任何东西,故返回B而非Result——护与拦是两件正交的事 (Haskellbracket、Kotlinuse、Kokafinally、Godefer无一返回 Result)。 要把失败拿成值就写catch_fault(() => bracket(...)),两个原语各做一件事。- 效果行是变量
!e:release、use与整个调用共用同一行,bracket自己不加任何 效果。所以纯资源的bracket是纯的,!io的是!io的,带标签的把标签原样传出去。 §9.8 那对屏障同样绑一个效果参数(catch_fault[T, !e]),所以「被跑的闭包行是自由的」 这一点三个原语都一样,而且三个原语自己的行也都就是那个变量(catch_panic在 2026-09-24 之前钉死!io)。理由见 §9.8.1 末与 `docs/effects-window-design.md` §4。
它不给
defer那样的面语法:受保护的区间恒为一次闭包调用,所以return/?/break在语言层面就跨不出去,编译器也就不欠一套逃逸改写。 判据与被推翻的旧结论见docs/core-move2-design.md§2.6 与 §6。
10.模块系统#
10.1文件与模块路径#
一个 .dawn 文件 = 一个模块。模块路径 = 相对模块根的路径去掉扩展名:
<root>/json/lexer.dawn → 模块 json/lexer。路径每段须匹配 [a-z_][a-z0-9_]*
(与文件名一致),否则编译错误。
模块根的确定:
- 目录模式
dawn check|doc|run|test|build <dir>:根 =<dir>/src,入口 =<dir>/src/main.dawn(缺失则报错并给出预期路径)。 - 文件模式
dawn check|doc|run|test|build <file.dawn>:从该文件所在目录向上找最近的名为src的祖先目录作为根;找不到则根 = 文件所在目录。LSP 用同一条启发式,故单独打开 一个子模块文件也能解析它相对根的use。
目录约定即工程定义:模块根、入口、模块路径全部由目录结构决定,不需要清单文件。
项目可选带一个 dawn.toml,只承载目录约定表达不了的东西——工程身份与依赖。
没有它的项目按上述规则照常工作。schema 1 的内容:
schema = 1 # 必须是第一个 key
name = "backend_dawn" # 工程身份([a-z_][a-z0-9_]*)
[java-deps] # Maven 依赖,`use java` 用得到
sqlite = "org.xerial:sqlite-jdbc:3.36.0.3" # 精确坐标;禁 SNAPSHOT、禁版本区间
[deps] # Dawn 源码包:别名 = 本地目录
web = "../packages/web"
[deps.json] # 或远端归档(zip / tar.gz)
url = "https://github.com/dawnop/dawn-lang/archive/refs/tags/v0.7.0.zip"
version = "1.0.0" # 严格 x.y.z;版本求解 = MVS
hash = "d1:<sha256>" # 解包文件树的内容哈希,即包身份
subdir = "packages/json" # 归档内的包根(可选)
[deps] 的别名只是本方源码的拼写(use <别名>/<模块>);包的身份是它自己
manifest 里的 name——类名命名空间、版本求解、全程序一名一份都按真名进行,
别名引入会在装载时规范化为真名。dawn add <坐标|url|路径> 可代写这些条目
(抓取并计算 hash,保留手写格式)。
一名一份比的是目录:路径依赖经符号链接到达与经实路径到达的是同一个目录,就是同一个包
(目录按解析链接之后的物理路径判同,路径不存在时退回词法拼写),.. 也按物理父目录退,
与内核一致。模块路径不受影响,仍是相对模块根的拼写:src/ 内的链接让同一个文件有两个
模块路径,那就是两个模块。
dawn check|doc|run|test|build 会拉取 [java-deps](含各依赖包声明的,取并集),供
编译期 use java 使用;check/doc 的每个 target 使用独立 classpath,
run/test/build 则把它与 --cp 合并后同时用于编译和运行。dawn build 另把
[java-deps] 复制进 jar 同级的 lib/。
仓库地址走 $DAWN_MAVEN_MIRROR,不进 manifest。
manifest 永远是数据,不是代码——不存在可执行的 build.dawn。理由与完整设计见
`package-design.md`。
10.2引入#
use json/lexer # 整模块引入;别名 = 末段 lexer,限定访问 lexer.next(...)
use json/lexer as jl # 显式别名,限定访问 jl.next(...)
use json/value.{Json, render} # 选择性引入,非限定使用
use java "java.lang.Math" # Java 互操作(§9),形式不变
- 一个模块的整模块引入与选择性引入各至多一次,两者可并存——前者绑别名、 后者绑名字,是两件事。同一形式出现两次是错误(两条选择性引入即使括号内名字不同也算)。
use可出现在顶层任意位置(与use java一致),dawn fmt不重排。as重命名:整模块引入可用use a/b/c as name显式指定别名(默认别名 = 末段); 选择性引入的每一项可写Name as Local(use json/value.{Json as J, render as show_json}), 对一切导出成员通用——函数、常量、类型、构造器、alias、trait、效果一视同仁。as是上下文关键字(只在整模块路径与选择性列表项之后特殊,不是保留字,仍可作普通标识符)。 两个整模块引入若末段同名,用不同as别名即可并存(否则末段同名 → 错误)。- 改名的规则:本模块只绑
Local,Name不可见;Local与Name必须同一大小写类别 (大小写是语义,§1),Name as Name是错误。改名只改本模块的名字表:被引入者的身份、 它随身带进来的名字(类型的构造器、效果的操作)、诊断里打印的名字都不变; 要改随身带进来的名字,就把它单独写进列表改。冲突检查按本地名做。 - 未使用的引入是编译错误(
unused import: <name>),不是警告——Dawn 只有一种 诊断等级。按名字判定:整模块引入看它绑定的别名,选择性引入逐个名字看(改过名的看本地名, 报告里写作者写下的导出名),use java看它绑定的类名;test 块里的使用也算使用。引入一个类型即可经它的构造器被使用, 引入一个效果即可经它的操作被使用(除非那个构造器或操作本身也被按名引入)。 模块有其它任何诊断时不报这一条;没有「为副作用而引入」的豁免写法,因为模块没有 初始化副作用,impl 也不靠use生效。理由与先例见 `unused-imports-design.md`。
10.3名字解析(消歧规则)#
- 整模块引入的别名与本模块顶层声明、局部绑定、参数同一命名空间:
声明任何与模块别名同名的顶层 fn/type/const、局部或参数都是编译错误
("
lexershadows the imported modulejson/lexer")。由此lexer.next(x)永不歧义——lexer要么是一个绑定(走 §4 的 UFCS 点调用),要么是模块别名(限定访问),不可能两者兼是。 - 限定访问支持表达式位置的
alias.fn(args)(调用一个 pub 函数);callee 位置的alias.fn(x |> alias.fn、(alias.fn)(x)、with v <- alias.fn)是同一次调用, 其它表达式位置的alias.fn是函数值(§4.4)。以及类型位置的alias.T[...](LANG-06,2026-07-30):类型位置的小写名只可能是模块别名,故无歧义; 可指向导出的 ADT/record 或pub alias(后者按声明方的解析展开)。 模式位置同样支持限定构造器m.C(..)/m.Pt { .. }:模式里的小写名后跟.大写名只可能是模块别名。限定常量m.NAME与限定构造器m.C(..)/m.C(表达式位置) 亦可用(同日):本模块可以另有同名的常量或构造器,两者互不干扰——Core 里常量的身份是 (声明模块, 名字) 而不是名字;限定构造器与不带限定的写法进的是同一段检查,故字段名、 arity、spread、泛型实例化的行为完全一致。 - 选择性引入一个
type同时引入其全部构造器(与pub type导出构造器+字段的规则一致)。 - 选择性引入的名字与本模块顶层声明或其他引入冲突 → 错误。与 prelude 名冲突:
选择性引入可以遮蔽 prelude 名(你逐字要来的名字,意图明确),包括 prelude
trait 的方法名;顶层 fn / trait 方法 / 效果操作也可以遮蔽 builtin、std 与
prelude trait 方法(Rust 式)——解析序是本模块声明 → std → 内建。
遮蔽只作用于这个拼写:被遮蔽的拼写在该模块不可达,这是声明者自己的选择。
运算符与糖(
==、${...}、for..in)按 trait 找 impl、不经名字,因此 不受遮蔽影响——遮蔽show不会改变${x}的输出,遮蔽iter_next不会 改变for..in的行为。 但内建与 prelude 的类型 / trait 名(Map/Option/Ord…)仍不可重定义。
10.4可见性#
所有声明默认模块私有;pub 导出 fn/type/alias/const/trait/effect
(pub type 连带构造器与字段,见 §3.3)。
访问或引入非 pub 项 → 错误(`parse` is private to module json/parser,附 hint:加 pub)。
标准库的非 pub 项例外:std 不归用户改,报它不属于 std 的公开 API(`atoi` is not part of std's public API (`std/fmt` declares it privately)),不给「加 pub」的 hint(#296)。
导出的声明内部也不得泄漏模块外无法命名的私有 type / trait / effect;transparent alias、opaque
边界、公开 trait/effect 与可达 impl 的完整判定在 §3.3,错误报在声明处而不是使用处。
包内可见 pub(pkg)(2026-09-24,设计):可见性共三档——
模块私有(不写)、pub(pkg)、pub。pub(pkg) 能写在 pub 能写的每个位置,含义相同,只是
边界不同:pub(pkg) 声明对同一个包(一个 dawn.toml 单元)内的所有模块可见,对包外不可见。
包是装载器眼中的一个源码单元:被检查的工程自身(有无 dawn.toml 都算一个)、每个 [deps]
源码包(按其 manifest 的真名)、捆绑标准库整体。包外引入或限定访问 pub(pkg) 项 → 错误
(`seam` is package-private to package `web`)。
pkg只在pub(之后是关键字;pub(后面不是pkg)是错误(Dawn 没有pub(super)、pub(in …))。pub(pkg) fn与pub fn一样必须写返回类型。- 公开面:
pub声明的签名不得提及pub(pkg)的 type / trait / effect(public function `f` exposes package-private type `T`);pub(pkg)声明的签名可以提及同包的pub(pkg)身份, 不得提及模块私有身份。判定与 §3.3 的公开面规则同一张表,只多一档。 dawn doc的输出只含pub项;pub(pkg)项不进公开面。- 可见性只是检查期概念:lowering 之后的 Core 与两个后端不区分三档。
- 字段没有单独的可见性:记录字段随所属类型可见。隐藏表示用
opaque type(§2.7)。
加载范围(2026-07-30,LANG-07):
dawn run/test/build <dir>默认加载src/下 全部模块——未被引用的模块也检查(bit-rot 防护,这是对的默认)。--closure收窄为「入口src/main.dawn的 use 闭包」,供大工程出产物用;dawn check恒为全仓。 CI 推荐:dawn check守全仓、dawn build --closure出产物。dawn check有诊断即 退出 1(§12.1),所以这条推荐靠退出码就能落地。
10.5编译单元与求值顺序#
- 目录模式加载
src/下全部.dawn文件(不止use闭包):未被引用的模块也要通过 类型检查(防 bit-rot),其 test 块也被dawn test执行。 use依赖图禁止成环,报错打印环路(json/a → json/b → json/a)。- 类型检查与 comptime 求值按依赖拓扑序进行;跨模块引用的
const值在使用方求值前已就绪。 - 类型同一性:同一
type声明在整个程序中是同一个类型(每个文件只解析/检查一次)。 - 入口:入口模块的
pub fn main() -> Unit !io。入口模块是命令点名的那个文件,或项目的src/main.dawn;main只在入口模块是保留名,形状(无参、返回Unit、!io、pub、 行里没有标签)只在那里检查。其它模块里的main是普通函数,任何签名、任何可见性都行。
10.6捆绑标准库与 prelude#
标准库以 Dawn 源码随编译器捆绑,组织为真模块(`stdlib-naming.md`)。
捆绑的模块恰好是下面这些,与编译器的清单 std/modules.txt 逐一对应(scripts/doc-check.py
对账,多一个少一个都红):
std/cursor:String中的位置(游标)std/str:字符串std/char:字符Charstd/fmt:数字的渲染与解析(见下)std/loc:调用点位置Loc的读法与here()(§8.4)std/dtype:设备元素格式Dtype[T]与HasDtype,定宽整数与内建三种格式的见证住这里或各自的模块std/narrow:窄二进制浮点 bf16、binary16、binary32,逐运算正确舍入(§11「数学内建」)std/int/i8:有符号 8 位整数I8,Int上的不透明类型,回绕算术(§11「定宽整数」)std/int/i16:有符号 16 位整数I16,Int上的不透明类型,回绕算术(§11「定宽整数」)std/int/i32:有符号 32 位整数I32,Int上的不透明类型,回绕算术(§11「定宽整数」)std/int/u8:无符号 8 位整数U8,Int上的不透明类型,回绕算术(§11「定宽整数」)std/int/u16:无符号 16 位整数U16,Int上的不透明类型,回绕算术(§11「定宽整数」)std/int/u32:无符号 32 位整数U32,Int上的不透明类型,回绕算术(§11「定宽整数」)std/int/u64:无符号 64 位整数U64,Int上的不透明类型,回绕算术(§11「定宽整数」)std/float:Float的两个无字面量的值INFINITY与NANstd/list:List的函数与Iter实例std/bytes:字节串,及 UTF-8、hex、base64 编解码std/io:控制台、文件、环境与子进程(Fs/Proc/Env等效果)std/reactor:状态跨调用存活的同步行协议,wasm reactor 模块的那个根(§12.5)std/map:Mapstd/set:Setstd/hamt:内部模块,Map/Set的表示std/pvec:内部模块,List的表示std/mem:平铺的可变 64 位整数缓冲I64Buf,与它的作用域效果Mem和with_mem(设计见 `mem-buffer-design.md`)。缓冲只存在于with_mem之内:读写缓冲的函数带!Mem, 检查器按std/mem的Mem这个效果拒绝四类逃逸(with_mem的结果类型不得含缓冲、带Mem的函数类型或任何类型变量; 只有std/mem能为Mem装 handler;效果操作的签名与 handler 的var不得含缓冲;带Mem的函数值不得越过use java)std/gpu:GPU 的宿主层,Gpu效果与设备缓冲(§12.6)std/memfs:内存中的Fshandler,供测试用;本规范别处不再讲它,设计见 `fs-real-path-design.md`
std/hamt 与 std/pvec 是内部模块——Map/Set/List 的表示(§11)。它们随
std 一起捆绑、在 std 内部互相引用,但 std 之外 use std/hamt / use std/pvec 是编译
错误:表示要能整体换掉,而能换的前提是没有程序依赖它。
(std/fmt 是数字渲染与解析的实现处——fmt.dtoa 即 to_string(Float)(§4.3);三个
parse_*(§11 的 EBNF)的实现不导出,只有内建拼写这一种写法,
fmt.atoi/fmt.atod/fmt.atoi_radix 不是可写的名字。模块名存在是因为实现是一份
普通的 Dawn 源码而非某个后端的宿主方法,不是因为它是一层 API。)
use std/x 命中编译器 jar 内的资源而非磁盘(磁盘上 src/std/ 路径保留,落文件报错);
之后与普通模块引入完全一致——限定访问 map.insert(m, k, v)、选择性引入
use std/list.{find}(§10.2/§10.3)。同名短名跨模块共存(str.at / bytes.at),
由限定或选择性引入消歧。
prelude 是其中隐式可用、无需 use 的高频核:List/Option/Result 的构造器、
println/print、map/filter/fold、sort 族(std/list)、parse_int(std/fmt)、内建的
panic/todo/caller/bracket/catch_fault/catch_panic/
discard/dbg/expect/unwrap_or/to_float/to_int/to_string/get/range/
sort_by/join/parse_float/code_points/
from_code_points/char_is_letter/char_is_digit/char_is_alnum/char_is_upper/
char_is_lower/char_is_space/args/cast,共一屏以内
(全集见标准库参考,由 dawn doc --stdlib 生成)。
顶层声明可以遮蔽 builtin/std 函数名(§10.3,Rust 式):解析序是本模块声明 →
std → 内建,std 模块自己的 pub fn len 正是靠这一条合法。prelude trait 的方法名
同待遇:它们随 prelude 进入函数命名空间(哪些名字,见
标准库参考的「预置 trait」一节,规范定义在 §3.5),
可被本模块的声明遮蔽,不是声明期错误。被遮蔽的只是那个拼写,trait 本身照旧——
impl Show[T] 照常声明与被找到,${...}、==、for..in 照常按 trait 找 impl(§10.3)。
prelude 追加是兼容的:往 prelude 加名字不会让任何已通过检查的程序失败—— 新名要么没被用到,要么被本模块的声明遮蔽。这条是 prelude 可以演进的前提 (`prelude-namespace-design.md`)。
模块化之前的平铺拼写(
map_insert、str_len等)已不在公开命名空间里—— 只剩 prelude 与模块限定两条路;写出旧拼写,报错会提示它搬去了哪个模块。 沿革见 `stdlib-naming.md`。
11.标准库(语义与判据)#
本节不列清单。 有哪些函数、签名长什么样、每个怎么用——全集在 标准库参考,由
dawn doc --stdlib从编译器 直接生成,因而不会与实现脱节。手抄一份到规范里只会烂:本节留下的是清单答不了的 那半边——接受什么输入、边界怎么办、为什么是这个取舍。实现位置对使用者不可见。 这些名字有两个来源:编译器内建表,以及随编译器捆绑的
std/模块(Dawn 源码,§10.6)。prelude 名隐式可见;其余以use std/x引入、x.fn(...)限定调用。哪边实现(内建 or std 包装)不影响拼写与语义 (`docs/builtins-to-stdlib.md`)。
数字的文本形式。 parse_int / parse_float 的接受语言是这段
EBNF,由 std/fmt 自己的扫描器实施(两后端不再各自委托宿主解析器的文法;宿主只在
parse_float 通过校验后做十进制→二进制的正确舍入,IEEE 754 最近偶数——在该子集上
strtod 与 Double.parseDouble 是同一个函数)。首尾空白先按 Dawn 自己的空白表修剪
(与 str.trim 同一张 char_is_space 表,不是宿主的):
int = [ "+" | "-" ] digit { digit } (* digit 仅 ASCII 0-9 *)
float = [ "+" | "-" ] mant [ exp ] | "Infinity" | "-Infinity" | "NaN"
mant = digit { digit } [ "." { digit } ] | "." digit { digit }
exp = ( "e" | "E" ) [ "+" | "-" ] digit { digit }
radix = [ "+" | "-" ] rdigit { rdigit }
parse_int(s, radix: r)(radix 缺省 10)用 radix 产生式:rdigit ∈ 0-9 a-z A-Z
(值 = 10..35,大小写同值),数字值 ≥ radix 拒绝;radix 不在 2..36 内答 None。radix 为 10
时它与 int 是同一语言。0.83.0 以前 radix 版本是另一个 builtin parse_int_radix,现已并入。整数超出 64 位范围是 None 不是环绕。
有意排除(今天的宿主解析器有的收、Dawn 一律拒绝):下划线、0x 前缀与十六进制浮点
(0x1p3)、f/F/d/D 后缀、inf/nan 等小写变体、带符号的 NaN 与 +Infinity
(合法特殊拼写恰是 to_string 能输出的三个,见 §4.3 的往返闭合)、全角与阿拉伯-印度等
非 ASCII 数字(宿主的 Character.digit 收它们,Dawn 的数字集是 ASCII 封闭的)。
接受语言属于函数本身,与调用方式无关:let f = parse_float 之后 f(s) 与 parse_float(s)
是同一个函数,parse_int 同理——只是它取作函数值时默认值丢失(§3.1),let g = parse_int
的类型是 fn(String, Int) -> Option[Int]。v0.79.0 及以前经函数值调用会绕过这段
EBNF(f("1.5d") 答 Some(1.5)),parse_int 取作函数值则编译期 panic;这是实现缺陷,
按 bug 修正而非语言变更(issue #283,
builtin-fn-value-lowering-design.md)。
大小写映射。 str.to_lower/str.to_upper 是 Unicode 简单(1:1)大小写映射:一个码点
进、一个码点出,无 locale、无上下文,故码点数不变。这排除了完整映射的三类特例——长度
会变的(ß → SS)、locale 相关的(土耳其语的 i)、上下文相关的(希腊语词尾 sigma)。
取简单映射不是为了省事:完整映射不是一个后端能从一张表实现的函数,而 Dawn 要求一个原语
在每个后端上是同一个函数。需要完整映射的场合属于能接收 locale 的库。
那张表是编译器的(selfhost/src/embed/unicode_case.dawn,生成物,记着生成它的 JDK),两个后端
各自领走一份:JVM 写进 dawn/rt/Strings,native 写进发出来的 C。所以 str.to_upper 的答案
不随宿主 JDK 的 Unicode 版本变——升级到新 Unicode 是重新生成这张表这一件明确的事,
而不是换台机器编译就悄悄换了答案。分类(char_is_*)同理,表在 selfhost/src/embed/unicode_class.dawn。
字符即码点。 code_points(s) -> List[Char] 拆成字符(增补平面的代理对合并为一个码点)、
from_code_points(cs: List[Char]) 由字符组装;len(s) 是码点数,str.at 回一个
Char,回 List[String] 的是 str.chars。字符串的下标一律是码点下标,不是
UTF-16 码元。char_is_letter/_digit/_alnum/_upper/_lower/_space 收 Char,
std/char 的 is_* 是它们的公开拼法。
三条判据(§4.8)在字符串族上的落点:str.at(s, i) 越界 panic(判据 1,i 是调用方声称
存在的位置,同 xs[i] 与 bytes.at);str.strip_prefix/strip_suffix 回 Option
(判据 2,判定与剥离一次完成,调用方不必自己算偏移);str.slice/take/drop 与
bytes.slice 两端钳位、from > to 得空串(判据 3,范围参数选取一段,不断言端点存在)。
truncate 就是 take,不另设一个名字。
字节与文本编码。 语言承诺的字符集只有两个,且函数名即定义域:bytes.decode_utf8_lossy
与 bytes.decode_latin1,没有字符集注册表。没有 charset 参数就没有「不认识的字符集」
这个失败面,故它们返回裸 String 而非 Option(沿革见
`stdlib-impl-notes.md`)。
UTF-8 解码有有损与严格两个,同样由函数名区分。decode_utf8_lossy 把每个非法序列换成
U+FFFD(上一段的替换规则);decode_utf8_checked 不改写任何字节,在第一个非法序列处回
Err(Utf8Error { offset }),offset 是该序列开始的字节下标,也就是输入前多少字节是合法
UTF-8。两者对「什么算非法」的判断是同一个:decode_utf8_checked 回 Ok 的输入,恰好
是 decode_utf8_lossy 原样返回的输入,这条是规范性的。decode_latin1 没有严格版本,
因为每个字节都是一个码点,它不会失败。decode_utf8 是 decode_utf8_lossy 的旧名,
按 CONTRIBUTING §7 的一代 forwarder 纪律保留,下个版本删除。
hex 与 base64 是纯 Dawn 字节算术(无 use java,故两后端同一份定义),规则是规范性的:
to_hex 每字节两位、小写为规范拼写,from_hex 大小写皆收、其余一概不收;
to_base64(b, enc: Base64 = Standard) 与 from_base64(s, enc: Base64 = Standard) 的编码是值
Standard | StandardRaw | Url | UrlRaw(Go encoding/base64 的四个同名值):Standard* 用
RFC 4648 section 4 的标准字母表,Url* 用 section 5 的 url/文件名安全字母表,*Raw 不补
=,另两个以 = 补齐到 4 的倍数。解码器只认 to_base64(_, enc) 写得出的拼写:补齐的编码
要求 padding,*Raw 拒绝 =,也只认自己的字母表(猜编码会把拼错的输入变成错的字节);
末组的空余低位必须为零——否则同一串字节会有多个拼写。这一族的解码器都属判据 2:
外来文本是要校验的,不是要断言的。
Bytes 与 Buf 见 §9.5.1;另有操作符 Bytes ++ Bytes 与按内容的 ==/!=。二进制请求体
(multipart 上传、WebDAV PUT)、crypto/签名、HTTP 收发都直接走 Bytes,不再借道 latin-1
字符串。
命名族。 长度只有 len 一个名字,判空只有 is_empty 一个名字——str/list/map/
set/bytes 五处同拼写。唯一具名例外是 bytes.buf_at(b: Buf, i):bytes.at 已经是成品
Bytes 的取字节,而语言无重载,故写入游标只能另取一名(判据与例外见 CONTRIBUTING「命名族」)。
返回位置的搜索一律回 Option,不发 -1(str.index_of、list.index_of 同一货币)。
排序与极值。 sort 族要求元素/键类型具 Ord(§3.5),全部稳定、平局取第一个。
list.any/list.all 短路(用 fold 假冒的那两个是一趟全走);空列表分别为 false /
true。list.unique 比其余多要一个 Hash:只有 Eq 的去重是二次的,而 Set 的插入序
恰好就是去重要的顺序。
Result 没有配套的库函数(无 map_err、无 ok)。match 和 ? 够用,跨错误类型见
§8.1 的本地 helper;把 Result 转成 Option 在 4 万行 Dawn 里一次都没出现过(32 个
-> None 臂无一对着 Err),且丢错误与本语言取向相反。
位置:Cursor。 按码点下标的函数每次调用都要从串首数到那个下标,即单次 O(n)、放进
循环就是 O(n²)。游标是位置而非计数,故每步恒定开销。位置只能从 std/cursor 的函数
取得、传回、比较(== 与 < <= > >=——同一字符串内位置的先后是位置类型的合法操作)、
存进容器/record 供回溯(它是普通值,回溯不需要额外机制)。不要对它做算术——只有算术
能凭空造出落在代理对中间的非法位置。
算术与凭空铸造都是编译错误:Cursor 是 std/cursor 用 §2.7 的
pub opaque type Cursor = Int 声明的,只有那个模块能把它看成 Int。要在类型位置写出
Cursor,除 use std/cursor 外还要 use std/cursor.{Cursor}(前者绑模块别名、后者绑名字,
是两件事)。cursor.seek / cursor.offset 是两套位置货币之间的桥,各一趟 O(n),用在边界
上(外面来的 Int 下标进来、位置要报给外面时);放进循环就变回它要消灭的 O(n²)。
单串一次调用用下标版没问题,循环里必须用游标版——`seq6-research.md` §五之补有实测。
度量是后端的,且永不成为可观察的值。 位置是字符串表示里的偏移,而各执行模型的
表示不同:JVM 是 UTF-16 码元,native 是 UTF-8 字节,编译期解释器没有表示可偏移、按码点
计数。故这个数字不出 std/cursor:读不到(opaque),折不进常量(§7.2),也不渲染。
Show[Cursor] 渲染 <cursor> 而不是那个数,否则同一个程序的输出会取决于谁编译了它。
要报一个位置就报 cursor.offset(s, c),那是走出来的字符数,各后端一致。度量选哪一种
因此是纯内部决定,可以随时更换,不构成破坏性变更。
跨串使用没有定义。 游标属于它所来自的那个字符串。越界位置会被 char/next/prev
钳位、被 slice 拒绝,但落在范围内的外来游标与本串的位置无从区分,故本规范不承诺任何
答案,各后端也确实不同。把游标绑到它的字符串上需要一个本身与后端无关的标签(否则判定会
在游标分歧的地方跟着分歧),代价是算它要走一趟,且每个位置多带一个字。2026-08-16 权衡后
不做,改为把这条边界写明;其余通道由 scripts/spike-native/cursor_currency.dawn 在两个
后端上对拍守住。编译期求值不在这个权衡里:解释器的位置本来就记着它取自哪个串(只在解释器
内部,不进产物,也不花运行期的字),所以常量折叠中把游标用到另一个串上、或拿两个串的游标
比较或判等,一律报错拒绝,而不是折出第三个答案。
容器的表示。 Map/Set 的表示是纯 Dawn 的 std/hamt(持久 HAMT)、List 的是
std/pvec(持久向量),它们是内部模块:use std/hamt / use std/pvec 在 std 之外是
编译错误,诊断指回 std/map/std/set/std/list(§10.6)。表示要能换,而能换的前提是
没人依赖它——标准库参考因此也不列这两个模块。
容器的语义(持久接口、键须 Eq + Hash、迭代按插入序、相等与顺序无关)在 §2.2。
IO 的表态。 std/io 的控制台、环境与子进程函数是 !io;文件函数走具名效果 Fs(§6.5),
生产 handler 是 io.with_fs_real,测试用一张表应答。会失败的一律回 Result[T, ForeignError](§9.8.1)——
结构化载荷而非屏障渲染好的那句话,理由见
`audit/error-model-design.md`。另有以下规范性行为:
io.write_file(path, content)自动创建缺失的父目录。Ok不带值io.list_dir(path)的条目名按码点序排序。排序在 std 层做(list.sort走语言自己的Ord[String]),io_list_names原语不承诺顺序——各后端不再各排各的。path 不是目录时Err,kind是"io.not_a_directory"——它与io.run的"io.no_program"、io.delete的"io.invalid_delete_path"、io.real_path的"io.relative_path"是 std 自己铸的四个 kind,其余都是后端给的io.delete(path)回Result[DeleteOutcome, ForeignError]:删掉文件或空目录是Ok(Deleted),路径不存在是Ok(NotFound);非空目录、权限等其他 host refusal 一律 是Err,且从不递归删除。空串与以/结尾的 path 在 std 层直接拒绝,kind为"io.invalid_delete_path",不会交给后端自行规范化;含 U+0000 的 path 同样是Err, 绝不能把 NUL 前的前缀当成目标io.exists(path)、io.is_dir(path)、io.is_symlink(path)都是 Bool 查询:路径不存在、 类型不匹配或 host path 无效都回false。特别地,含 U+0000 的 path 不得 fault,也不得把 NUL 前缀拿去查询;三者都直接回falseio.real_path(path)回跟随全部符号链接、由宿主消去.与..之后的路径(realpath(3)语义):链接之后的..退到的是链接目标的父目录,不是链接所在的目录。path必须是 绝对路径且必须存在。相对路径在 std 层直接拒绝,kind为"io.relative_path",不交给 后端——解析它要读工作目录,那是Env的应答而不是Fs的;不存在、或含 U+0000 的 path 是后端给的Err。wasm32-wasi 没有绝对路径可解析,在那里一律是Errio.getenv(name)在变量未设置或 name 不能由 host 环境 API 表示时回None。特别地,含 U+0000 的 name 不得 fault,也不得查询 NUL 前缀;它直接回Noneio.read_line()按BufferedReader.readLine的规则断行,两个后端一致:\n、\r、\r\n都是行终止,终止符不进结果;最后一行没有终止符仍算一行,输入结束回None。 遇\r立刻交出这一行、不预读下一字节(终端发完\r正等回应,预读会卡住), 紧跟其后的\n由下一次调用丢掉io.read_stdin(n)回恰好n字节,只在输入结束时变短。n是调用方肯收的上限而不是要 预先分配的大小:很大的n遇短输入回已有的那些字节,n <= 0回空Bytes。JVM 上单次 至多回2^31 - 1字节(一个字节数组的上限)- 标准输出被关(
prog | head -1里head先退)不结束程序:两个后端都把写失败吞掉, 程序照常跑完、退出码不受影响。JVM 上这是PrintStream的行为(异常只记进checkError);native 运行时处理掉 SIGPIPE,写失败只置 stdio 的错误标志。由程序起的子 进程拿到的是默认的 SIGPIPE 处置 io.stdin_ready(timeout_ms)只答一件事:此刻是否至少有一字节可读。 输入结束不算就绪——写端已关和「连着但静默」给同一个false,两者的区别由io.read_stdin报告,它仍是唯一的读者。故只靠它驱动的循环会在输入结束后空转: 没活干的时候必须去做那次阻塞读(selfhost/src/lsp/server.dawn的读循环是这个形状)。timeout_ms是上界不是下界:提早回false一律合法(常规文件到达末尾会立刻回), 因为拿到false的调用方唯一能做的就是别等了;true才是不许猜的那个。 取这个形状是因为它是两个后端都不起线程就能实现的唯一一个:「读不会阻塞」那种读法要 在输入结束时答true,而 JVM 分不出那个状态与静默(available()两边都是 0), 起后台读线程又会替继承了 stdin 的子进程把字节吃掉(实测见 `docs/audit/lsp-robustness-design.md` §2.2.1)读进来的字节不是 UTF-8 时:操作系统给的字节没有任何东西保证它是 UTF-8,故每个把字节 变成
String的读取原语都必须表态。分两类,且两个后端一致:io.read_file失败(Err,即屏障接得住的 fault)。读文本读回来的却不是盘上那段 文本,正是这条规则要挡的事;输入本来就不是文本时该用io.read_file的字节孪生io.read_bytes,它一个字节都不看io.read_line/io.cwd/io.getenv/io.list_dir/io.temp_dir/args替换:非法序列换成 U+FFFD,规则逐字沿用bytes.decode_utf8_lossy(一个非法序列一个 U+FFFD,不是一个字节一个)。文件名不是 UTF-8 仍然是个文件名,拒绝它等于让程序连目录 都列不了- 由此得一条不变式:
String里没有非良构的 UTF-8。这不是修辞——它挡的是 overlong 编码顺流而下、被下游按码点走的原语当成真字符 (`stdlib-impl-notes.md`)
数学内建。 当前没有通用数学函数族,也没有宿主数学库包装。数字的内建表面限于
Int/Float 算术运算符;内建转换函数是
to_float、to_int。
额外运算由纯 Dawn 源库提供,例如 std/narrow 用整数算法计算平方根,不依赖宿主数学库。
std/narrow 的三个格式有 + - * / %、一元 -(§3.5 的 Add…Neg,默认舍入即最近偶数,
% 是精确的截断余数)与 FromFloat:let w: BF16 = 0.1 就是 bf16(0.1),在编译期折叠;
它们不收整数字面量。
定宽整数。 编译器只拥有 Int 与 Float 两种数(literal-system-design.md D1);
I8、I16、I32、U8、U16、U32、U64 是 std 里各自一个模块(std/int/i8 … std/int/u64;use std/int/u32 绑定的限定名是末段 u32)的
Int 上的不透明类型(§2.7),由 §3.5 的算术与字面量 trait 变成一等的数。判据:
- 不变量与表示:值就是范围内的那个
Int(I8为 -128..127,U32为 0..2^32-1,以此类推);U64没有不变量,任一Int都是某个U64的位模式,2^63及以上的值其Int为负。 - 算术回绕,与
Int同一条规则(§4.3):+ - *与一元-取结果的低 w 位(有符号类型再按补码读);/与%截断,除零按Int的消息 panic;有符号的MIN / -1回绕成MIN;U64的/ % <与渲染按无符号。 不检查溢出:同族类型两种溢出语义会让读者每次都要查。要检查用checked_add/checked_sub/checked_mul,答Option。 - 字面量:经
FromInt,纯 impl 在编译期折叠,所以let b: U8 = 300是编译错误,范围由库陈述(§1.5); 浮点字面量不进整数类型;U64不收负字面量,大值写u64.wrap(-1)或u64.MAX。声明模块内照旧按目标定型。 - 不混宽:
U8 + U16是两侧类型不同的编译错误,没有隐式加宽或收窄。转换只经Int:x.of(n)答Option(范围外None),x.wrap(n)取低位,x.to_int(v)回Int(U64回位模式)。 - 相等、哈希与序:继承目标
Int(§2.7),对范围内的值正确;只有U64写自己的Ord,否则2^63以上的值会比 0 小。 渲染是十进制(impl Show,U64无符号)。 - 按位:具名函数
and、or、xor、complement(not是关键字)、shl、shr、rotl、rotr; 有符号的shr补符号位,无符号的补零;移位与旋转的计数取模 w(同Int移位取低 6 位)。运算符形式等按位 trait 立项。
实现策略:能薄包 Java 就薄包(String 直接是 java.lang.String),持久 List/Map/Set
全部是纯 Dawn 源(List = std/pvec 持久向量,Map/Set = std/hamt 持久 HAMT,
均保插入序确定),后端只需实现 Array 一个原语。
12.编译模型#
12.1产物#
有两个后端,各有一个驱动。dawn 是 JVM 工具链(发字节码),dawnc 是 C 后端
(发 C11 源码,再交给 cc)。两者不是同一条路:dawn build --native 仍然是 JVM 后端,
只是把上一步的 jar 交给 GraalVM native-image(§12.3);dawnc 根本不产生字节码。
JVM 工具链 dawn(本章其余部分若不点名,说的都是它):
| 命令 | 产物 |
|---|---|
dawn check <file 或 dir>... | 只做类型检查。干净时打印 ok 退出 0;有任何诊断则渲染诊断并退出 1;用法错误退出 2 |
dawn run [compiler-options] <file.dawn 或 dir> [-- <program-args>...] | 编译到内存/临时目录,起 JVM 执行 |
dawn build <file 或 dir> -o app.jar | 可执行 jar(Main-Class: main 已设) |
dawn build ... --native -o app | 前一步 + GraalVM native-image,独立二进制(§12.3) |
dawn test <file 或 dir> | 编译含 test 块的变体并执行(目录模式聚合全部模块的 test) |
dawn fmt <file 或 dir>... | 格式化(目录模式递归全部 .dawn;直接点名的文件必须以 .dawn 结尾,否则退出 2) |
dawn __emitc <file 或 dir> -o out.c | C 翻译单元。隐藏子命令:这是 JVM 工具链上的 C 后端入口,dawnc 的自举与差分都经它 |
C 后端驱动 dawnc(单文件静态可执行程序,随 release 发布;不需要 JVM,也不需要
本仓库):
| 命令 | 产物 |
|---|---|
dawnc check <target>... | 只做类型检查,聚合全部 target 的诊断 |
dawnc emitc <target> [-o out.c] | C 翻译单元(配 runtime/c/ 的运行时一起编) |
dawnc build <target> [-o out] | 前一步 + 调 cc($CC 可覆盖),独立可执行文件 |
dawnc build --target wasm [--reactor] <target> [-o out] | 同一份 C 交给 clang,WebAssembly 模块(§12.5) |
dawnc run [--std <dir>] <target> [-- <program-args>...] | 同上,编完直接执行 |
dawnc test <target> | 编译含 test 块的变体并执行 |
dawnc fmt / doc / add / lsp | 与 dawn 的同名子命令输出逐字节一致(scripts/native-cli-diff.sh 把这四件钉在 JVM 的字节上) |
dawnc version | 版本号(自报 (native),故不与 dawn --version 逐字相同) |
两个 lsp 子命令共用同一份 stdio framing。头部从首字节到 CRLF CRLF 最多
8192 bytes:终止符恰好落在第 8192 byte 合法,到达上限仍未完成则立即拒绝。body 最多
67108864 bytes。每个非空 header 行必须是 1*tchar ":" field-value;tchar 是 ASCII
字母、数字,以及 ! # $ % & ' * + - . ^ _ | ~ 和反引号,所以无冒号、空字段名、字段名中的
空格/括号等都属于 framing failure。语法正确的未知 header 可忽略。字段名 Content-Length 仅按 ASCII
大小写不敏感比较;字段值只能是两端
可有 SP/HTAB 的一个或多个 ASCII 十进制数字。空值、符号、小数、指数、下划线、Unicode
数字、Int 溢出与超出 body 上限都非法;0 合法。重复字段仅在每次独立解析所得数值相同
时合法,故前导零不构成冲突;任一副本非法或数值冲突都拒绝。
body 必须是严格 UTF-8。完整且有界的 frame 若 UTF-8 或 JSON body 解析失败,server 回
-32700、id: null,随后继续读下一帧;替换式 UTF-8 decoding 不得把非法 wire bytes
修成合法 JSON。其他 framing failure(畸形/部分 header、部分 body、缺失/非法/冲突/超限
长度、header 超限)统一只回一次下面的 parse error,并关闭读循环;失败后的字节不得再
解释成新帧。零 header byte 的 clean EOF 静默退出。header 语法与长度必须在调用底层
stdin read 之前完成上述校验。
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}
两个驱动各自解析 argv,但 target 基数是一份共同契约(TOOL-04):
| 子命令 | target / selector 基数 |
|---|---|
check | 1..N 个 target |
test | 恰一个 target,或 --stdlib;两者异或 |
doc | 恰在“一个 target / --stdlib / --builtins”中选择一项 |
build | 恰一个 target |
emitc | 恰一个 target(JVM 入口拼作隐藏命令 __emitc) |
fmt | 1..N 个 target |
缺参、多 target 或 selector 冲突属于用法错误:在开始加载 target 或生成后端产物前退出 2;
两个驱动对同一错误使用相同诊断字节。check 与 fmt 的 N 没有人为上限;写两个 target
不是“最大为二”,而是证明它们确实是批处理命令。
run 的 argv 使用显式双名字空间(TOOL-03):
dawn run [compiler-options] <target> [-- <program-args>...]
compiler option 只在 target 前解析。没有程序参数时可省略 --;run target 与
run target -- 都向程序传空 argv。target 后若还有 token,第一个必须是 --,否则 stdout
为空、stderr 恰为下列一行并退出 2:
error: usage: dawn run [compiler-options] <target> [-- <program-args>...]
分隔符自身不转发;其后的 token 不再解释,逐字原样进入程序的 args(),包括空串、--、
--closure 与 -o。args() 是整程序输入:无论调用写在入口模块还是任意依赖模块,
都返回启动该程序时传入的同一份列表。JVM 与 native 驱动各自实现 parser,以共同的绝对
stdout/stderr/exit 契约保持一致;完整理由见 run-argv-boundary-design.md。
dawnc 少的那几个子命令不是缺口,是后端的边界:它拒绝 use java(Java 互操作是
JVM 后端的能力,§9),build-to-jar、lock、cache 同理只在 JVM 侧有意义。
两个驱动都接受 --std <dir> 换标准库源;不给时取 DAWN_STD(非空),再不然用编进工具链的那份,
从不读工作目录下的 std/(std-dir-loading-design.md「缺省来源」)。
参数可为单文件或工程目录(§10.1):目录模式加载 src/ 全部模块,入口
src/main.dawn;单文件模式向上找 src 祖先为根。jar 收全部模块类,Main-Class = 入口
模块类 main。
第三方 jar:--cp <jars>(run 的 target 前、test/build 通用;路径分隔符分隔、可重复)。
编译期 use java 解析与运行期加载共用这份 classpath。build 把各 jar 记入 manifest
的 Class-Path(相对产物目录,jar 挪走要一起挪),产物仍 java -jar 直接跑;
build --native 改以 -cp 形式调 native-image(第三方库的反射/JNI 是否过
native-image,责任在库,见 §12.3)。
--cp 本身不做依赖解析:它要什么就挂什么,传递依赖得自己列全。需要依赖树的库走
[java-deps](§10.1 的 dawn.toml)——那条路径经 coursier 解析 Maven 传递依赖。本节曾写「Dawn
无依赖解析——只接受单 jar、零传递依赖的库」,那是 [java-deps] 出现之前的事实,
与 §10.1 直接冲突。
保证:同一程序在三种产物下行为一致——JVM 上跑的字节码、--native 出的 native-image
二进制、C 后端出的可执行文件(除启动时间与内存占用)。带 use java 的程序只有前两种
产物,那是它自己声明的边界,不是不一致。
12.2字节码映射#
| Dawn 构造 | JVM 实现 |
|---|---|
模块 json/lexer | 一个类,内部名 json/lexer(包 json、类 lexer),函数为 static 方法 |
| ADT/record | 类名带模块前缀:json/lexer$Token、构造器 json/lexer$Token$Num |
| 跨模块调用 | 对方模块类上的 invokestatic;构造器/字段照常(类公开) |
| ADT | abstract class + final 子类;无载荷构造器为单例 |
| record | final class + 字段(不依赖 Java record,兼容旧字节码目标) |
match | instanceof 链 + 字段读取(不用 indy、不用 pattern switch) |
| lambda/闭包 | 每个 closure 生成一个实现 FnN 的普通类,捕获值存入 final 字段;SAM 转换另生成持有该 FnN 的 adapter class |
| 泛型 | 擦除 + 装箱 |
| 结构相等类型 | ADT/record/元组仍生成配套 equals 与 hashCode,但那是给 Java 调用方看的——Dawn 的 ==/hash 是 lowering 按结构展开的 Core 函数(§4.3),Map/Set 经字典走它。有 impl Eq/impl Hash 时这两个方法转发到该 impl |
Int/Float/Bool | 原生 long/double/boolean,仅泛型位置装箱 |
Unit | Ldawn/rt/Unit;——单例引用,占一个槽位,形参/字段/捕获位与别的引用无异 |
Never | Dawn 静态调用使用返回描述符 V。擦除后的 FnN.apply 调用返回 Object,调用方先用 POP 丢弃它再终止。SAM adapter 调 bridge 时按 SAM 返回描述符处理结果:void 不产生栈值,单槽结果用 POP 丢弃,双槽结果用 POP2 丢弃;随后生成 aconst_null; athrow。Never 没有形参、字段或其他存储表示 |
panic | 抛 dawn.rt.PanicError(Error 子类,故 catch_fault 不拦;只有隔离点 catch_panic 拦,见 §9.8) |
运行时支持类(dawn/rt/Lists、Strings、Io、Show、Maps、Tuple*、Fn* 等)
每个程序生成一份,被全部模块类共享。
12.3提前编译:native-image 契约与 C 后端#
两条路都能得到不依赖 JVM 的可执行文件,但它们在编译栈的哪一层分岔,决定了各自的约束。
dawn build --native(GraalVM native-image):走完整个 JVM 后端,拿 jar 去做封闭世界
分析。语言构造保证不产生:反射调用、自定义 indy bootstrap、动态类加载、
JNI(Java 互操作走普通 invoke)。因此 --native 构建不需要 reachability 配置。
若引入的 Java 库自身用反射,责任在库——错误信息会提示这超出 Dawn 的保证范围。
dawnc(C 后端):在 Core 之后分岔,不经过字节码,因此上面那份契约对它无意义——
没有类要被分析,也没有 native-image 参与。代价是它没有 Java:use java 直接拒绝
(§12.1)。产物是 cc 编出来的普通可执行文件,运行时在 runtime/c/。
12.4尾调用#
自递归尾调用保证编译为循环(不长栈)——顶层函数与局部命名函数(§3.1)皆然。 互递归尾调用不保证。判定规则:函数体内对自身的调用处于尾位置(返回位置、 match/if 分支的尾位置、块的末表达式)。
12.5构建目标#
构建目标有三个。选目标就是选驱动与命令;jvm、native 是本节的称呼,不是可写的拼写。
| 目标 | 命令 | 产物 |
|---|---|---|
jvm | dawn build <target> -o app.jar(加 --native 再交 GraalVM native-image,§12.3) | 可执行 jar |
native | dawnc build <target> [-o out] | cc 编出的可执行文件 |
wasm | dawnc build --target wasm [--reactor] <target> [-o out] | WebAssembly 模块(WASI preview 1) |
--target 只有 dawnc build 接受,唯一取值是 wasm,其它取值是用法错误(退出 2)。
dawn build 没有 --target,写出来同样退出 2。--reactor 必须与 --target wasm 同时给出,
否则退出 2。
wasm 与 native 是同一个后端:同一份发出的 C、同一份运行时 runtime/c/dawn_rt.c,
后者以 __wasi__ 条件编译换掉目标没有的部分。编译器是 clang:DAWN_WASM_CC 覆盖,缺省
clang,CC 不被读取。三元组取编译器手上有 sysroot 的那一个,先 wasm32-wasip1,
否则 wasm32-wasi。不给 -o 时产物名是 native 的缺省名加 .wasm。use java 被拒绝,
与 native 相同(§12.1)。
命令模块(不加 --reactor):导出 _start 与 memory。宿主调一次 _start,main
跑完即结束,语义与 native 进程相同。
reactor 模式(--reactor):链接时没有 _start,模块的导出恰好是 memory、
_initialize、dawn_turn 三项。
- 宿主必须在实例化之后、第一次
dawn_turn之前调一次_initialize(wasi-libc 的构造器)。 dawn_turn无参、答i32。一次调用是一轮:以空 argv 调程序的main(args()为[]),main返回后冲刷标准输出与标准错误的缓冲,答 0。- 每一轮从头进入
main,轮边界上不保留任何 Dawn 栈。同一实例内跨轮存活的只有std/reactor的serve装下的那个根;除此之外,本规范不承诺任何值跨轮存活。serve的契约(一个实例只用一种状态类型;step失败或 panic 时保留原状态)写在它的文档里。 - reactor 没有自己的 io 处理器:这个模式不新增任何效果、handler 或 io 原语。程序的
!io仍是 std 的那一份,io.read_line读 WASI fd 0,println写 fd 1,标准错误是 fd 2; 每轮的字节由宿主供给与收取,宿主给的输入读完即是输入结束。模块只导入wasi_snapshot_preview1的函数;导入哪几个由所链接的 wasi-libc 按程序用到的东西决定, 不属于本规范。宿主没有实现的导入答什么,由宿主决定。 - 桥看到的就是上面这些:三个导出,和 stdio 上的字节。一行 JSON 进、一行 JSON 出的消息格式
是
packages/tea-dom的契约(tea_dom/wire),不是语言的;设计与理由见 `dom-bridge-design.md`。 - 程序经
Exit退出,或有 panic 没被接住时,guest 调proc_exit,这个实例从此不可再用。 要让实例在应用 panic 之后继续应答,就在轮内用catch_panic接住它,serve就是这么做的。
wasm 上的标准库(两种模式相同):捆绑 std 的每个模块都能为 wasm 编译,差别全在运行时。 下表各项是运行时的明确拒绝,不是模拟;表外的行为与 native 产物一致(§12.1 的保证延及 wasm)。
| 能力 | 在 wasm 上 |
|---|---|
文件 io(Fs) | 经 WASI preopen;能看见哪些目录由宿主决定 |
io.run(Proc) | Err(fault,io_run: no processes on wasi) |
io.real_path | Err(fault):wasm32-wasi 没有绝对路径可解析(§11) |
io.temp_dir / io.temp_file | Err(fault) |
io.copy_permissions | src 存在即成功,但什么也不改:目标没有文件模式 |
一次性续延(控制臂、discard,§6.5) | panic:dawn: one-shot resumption is not available on wasm |
std/gpu 的 with_gpu_real | 凡碰设备的操作答 gpu.unsupported_backend(§12.6) |
| 线程、大栈 | 没有;递归深度的上限取决于引擎的调用栈,未定义 |
机器核对:scripts/wasm-contract/run.sh 把一组语料同时编成 native 与 wasm(命令模块),
在 node 的 WASI 下运行,要求 stdout 与 native 逐字节相同、运行时的对象生死账归零、未接住的
panic 在两个目标上都退出 1;scripts/wasm-dom-contract/run.sh 把 reactor 与 DOM 桥跑在
一个记录型 document 桩上,逐字节比对转录。两者都在 CI 的 wasm-target job 里。
12.6设备后端:GPU#
设备后端不是编译器的第三个后端。两个驱动都不认识 kernel、Tile IR 或 GPU,也不调用任何 设备工具链。它是两层库:
- 宿主层
std/gpu(随 std 捆绑):Gpu效果、DeviceBuffer[D]、两个 handler;元素格式在std/dtype(Dtype[T])与各值类型的模块里。 - 设备层
packages/tileir(源码包,经[deps]引入,不在 std 里):Dev效果、记录 handler、TileProg、Tile IR 文本渲染器与字节码写入器。kernel 的宿主参考实现在packages/tileref。
理由、路线与实测见 `tile-backend-design.md` §4–§6。
kernel 体是效果行只有 Dev 的普通 Dawn 函数,形如
fn(a: Param[Float], b: Param[Float], out: Param[Float]) -> Unit !Dev。
- 它不在设备上执行,也不在宿主上计算任何数值。
Dev的每个操作产生或消费句柄:Tile[D](任意秩,0 秩即标量)、Idx、Param[D]等不透明类型,幻影参数D是格式的值类型(§2.7)。 句柄由 handler 签发;把一种句柄当另一种用,或把宿主的Int当句柄用,是类型错误。 - 记录:
tileir/prog的trace_kernel(name, params, body)在宿主运行期、在记录 handler 下把body跑恰好一次,答一个TileProg(SSA 形式的 ADT)。params是入口各参数 的格式名,按位置。同一个体按同一顺序发同样的操作,得到的记录相等。 - 体里普通的 Dawn 控制流(
if、for、递归)在记录时由宿主求值:只依赖宿主已知量的分支被 展开,宿主的Int/Float进入记录时成为常量。依赖 tile 值的控制流必须走tileir/dev的结构化函数(d_range、d_loop、d_if、d_reduce等),它们在记录里成为区域。 - 内存操作之间的顺序只由记录 handler 维护的 token 链给出,程序序不给出任何顺序。
- 下列情形在记录时 panic(不是
Err):param(d, pos)与params不符(位置越界或格式不同); tile 的某一维不是 2 的幂;循环嵌套深于MAX_LOOP_DEPTH(16);一次记录签发的句柄超过MAX_HANDLES(65536);区域没有闭合,或闭合时的值个数不对。 - 产物:
tileir/render的render(prog)给cuda_tile方言的文本,tileir/bytecode的encode(prog)给cuda-tile字节码,版本钉在BYTECODE_MAJOR/BYTECODE_MINOR。一个TileProg是一个模块,含一个entry @<name>,name即trace_kernel的第一个实参;每个 入口参数要么是其格式的指针(ByPtr,缓冲),要么是 rank-0 的值(ByValue,按值传的标量)。字节码变成设备映像(cubin)由 NVIDIA 的tileiras完成,在 Dawn 工具链之外;本规范不规定由谁、在何时调用它。
Gpu 效果有七个操作(gpu_alloc、gpu_copy_from_host、gpu_copy_to_host、gpu_launch、
gpu_module_global、gpu_free、gpu_sync),单态、句柄级;程序用的是其上的类型化函数
alloc、copy_from_host、copy_to_host、free、launch、module_global、sync。
- 每个操作答
Result[_, ForeignError]。std 在操作之上自己发三种拒绝,在每个 handler 下字节相同:gpu.bad_length(alloc的长度小于 1)、gpu.length_mismatch(copy_from_host的数据长度不等于len(t))、gpu.bad_grid(grid 某一轴小于 1)。这三种都在问 handler 之前发出。其余答复是 handler 所代表的设备的原样结果:接缝在错误面之下。 launch以字符串点名 kernel:launch(kernel, grid, args, gy: Int = 1, gz: Int = 1),grid、gy、gz是 grid 的三根轴,计 tile block 的个数;args是LaunchArg的列表,按入口参数的顺序:erase(buffer_arg(t))是缓冲,erase(scalar_arg(v))是按值传的标量(i32、i64、f32、f64)。 名字到 kernel 的绑定是安装 handler 时交给它的表,名字不在表里,两个 handler 都答gpu.no_kernel。语言不检查这个名字与trace_kernel用的名字一致,也不检查args的个数与 格式与入口签名一致:那是程序的责任。launch返回不等于 kernel 已经跑完。sync之后的copy_to_host才保证看到此前各次 launch 的写入。module_global(d, kernel, name)答一个覆盖该 kernel 所在模块中一个导出全局的DeviceBuffer[D], 与模块共享存储;同一符号查两次答同一个句柄;对它free被接受且什么也不做。
两个 handler:
pub fn with_gpu_fake[T](kernels: Map[String, (Int, WideRefFn)], body: fn() -> T !Gpu,
globals: Map[String, List[(String, String, List[Float])]] = map.empty()) -> T
pub fn with_gpu_real[T, !e](kernels: Map[String, Bytes], body: fn() -> T !Gpu !e) -> T !io
- 假设备
with_gpu_fake是纯的:在任何后端、任何 test 块里都能用,不需要设备、驱动或!io;两次安装互不共享。它不能在 comptime 用:它的表是Map(§7.2),它的 handler 带状态格 (var),comptime 解释器两样都表示不了。 - 假设备从不运行 kernel 体。表把名字映到(缓冲个数,宿主参考实现)。
launch先查实参: 一个都没有答gpu.no_arguments,个数与表项不等答gpu.bad_arity,句柄未签发或已释放答gpu.no_such_buffer;然后把每个实参缓冲的格式名与内容按序交给参考实现。参考实现答[(实参位置, 内容)],每一对按该缓冲的格式舍入(round_to)后写回;有位置越界则答gpu.bad_write_back,什么也不写。所有输入在任何一次写回之前读完。grid 不被读取。 - 缓冲持有的是字节,和内存一样:
copy_from_host原样存,copy_to_host原样答,所以对任何格式、任何值, 上传再下载都是恒等。参考实现看到的是Float,所以 launch 时假设备把每个缓冲解码成Float;i64缓冲里有Float装不下的值(超出 ±2^53)时,以gpu.fake_inexact拒绝(消息指明哪个 缓冲、哪个下标),而不是舍入。写回按缓冲格式舍入。 globals把 kernel 名映到它所在模块导出的全局(符号名、格式、声明给的内容),module_global从这里答,表里没有的名字答gpu.no_module_symbol;省略即每个模块都不导出全局。它排在body之后(带默认的形参排在不带默认的之后),所以尾块落在globals上,body写在括号里。- 真设备
with_gpu_real只在 native 目标上有实现。运行时在第一个需要设备的操作时dlopen("libcuda.so.1")并在 device 0 上开一个上下文;body返回后释放上下文与库,程序 没有free的缓冲随之释放。一个 kernel 的 cubin 在它第一次被 launch(或被module_global查询)时装载,本次安装内复用。 - 驱动的拒绝原样交出,kind 是
cuda.<CUresult 名>;没有 libcuda 可装载答gpu.no_driver; 驱动装不了 cubin 所用的 ELF ABI 时答gpu.driver_too_old,cubin 不交给装载器。 - JVM 与 wasm 上没有设备运行时:凡碰设备的操作答
gpu.unsupported_backend,handler 原样 交出。真设备 handler 不检查实参的个数与格式是否与 kernel 入口相符。
两个 handler 之间的契约:
- 同一个
!Gpu函数不改一个字,在两个 handler 下都能运行。 - 不碰设备的拒绝在两个 handler 下 kind 相同:
gpu.unsupported_dtype、gpu.no_such_buffer、gpu.no_kernel、gpu.no_arguments。在真设备一侧,这些答案在任何后端、有没有驱动都一样。 - 对一个 kernel K 与登记在假设备表里、与它同名的参考实现 R:假设备答的值必须等于真设备
运行 K 答的值。逐位档的 kernel 整段缓冲逐位相等(分得清
-0.0与0.0);容差档的 kernel 逐元素满足|got - want| <= atol + rtol * |want|,atol = rtol = 1e-5。档位是 kernel 的属性,由层 2 的对拍程序声明,判据见 `tile-backend-design.md` §6.6。 这条义务落在写这一对 K、R 的人身上:编译器与 std 都不检查它,下面的层 2 检查它。
跨界的类型:宿主与设备之间只过缓冲。
DeviceBuffer[D]是不透明类型,表示是(句柄,元素数);D是格式的值类型,DeviceBuffer[Float]与DeviceBuffer[BF16]是两个类型,传错格式是类型错误而不是 launch 失败。句柄是 handler 签发的 不透明整数,程序从不编造它。- 格式有 16 个,每个是一个值类型加一个同名的见证常量
Dtype[T](use std/narrow.{BF16}同时带来类型与见证;alloc(BF16, n)、DeviceBuffer[BF16]),只有它们有HasDtype:Float(见证F64)、Int(I64)、Bool(I1,只是 tile 格式) 在std/dtype;BF16、F16、F32、TF32、F8E4M3FN、F8E5M2、F8E8M0FNU、F4E2M1FN、F8E5M3FNU在std/narrow(后六个只作存储,没有算术);I8、I16、I32、U8在std/int/*;I4在std/gpu。U16、U32、U64没有(方言没有无符号整数 tile 类型), 所以DeviceBuffer[U32]在alloc处是类型错误。 - 类型位置写值类型,值位置写格式名。对
std/narrow与定宽整数两者同名(Tile[BF16]、alloc(BF16, n));对三个内建类型分别是Float/F64、Int/I64、Bool/I1。 - 能分配成缓冲的格式恰好是
element_bytes认识的 12 个:f64、i64、i32、tf32、bf16、f16、i16、i8、u8、f8E4M3FN、f8E5M2、f8E8M0FNU。其余格式两个 handler 都答gpu.unsupported_dtype,包括F32:它可以写进类型,不能分配。I4、F4E2M1FN、F8E5M3FNU只是 tile 的格式,没有这种缓冲。 - 值以
List[T]进出,T是缓冲的元素类型:copy_from_host(b: DeviceBuffer[T], xs: List[T])与copy_to_host(b: DeviceBuffer[T]) -> Result[List[T], _]要求T: HasDtype + DeviceBits。copy_from_host把每个元素 写成格式的位型(小端),上传时不舍入(值在T里已经是格式的值:BF16就是 bfloat16,Int整个过去);copy_to_host精确答出缓冲持有的值。所以每种格式都无损,超出 ±2^53 的i64与 NaN 的 载荷也在内。DeviceBits由能成缓冲的格式实现;Bool、I4、F4E2M1FN、F8E5M3FNU有格式没有缓冲, 上传DeviceBuffer[Bool]是类型错误。在效果上数据以Bytes过界(gpu_copy_from_host(handle, data: Bytes)、gpu_copy_to_host(handle) -> Result[Bytes, _]),第i个元素占[i * w, (i + 1) * w)字节,w是格式的宽度, 小端。encode_floats(dtype, xs: List[Float])与decode_floats(dtype, bytes)是同一编码按格式名对Float的视图, 给缓冲没有共同元素类型的程序用;超出 ±2^53 的i64过不了它们。 - 标量按值过界:launch 的实参是缓冲句柄或按值传的标量(
i32、i64、f32、f64;更小的格式的参数区布局 没量过,不收)。宿主的数要进 kernel,可以在记录时作为常量写进程序(每个值一个程序), 或者作为ByValue参数运行期传入(同一个 cubin)。
机器核对(在哪、核什么):
| 层 | 核什么 | 在哪 | 何时 |
|---|---|---|---|
| 0 | 每个 kernel 记两次,记录相等;文本 golden(*.mlir)与字节码 golden(*.tilebc),两个后端逐字节 | scripts/tile-golden/run.sh | CI tile.yml |
| 1 | 钉版本的 tileiras --gpu-name sm_86 接受每个字节码 golden:退出 0、输出里没有 error:、cubin 的符号表有 GLOBAL FUNC <kernel> | scripts/tile-golden/run.sh 的 assemble 步 | CI tile.yml |
| 2 | 同一组 !Gpu 程序在两个 handler 下运行,按 kernel 的档位逐位或在容差内相等;JVM 上真设备 handler 在第一个操作答 gpu.unsupported_backend | scripts/tile-gpu-diff/run.sh,结果追加进台账 | 装有驱动的机器上,手动 |
- 层 2 的台账
scripts/tile-gpu-diff/ledger.txt(sm_86)由 CI 的run.sh --check读取:末行 能解析,inputs=摘要等于 HEAD 上 tile 路径的摘要,结果是pass或blocked:...,工具链 文件的driver与tileiras行与它一致。ledger-sm90.txt、ledger-sm100.txt是另两台 机器的记录,没有门读它们。 scripts/tileir-features/check.py(CI)把 opcode、类型 tag、属性值三张覆盖台账与写入器的 操作码表双向对账。dawn test --stdlib跑std/gpu的 test 块:假设备的行为,以及真设备 handler 不碰设备的拒绝。
不承诺的:
- 假设备与 kernel 一致。假设备只运行参考实现;参考与 kernel 的一致只由层 2 在台账记下的机器上
证明,台账之外的架构、驱动与
tileiras版本没有证据。 --check接受末行为blocked:...的台账:它证明有人在这棵树上跑过层 2、驱动答了什么, 不证明设备的答案到达了 CI。- 归约与扫描的折叠顺序:Tile IR 不规定树形,容差档 kernel 的逐位结果未定义。
- 实参个数或格式与 kernel 入口不符时真设备的行为:未定义。
- 假设备在超出 ±2^53 的
i64上做计算:它能持有,但拒绝在其上 launch。 - 在 comptime 记录、编码或运行 kernel。
13.语法速查#
# ---- 声明 ----
use geo/shape.{Shape, area}
use java "java.nio.file.Files"
pub type Color = Red | Green | Blue derive Show
type Point = { x: Float, y: Float }
alias Distance = Float # 透明别名(§2.6)
pub opaque type UserId = Int # 模块外看不穿(§2.7)
const ORIGIN: Point = Point { x: 0.0, y: 0.0 }
pub trait Named[T] { fn name(x: T) -> String }
impl Named[Point] { fn name(p: Point) -> String = "point" }
pub effect Ask { fn ask() -> Int }
pub fn dist(a: Point, b: Point) -> Float =
sqrt(pow(a.x - b.x, 2.0) + pow(a.y - b.y, 2.0))
fn double(x: Int) = x * 2 # 私有函数可省返回类型(§3.1)
# ---- 表达式 ----
let n = 42 # 不可变绑定
var acc = 0 # 可变绑定
acc = acc + 1 # 赋值(只对 var)
let (a, b) = pair # 解构
if x > 0 { "pos" } else { "non-pos" }
match opt { Some(v) -> v, None -> fallback }
xs |> filter(x => x > 0) |> map(x => to_string(x)) |> join(", ")
xs[0] # 下标:走 Index trait;越界 panic,问询用 get(§4.8)
[a, ..xs, if c { b }] # 列表元素形式:展开 / 条件元素;多行时换行也分隔(§4.11)
read_file(path)? # Result 传播
if n < 0 { return "negative" } # 提前返回(§4.9)
xs.each { x => println("${x}") } # 尾块:最后一个实参(§4.3)
with f <- bracket(open(p), close) # 块剩下的部分成为 use 闭包(§4.10)
comptime { heavy_pure_calc() } # 编译期求值
# ---- 块内局部函数(可递归,§3.1)----
fn sum(xs: List[Int]) -> Int = {
fn go(i: Int, acc: Int) -> Int =
if i == len(xs) { acc } else { go(i + 1, acc + xs[i]) }
go(0, 0)
}
# ---- 测试 ----
test "dist is symmetric" {
assert dist(p, q) == dist(q, p)
}
test dist { assert dist(p, p) == 0.0 } # 以声明命名:dist 的示例(§3.4)