Dawn 语言规范

状态: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源文件

1.2注释

# 行注释,到行尾
## 文档注释,附着于紧随其后的声明(工具链提取)

没有块注释。 注释只有 # 开头、到行尾为止的这一种;要注释掉一段代码,每行各加 #。 /* 是词法错误(Dawn has no block comments; use `#` on each line),报在 /* 处, 其后到 */(没有则到文件尾)的文本整段跳过,不再连带报别的错。 理由:Dawn 的行结构有语义(§1.7 换行分隔语句、行尾 token 决定是否续行),插值也规定单行(§1.6); 没有块注释,任何一行都能脱离上文独立切词,「这一行最后一个 token 是什么」不依赖前文, dawn fmt 也不必为跨行注释定缩进规则。Gleam、Zig 同样只有行注释。

文档注释。 以 ## 开头的整行注释是文档注释(### 也是,第三个 # 属于正文); 只以一个 # 开头的注释对工具链没有意义。

理由:文档注释仍是注释。一条注释是不是文档只看它自己那一行(开头是不是 ##、前面有没有代码), 与上文一样每一行都能独立切词,dawn fmt 也不必为它另立规则;附着靠「正上方连续」这一条行规则判定, 不需要语法树里多一个节点。行尾 ## 若也算,它会被下一行的声明认领,说明的却是本行的代码。 Rust、Zig、Gleam 的 /// 都是同一个形状。链接的记号取 Rust 与 KDoc 的 [`name`]:在 CommonMark 里它是未定义的引用,按字面渲染, 不认识它的渲染器照样能读。不把所有反引号里的名字自动当链接:仓里写在反引号里的大多是代码片段, 哪些算链接会变得无法预测。解析放在工具层而不是编译器:注释不影响语义,dawn run 不该因注释里的 错字拒绝编译,也不该为读注释付费。

1.3标识符与命名约定

强制的部分——首字符决定语法类别,parser 靠它消歧(模式匹配中 x 是绑定、 X 是构造器;TYPEIDENT 是独立 token):

风格约定——工具链不强制,但全仓遵守:值用 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 都合法):

词唯一的语法位置
javause java "a.B":use 之后且后面跟类名字符串(§9.1)
test顶层声明首:test "名字" { … }(§3.4)
assert语句首且后面跟一个表达式(§3.4);assert = 1、assert.x 里是名字
with语句首且后面跟一个名字或 <-(§4.10)
infor 头里 pattern 之后(§4.7)
derivetype 声明尾部(§3.5)
asuse 的重命名位(§10.2)
handlewith 之后且下一个 token 不是 <-(§6.5)
opaque紧接 type,引入不透明类型(§2.7)
ctl紧接 effect,声明可挂起的效果(§6.5)
resumehandler 臂的参数表之后: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无期望时 Int64 位有符号;下划线可作分隔;随期望定型(见下)
3.14, 1.0e-9无期望时 FloatIEEE 754 double;随期望定型(见下)
true / falseBool
"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:

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 与其他标量一样有 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]:

其余复合类型不属于这张 builtin 名字表:

类型位置的 (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)。语义要点:

2.3和类型(ADT)

首个 | 可以省略;格式约定是单行声明不写首竖线,多行声明在每个构造器行首写竖线。

type Shape =
  | Circle(r: Float)
  | Rect(w: Float, h: Float)
  | Point                       # 无载荷构造器

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泛型

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}")
}

默认参数(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)

局部命名函数:块内可写 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 可以声明公开具名效果(见 §6.5):调用方能导入、传播或安装 handler。 私有效果出现在公开面才是错误。

3.4测试块

test "precedence" {
  assert eval("2+3*4") == Ok(14)
}

test eval {
  assert eval("1") == Ok(1)
}

以声明命名的测试。 test 后面除了字符串,还可以写本模块一个顶层声明的名字,这个测试就是 该声明的示例:

理由:示例应当是代码,而不是注释里的文本。写成测试,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 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

异构集合

没有 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

四步各有依据,都在本文件内:

适用范围:


4.表达式

Dawn 是表达式导向的:if、match、块都产生值。

4.1绑定

