Dawn 语言规范

English —— 本文是正本;英文是它的译本,scripts/doc-check.py 盯着两者不脱节。

状态:normative(权威)。适用版本:0.70.0(selfhost/src/version.dawnVERSION)。 实现与本文冲突时,以本文为准并把实现当 bug——除非本文某条被显式标注为「已被 X 取代」。

标题曾长期写「v0.1 草案」,改掉它那天工具链已经到 0.11:一份自称草案的文档没法充当裁判, 而这是仓库里唯一有资格裁判语义争议的文档。版本号跟 VERSION 走,不再单独编号。

本文是语法与语义的权威定义。设计动机见 design.mdgrammar.ebnf 是一份历史的机器可读语法,已落后于 parser (它自己头部列了已知不符)——当参考读,别当裁判;文法有争议时以本文与 selfhost/src/front/parser.dawn 为准,可执行的那份期望在 scripts/grammar-corpus/

规范用词:必须(违反即编译错误)、保证(实现承诺的行为)、 未定义(本规范不承诺,勿依赖)。


1. 源文件与词法

1.1 源文件

1.2 注释

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

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 java pub
match if else for in while with
return break continue
comptime unsafe_pure test assert
trait impl effect
true false not

关键字不可用作标识符。panictodo 是预置函数而非关键字。上下文关键字另有四个, 它们在别处仍是普通标识符:derive(只出现在 type 声明尾部)、as(只出现在 use 的重命名位,§10.2)、handlewith 之后且下一个 token 不是 <- 时,§6.5)、 opaque(只在紧接 type 时引入不透明类型,§2.7)。

符号 token 取最长匹配(同 >>>/>=/->/|>)。with 语句的绑定箭头 <-(§4.10)也照这条:a<-b 读作 a <- b,不是 a < (-b)。写了空格的 a < -b 不受影响,且 dawn fmt 给每个二元运算符两侧加空格(§1.8), 所以格式化过的源码到不了第二种读法。

1.5 字面量

形式类型说明
42, 1_000_000, 0xFF, 0b1010Int64 位有符号;下划线可作分隔
3.14, 1.0e-9FloatIEEE 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)报非法字面量,不与范围错误混为一类。

字符是自己的类型 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/ str.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.atstr.charscode_pointscursor.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、代码生成很方便。

插值由 $ 引导(同 Kotlin/Swift):$name 插入一个简单标识符,${expr} 插入 任意表达式;被插值的类型必须有 Show 见证——预置标量、写了 impl Showderive Show 的用户类型、以及元素可渲染的容器与元组(见 §4.3):

let n = 3
println("got $n items, first = ${list.get(0)}")

$ 后不接标识符或 { 时就是字面美元号("$5" 无需转义);要强制字面 $\$。 插值内的表达式效果并入整个字符串表达式的效果。

${...} 必须写在一行内(跨行报 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 负责统一。

换行还在两处兼作分隔符:match 的臂之间(§5)、列表字面量的元素之间(§4.11)。 两处都是先按上面的规则把表达式解析完,剩下的那个换行才是分隔符,所以续行规则优先, 分隔靠后。

1.8 dawn fmt

dawn fmt <文件>... 就地格式化;dawn fmt --check <文件>... 只报告未格式化的文件 (有则退出码 1,供 CI)。实现是基于 token 流的重排器:逐 token 按原样重印 (字符串与插值按源码区间原样保留,一字不改),只改动 token 间的空白——行内间距、 2 空格缩进、折叠连续空行(保留作者的物理换行)。故格式化保 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)、Float(double)、BoolStringBytes(不可变字节序列)与 Unit。 这七个名字与下节三个公开泛型名字构成完整的 public builtin type surface。

Unit 是一等值(唯一值 ()),可出现在任何值能出现的位置:形参、局部变量、 闭包捕获、元组元素、返回值,以及实例化类型参数(Result[Unit, E]List[Unit]catch_fault(() => <void 调用>))。运行期它有真实表示——一个单例对象 (dawn/rt/Unit,与 None 及无字段构造子同一表示),占一个引用槽位, C 后端给它一个字节。

只有两条例外,都不是表示上的限制:

没有 null。 所有类型的值都必然有效;可缺失用 Option[T] 表达。 没有隐式转换。 IntFloat 必须显式 to_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

OptionResult 就是 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)生成的。理由是拼写与含义一一对应:花括号形式的字段必须带名, 按位置填一个记录会让字段顺序变成 ABI;而记录的裸名(§2.3 末条)也不是构造函数。 x |> Point { ... } 不是「填字段」,它是在调用一个记录值,报「不可调用」。

fn 类型的字段可以直接调用r.f(x) 调用字段里存的函数值,等价于 let g = r.fg(x);字段的效果照常并入调用方。作用域里同时存在 名为 f 的函数时,r.f(x)编译错误(歧义)——静默优先级会让远处新增 一个同名函数悄悄改变既有调用的含义(§10.3 对模块别名同名早已拒绝同类歧义, 此处同规)。消歧:取字段先绑定 let g = r.f;调函数写直呼 f(r, x)

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)也可别名

别名有自己的关键字 aliastype 只声明名义类型(ADT 或 record)。 别名是透明的(不是 newtype):解析期展开,与被指的类型完全互换。

alias Meters = Float 不提供任何单位安全。 它与 Float 完全互换: MetersSeconds 可以相加,函数收 Meters 时传裸 Float 也照过。 上面第一行只是「别名可以指内建标量」的语法示例,别把它读成领域建模的推荐做法—— 单位类型是 alias 最容易误导的用法。别名的真实价值在下面几行:给长类型起短名 (HandlerLookup[T])。

想要单位安全,用的是下一节的 opaque typepub opaque type Meters = Float 在模块外与 Float、与 Seconds 都不互换,出入写两个一行函数(§2.7)。 这句曾写着「Dawn 目前没有」——那是 §2.7 落地前的话,忘了回来改(LANG-05 的病根 正是示例与指路不同步,这句自己也犯了一次)。

历史注记:两者曾共用 type,靠「右侧形状」启发式区分——同形不同义,且用户类型 无法被别名(裸大写名恒被读作构造器)。现在 type X = <fn 类型/元组/Name[...]/内建标量> 是编译错误,hint 指向 aliastype 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[..] = Talias 的语法一样,差别只有一条:谁被允许看穿它。 声明它的模块里,NT 可以互相转换;模块之外,两者是不同的类型。

# 在 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 的纪律,也让「不透明」在实现上只是一条判定,而不是散落各处的特判。

不透明只挡视线,不改语义:运行期一个不透明类型就是它的目标类型——同样的表示、 同样的相等、哈希、序与渲染,两个后端都如此,零开销。opaque 是软关键字, 只有 opaque type 有意义。

泛型不透明类型的实例身份由声明 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)。 其余每一个吃 Ty 的函数——宽度、描述符、槽位、装箱、哪条指令、能不能当常量、 某个 trait 有没有答案——都取目标的答案。 次序也是定的:先问身份再问表示impl Eq[UserId] 必须先于「按 Int 比较」, 否则声明它就没意义了。 机器化在 scripts/opaque-twin/:每个语料跑两遍,一遍原样一遍换成 alias, 输出必须一致(编译错误也算输出)。2026-07-27 用手工做这件事一次抓出 12 处。

可以给不透明类型写自己的 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 没有目标,它就是表示;且两者方向相反, 本机制公开名字、隐藏表示,Arrayis_std_module 门控隐藏名字、对 std 公开表示。 那道门控留着,理由见 `trait-v2-design.md` §8.3。


3. 声明

模块顶层只允许:usetype(含 opaque type)、aliasconstfntesttraitimpleffect。 没有顶层可变状态。

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 [!io] = body 语句——本质是 「名字在自身 body 内可见的 lambda」,因此可递归(自尾调用编译为循环,§12.4)、 可捕获外围绑定(按值,同 lambda 规则)、可当值传递。参数类型与返回类型必须写全; 效果只能是 !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 可用于 fntypealiasconsttraiteffectpub type 同时导出其构造器与字段。

一个 pub 声明的完整已解析导出面都必须能由模块外命名。只给最外层加 pub,不会把它 引用到的私有名义身份一并公开:

普通 pub fn 可以声明公开具名效果(见 §6.5):调用方能导入、传播或安装 handler。 私有效果出现在公开面才是错误。