let x = 42              # 不可变绑定,类型推导
let y: Float = 1.0      # 可选标注
var acc = 0             # 可变局部变量
acc = acc + 1           # 赋值,仅对 var 合法

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
12not、一元 -、~前缀一元 - 在 Int/Float 外经 Neg;~ 仅 Int 按位取反
13? . () []调用后缀? 见 §8.1;() 自 2026-07-30 起是一般后缀(见下)

数值边缘语义(全部为保证——自举后两套实现以此对拍,不允许「碰巧一致」):

点调用(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 是什么,决定这是哪一种调用,按下列顺序认:

  1. 模块别名:recv 是整模块引入的别名,则是限定调用 alias.name(args)(§10.3)。 别名与本模块任何绑定同名是编译错误,所以这一步不会与下面几步争。
  2. Java 静态:recv 是 use java 引入的类名(Math.floor(x)、StringBuilder.new())。
  3. Java 实例:recv 是一个值,其类型是 Java 类,且该类有名为 name 的实例方法。
  4. fn 类型的记录字段:recv 是记录值且有名为 name 的 fn 类型字段,则调用字段里存的函数值 (§2.4)。作用域里同时有名为 name 的函数时,这个写法是编译错误(歧义), 消歧写 (r.name)(a) 或直呼 name(r, a)。
  5. 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)        构造器也只是被调用的东西

管道不引入任何新节点,因此:

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)             # 单参数可省括号

4.6if

let sign = if x > 0 { 1 } else if x < 0 { -1 } else { 0 }

4.7循环

for x in [1, 2, 3] { println("${x}") }
for (key, value) in entries { println("${key}=${value}") }
while queue.non_empty() { ... }

惯用风格优先 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]   # 可链式、可与 ?/./() 组合

用户类型的 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 的库函数)恰属其一,按参数的含义归类而不是逐函数各定各的:

  1. 断言——参数是一个位置,调用方声称它合法,越界是 bug:xs[i]、m[k]、 bytes.at、str.at、pvec.index/nth、cursor.slice 的非法区间 → panic(含负下标)。
  2. 问询——越界/缺键是调用方要区分的正常分支: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)。
  3. 钳位——参数是一个区间,说的是「要这一段里有的部分」,不断言端点存在: 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"
}

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()!
})

为什么是 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规则

  1. 函数体的效果 = 体内所有调用效果的并。
  2. 签名未标 !io 的函数,体内出现 io 效果 → 编译错误 (报错指出哪个调用引入了 io,并建议在签名加 !io 或消除该调用)。
  3. 标了 !io 但体是纯的 → 允许(预留演化空间)。Dawn 只有一种诊断等级,没有 lint; 「多余 !io」将来要么做成错误,要么不做。
  4. 纯函数保证:给定相同参数返回相同值、无可观测副作用。 编译器可据此折叠、消重、在 comptime 调用。唯一的例外是 dbg(§8.3):它写的 stderr 行 定义为诊断旁路、不算程序的副作用,所以它的类型是纯的。具名效果也在这条保证之内: 函数值的类型带着完整的效果行,能从行里减去一个标签的只有真正应答了它的那个 with handle(§6.5),所以签名为纯的函数跑不出别人的 handler 臂。
  5. 函数值的效果在调用点应答,不在写下它的地方。闭包的行就是它体内发出的效果, 创建点不做改写;一次调用实例化出的行里若有没人应答的具名效果,错误报在这次调用上 (§6.5)。
  6. panic/todo/assert 不算 io——它们不返回(发散不是效果)。
  7. 吸收禁令:效果行不得在无 handler 的前提下丢掉标签、效果变量或关联效果投影。 并不丢(§6.6);把一个函数值送进某个位置时,目标位置的行必须逐原子容得下它的行, 丢一个原子就是丢一格证据。唯一的减法点是 with handle(§6.5),它只减自己应答的 那个标签。签名承诺 !io 覆盖住自己绑定的变量不算丢弃:那是包含关系(§6.6), 变量在这条签名的绑定者列表里仍有证据槽。
  8. 局部 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)

pure 在行的任何位置都被拒绝,不只是绑定者位置:它读起来是「只许纯」, 当变量用却是「什么行都行」,两者相反。空行有自己的拼法 !()(§6.6)。 绑定者列表也是顺序的来源:它按写下的次序绑定,未写绑定者的名字仍按首次出现的次序引入。

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
}