3.4 测试块

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

3.5 trait 与 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 是表达式导向的:ifmatch、块都产生值。

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+ -仅数值,两侧同类型
11* / %仅数值;Int 除零 panic
12not、一元 -~前缀~Int 按位取反
13? . () []调用后缀? 见 §8.1;() 自 2026-07-30 起是一般后缀(见下)

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

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.5 Lambda

let double = (x: Int) => x * 2
let add = (a, b) => a + b         # 参数类型可推导时可省略
let now = () => 0                 # 零参数
xs |> map(x => x * x)             # 单参数可省括号

4.6 if

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.atstr.atpvec.index/nthcursor.slice 的非法区间 → panic(含负下标)。
  2. 问询——越界/缺键是调用方要区分的正常分支:get 族(list.getmap.getset.has 的缺席、index_of 族(str/bytes/list)的未命中、str.strip_prefix/ strip_suffix 的不匹配、bytes.from_hex/from_base64fspath.extension(包)的 格式不合)→ Option(或 Bool)。
  3. 钳位——参数是一个区间,说的是「要这一段里有的部分」,不断言端点存在: list.take/drop/slicebytes.slicestr.slice/take/dropcursor.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.9 return

fn classify(n: Int) -> String = {
  if n < 0 { return "negative" }   # guard 子句
  if n == 0 { return "zero" }
  "positive"
}

4.10 with 语句

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 臂。 运行时选择第一个匹配的 alternative,选中后不在同一 or-pattern 内回溯。match 臂的 guard 作用于整个 or-pattern,最多执行一次;guard 为 false 时进入下一臂,body 也最多执行一次。

每个 alternative 必须绑定完全相同的名字集合,且同名绑定的类型必须一致。外围的 letvar 为整个 pattern 一次性决定可变性,各 alternative 不可能在这点上不同。第一支的 绑定是共享环境中的 canonical binding,后续支只为它提供另一个取值路径。

5.2 穷尽性

match 必须穷尽。编译器对 ADT/Bool/Option/Result/元组做穷尽性检查, 缺分支报错并列出缺失构造器。Int/String/Float 上的 match 必须有 _ 或绑定兜底分支。

let 也接受不可反驳模式:let (a, b) = pairlet 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 是上界, 一条签名承诺 !io 就覆盖得住更纯的实现(包括只发效果变量的实现);在运算里 io 不是吸收元!io !e 不得消去 !e,两个原子都留在规范形里(印作 !(e|io))。 完整规则见 §6.6。

两条轴都平凡可判定:并是有限集合按分量取并加一位布尔或,包含是逐轴的集合判定。

6.2 规则

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

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(仅限 std)

unsafe_pure { <表达式> }纯 FFI 的表达式块:作者担保被包裹的表达式为纯, 类型系统据此把它的效果由 !io 屏蔽为 pure,于是一个宿主互操作调用可以支撑一个 纯函数。设计见 `docs/pure-ffi-design.md`

用户代码不可用(2026-07-30 收窄,LANG-01):这个戳无条件抹掉检查器证明过的效果, 而纯性许可的一切推理(折叠、重排、省略调用)都会相信它——这是健全性的口子, design.md 的原始裁决本就是「unsafe escape 不向用户代码开放」。它只在捆绑 std 模块内 合法(is_std_module);用户模块中出现即编译错误。std 是唯一随编译器一起发布、 一起自举、一起被 N vs N−1 差分守护的代码——担保收在那儿才有人对账。 真有 std 之外的纯包装需求,它应该成为一个 std 函数(不给逃生阀:给了等于没收窄)。

use java "java.lang.Math"

pub fn sqrt(x: Float) -> Float = unsafe_pure { Math.sqrt(x) }   # 仅 std 模块内合法

而 std 今天也不用它。 上面这个例子曾经是 std/str 的真实写法;今天 std 一处 unsafe_pure、一处 use java 都没有——那些操作已成为 intrinsic 契约的一部分(§11), 由后端负责兑现,而不是由调用点逐个作保。所以 unsafe_pure 在整个生态里零使用点: 留着它是给未来 std 底层包装的机制,不是语言表面。

被包裹的必须是静态方法调用:Dawn 原生类型(String/List/Bytes/Map/Set)不是 Java 类型, s.substring(…) 这种实例调用今天走不通(`pure-ffi-design.md` §九)。

6.5 具名效果与 with handle

effect 声明一组操作签名,调用处直接调操作,由调用处词法上最近的 with handle 应答。 这一档是尾恢复(tail-resumptive):handler 臂就是「就地调用、返回值即操作结果」的 普通闭包,没有延续捕获。设计与裁决见 `docs/effects-design.md`

声明

effect Ask {
  ## 向上下文要一个 Int。
  fn ask() -> Int
}

effect State {
  fn get() -> Int
  fn put(v: Int) -> Unit
}

拼写与传播

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 应答。

边界(v1)

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

实现(信息性)

每个 effect E 让 lowering 合成一个普通记录类型(用户拼不出它的名字),字段是各操作的 闭包,末尾多一格 env:臂运行时所在的证据环境,在安装点填好。with handle 构造这个 记录并绑成一个局部,操作调用 = 取字段 + 调闭包,被调臂的证据格取自 env 而不是发出点。

证据有两套约定,翻译点唯一。

具名调用按行逐原子给槽。 签名里写出的每个标签给函数追加一格类型精确的隐藏证据参数, 排在字典参数之后、按效果 id 升序;每个签名内引入的效果变量追加一格擦除的; 每个关联效果投影再追加恰好一格擦除的,排在标签的证据之后、按(主体、trait、成员名) 排序,由 impl 侧的桥接还原成具体证据记录。

函数值恒一格。 不论行写了什么,函数值的运行期元数是「写出的参数 + 一格证据包」, 纯函数值也背一格空包。证据包是不可变链,节点为 (key, evidence, outer)按原子键寻址: 标签、效果变量、关联投影三条轴各自分段取键,所以两条行在一格里相遇是一次 cons、查询是按键 走链,不必重排也不会串位。调用点建包的规则:行里已定的原子(写出的标签、已归约成标签的 投影)各 cons 一个节点,压在本帧已经持有的未定环境之上;行里没有已定原子、只剩一格未定原子 时,包就是那一格,不分配。超集无害:查询按键,多一个节点只多走一步;缺键才是错误。

两套约定在 lift_fn_value 合成的 wrapper 上换手:把具名侧要的每一格按键从函数值那一格包里 取出来。高阶库函数(list.map 之流)走的是「只剩一格未定原子」那条,原样转发,不建包。

这就是并不设吸收的原因(§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 覆盖 pureio 与效果变量(含它们的并); 具名标签与关联效果投影则按精确的集合包含判,!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 落下 绑定之后,代换后的行里已经没有变量可找了。

实参的行不必含有 RS 短于 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/cursorCursor 不可以。它的 Int后端各自的字符串表示里的偏移 (JVM 是 UTF-16 码元、native 是 UTF-8 字节),而常量只折叠一次、写进两个后端 共读的 Core,所以折出来的位置至多对一个后端成立。要在编译期算位置,就持有字符串、 在用它的地方走过去。这条例外是 §11「度量永不成为可观察的值」的一个推论,不是它的 全部:渲染那条路同样被堵上了,否则 const S: String = to_string(c) 会绕过本条、 把度量以字符串形式折进产物(解释器按码点计数,于是那还是第三个答案)。 等游标统一度量后这条例外撤销。 Map/Set 暂不可以:它们是 Array 之上的 HAMT,而 comptime 解释器没有 Array 原语;List 能行是因为解释器自带列表表示,不是因为它跑得动 std/pvec
  3. 求值有步数预算(默认 10⁸ 步,--comptime-fuel 调整),超限报错—— 保证编译必然终止。
  4. comptime 里没有 Java 互操作、没有 io(由约束 1 自动保证)。

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):打印消息与 Dawn 层栈迹,进程以非零退出。 todo() 等价于 panic("not yet implemented") 且能通过任意类型检查 (返回类型为底类型 Never)。

后缀 !o!Option[T] 解成 TNone 则 panic。语义同 expect(o, msg),唯一区别是消息由编译器生成——含产生 None 的调用与源位置 (unwrapped None from URI.create() at src/http.dawn:23),故不必为它编造占位串。

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 数值边缘语义)。


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 返回自动加宽为 Intfloat 加宽为 Floatchar 出入参当前不支持;数组走不透明直通(§9.5)。Option 实参传 null 同样不支持(Dawn 侧目前不能把 null 传给 Java)。