拼写与传播

fn sum_three() -> Int !Ask = ask() + ask() + ask()

fn logged(x: Int) -> Int !Ask !io = {
  io.println("asking")
  ask() + x
}

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)、 ? 透明穿过。

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。推论:

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 效果之下写不带绑定子句的尾恢复臂合法。

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 是下面那条裁决的两半。

放弃一条续延

捕获了却不恢复的续延有两种结局,而语言只承认一个显式的触发器。

四种误用各有一条逐字的消息,它们只在下面这段程序的输出里写一次,按这个顺序:恢复一条 已经用掉的续延;丢弃一条已经用掉的续延(丢弃后恢复、恢复后丢弃、丢弃两次是同一张票, 所以是同一条消息);对一个不是续延的函数值 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

另外四条规定:

两个后端只承诺可观察的一致。 冻结与恢复的机制不写进本规范:JVM 上是把栈冻起来, native 上是停下那条栈、换另一条来跑,两者不同是刻意的。本规范承诺的是同一份源程序在两个 后端上给出同一串可观察行为:答案、release 的次数与顺序、panic 的消息与它落在哪里。 逐字节比对这三样的语料是 scripts/spike-native/ctl_resume.dawn、ctl_nested.dawn 与 ctl_discard.dawn。

边界(v1)

以下是 v1 的边界。「写出来的具名效果」不再是其中任何一条的理由,各条各有各的判据。

实现(信息性)

每个 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)。

行的等价由且仅由以下五条规则生成。

  1. 并按分量取:两条行的并 = io 位取布尔或,效果变量、关联效果投影、具名标签三个集合 各自取并。于是 | 交换、结合、幂等,pure 是幺元。多个注记叠写与写成一个并等价: !Ask !io 就是 !(io | Ask)。
  2. 规范形取最小形状:基轴分量全空即 pure,只剩 io 即 !io,无 io 且只剩一个基轴 原子即该原子本身,否则是这些基轴分量的并;标签集合为空就不带标签层,非空则是这条基轴 行加上这些标签。
  3. 规范形有确定次序:效果变量按引入序(签名内首次出现的次序,显式绑定者按写下的次序 排在前面),关联效果投影按(主体类型参数、trait、成员名),具名标签按效果 id。这个次序 同时是证据槽的布局次序(§6.5「实现」)。渲染是另一回事:印出来的原子按名字字典序排, 所以 !io !e 印作 !(e|io)。
  4. 投影按 impl 归约:主体定下来时,关联效果投影换成该 impl 绑定的那条行,换完按上面 重新规范化。一条 ground 的行里不再有投影。
  5. 两条行等价当且仅当规范形相等。

没写下的等式就是没有。 尤其是:并里没有吸收律。!(io | Ask)、!(e | io) 都是 不动的规范形,任何把它们等同于 !io 的读法都不是这五条的推论,而是新增了一条规则。

并不区分「占证据槽的效果」与「环境效果」。 io 是环境效果,自己不占证据槽 (§6.5「实现」),这不构成它可以吸收 !e 的理由:!e 可以实例化成占槽的具名效果, 而槽的有无要到实例化之后才知道,并却是在实例化之前算的。这条最容易被反向推翻成 「io 又不占槽,吸掉有什么关系」,所以写成条文而不是注释:吸收的对象是原子,不是槽。

包含是另一条关系,不参与等价。 !io 覆盖 pure、io 与效果变量(含它们的并); 具名标签与关联效果投影则按精确的集合包含判,!io 既不覆盖 !Ask 也不覆盖 !C.E。 两条关系分工明确:

行相减是「逐原子」的唯一例外。把一个函数值送进形如 !(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约束

  1. comptime 代码必须是纯的(可调用任何纯函数,包括本模块与依赖模块的)。
  2. 结果类型必须常量可序列化——即编译器能在类初始化时把这个值重新造出来: 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。
  3. 求值有步数预算(默认 10⁸ 步,--comptime-fuel 调整),超限报错—— 保证编译必然终止。产出与输入等长的原语按产出大小计步,在分配之前扣: String 的 ++ 按结果的码点数,List 的 ++ 按右操作数的元素数(左边是共享的, 见 §2.2),range、列表切片、join、code_points/from_code_points、 大小写转换、字符串切片与引号渲染按产出的元素数或码点数。于是预算同时界住 内存与单步耗时:s = s ++ s 翻倍一次付一次结果的长度,而不是几步。 产出个数大到 Int 装不下时(例如 range 两端之差溢出)按超出任何预算处理,不按零计。
  4. comptime 里没有 Java 互操作、没有 io(由约束 1 自动保证)。
  5. 纯不等于能折叠:有些纯的 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)
}