9.3 重载消解

按"实参个数 + 静态类型"打分选唯一候选(精确匹配 long/double 优于收窄到 int/floatString 优于 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/Boolchar 出入参不支持,char... 随之不支持。

9.4 SAM 转换:函数值跨边界

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.writeMessageDigest.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.1 Bytes:一等不可变字节序列

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)、 bytes.lenbytes.at(b, i) -> Int(0..255,越界 panic)、bytes.slice(b, start, end)[start,end),下标 clamp 进范围)、bytes.index_of(b, needle, from) -> Option[Int]index_of 把负的 from 钳到 0;非空 needle 从该字节下标起找首次完整命中。 空 needle 在合法位置 [0, len(b)] 命中(所以 from == len(b) 返回 Some(len(b))), 但 from > len(b) 即使对空 needle 也返回 NoneBytes ++ 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 形参,却不能反向匹配 PathInputStreambyte[] 等具体引用 形参。编译器不会在参数桥接处插隐藏 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 上 kindjava.lang.ClassCastException)。T 须是引用类型 (编译期拒绝 primitive / 无期望类型)。当前表面无法命名的 Java array 或参数化类型也 不获得隐式例外;真实用例需要它们时,应先补可书写的目标类型。

签名为纯的函数不该能用宿主异常退出(LANG-02)——cast 从前抛 ClassCastException, 那正是纯签名本该排除的那条出口。现在失败是值。迁移经过的三期(含一个只活一个 版本的过渡拼法)记在 `error-model-design.md` §6.10–§6.12,已收尾。

9.6 List 桥接:Dawn List 直达集合形参

Java 形参声明为 java.util.List / java.util.Collection / java.lang.Iterable 时, 实参可传 Dawn List[T]零拷贝:桥接处套一层不可变视图 (Collections.unmodifiableList),Java 侧的变异方法抛 UnsupportedOperationException——同 Scala asJava / Clojure 持久集合的约定。

9.7 限制

不能继承 Java 类;不能以命名类形式实现 Java 接口——函数值经 SAM 转换(§9.4) 传出是唯一路径。只读静态字段可访问(Class.FIELD,§9.1)——枚举常量与静态常量直接读 (TimeUnit.SECONDSInteger.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 无关了;名字比理由多活了一阵。 用旧名字会得到「java_try is not a builtin; renamed to catch_fault」。

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] !io(同一形状, 同一理由)拦的是Dawn panic(PanicError)与 Exception 两类——不是任意 ThrowableVirtualMachineError(堆耗尽、栈溢出)穿透,资源耗尽不是一个值。它用于监督边界—— 服务器的单个请求、任务 runner 的单次执行:一个请求 panic 应变成 500 并记录,而非掀翻 整条连接或进程。它与 catch_fault 分工明确:catch_fault 处理预期外部失败、放 panic 穿透;catch_panic隔离点。普通业务失败仍走 Result,别拿 catch_panic 当常规错误处理。