跨错误类型:写个本地 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>,由编译器在编译期按调用点填入:

后缀 !: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,于是解包是常态。

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

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

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")
}

9.2类型映射

DawnJava方向
Intlong(接收 int 自动加宽)双向
Floatdouble双向
Boolboolean双向
Stringjava.lang.String双向
Unitvoid返回
引入的类 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

为什么方法包、构造子不包——这不是两套随意的规则,而是各有依据:

基本类型返回值不包 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()
}

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 侧此后的任何新版本。

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_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] }

载荷契约(对每个后端):

屏障只有这一对(拦失败的只有这一对;§9.8.2 的 bracket 什么都不拦), 载荷只有 ForeignError,不保留 String 版本。

这一对的效果行是同一个:被护闭包的行,屏障自己什么都不加。纯函数的承诺是 同一程序、同一构建里,相同实参得相同值(§6.2 第 4 条),按观察的是哪一种失败逐个对:

于是三个屏障共用一条行:§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...

三条保证:

它不给 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.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),形式不变

10.3名字解析(消歧规则)

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`)。

加载范围(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编译单元与求值顺序

10.6捆绑标准库与 prelude

标准库以 Dawn 源码随编译器捆绑,组织为真模块(`stdlib-naming.md`)。 捆绑的模块恰好是下面这些,与编译器的清单 std/modules.txt 逐一对应(scripts/doc-check.py 对账,多一个少一个都红):

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`。另有以下规范性行为:

数学内建。 当前没有通用数学函数族,也没有宿主数学库包装。数字的内建表面限于 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 变成一等的数。判据:

实现策略:能薄包 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.cC 翻译单元。隐藏子命令:这是 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 基数
check1..N 个 target
test恰一个 target,或 --stdlib;两者异或
doc恰在“一个 target / --stdlib / --builtins”中选择一项
build恰一个 target
emitc恰一个 target(JVM 入口拼作隐藏命令 __emitc)
fmt1..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;构造器/字段照常(类公开)
ADTabstract class + final 子类;无载荷构造器为单例
recordfinal class + 字段(不依赖 Java record,兼容旧字节码目标)
matchinstanceof 链 + 字段读取(不用 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,仅泛型位置装箱
UnitLdawn/rt/Unit;——单例引用,占一个槽位,形参/字段/捕获位与别的引用无异
NeverDawn 静态调用使用返回描述符 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 是本节的称呼,不是可写的拼写。

目标命令产物
jvmdawn build <target> -o app.jar(加 --native 再交 GraalVM native-image,§12.3)可执行 jar
nativedawnc build <target> [-o out]cc 编出的可执行文件
wasmdawnc 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 三项。

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_pathErr(fault):wasm32-wasi 没有绝对路径可解析(§11)
io.temp_dir / io.temp_fileErr(fault)
io.copy_permissionssrc 存在即成功,但什么也不改:目标没有文件模式
一次性续延(控制臂、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,也不调用任何 设备工具链。它是两层库:

理由、路线与实测见 `tile-backend-design.md` §4–§6。

kernel 体是效果行只有 Dev 的普通 Dawn 函数,形如 fn(a: Param[Float], b: Param[Float], out: Param[Float]) -> Unit !Dev。

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。

两个 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

两个 handler 之间的契约:

跨界的类型:宿主与设备之间只过缓冲。

机器核对(在哪、核什么):

层核什么在哪何时
0每个 kernel 记两次,记录相等;文本 golden(*.mlir)与字节码 golden(*.tilebc),两个后端逐字节scripts/tile-golden/run.shCI 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_backendscripts/tile-gpu-diff/run.sh,结果追加进台账装有驱动的机器上,手动

不承诺的:


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)