这条分工与后端无关。 JVM 从类层次白拿它(ErrorException);native 没有异常,一切失败走同一条 longjmp,所以失败带一个种类、handler 记住自己 收不收 panic。判据是同一条:语言自己定义的失败是 panicpanicexpect、 越界下标、除零、非法码点),外部世界造成的失败不是(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 版本

这一对的效果行钉死 !io,不是变量——纯闭包也一样:捕获一个 panic 的结果取决于 调用栈深度、取决于烘进消息里的 file:line,也取决于优化器折叠了多少次纯调用, 三者都不是纯函数允许有的差别。§9.8.2 的 bracket 因此可以是效果多态的而它们不能: bracket 不观察失败。完整论证与重开条件见 `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

第三个参数叫 bodyuse 是关键字,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,保留手写格式)。

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/effectpub type 连带构造器与字段,见 §3.3)。 访问或引入非 pub 项 → 错误(`parse` is private to module json/parser,附 hint:加 pub)。 导出的声明内部也不得泄漏模块外无法命名的私有 type / trait / effect;transparent alias、opaque 边界、公开 trait/effect 与可达 impl 的完整判定在 §3.3,错误报在声明处而不是使用处。

加载范围(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/strstd/fmtstd/bytesstd/iostd/liststd/mapstd/setstd/cursorstd/char。 另有两个内部模块 std/hamtstd/pvec——Map/Set/List 的表示(§11)。它们随 std 一起捆绑、在 std 内部互相引用,但 std 之外 use std/hamt / use std/pvec 是编译 错误:表示要能整体换掉,而能换的前提是没有程序依赖它。 (std/fmt 是数字渲染与解析的实现处——fmt.dtoato_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.len / bytes.len), 由限定或选择性引入消歧。

prelude 是其中隐式可用、无需 use 的高频核:List/Option/Result 的构造器、 println/printmap/filter/foldsort 族(std/list)、内建的 len/get/range/ to_string/join/parse_*/panic/todo/expect/unwrap_or/cast/ catch_fault/catch_panic/bracket/args 等一屏以内 (全集见标准库参考,由 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_insertstr_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 / parse_int_radix接受语言是这段 EBNF,由 std/fmt 自己的扫描器实施(两后端不再各自委托宿主解析器的文法;宿主只在 parse_float 通过校验后做十进制→二进制的正确舍入,IEEE 754 最近偶数——在该子集上 strtodDouble.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_radixradix 产生式:rdigit0-9 a-z A-Z(值 = 10..35,大小写同值), 数字值 ≥ radix 拒绝;radix 不在 2..36 内答 None。整数超出 64 位范围是 None 不是环绕。 有意排除(今天的宿主解析器有的收、Dawn 一律拒绝):下划线、0x 前缀与十六进制浮点 (0x1p3)、f/F/d/D 后缀、inf/nan 等小写变体、带符号的 NaN+Infinity (合法特殊拼写恰是 to_string 能输出的三个,见 §4.3 的往返闭合)、全角与阿拉伯-印度等 非 ASCII 数字(宿主的 Character.digit 收它们,Dawn 的数字集是 ASCII 封闭的)。

大小写映射。 str.to_lower/str.to_upperUnicode 简单(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]) 由字符组装;str.len 是码点数,str.at 回一个 Char,回 List[String] 的是 str.chars。字符串的下标一律是码点下标,不是 UTF-16 码元。char_is_letter/_digit/_alnum/_upper/_lower/_spaceCharstd/charis_* 是它们的公开拼法。

三条判据(§4.8)在字符串族上的落点:str.at(s, i) 越界 panic(判据 1,i 是调用方声称 存在的位置,同 xs[i]bytes.at);str.strip_prefix/strip_suffixOption (判据 2,判定与剥离一次完成,调用方不必自己算偏移);str.slice/take/dropbytes.slice 两端钳位from > to 得空串(判据 3,范围参数选取一段,不断言端点存在)。 truncate 就是 take,不另设一个名字。

字节与文本编码。 语言承诺的字符集只有两个,且函数名即定义域bytes.decode_utf8_lossybytes.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_checkedOk 的输入,恰好decode_utf8_lossy 原样返回的输入,这条是规范性的。decode_latin1 没有严格版本, 因为每个字节都是一个码点,它不会失败。decode_utf8decode_utf8_lossy 的旧名, 按 CONTRIBUTING §7 的一代 forwarder 纪律保留,下个版本删除。

hex 与 base64 是纯 Dawn 字节算术(无 use java,故两后端同一份定义),规则是规范性的: to_hex 每字节两位、小写为规范拼写,from_hex 大小写皆收、其余一概不收;to_base64 用 RFC 4648 section 4 的标准字母表并以 = 补齐,to_base64_url 用 section 5 的 url/文件名安全字母表且 不补 =;两个解码器各只认自己的字母表(猜字母表会把拼错的输入变成错的字节),padding 可有可无,但末组的空余低位必须为零——否则同一串字节会有多个拼写。这一族的解码器都属判据 2: 外来文本是要校验的,不是要断言的。

BytesBuf 见 §9.5.1;另有操作符 Bytes ++ Bytes 与按内容的 ==/!=。二进制请求体 (multipart 上传、WebDAV PUT)、crypto/签名、HTTP 收发都直接走 Bytes,不再借道 latin-1 字符串。

命名族。 长度只有 len 一个名字,判空只有 is_empty 一个名字——str/list/map/ set/bytes 五处同拼写。唯一具名例外是 bytes.size(b: Buf)bytes.len 已经是成品 Bytes 的长度,而语言无重载,故写入游标只能另取一名(判据与例外见 CONTRIBUTING「命名族」)。 返回位置的搜索一律回 Option不发 -1str.index_oflist.index_of 同一货币)。

排序与极值。 sort 族要求元素/键类型具 Ord(§3.5),全部稳定、平局取第一个。 list.any/list.all 短路(用 fold 假冒的那两个是一趟全走);空列表分别为 false / truelist.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 供回溯(它是普通值,回溯不需要额外机制)。不要对它做算术——只有算术 能凭空造出落在代理对中间的非法位置。

算术与凭空铸造都是编译错误Cursorstd/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,会失败的一律回 Result[T, ForeignError](§9.8.1)—— 结构化载荷而非屏障渲染好的那句话,理由见 `audit/error-model-design.md`。另有以下规范性行为:

数学abs min max sin cos sqrt pow to_float to_int ...)是纯的——内部以 unsafe_pure 包装 java.lang.Math

实现策略:能薄包 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 run [--std <dir>] <target> [-- <program-args>...]同上,编完直接执行
dawnc test <target>编译含 test 块的变体并执行
dawnc fmt / doc / add / lspdawn 的同名子命令输出逐字节一致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-valuetchar 是 ASCII 字母、数字,以及 ! # $ % & ' * + - . ^ _ | ~ 和反引号,所以无冒号、空字段名、字段名中的 空格/括号等都属于 framing failure。语法正确的未知 header 可忽略。字段名 Content-Length 仅按 ASCII 大小写不敏感比较;字段值只能是两端 可有 SP/HTAB 的一个或多个 ASCII 十进制数字。空值、符号、小数、指数、下划线、Unicode 数字、Int 溢出与超出 body 上限都非法;0 合法。重复字段仅在每次独立解析所得数值相同 时合法,故前导零不构成冲突;任一副本非法或数值冲突都拒绝。

body 必须是严格 UTF-8。完整且有界的 frame 若 UTF-8 或 JSON body 解析失败,server 回 -32700id: 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; 两个驱动对同一错误使用相同诊断字节。checkfmt 的 N 没有人为上限;写两个 target 不是“最大为二”,而是证明它们确实是批处理命令。

run 的 argv 使用显式双名字空间(TOOL-03):

dawn run [compiler-options] <target> [-- <program-args>...]

compiler option 只在 target 前解析。没有程序参数时可省略 --run targetrun target -- 都向程序传空 argv。target 后若还有 token,第一个必须是 --,否则 stdout 为空、stderr 恰为下列一行并退出 2:

error: usage: dawn run [compiler-options] <target> [-- <program-args>...]

分隔符自身不转发;其后的 token 不再解释,逐字原样进入程序的 args(),包括空串、----comptime-ffi-o。JVM 与 native 驱动各自实现 parser,以共同的绝对 stdout/stderr/exit 契约保持一致;完整理由见 run-argv-boundary-design.md

dawnc 少的那几个子命令不是缺口,是后端的边界:它拒绝 use java(Java 互操作是 JVM 后端的能力,§9),build-to-jar、lockcache 同理只在 JVM 侧有意义。 两个驱动都接受 --std <dir> 换标准库源。

参数可为单文件或工程目录(§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/元组仍生成配套 equalshashCode,但那是给 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; athrowNever 没有形参、字段或其他存储表示
panicdawn.rt.PanicError(Error 子类,故 catch_fault 不拦;只有隔离点 catch_panic 拦,见 §9.8)

运行时支持类(dawn/rt/ListsStringsIoShowMapsTuple*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 参与。代价是它没有 Javause java 直接拒绝 (§12.1)。产物是 cc 编出来的普通可执行文件,运行时在 runtime/c/

12.4 尾调用

自递归尾调用保证编译为循环(不长栈)——顶层函数与局部命名函数(§3.1)皆然。 互递归尾调用不保证。判定规则:函数体内对自身的调用处于尾位置(返回位置、 match/if 分支的尾位置、块的末表达式)。


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