Dawn 语言规范
English —— 本文是正本;英文是它的译本,scripts/doc-check.py 盯着两者不脱节。
状态:normative(权威)。适用版本:0.70.0(
selfhost/src/version.dawn的VERSION)。 实现与本文冲突时,以本文为准并把实现当 bug——除非本文某条被显式标注为「已被 X 取代」。标题曾长期写「v0.1 草案」,改掉它那天工具链已经到 0.11:一份自称草案的文档没法充当裁判, 而这是仓库里唯一有资格裁判语义争议的文档。版本号跟
VERSION走,不再单独编号。
本文是语法与语义的权威定义。设计动机见 design.md。
grammar.ebnf 是一份历史的机器可读语法,已落后于 parser
(它自己头部列了已知不符)——当参考读,别当裁判;文法有争议时以本文与
selfhost/src/front/parser.dawn 为准,可执行的那份期望在 scripts/grammar-corpus/。
规范用词:必须(违反即编译错误)、保证(实现承诺的行为)、 未定义(本规范不承诺,勿依赖)。
1. 源文件与词法
1.1 源文件
- 编码 UTF-8,扩展名
.dawn。 - 一个文件即一个模块(见 §10)。
1.2 注释
# 行注释,到行尾
## 文档注释,附着于紧随其后的声明(工具链提取)
1.3 标识符与命名约定
强制的部分——首字符决定语法类别,parser 靠它消歧(模式匹配中 x 是绑定、
X 是构造器;TYPEIDENT 是独立 token):
- 值、函数、模块名:首字符是小写字母或
_ - 类型与构造器:首字符是大写字母
- 效果变量:
!后接一个值标识符(io为保留效果名)
风格约定——工具链不强制,但全仓遵守:值用 lower_snake_case
([a-z][a-z0-9_]*),类型用 UpperCamelCase([A-Z][A-Za-z0-9]*)。
关于非 ASCII:「大写 / 小写」按 Java 的
Character.isUpperCase判定,后续字符按Character.isLetterOrDigit或_。于是fn 中文()、fn _hidden()都能编译——汉字既非大写也非小写,走「不是大写」这一支,即值标识符。这是实现定义的,不是设计过的:Unicode 的 XID_Start/XID_Continue、 normalization(
é的两种写法算不算同一个名字)、同形字符,本规范全部未定义, 依赖它们的代码不可移植。无大小写文字(汉字、阿拉伯文、假名)因此只能作值名, 作类型名没有自然写法。收敛到明确定义的 Unicode 标识符语法是一项未决工作 (docs/codebase-audit.md 的 SYN-01)。
1.4 关键字
fn let var type alias const use java pub
match if else for in while with
return break continue
comptime unsafe_pure test assert
trait impl effect
true false not
关键字不可用作标识符。panic、todo 是预置函数而非关键字。上下文关键字另有四个,
它们在别处仍是普通标识符:derive(只出现在 type 声明尾部)、as(只出现在
use 的重命名位,§10.2)、handle(with 之后且下一个 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, 0b1010 | Int | 64 位有符号;下划线可作分隔 |
3.14, 1.0e-9 | Float | IEEE 754 double |
true / false | Bool | |
"hello" | String | 见 §1.6 |
() | Unit | 唯一值 |
[1, 2, 3] | List[Int] | 允许尾逗号;分隔符与元素形式见 §4.11 |
(1, "a") | (Int, String) | 元组,2 至 8 元 |
'a', '\n', '世', '\u{1F600}' | Char | 字符字面量(见下) |
整数 token 的非负 magnitude 通常必须在 0..9223372036854775807。唯一例外是精确的
2^63:它只可直接作一元 - 的操作数,所以十进制 -9223372036854775808、十六进制
-0x8000000000000000 和对应的 64 位二进制写法都表示 Int 最小值;表达式与字面量 pattern
同此规则。括号包住整个负值仍合法((-9223372036854775808)),但把 magnitude 与负号隔开
不合法(-(9223372036854775808)),裸 2^63 也不合法。大于 2^63 报范围错误;某进制
不允许的数字(如 0b2)报非法字面量,不与范围错误混为一类。
字符是自己的类型 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.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、代码生成很方便。
插值由 $ 引导(同 Kotlin/Swift):$name 插入一个简单标识符,${expr} 插入
任意表达式;被插值的类型必须有 Show 见证——预置标量、写了 impl Show 或
derive 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)、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/Show对Unit无实现——只有一个值,答案会是常量。
没有 null。 所有类型的值都必然有效;可缺失用 Option[T] 表达。
没有隐式转换。 Int → Float 必须显式 to_float(n)。
2.2 复合类型与命名层次
用户可直接命名、由编译器拥有的泛型类型恰为 List[T]、Map[K, V] 与 Set[T]:
List[T]— 不可变持久列表。实现是32 叉 trie + 尾块的持久向量(std/pvec,纯 Dawn 源, 建在Array原语之上):索引 O(log32 n)——长度 ≤32 时全在尾块、即一次数组读;++逐元素追加,尾块未满时只复制尾块(≤32 槽),每 32 次把尾块压进 trie、复制一条 根到叶的路径,故acc = acc ++ [x]的累积循环是线性的、O(1) 摊还。 结构共享,已发布的列表永不改变;追加到旧版本不复制整表,只复制它自己的那一小块。Map[K, V]— 不可变映射(见下)Set[T]— 不可变集合(见下)
其余复合类型不属于这张 builtin 名字表:
- 元组
(A, B, ...)与函数类型fn(A, B) -> C !e是结构类型,没有名义类型名;!e可省略,表示纯。 Option[T](Some(T) | None)、Result[T, E](Ok(T) | Err(E))与ForeignError是 prelude 注入的普通名义 ADT;它们不是 compiler-owned builtin type。Array[T]是 bundled std 可命名的 compiler-owned 表示原语。用户模块不能借这个名字 访问该原语;在 std 外声明的Array只是用户自己的名义类型。Never是 compiler-owned 的底类型与硬保留名,没有值或 constructor。它只能直接写在 顶层函数、局部函数、trait/impl 方法、effect operation,以及任意函数类型的返回位。 因此fn() -> Never与alias F = fn() -> Never合法;形参、字段、const/let 标注、 generic/collection/tuple 等存储位、associated binding,以及直接别名alias N = Never都非法。type、alias、trait、effect、constructor 名与 type parameter 都不能遮蔽Never。声明返回Never的函数体本身必须发散。LSP 只在函数返回上下文补全该名;dawn doc --builtins以"use": "return"标明它不是全局可用的 public builtin。io.exit仍返回Unit,本规则不改变其既有 API。
类型位置的 (T) 只做分组,解析后仍是 T,不会产生额外类型节点。元组仍至少有
两个元素:(A, B) 是元组,(T,) 不是单元素元组,空的 () 也不是类型(单位类型写
Unit)。
函数类型的箭头保持右结合;后缀效果属于它紧跟的那一层函数。返回值本身也是函数时, 括号用来把外层效果隔开:
fn() -> fn() -> Int !io # 外层纯,返回的函数是 !io
fn() -> (fn() -> Int) !io # 外层 !io,返回的函数纯
fn() -> (fn() -> Int !io) !io # 两层都是 !io
Option 与 Result 就是 prelude 的普通 ADT,无特殊地位
(? 运算符对它们有语法支持,见 §8.1)。
Map[K, V] / Set[T] 是与 List 同级的内建持久容器,无字面量语法,全部经
内建函数操作(清单见 §11)。语义要点:
- 持久(不可变)接口:
map.insert/map.remove返回新映射,原值不变。 键类型 = 有
Hash的类型,没有第二条规则。每个会摸到键的操作(map.insert/map.get/map.has/map.remove/m[k]与set的同名者)都带[K: Eq + Hash]bound,键合法性就是这条 bound 解得开——Int/String/Bool/Bytes、元组、ADT、 record 都可以(没写 impl 的按自身结构合成一份,§4.3),Float不行(NaN/-0.0给了它两套相等,§4.3 因此没给它Hash),Array不行(按同一性持有,非按内容)。 报错落在用到键的那个操作上,不在类型标注上:Map[Float, Int]这个类型本身 拼得出来,只是没有任何操作能往里放键。2026-07-27 之前另有一条独立的结构行走(
invalid_key_part)走在类型标注与泛型 实例化点上。它与 bound 平行且打架:它禁Bytes(理由「哈希是引用同一性」, 而Bytes早已改成内容哈希),而 bound 放行;它只在键位置拦Float, 于是hash(1.5)在键位置之外照样能过。删掉的是那条行走,不是规则。- 迭代顺序 = 插入顺序,确定且 JVM/native 一致(
map.keys/map.entries/set.to_list按插入序)。map.insert遇已存在的键替换值、保留原插入位置。 - 相等与顺序无关:两个键值对相同的
Map相等,无论插入次序。 - 实现是持久 HAMT(
std/hamt,纯 Dawn 源,建在Array原语之上):map.insert/map.removeO(log32 n),只复制根到叶的路径,其余结构与原映射共享(插入序由逐键序号维持)。 键的相等与哈希取自键类型的Eq/Hash(以字典传入),不是宿主的对象同一性。
2.3 和类型(ADT)
type Shape =
| Circle(r: Float)
| Rect(w: Float, h: Float)
| Point # 无载荷构造器
- 构造器字段必须带名字;构造调用可按位置或按名:
Rect(2.0, h: 3.0)。 - 构造器(含字段的)裸名在函数位置是普通函数值:
map(xs, Some)等价于map(xs, x => Some(x))。类型参数从期望的函数类型推导(Some的元素类型来自 上下文);仅由字段无法确定的类型参数(如Ok的错误类型E)需上下文补足, 否则报「无法推导类型参数」。无载荷构造器(Point)本身是值不是函数;记录构造用 花括号语法,不参与此规则。 - 泛型:
type Tree[T] = | Leaf | Node(left: Tree[T], value: T, right: Tree[T])
2.4 记录(record)
type Point = { x: Float, y: Float }
let p = Point { x: 1.0, y: 2.0 }
let q = Point { ..p, x: 3.0 } # 函数式更新
let d = p.x # 字段访问
记录是单构造器积类型的糖,同样支持模式匹配。字段不可变——"修改"即函数式更新。
记录只能用花括号构造。 Point(1.0, 2.0)、Point(x: 1.0, y: 2.0)、Point()
都是错误(「record Point must be built with braces」),无论括号是直接写的还是
由管道(§4.4)生成的。理由是拼写与含义一一对应:花括号形式的字段必须带名,
按位置填一个记录会让字段顺序变成 ABI;而记录的裸名(§2.3 末条)也不是构造函数。
x |> Point { ... } 不是「填字段」,它是在调用一个记录值,报「不可调用」。
fn 类型的字段可以直接调用:r.f(x) 调用字段里存的函数值,等价于
let g = r.f 后 g(x);字段的效果照常并入调用方。作用域里同时存在
名为 f 的函数时,r.f(x) 是编译错误(歧义)——静默优先级会让远处新增
一个同名函数悄悄改变既有调用的含义(§10.3 对模块别名同名早已拒绝同类歧义,
此处同规)。消歧:取字段先绑定 let g = r.f;调函数写直呼 f(r, x)。
2.5 泛型
- 类型参数用
[T, U]声明在名字后:fn map[T, U](xs: List[T], f: fn(T) -> U !e) -> List[U] !e - 单态性:类型参数在每个调用点必须能完全推导,不支持高阶类型(HKT)。
- 实现为擦除 + 装箱;单态化是可选的后续优化,不影响语义。
- 没有子类型、没有继承、没有变型(variance)。类型要么相等要么不同。
- 函数体内的局部标注可以引用签名的类型参数(
let acc: List[T] = [])—— 刚性类型参数在推断里视同已知的具体类型。
2.6 类型别名
alias Meters = Float # 内建标量
alias Pair = (Int, String) # 元组
alias Names = List[String] # 泛型应用
alias Handler = fn(Request) -> Result[Response, HttpError] !io # 函数类型
alias Lookup[T] = fn(String) -> Option[T] # 可带类型参数
alias Paint = Color # 用户类型(ADT/record)也可别名
别名有自己的关键字 alias;type 只声明名义类型(ADT 或 record)。
别名是透明的(不是 newtype):解析期展开,与被指的类型完全互换。
alias Meters = Float不提供任何单位安全。 它与Float完全互换:Meters和Seconds可以相加,函数收Meters时传裸Float也照过。 上面第一行只是「别名可以指内建标量」的语法示例,别把它读成领域建模的推荐做法—— 单位类型是 alias 最容易误导的用法。别名的真实价值在下面几行:给长类型起短名 (Handler、Lookup[T])。想要单位安全,用的是下一节的
opaque type:pub opaque type Meters = Float在模块外与Float、与Seconds都不互换,出入写两个一行函数(§2.7)。 这句曾写着「Dawn 目前没有」——那是 §2.7 落地前的话,忘了回来改(LANG-05 的病根 正是示例与指路不同步,这句自己也犯了一次)。
历史注记:两者曾共用
type,靠「右侧形状」启发式区分——同形不同义,且用户类型 无法被别名(裸大写名恒被读作构造器)。现在type X = <fn 类型/元组/Name[...]/内建标量>是编译错误,hint 指向alias;type Color = Red维持 ADT 语义不变。
限制:别名不可递归(环报错);效果变量必须由参数表绑定(alias Mapper[T, U, !e] = fn(T) -> U !e),
写一个没绑定的变量是错误,具名标签与 !io 则直接写(§6.3);pub 后可跨模块导入(use m.{Handler})。
2.7 不透明类型
pub opaque type UserId = Int # 只有本模块知道它是 Int
pub opaque type Env = Map[String, Int]
pub opaque type Pair[T] = (T, T) # 可带类型参数
opaque type N[..] = T 与 alias 的语法一样,差别只有一条:谁被允许看穿它。
声明它的模块里,N 与 T 可以互相转换;模块之外,两者是不同的类型。
# 在 ids 模块内
pub fn wrap(n: Int) -> UserId = n # ✅ 本模块可转换
pub fn unwrap(u: UserId) -> Int = u # ✅
# 在别的模块
let bad: Int = wrap(7) # ❌ annotated type is Int but the
# initializer is UserId
转换发生在赋值、传参与返回位置(即类型可赋值性判定处),不在表达式内部:
本模块里 u + 1 仍是错的,写 let n: Int = u 再算,结果回到 UserId 位置时自动转回。
这是 newtype 的纪律,也让「不透明」在实现上只是一条判定,而不是散落各处的特判。
不透明只挡视线,不改语义:运行期一个不透明类型就是它的目标类型——同样的表示、
同样的相等、哈希、序与渲染,两个后端都如此,零开销。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没有目标,它就是表示;且两者方向相反, 本机制公开名字、隐藏表示,Array的is_std_module门控隐藏名字、对 std 公开表示。 那道门控留着,理由见 `trait-v2-design.md` §8.3。
3. 声明
模块顶层只允许:use、type(含 opaque type)、alias、const、fn、test、trait、impl、effect。
没有顶层可变状态。
3.1 函数
fn add(a: Int, b: Int) -> Int = a + b
fn greet(name: String) -> Unit !io = {
println("hi, $name")
}
- 参数类型必须写全(所有函数);
pub fn还必须写返回类型——公开签名是 API 契约。 - 私有函数可以省略返回类型:
fn double(x: Int) = x * 2。返回类型省略时由函数体 推断;如果同时完全省略效果注记,base effect 也在同一条推断路径中确定。三类函数 必须标注返回类型: ①pub;② 递归/互递归(编译器按调用图拓扑序推导,环上无法推导); ③ 体内用了return或?(二者需要已知的返回类型)。 - base effect 只在双省略形态中推断:只有私有函数同时省略返回类型和全部效果注记,
checker 才从函数体推断返回类型与 base effect(pure /
!io)。显式写出-> T却省略 效果注记,表示pure承诺而不是请求推断;函数体做 IO 会报错。如果省略返回类型但写出 效果行,则只推断返回类型,效果行按所写内容固定。具名效果从不推断,必须显式声明;一旦 写出任一具名效果,整条效果行就是固定承诺,不会再自动补 io。例如一个!Ask函数同时 做 IO,必须写!(io | Ask);只写!Ask会报错。 - 函数体是
=后的单个表达式;块{ }也是表达式(§4.2)。 - 有默认参数(见下);没有变长参数、没有重载。
默认参数(2026-08-08,#207):形参可写 name: Type = expr,调用省掉该实参时
在每次调用求值一次(不做 Python 那种「定义时求一次共享」):
fn column(kids: List[Int], align: Int = 0, gap: Int = 0) -> Int = align + gap * len(kids)
column(kids)
column(kids, gap: 12)
column(kids, align: 1, gap: 12)
- 默认值表达式必须纯(
!io与具名效果都不行):否则函数的效果行会随「调用方 有没有传这个实参」而变——同一个签名两个效果行,类型系统不能要。 - 在声明处的作用域求值:可以引用本模块的私有函数与
const,引用不到本函数的 其他形参(默认值在一个不含形参的作用域里检查,引用形参就是undefined variable)。 这一条以后放宽不是破坏性变更。 - 形参类型可以提及本函数的类型参数(
fn join[T](xs: List[T] = []) -> T):合成出来的f$default$k原样带上本函数的类型参数与它们的 bound,省掉实参的调用点按该次调用自己的 实例化去调它——类型参数替换与见证字典都取调用点已经解出来的那一份。于是默认值表达式里 可以用 bound 提供的 trait 方法(fn pick[T: Zero](v: T = zero())),得到哪个实现由调用点 的实例化决定。 - 默认值不必在参数表尾部:
fn f(a: Int = 1, b: Int)合法,只是a只能具名跳过 (位置前缀从左到右占槽,跳不过去);漏掉必填形参报 「missing argument(s) forf: …」,实参个数出区间报「ftakes 1 to 3 argument(s), got 0」。 - 实现形状:每个带默认值的形参在声明模块合成一个零元纯函数
f$default$k($不在标识符词法里,源码拼不出来),省掉实参的调用点就是对它的一次普通调用—— 两个后端与 comptime 解释器都把它当普通顶层函数。 - 把函数当值用则默认值丢失:
let g = f之后g的类型是全参的fn(A, B, C) -> R——与「构造器裸名当函数值时字段名丢失」(§2.3)是同一条纪律。 - 范围:只有顶层函数(含私有、模块函数)。局部 fn(其调用走函数值路)、lambda、
构造器字段(record 已有
{ ..base, x: v })、trait 方法(Dawn 已有一个叫「default」 的东西——默认方法体;且字典传递下 impl 无法覆盖值默认)、effect operation (operation 调用是读证据字段 + 调闭包,没有可挂符号)、Java 方法都不收,各自报错说明理由。
局部命名函数:块内可写 fn name(params) -> T [!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 可用于 fn、type、alias、const、trait、effect。
pub type 同时导出其构造器与字段。
一个 pub 声明的完整已解析导出面都必须能由模块外命名。只给最外层加 pub,不会把它
引用到的私有名义身份一并公开:
pub fn检查类型参数的 bound、参数、返回值与完整效果行;pub const检查声明类型, initializer 仍是实现细节。pub type/ record 检查全部构造器字段;pub trait检查全部方法签名、约束与效果行, default body 仍是私有实现细节;pub effect检查每个操作的参数与返回值。- transparent alias 在这个边界完整展开:私有 alias 若最终只指向公开类型,可以出现在公开面;
展开后触及私有名义类型仍是错误。
pub opaque type相反,停在自己的公开 identity 上—— representation 可以用私有类型,但公开拼写里的每个实例化实参仍须公开可名,而私有 opaque identity 本身不得出现在公开面。 - 类型投影同时检查 trait identity 与 subject;具名效果必须是
pub effect。 - impl 没有
pub拼写:只有「公开 trait + 模块外可命名的 subject」的 impl 属于导出面, 它的泛型约束与全部 associated type binding 也必须公开可名。私有 trait 或私有 subject 的 impl 对外不可达,因而不受此约束——这也是dawn doc不列它的原因。
普通 pub fn 可以声明公开具名效果(见 §6.5):调用方能导入、传播或安装 handler。
私有效果出现在公开面才是错误。
3.4 测试块
test "precedence" {
assert eval("2+3*4") == Ok(14)
}
test块只被dawn test编译执行,dawn build剥除。- 块内允许
!io。 assert expr:expr为Bool;失败时报告源文本与两侧子表达式的值 (编译器对==、比较运算符做拆解以给出好的失败信息)。
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 恰有一个类型参数;方法进入模块函数命名空间(可直呼、可 UFCS、可管道)。
- 注入是逐 trait 的属性:一个 trait 的方法名是否占据函数命名空间由该 trait 决定。
今天
trait声明恒注入,Ord/Eq/Hash/Show/Iter五个预置 trait 也注入; 方法名由语言代用户消费掉的两个不注入,它们的方法名只在 impl 体、文档与错误消息里 出现:Index([]消费它,§4.8)与Display(to_string与${...}消费它,§4.3)。 - 预置 trait 七个:
Ord(cmp,背后是</<=之外的排序)、Eq(eq, 背后是==/!=)、Hash(hash)、Show(show,嵌套渲染,也是to_string要的 bound)、Iter(背后是for..in,§4.7)、Index(背后是[],§4.8)、Display(display,顶层渲染,背后是to_string与${...},§4.3)。 标量的 impl 随语言提供;derive Ord/derive Show铸的是普通 impl,泛型类型上铸的是条件 impl;Display不可 derive(理由见下面Display那条)。 元组没有 head,写不出 impl,前四者对元组由编译器按结构合成。 Iter声明两个关联类型与四个方法(关联类型见本节下方):trait Iter[C] { type Cur type Item fn iter_start(c: C) -> C.Cur fn iter_done(c: C, k: C.Cur) -> Bool fn iter_next(c: C, k: C.Cur) -> C.Cur fn iter_get(c: C, k: C.Cur) -> C.Item }——游标式四方法而非返回配对的next, 一步不强制任何分配(游标形状由 impl 定)。std 为List/String/Bytes/Map/Set各提供一个 impl(元素分别是T/单字符String/Int字节/(K, V)/T);用户类型实现Iter即可被for迭代。四个方法名随 prelude 注入函数命名空间,与其余 prelude 名同待遇——可被本模块的声明遮蔽(§10.3)。Index声明两个关联类型与一个方法:trait Index[C] { type Idx type Item fn index(c: C, i: C.Idx) -> C.Item }。 语言为List(Idx = Int)与Map(Idx = 键类型)提供 impl;用户类型实现Index即可用[](示例见 §4.8)。方法名index不进入函数命名空间 (见上条注入规则),只在 impl 体、文档与错误消息里出现。一个类型只有一个索引 类型——Index是单参数 trait,写不出同一容器的第二种下标(如切片)。Display声明一个方法、无关联类型:trait Display[T] { fn display(x: T) -> String }。它是顶层渲染那一层:to_string(x)与"${x}"有 impl 就用它,没有就照 §4.3 的既有规则渲染。方法名display不进入函数命名空间(见上条注入规则)。三条边界:Show[T]仍是to_string要的 bound。Display只改一个已经存在的答案, 不让任何原本渲染不出来的类型渲染得出来。- 不可 derive。
derive Show说的是「按我的结构渲染」,而一份Display是一个 呈现决定:一个类型一份,手写。 - opaque type 逐层问(§4.3):
opaque type A = B上没写Display时,用B的 那一份;A写了就是A的。
语言自带的唯一一份是
impl Display[Char](在std/char,§1.5;同一个模块也写了 那一层对应的impl Show[Char])。- 一致性:全程序每个「trait × 类型」至多一个 impl;孤儿规则:impl 只能
写在 trait 或主体类型的声明模块。impl 全局生效,不需要
use。 - 主体形状:一个类型构造器,作用在互不相同的类型变量上,而那些变量恰好是
这个 impl 自己的参数——
impl Eq[Money]、impl[T: Eq] Eq[List[T]]、impl[K: Eq, V: Eq] Eq[Map[K, V]]都合法;impl Eq[List[Int]](具体实参)与impl[T] Eq[Map[T, T]](重复变量)不合法。这一条同时决定了「哪个 impl 匹配」 (head 相等)、「两个 impl 是否重叠」(同上)与「递归求解是否终止」(子目标是 父目标的真子项)。 - 条件 impl:
impl[T: Eq] Eq[List[T]]的方法是泛型函数,按约束接收字典;Eq[List[Int]]这样的具体目标在编译期解成常量字典,Eq[List[T]](T刚性) 则在运行期由Eq[T]构造。无 dyn(异构集合的写法见本节末「异构集合」;裁决理由与 重开条件见 trait.md §10)、无 supertrait、无特化(不问「哪个更特化」)。 trait 方法的效果行是完整的行:纯、!io、效果变量、具名标签、关联效果投影都可以 (§6.5 边界);impl 的效果 ⊑ trait 声明(经该 impl 的关联效果绑定归约后),效果变量 按位置配对,标签两边必须恰相等——每个标签是一格隐藏证据参数,是方法形状的一部分。 关联类型(设计与裁决见 assoc-types-design.md):trait 体可声明裸
type Item成员(无 bound、无默认),impl 对每个声明恰绑定一次type Item = X(X 在 impl 自己的参数作用域解析,且不得又是投影);漏绑、多绑、绑 trait 没有的名字,各是编译错误。方法签名以T.Item投影它——主体必须是带该 trait bound 的类型参数(恰一个 bound 声明该名字;零个与歧义都是错误),List[Int].Item这样的具体主体不支持。- 归约是急切的:调用点实例化让主体落到具体头时,投影立即经该主体的
impl(与字典选择同一扇门)换成绑定值——具体类型里不存在未归约投影。
刚性主体(泛型体内的
T)上的投影保持原样,只与逐字相同的投影相互统一。 - 关联类型名只活在 trait 作用域内,不进模块类型命名空间;
T.Item是 唯一到达方式。opaque 主体先查自己的 impl,无则落到 target 的(与 witness 同序)。运行期零表示:类型照常擦除,字典不多一个槽位。 - 投影出现在参数位时不参与推断:类型参数由非投影的参数位定下,投影随后
按该主体的 impl 绑定归约、再与实参比对。推论:类型参数只出现在投影里的方法
按名字不可调用(报「无法推断」);trait 作者应把主体放进参数表——
Iter四个方法都把c: C放在第 0 位,正是这个原因。
- 归约是急切的:调用点实例化让主体落到具体头时,投影立即经该主体的
impl(与字典选择同一扇门)换成绑定值——具体类型里不存在未归约投影。
刚性主体(泛型体内的
trait Head[C] {
type Item
fn first(c: C) -> Option[C.Item]
}
impl[T] Head[List[T]] {
type Item = T
fn first(c: List[T]) -> Option[T] = get(c, 0)
}
fn head_or[C: Head](c: C, d: C.Item) -> C.Item = # 投影随实例化归约
match first(c) { Some(x) -> x, None -> d } # head_or([1], 9) 是 Int
关联效果(设计与裁决见 effect-params-design.md 刀 5;默认值为 2026-08-26 裁决): trait 体可声明
effect E成员(无 bound),并可给它默认行effect E = !X;impl 对 每个声明至多绑定一次effect E = !X,右侧是具体行——一个具名效果、!io或空行!(),效果变量与投影都不可绑,默认行的右侧同此纪律。impl 省略有默认的成员即取默认, 写了绑定即覆盖;省略无默认的成员、多绑、绑 trait 没有的名字,各是编译错误。注册期把 默认物化成该 impl 的绑定,此后归约、证据与后端对「默认来的」与「写出来的」绑定不可分辨 (tea 的五个纯 impl 删绑定前后 Core 逐字节相同,是这句话的可执行判词)。方法的行以!T.E投影它 ——主体必须是带该 trait bound 的类型参数(恰一个 bound 声明该名字;零个与歧义都是 错误),许可位置与类型投影相同:方法签名内,含参数位与返回位的函数类型行。- 归约是急切的,与类型投影同一扇门:调用点实例化让主体落到具体头时,投影立即经该 主体的 impl(与字典选择同一个答案)换成绑定的行——具体行里不存在未归约投影。
- 证据按参数结算,恰一格:行里每个写出的投影给函数追加一格擦除的隐藏证据参数,
排在写出的标签所合成的证据之后。调用点归约出标签,就交该标签的证据;归约到调用方
自己的刚性投影,就转发自己那一格;归约成纯或
!io,交占位值。字典本身不携带任何 证据,槽位形状只取决于 trait 声明。v1 边界:一个关联效果成员至多绑一个标签, 要两个效果就写两个成员。 - bound 是检查,不是定义:投影读出的永远是 impl 的实际绑定(每个主体唯一); 消费者在 bound 上写下的行只是上界(impl 侧 ⊑ 它),两者并存。
预置
trait Ord[T] { fn cmp(a: T, b: T) -> Int }及Int/String的 impl (Float没有,NaN 下无全序可给——拒绝理由见 §4.3 数值边缘语义,2026-07-26 撤销了此前照Double.compare给的那份)。cmp只承诺符号:小于返回-1、相等返回0、大于返回1, 不承诺其它幅值——此前cmp("a", "z")会把宿主的码元差-25漏出来, 2026-07-31 起全类型统一 −1/0/1(把结果当差值缩放本就可能溢出,std 一直只用符号)。Ord[String]是码点序:逐码点比较,首个不同的码点定大小,前缀相同时短者小。 UTF-8 的字节序与码点序等价(编码的设计属性);哈希的货币仍是 UTF-16 码元 (见下文哈希叶子),有意不跟走——哈希只须与==一致,不必与序同货币。 语言其余各处(str.len/slice/chars/code_points/游标)本就以码点为货币, 序是最后一处对齐的(2026-07-31,此前是 UTF-16 码元序,只在增补平面字符上可观测)。
derive Ord生成字段字典序比较 (和类型先比构造器声明顺序,构造器不同时只产生-1/1),字段须为Int/String、自身具 Ord impl 的类型,或该类型自己的类型参数 (此时生成的是条件 impl:type Box[T] = { v: T } derive Ord得到impl[T: Ord] Ord[Box[T]])。List[T]有 std 写的词典序 impl。预置
trait Eq[T] { fn eq(a: T, b: T) -> Bool }与trait Hash[T] { fn hash(x: T) -> Int }。Eq的标量 impl 是Int/Float/Bool/String/Bytes;Hash的没有Float(拒绝理由见 §4.3 数值边缘语义,同Ord[Float]),故是四个。 没有derive Eq:==本来就对每个类型结构化(§4.3),等于每个类型 隐式实现 Eq;写 impl 是为了覆盖它。- 覆盖后
==即该 impl,容器与嵌套比较一并跟随。 impl Eq与impl Hash必须成对出现(编译错误):相等的值必须哈希相同, 否则该类型作Map/Set键即失效。- 覆盖体内不能用
==比较主体类型本身——那就是这个 impl,会无限递归;比字段。 Eq/Hash约束实例化到没有 impl 的类型(ADT、元组)时,编译器按这个类型 自己的结构合成一份实现——等价于一条隐式的条件 impl,主体含类型参数时要求 该参数有对应 bound。主体静态已知时直接塌缩成原语,不建字典。- 合成的哈希是可观测的数(
hash(x)可以打印),故定义在此:种子1, 逐部分h = 31*h + hash(part),32 位环绕算术;元组按元素序,构造器按字段序, 有一个以上构造器时构造器序号作为第一个部分先折进去(一个构造器时没有可分辨的 标签,与==/cmp同一条规则)。 四个标量叶子的算法同样定义在此(全部 32 位环绕算术,结果为 32 位数):
Int:v ^ (v >>> 32),取低 32 位。Bool:true得1231,false得1237。String:种子0,逐 UTF-16 码元h = 31*h + unit。哈希的货币是 UTF-16 码元,与序的货币是两回事,这是有意的:哈希只须与==一致 (内容相同 ⟹ 哈希相同),不必与任何序同货币,序的货币变时哈希不跟走。Bytes:种子1,逐字节h = 31*h + b,b取有符号 byte(−128..127)。 恰与上面的复合规则同形——把每个字节当一个 part 折。
List不在此列:std 写了impl[T: Eq] Eq[List[T]]与对应的Hash/Ord。Map/Set/元组仍走合成。
- 覆盖后
- trait 方法与带约束的函数可以直接当函数值:期望的函数类型定型主体之后,编译器
写出 eta 展开,约束在展开处解析成字典。约束落在外层函数的类型参数上时,合成的
闭包捕获外层那个字典参数,与手写 lambda 同形。主体没有定型(
let f = to_string) 仍报「无法推断类型参数」,写出期望类型或手写带标注的 lambda 即可。带效果标签的 函数照样可以当值:标签进入值的类型,证据由调用点供(§6.5 边界)。效果操作不行, 它没有可取的函数符号。 - 限制:comptime 中不允许 trait 约束的调用与 impl 排序。
异构集合
没有 dyn,异构集合的惯用法是一等函数 + 不透明类型:在类型还具体的地方把方法调用 包成闭包,装进一条记录,把记录声明成 opaque 类型,再给这个 opaque 类型写 impl。
type ShownRepr = { render: fn() -> String }
pub opaque type Shown = ShownRepr
pub fn shown[T: Show](x: T) -> Shown = {
let r: ShownRepr = ShownRepr { render: () => show(x) }
r
}
impl Show[Shown] {
fn show(s: Shown) -> String = {
let r: ShownRepr = s
r.render()
}
}
pub fn main() -> Unit !io = {
let xs: List[Shown] = [shown(1), shown("two"), shown(true)]
for x in xs { println("${x}") }
}
1
"two"
true
四步各有依据,都在本文件内:
shown里Show[T]消解出的字典是普通值,被 lambda 按值捕获(本节「trait 方法与 带约束的函数可以直接当函数值」一条:与手写 lambda 同形;§4.5:闭包按值捕获绑定)。T到此为止,Shown里不再有它。Shown是不透明类型,运行期就是ShownRepr,零开销(§2.7)。impl Show[Shown]合法且优先于目标类型的 impl:孤儿规则把不透明类型算作声明模块的 本地类型(§2.7)。let r: ShownRepr = s不能省:不透明类型的转换只发生在赋值、传参与返回位置,不在 表达式内部(§2.7),s.render()是编译错误。
适用范围:
- 单方法 trait:完整替代。
- 多方法 trait:每个方法写一个字段,即手写一张函数表;语言不代劳。
Eq/Ord写不出来:二元方法(fn eq(a: T, b: T))要求两个实参是同一类型, 而两个Shown的真实类型可以不同——「同类型」这件事在Shown的类型里无处安放。 推论:Map[Shown, V]这样的东西做不出来。
4. 表达式
Dawn 是表达式导向的:if、match、块都产生值。
4.1 绑定
let x = 42 # 不可变绑定,类型推导
let y: Float = 1.0 # 可选标注
var acc = 0 # 可变局部变量
acc = acc + 1 # 赋值,仅对 var 合法
let不可变、不可遮蔽(同一作用域重复绑定同名是错误;嵌套作用域允许遮蔽)。var仅限函数体内的局部变量;record 字段、参数、顶层均不可变。- 赋值是语句(类型
Unit),不是表达式——if (x = 1)这类错误不存在。
4.2 块
let area = {
let w = 3.0
let h = 4.0
w * h # 最后一个表达式是块的值
}
块引入新作用域;最后一个表达式是块的值,其余语句必须是 Unit 类型
(防止悄悄丢弃 Result——丢弃非 Unit 值必须显式 let _ = ...)。
4.3 运算符与优先级
自低到高:
| 优先级 | 运算符 | 结合性 | 说明 |
|---|---|---|---|
| 1 | |> | 左 | 管道,见 §4.4 |
| 2 | || | 左 | 逻辑或,短路 |
| 3 | && | 左 | 逻辑与,短路 |
| 4 | == != < <= > >= | 不结合 | 比较;链式比较是语法错误 |
| 5 | | | 左 | 按位或;仅 Int |
| 6 | ^ | 左 | 按位异或;仅 Int |
| 7 | & | 左 | 按位与;仅 Int |
| 8 | << >> >>> | 左 | 移位;仅 Int。>> 算术(补符号)、>>> 逻辑(补零) |
| 9 | ++ | 右 | String/List 连接 |
| 10 | + - | 左 | 仅数值,两侧同类型 |
| 11 | * / % | 左 | 仅数值;Int 除零 panic |
| 12 | not、一元 -、~ | 前缀 | ~ 仅 Int 按位取反 |
| 13 | ? . () []调用 | 后缀 | ? 见 §8.1;() 自 2026-07-30 起是一般后缀(见下) |
- 调用是一般后缀(SYN-02):任何后缀表达式后同一行紧跟
(即为应用——make()(1)、(if c { f } else { g })(1)、get_handler()(req)都合法。f(x)与Circle(1.0)走的是同一条后缀路径(2026-07-31 起没有各自的特判), 是哪种调用由 callee 是什么决定,而不是由写法决定:构造器名→构造、 函数名→静态调用(名字绑的是函数值则动态调用)、Java 成员→Java 调用、 其余表达式→应用一个函数值。 跨行的(不吃:换行结束后缀链(只有.可跨行续链,§1.7), 故let x = f换行(1 + 2)仍是两条语句——与一元-不续行同一条纪律。 尾块:同一行紧跟的裸
{ ... }是最后一个实参(2026-08-08,#206)。f(a) { e }就是f(a, () => e);带参数写花括号头——items(xs) { item => e }就是items(xs, item => e),多参/带注解用括号:fold(xs, 0) { (acc, x) => e }、{ (x: Int) => e }、{ () => e }。落点与(完全相同(所以xs.each { e }出方法调用、裸名column { e }出应用),糖只换拼写不换节点。 历史裁决「裸{}与无括号if头的体无法区分,永久不做」已推翻(2026-08-08): 区分机制就是头部禁记录字面量的那个开关(nb,见下),当年裁决写在它落地之前。 边条件四则:- 同一行才吃,与
(同一条纪律:let y = g(1)换行{ ... }是两条语句, 独立块语句不会被上一行的调用吞掉。 if/while/for的头部与match被检者不开尾块(with的 callee 同, §4.10):if f(x) { ... } { ... }里第一对花括号是if的体。 加括号即恢复——if (f(x) { ... }) { ... }。match 臂的 guard 不禁: guard 后面跟->不跟块,没有可被吃掉的体。- 前一个记号是
}时拒绝:if c { 1 } else { 2 } { 3 }、match x { ... } { ... }、P { x: 1 } { ... }、f(a) { ... } { ... }都不是把块应用到值上的意思;顺带「一次调用至多一个尾块」不需要另立规则。 要把块应用到一个值,先加括号:(...) { ... }。 - 大写名的花括号归记录字面量(§2.3):
Column { ... }是构造, 尾块永远落不到裸 TYPEIDENT 上;组件函数用小写名。Column(align: c) { ... }不受此限——括号在前,块照常附加。
尾块在尾块位绑定形参:
{ x => ... }的头是独立产生式,不是「块被重新解释」 (如同Point { x }用花括号但不是块)。要写「值是一个 lambda 的块」, 加括号即可:f(a) { (x => e) }——括号组不再以=>收尾,头就读不出来。 尾块填最后一个声明的形参/字段(Kotlin 同规),与它前面有没有具名实参无关, 「位置实参不得跟在具名实参后」对它豁免;默认值填剩下的洞(#207):column(gap: 12) { text("hi") }里块填body、align走默认。 那个形参已被填过则报「the last parameterbodyis already given; the tail block is what fills it」。- 同一行才吃,与
- 尾块是唯一的尾实参形式(#206 期 2):过渡期接受的
f(a) fn(x) => e已退役,现行拼写只有f(a) { x => e }。fn在表达式中没有位置,只用于具名函数 声明(§3.1)和函数类型(§2.2);旧尾闭包拼写会得到迁移到尾块的专有诊断。 裸箭头 lambda 仍进不了尾位:f(a) (x) => e里(x)已是柯里化调用f(a)(x), 两种读法在=>之前无法区分——这条与花括号无关,维持原判。 - 具名实参(
f(a: 1))语法上在每个调用位置都合法,包括点调用与应用函数值; 能不能用由 callee 是什么决定(2026-08-08,#207)。带签名的 callee 接受: 顶层函数、模块函数、UFCS/点调用、管道、trait 方法(名字取 trait 声明的形参名, impl 的形参名纯属局部)、effect operation、构造器(字段名,见 §2.3)。 函数值不接受——函数类型不携带参数名,let g = f之后g(a: 1)是类型错误 (「gis a function value, and a function type carries no parameter names」); Java 成员不接受——Java 元数据里没有形参名。 分派规则与构造器是同一份:位置前缀从左到右占槽,具名按名占槽、具名之间可任意重排, 位置实参不得跟在具名实参之后;点调用的接收者已按位置占掉第一个槽,x.f(self: y)报的是「parameterselfis given twice」。f(1)(2)能解析,语义仍是「f(1)的结果必须是函数」——Dawn 没有自动柯里化; 第二次应用的 callee 是函数值,故不可具名。 - 实参求值顺序 = 写序(2026-08-08,#207 裁决):调用与构造的实参一律按书写顺序
求值(与 Python / C# / Scala / Swift / Kotlin 相同);具名实参重排的是落槽,
不是求值顺序——
R { b: f(), a: g() }先跑f()后跑g(),值各归各的字段。..base在语法上只能写在最前,也最先求值。二元运算符从左到右(&&/||短路照旧)。 此前构造器按声明序求值、C 后端把实参顺序留给 C(gcc 从右到左)——两者都已按本条修正, 两后端对拍钉住(scripts/spike-native/named_args.dawn、eval_order.dawn)。 - 按位
& ^ \|与移位仅作用于Int(无Float位模式);它们紧于比较, 故a & b == c是(a & b) == c,无 C 家族那个坑。移位计数取低 6 位(同 JVMLSHL)。
数值边缘语义(全部为保证——自举后两套实现以此对拍,不允许「碰巧一致」):
Int溢出环绕(二补码,同 JVM):MAX + 1 == MIN。唯一可能溢出的除法MIN / -1同样环绕(结果MIN),MIN % -1 == 0。/向零取整(-7 / 2 == -3,不是 floor);%的符号随被除数 (-7 % 2 == -1、7 % -2 == 1),恒满足a == (a / b) * b + a % b。/与%除零是 panic(消息Int division by zero/Int modulo by zero)—— panic 而非 Java 异常,故catch_fault不拦、只有catch_panic拦(§9.8)。Float算术遵循 IEEE 754:除零不 panic(得±Inf/NaN);==与< <= > >=是 IEEE 比较——NaN 与任何值(含自身)比较均为 false,-0.0 == 0.0为 true。Float没有Ord(2026-07-26 改;此前照 JavaDouble.compare给了一个 全序)。NaN 与任何值(含自身)都不可比,所以 Float 上没有全序可给,而Ord正是sort/max/min/max_by/min_by/derive Ord与[T: Ord]所依赖的东西。「降成偏序」在 Dawn 里是少一条 impl,不是多一个 trait:</<=/>/>=对标量本就不解见证,照旧可用、照旧是 IEEE 语义。 变的只有一条:[T: Ord]不再收 Float,cmp(1.5, 2.5)不再编译。 (Rust 的total_cmp是f64的固有方法,f64同样没有Ordimpl。)Float没有Hash,因此不能作 Map/Set 的键:-0.0 == 0.0为真而两者 位模式不同,没有哈希能同时与这个相等和自身一致(同 Rust)。Float有Eq,而它不自反(nan == nan为 false)。这是 IEEE 的事实, 不是漏洞,Dawn 不为此拆出PartialEq/Eq两个 trait(2026-07-26 明确 裁定不拆):Dawn 只有一个Eq,拆开会让1.5 == 2.5这种日常写法多背一层 概念,代价远大于收益。用到自反性的地方(容器查找)另有 Float 不能作键这条挡着。Float的to_string/Show渲染是语言自己写下的规则(实现是std/fmt.dtoa一份 Dawn 源码,两后端与 comptime 折叠共用;2026-07-31 起不再引用宿主方法—— 规则的形状取自当日 JVMDouble.toString(JDK 19+ 的 Schubfach 最短往返), 故已发布的 JVM 字节一个不变,但此后宿主换算法也不跟):- 最短往返:输出是能唯一读回原 double 的最短十进制数字串;同长候选取 最接近真值者,仍平局取偶数尾数。
- 两种形式,按数值大小切换:
10^-3 ≤ |v| < 10^7用普通小数形式(0.001、9999999.0),此外用科学计数法d.dddEe——首位恰一个非零数字、小数点后至少 一位、大写E、指数无前导零、负指数带-、正指数不带+(1.0E7、9.999999999999998E-4、4.9E-324)。 - 整值保留
.0(1.0不是1);小数部分至少一位;-0.0保留符号。 - 三个特殊拼写:
NaN(无符号)、Infinity、-Infinity——与parse_float认的三个拼写相同,故parse_float(to_string(x))对每个x闭合(§11)。
to_int(x)向零截断且饱和(同 JVMD2L):to_int(2.7) == 2、to_int(-2.7) == -2;NaN得0,超出Int范围的(含±Inf)得较近的那一端 (to_int(1.0 / 0.0) == Int的最大值)。写下来是因为 C 的强转对这三种输入全是未定义、 且在 x86-64 上三者都答Int的最小值——2026-07-30 之前 native 就是这么答的。==/!=默认是结构相等,对任意类型可用(函数类型除外——比较函数是编译错误); 类型可用impl Eq覆盖这个默认(§3.5)。排序比较
</<=/>/>=有两套机制,不是一套:- 在
Int/Float/String上它们是不解见证的原生运算——不查 impl、不建字典。Float上是 IEEE 语义(NaN 参与的比较一律 false,见上文数值边缘语义)。 其他具体类型桥接到预置 traitOrd的cmp(见 §3.5)——有 impl (手写或derive Ord)即可比较。 [T: Ord]是 trait bound,走字典:受它约束的类型参数上,<与cmp/sort一样解Ord的见证。Float没有 impl(拒绝理由见上文「Float没有Ord」, 不在此重复),故cmp(1.5, 2.5)不编译,而1.5 < 2.5照旧合法。- 两套机制在
Int/String上答案相同(String上<与cmp是同一条 比较、同一个序——码点序,见 §3.5),在Float上有意不同:原生<是 IEEE 偏序,Ord要的是全序,Float 只有前者。
- 在
用户类型的打印:
type声明后加derive Show获得to_string与字符串插值支持 (可 derive 的还有Ord,见 §3.5;多个用逗号:derive Show, Ord)。Show是预置 trait,derive Show铸的就是一条 impl,所以也可以手写impl Show[T] { fn show(x: T) -> String }自定义渲染,泛型类型则用条件 impl (impl[T: Show] Show[Box[T]])。to_string的签名是[T: Show]。 渲染形如合法 Dawn 源码:- 无载荷构造器 →
Red;带位置字段的构造器 →Circle(1.5); - 记录 →
Point { x: 0.0, y: 2.5 }(带字段名); String字段带双引号并转义("a\nb");Int/Float/Bool同各自to_string;- 容器递归渲染:
List→[a, b]、元组 →(a, b)、Option/Result随载荷(Some(Red))。Map/Set没有字面量语法,渲染成重建它的那个调用:map.from([(k, v), ...])、set.from([e0, e1])——写的是公开拼写,因为「读起来像源码」的意义在于能粘回去 (曾输出已退役的平铺名map_from,粘回去报 undefined function;审计 RD-14)。 - 每个字段类型必须可打印(函数字段、未
derive Show的嵌套用户类型 → 声明处报错); 泛型类型可打印 当且仅当 其类型实参都可打印(Box[Int]可,Box[fn(...)→...]不可)——derive Show在泛型类型上铸的是impl[T: Show] Show[Box[T]],这条就是它的 bound。 - 顶层的
String不加引号,嵌套的加。to_string("a")是a,而["a"]是["a"]——引号是「这里是一个值,不是周围的标点」的记号。Char同理,定界符换成单引号:to_string('a')是a,而['a']是['a'](§1.5)。于是List[Char]、List[String]、List[Int]三者的渲染互不相同。 trait 方法show是嵌套那一份,所以经[T: Show]约束渲染一个字符串 会带引号;to_string/${}只在静态类型就是String时去掉它。 这条规则由静态类型定,不由值定:to_string("hi")是hi,而同一个调用写在fn f[T: Show](x: T) = "${x}"里面出的是"hi",因为那里静态类型是T,渲染走Show见证。这一点与下条的Display无关,两层分开的那天起就是这样。 顶层这一层类型可以自己接管,写法是
impl Display(§3.5)。to_string(x)/${x}先问x的静态类型有没有Displayimpl:有就用它, 没有才落到本节其余各条(含上一条的String恒等)。两条边界:- opaque type 是逐层剥的,每剥一层重问一次。
opaque type A = B上没写Display时拿到B的那一份,B也没写就再往下。一次剥到底再问会让写在里层的 渲染在顶层失效,那是审计 SEM-03 记下的缺陷。 Show那一层不动。 写了Display的类型嵌在结构里仍走Show,[T: Show]约束下的类型变量仍走见证(同上条)。
Show是嵌套那一份、Display是顶层那一份,两个名字对上 Rust 的Debug与Display;这里曾经只有一个 trait 兼二职,这两条就是它拆开的位置。- opaque type 是逐层剥的,每剥一层重问一次。
- 无载荷构造器 →
4.4 管道
x |> f(a, b) 等价于 f(x, a, b)——把左侧塞进第一个参数。
x |> f 等价于 f(x)。标准库 API 均按"主数据是第一参"设计以配合管道。
|> 是普通调用的参数插入,不是别的东西。 右侧照 §4.3 的后缀规则解析成一个
表达式;若它最外层已经是调用(f(a)、m.f(a)、r.m(a)、make()(a)),
左侧插到那次调用写下来的参数表首位;否则整个右侧被应用到左侧。
x |> f(a) # f(x, a)
x |> f # f(x)
x |> m.f(a) # m.f(x, a) 模块别名不是接收者
x |> r.m(a) # r.m(x, a) 接收者原地不动,不重复插入
x |> make()(a) # make()(x, a) 不是 make()(a)(x)
x |> One(a) # One(x, a) 构造器也只是被调用的东西
管道不引入任何新节点,因此:
- 求值顺序就是把调用写开的顺序:先求 target(动态 callee / 接收者),
再按写序求各参数,而左侧只是第一个参数。
lhs |> make()(arg)先跑make(), 再跑lhs,最后跑arg。这条是规定的,不是实现巧合——静态 callee 没有求值 效应,所以常见管道看起来仍是左侧先跑。不为「视觉上左侧在前」引入隐藏临时变量。 - 可调用性、元数、重名参数与记录限制全部按普通调用判。
x |> One(1, 2)报的是 元数错误,x |> k(k: Int)报的是「不是函数」,x |> Point { ... }报的是 「不可调用」,x |> Point(1)报的是「记录必须用花括号」(§2.4)。 - 模块函数仍不是裸函数值(§10.3):
x |> m.f报「模块没有导出这个值」。 带(...)的x |> m.f(a)才是调用,两者的区别与管道无关。 - 记录里存的函数值字段照常动态调用:
x |> r.callback就是r.callback(x)。
4.5 Lambda
let double = (x: Int) => x * 2
let add = (a, b) => a + b # 参数类型可推导时可省略
let now = () => 0 # 零参数
xs |> map(x => x * x) # 单参数可省括号
- 写法只有一种:
params => expr。params是x(单参数且不带注解时括号可省)、(a, b)、(),每个参数可带: Type。=>独占「一个匿名函数从这里开始」这个职责。 fn前缀已彻底退休:fn(x) => e不再解析成 lambda,原先仅在尾位保留的f(a) fn(x) => e也已于 #206 期 2 退役。fn现在只用于具名函数声明和函数类型; 两种旧表达式拼写都有专有迁移诊断。- body 要多条语句就用块:
(x) => { ... }。 - 写在调用后面(同一行)时它是那次调用的最后一个实参——尾块,见 §4.3:
f(a) { x => e }(花括号头绑形参);这是唯一的尾实参拼写。 裸箭头进不了尾位:f(a) (x) => e里的(x)已经是柯里化调用f(a)(x), 两种读法在读到=>之前无法区分。诊断会点名这件事。 (开头的形式先看)之后是不是=>再决定怎么解析:(a: Int)是合法的 参数表而不是合法的表达式,覆盖文法(先当表达式解析、见到=>再重解释)在这里 不成立。判定失败时报「invalid parameter list before=>」并点名出错的那个记号 ((a + 1) => e)。- lambda 不能直接当
if/while的条件:if x => { ... }会把本该是循环体的 花括号吃成 lambda 的 body。这是一条专门的诊断,不是「expected{」。 - 闭包按值捕获绑定(捕获
var是编译错误——想共享可变状态请显式传递)。 - 箭头分工(设计裁决,2026-07-31,刻意不统一):
->是子句箭头,只出现在 「声明形状」的位置——函数类型(§2.2)与 match 臂(§5.1);=>是表达式箭头, 唯一职责是标记一个匿名函数的起点。两者不互相客串:臂体是 lambda 时 (Some(f) -> x => f(x)),两种箭头正是读者辨认层级边界的信号—— 这与 Rust 保持->/=>分立同理,与 Scala 的统一相反。
4.6 if
let sign = if x > 0 { 1 } else if x < 0 { -1 } else { 0 }
- 条件必须是
Bool,分支体必须是块。 - 作为值使用时
else必须存在且分支同类型; 作为语句(值被丢弃)时可省else,此时分支必须是Unit。
4.7 循环
for x in [1, 2, 3] { println("$x") }
for (key, value) in entries { println("$key=$value") }
while queue.non_empty() { ... }
for/while是Unit类型的语句,体内可用var累积。for pattern in source与for pattern in from..to复用完整递归 pattern 文法,包括 tuple、list、constructor、qualified constructor、or-pattern 与行首|continuation。 pattern 必须对元素类型不可反驳;可反驳 pattern 是编译错误,不会被解释成 filter 或静默跳过不匹配元素。- pattern binding 只在循环体内可见。source 与 range 两端都在外层 scope 检查和求值, 看不到这些 binding;循环后的代码也看不到 pattern binding 或循环体局部变量。
- 若 iterable source 不返回(类型为
Never),它仍恰好求值一次,并作为整个语句的 bottom 路径;不解析迭代 witness,也不进入 loop、pattern binding 或 body。 break退出最内层循环,continue跳到其下一轮。二者是类型Never的表达式 (同return,可出现在 match 臂等表达式位置);只在循环体内合法,且不可穿越 lambda/局部函数边界去够外面的循环(lambda 是独立函数,想退出它用return)。 无标签形式——需要多层跳出请提取函数用return。comptime 循环同样支持。with之后的语句也是一个闭包的体(§4.10),所以那里的break同样够不到外面的 循环——诊断会点名with,而不是说一个作者没写的 lambda。for pattern in e迭代任何实现了Iter的类型(预置 trait,§3.5):e在进入 循环前恰好求值一次;迭代脱糖为对iter_start/iter_done/iter_next/iter_get的普通 trait 调用,元素类型是该 impl 的Item。std 的五个容器(List/String/Bytes/Map/Set)开箱可迭代; 泛型函数里[C: Iter]的参数同样可for(字典转发)。迭代序即 impl 的游标序 (String按码点、Map/Set同entries/to_list)。for pattern in a..b支持右开区间的整数范围(不经Iter,元素类型是Int)。a先于b求值; 两端各恰好求值一次,且都在进入循环前求值。即使区间为空,这些保证仍成立。
惯用风格优先 map/filter/fold;循环是给性能敏感处和口味用的。
4.8 下标
let x = xs[i] # List[T] -> T;越界 panic(含负数)
let v = m["key"] # Map[K, V] -> V;缺键 panic(消息含键值)
let c = rows[1][0] # 可链式、可与 ?/./() 组合
[]是断言,get 家族是问询:xs[i]用于「下标必然合法,越界是 bug」的场合 (panic 语义,同 Rust);越界/缺键是正常分支时用get(xs, i)/map.get(m, k)(返回Option)。- 下标由预置 trait
Index求解(§3.5):List(Idx = Int)与Map(Idx = 键类型)的 impl 随语言提供,用户类型写一个impl Index即可支持[]; 没有 impl 的类型是编译错误。opaque type沿用其目标类型的 impl(同==/${…}/for..in)。 - comptime 中支持
List下标(越界为编译错误)。 - 只读——没有
xs[i] = v,Index也没有对应的写方法。列表与映射不可变; 用户类型即使可变也不经[]写入。 - 比较运算符
</==不经 trait 路由到标量的 native 实现(见 §4.3)——Index只管[]。
用户类型的 impl:
type Grid = { w: Int, cells: List[Int] }
impl Index[Grid] {
type Idx = (Int, Int)
type Item = Int
fn index(g: Grid, p: (Int, Int)) -> Int = {
let (x, y) = p
g.cells[y * g.w + x]
}
}
let g = Grid { w: 3, cells: [1, 2, 3, 4, 5, 6] }
let v = g[(2, 1)] # 6
fn first[C: Index](c: C, i: C.Idx) -> C.Item = c[i] # 泛型消费者
越界三判据。语言只承诺三条越界策略,每个带下标/区间参数的操作(本节的 [] 与
§11 的库函数)恰属其一,按参数的含义归类而不是逐函数各定各的:
- 断言——参数是一个位置,调用方声称它合法,越界是 bug:
xs[i]、m[k]、bytes.at、str.at、pvec.index/nth、cursor.slice的非法区间 → panic(含负下标)。 - 问询——越界/缺键是调用方要区分的正常分支:
get族(list.get、map.get、set.has的缺席、index_of族(str/bytes/list)的未命中、str.strip_prefix/strip_suffix的不匹配、bytes.from_hex/from_base64与fspath.extension(包)的 格式不合)→Option(或Bool)。 - 钳位——参数是一个区间,说的是「要这一段里有的部分」,不断言端点存在:
list.take/drop/slice、bytes.slice、str.slice/take/drop、cursor.next/prev/back/seek→ 两端各自夹进[0, len](负数取0、超长取len),from > to得空序列,永不 panic。
唯一具名例外:cursor.char(s, c) 到尾返回哨兵 -1 而非 Option(§11「std/cursor」)。
它是逐字符前进的原语——每步包一层 Option 就是每步一次分配;同一个哨兵在 bytes.at
那里被 std 包装成 panic(判据 1),在 cursor.char 这里则是公开契约的一部分
(packages/json 的 lexer 依赖它)。除此之外,任何库函数不得以哨兵值表达越界。
4.9 return
fn classify(n: Int) -> String = {
if n < 0 { return "negative" } # guard 子句
if n == 0 { return "zero" }
"positive"
}
return expr/ 裸return(仅Unit函数)从最内层函数提前返回—— 在 lambda 内则退出该 lambda(同?的作用域规则)。return是类型为Never的表达式,可出现在任意表达式位置(如 match 分支)。- 所在函数(或 lambda 的期望类型)必须已声明返回类型——省略返回类型的推导函数内不可用。
- 糖区内不可用:
with之后的语句是一个闭包的体(§4.10),return到不了外面, 编译器在那里直接报错并点名with。
4.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()!
})
- 只在块里,且只能是语句:它要吃「块剩下的部分」,没有块就没有可吃的
(
fn f() = with x <- g()是语法错误,提示加大括号)。 - 一个块里多个
with自然嵌套——第二个吃的是它自己之后的部分。配合bracket时释放顺序因此天然是由内向外(§9.8.2)。 - 落点与尾块同一处(§4.3):
f(a, b)长一个实参、r.m(a)长一个实参、 裸名字g变成g(x => ...)。所以with与尾块组合起来只是普通调用, 没有第三条规则。 with的 callee 上不再开尾块(#206):with x <- f(a) { ... }被拒——with已经要把块的剩余部分作为f的最后一个实参,{会是同一次调用的 第二个闭包。诊断点名这件事;要附加块就加括号,或者不用with。?透明穿过:闭包交回的就是with调用的值,而那就是块的值,所以糖区内的expr?传播出去的Err会成为外层函数的返回值——bracket的 release 照跑 (对拍见scripts/spike-native/with_sugar.dawn)。return/break/continue在糖区内被拒,诊断点名with:它们要穿的是一个 作者没有写出来的闭包边界,而「穿出去」只在这个块恰好处于尾位时才等于读者以为的 意思。糖区内自己的循环不受影响(for在with之后,break属于它)。糖区内碰不到
with之前的var,诊断点名with引入的那个闭包:糖区是闭包体, 而闭包按值捕获、拒绝捕获var(§4.5)。所以with之前声明的每个var, 在它之后的块剩余里既不能赋值也不能读;with之后声明的var属于糖区自己, 不受影响。要把糖区之前的值带进来,先let一个快照,或者把它作为参数传进去。var n = 1 let base = n # 先 let 化;此后再读 n 会被拒 with x <- g var acc = base + x # 糖区里自己声明的 var 正常- 绑定处写一个名字,规则同 lambda 形参;没有
_形式(lambda 形参也没有)。
为什么是
bracket而不是defer:受保护的区间恒为一次闭包调用,return/break在语言层面就跨不出去,编译器因此不欠一套逃逸改写。 判据见docs/core-move2-design.md§2.6 与 §6。
4.11 列表字面量与元素形式
list_lit = "[" [ list_elem { list_sep list_elem } [ list_sep ] ] "]"
list_sep = "," | NL (* 换行与逗号是同一个分隔符,相邻时算一个 *)
list_elem = expr (* 普通元素:贡献一个 *)
| ".." expr (* 展开:贡献 0..n 个 *)
| if_no_else (* 条件元素:贡献 0 或 1 个 *)
if_no_else = "if" expr block { "else" "if" expr block } (* 末尾没有 else *)
column([
header,
..body, # body: List[Widget[Msg]],整段接进来
if m.note != "" { text(m.note) }, # 有话说才占一行
help,
])
分隔符:换行与逗号等价。 多行的列表字面量里,元素之间的逗号可以省略, 一个换行就是一个分隔符:
column([
header
..list.map(m.todos, t => item_row(t))
if m.note != "" { text(m.note) }
dim(text("commands"))
])
两种分隔符可以在同一个字面量里混用,尾随的逗号或换行照旧允许,空字面量 [] 与
跨行的 [\n] 都还是零个元素。这是纯语法加法:[1, 2, 3]、多行带逗号、尾逗号
这些既有写法的解析结果一字未变。dawn fmt 也不在两者之间改写,作者选的分隔符保留
(它只调整行内空白与缩进,不动作者断的行)。
省的只是换行处的逗号。同一行上的两个元素仍然要逗号:[a b] 是语法错误,
诊断仍是「expected ], found b」,位置不变。
哪个换行是分隔符,由 §1.7 决定,不由这条规则决定。元素先按普通表达式规则解析完,
可以领起行首的续行运算符(|>、.、二元运算符)照旧被上一行的元素吃掉,
剩下的那个换行才是分隔符:
[
xs
|> f # 一个元素:`|>` 领起续行,与列表无关
a
-1 # 两个元素:`+`/`-` 不作行首续行(§1.7),`-1` 是新元素的前缀负号
]
+ 没有前缀含义,于是行首的 + 落在元素位置上被拒,诊断是
「expected an expression, found +」。这是 +/- 那条例外在元素位置上的自然结果,
不是列表字面量的额外规则。
行首的 .. 一律读作展开,不是 range 的续行。.. 从来不是表达式运算符
(它的四处含义都是位置,见下),range a..b 只出现在 for 头部(§4.7),
所以下面左边是两个元素、而不是右边那个 range:
[
a
..xs # 展开元素
]
[0..3] # 仍然被拒:`..` 夹在两个值之间,又不在元素开头,两种含义都不是
三种元素形式只在列表字面量里成立。元组、记录、构造器实参不认识它们;
pattern 里的 [x, ..rest](§5.1)是另一件事——那是解构的余项,与这里的展开
拼法对称、语义相反。
展开 ..xs:操作数必须是 List[T],它的每个元素按位置铺开。不是列表报
「.. spreads a list, but this is …」。.. 在表达式位置永远不合法,只在这三处:
for 头的范围(§4.7)、record 展开 P { ..p }(§2.4)、pattern 余项(§5.1),
再加上这里的元素展开。
条件元素 if c { x }:条件为真贡献一个元素 x,为假贡献零个。
x 的类型参与元素类型合流,它不必是 Unit——普通表达式位置上
「没有 else 的 if 必须是 Unit」那条规则(§4.6)在这个位置不适用,
因为这里不需要它凑出一个值。
else if 链算一整个元素形式:if a { x } else if b { y } 贡献 0 或 1 个,
取第一个条件为真的那一臂;全不真则零个。写多深都是一个元素形式。
带 else 的链不是元素形式,它是一个普通元素,语义与本特性存在之前完全相同:
[a, if c { x } else { y }, b] 永远是三个元素。于是有一处刻意的不对称——
不带 else 能省略,带 else 不能展开:
[a, if c { x }] # 0 或 1 个:条件元素
[a, if c { x } else { y }] # 恒 1 个:普通元素,二选一
[a, if c { ..xs } else { ..ys }] # 不合法:`..` 不在元素位置上
理由是取最小的那一刀。要让带 else 的形式也能展开,就得让 if 的两个分支
各自是「一串元素」而不是一个值,也就是 Dart 的 collection-if/collection-else 那套
——一个只在集合字面量内部成立的第二套 if 文法,还要回答 else 分支能不能再套
for、能不能嵌套之类的一串问题。而不带 else 的形式不需要任何这些:它在普通
表达式位置本来就只能是 Unit,把它挪到元素位置上没有和任何既有含义冲突。
同样的理由否掉了 collection-for([for x in xs { f(x) }]):list.map 已经写得下,
而加了 for 立刻要回答「为什么没有 while」。
求值顺序是从左到右,元素形式不改变这一点。条件元素的 body 只在它那一臂 被选中时求值。
类型规则:所有元素形式的贡献汇成同一个元素类型 T,字面量的类型是 List[T]。
普通元素贡献自身类型,展开贡献 List[T] 的 T,条件元素贡献各臂 body 的共同类型。
期望类型向下传,这是元素形式真正的用处:一旦 T 定下来(从字面量的期望类型,
或从任何一个已检查的元素),后面每个元素都以 T 作为期望被检查——展开的操作数
拿到的是 List[T],条件元素的每个 body 拿到的是 T。于是
# text: fn text[M](s: String) -> Widget[M],M 只出现在返回类型里
[text(s)] # 报错:cannot infer type parameter(s) M
[header, text(s)] # 可以:header 先把 M 定成 Msg
[header, if c { text(s) }] # 同样可以,期望穿过条件元素进到 body
第三行是 let note: List[Widget[Msg]] = if … 这类中间变量消失的原因:
一个单独的 [text(s)] 绑定没有兄弟可问,只能靠标注;同一串东西写成一个字面量的
元素,标注就不需要了。需要期望才能检查的元素(裸 None、嵌套 [])仍然排在
最后一轮检查,所以它们和源序无关。
破坏性语义改变(v0.67.0 之后):
[if c { <Unit 表达式> }]以前是合法的, 类型List[Unit],长度恒为 1——里面那个if是普通元素,作为语句求值出()。 现在同一份源码长度是 0 或 1,且条件为假时 body 根本不求值。不报任何错,静默改行为。受影响的程序恰好是「条件元素的 body 类型为
Unit」这一族,一个不多一个不少: 元素形式之前,没有 else 的if在表达式位置只能是Unit(§4.6)。 落地时对 dawn-lang(566 个.dawn)与 dawnop-site 后端(84 个)逐文件用新 parser 扫过,既有出现 0 处。没有给过渡期诊断,理由是它只能是错误:Dawn 的诊断没有严重级别,
Diag只有msg/lo/hi/hint,全部诊断都是错误。而把「body 是Unit的条件元素」定成错误, 等于为了保护一类实测不存在的程序,在新形式的类型规则上永久挖一个洞——List[Unit]长度 0 或 1 在新规则下是自洽的。过渡期会过去,类型规则里的特例不会。
5. 模式匹配
match shape {
Circle(r) if r > 100.0 -> "big circle"
Circle(r) -> "circle $r"
Rect(w, h) -> "rect ${w}x$h"
Point -> "point"
}
相邻 match 臂必须由物理换行或 , 分隔;, 后可以换行,最后一臂后也允许
尾逗号。空格不是分隔符:match x { 0 -> 1 1 -> 2 } 会在第二个 1 处明确报缺少
换行或逗号,而不会把“下一 token 看起来像 pattern”当作隐式边界。该规则不改变
§1.7 的换行续接:嵌套在 ()、[]、{} 内的换行仍属于臂体表达式。
5.1 模式形式
| 模式 | 例 | 匹配 |
|---|---|---|
| 字面量 | 0, "yes", true | 相等则匹配 |
| 绑定 | x | 恒匹配并绑定 |
| 通配 | _ | 恒匹配不绑定 |
| 构造器 | Some(x), Rect(w, h), Rect(w: w, ..) | 按位置或按名解构,.. 忽略其余字段 |
| 记录 | Point { x, .. } | 字段解构 |
| 元组 | (a, b) | |
| 列表 | [], [x, ..rest] | 空表 / 头与余下 |
| 或 | 0 | 1 | 2 | 任一匹配(各分支绑定必须一致) |
| 守卫 | pat if cond | 模式匹配且守卫为真 |
| 是 pattern 的最低优先级,递归出现在构造器、记录、元组与列表 pattern 内,并按
源码顺序收集为扁平的 n-ary or-pattern。(pat) 只用于分组,tuple pattern 仍需要逗号。
| 可以放在续行行首,例如 A\n | B;换行后若没有 |,pattern 仍开始下一个 match 臂。
运行时选择第一个匹配的 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 是上界,
一条签名承诺 !io 就覆盖得住更纯的实现(包括只发效果变量的实现);在并运算里
io 不是吸收元,!io !e 不得消去 !e,两个原子都留在规范形里(印作 !(e|io))。
完整规则见 §6.6。
两条轴都平凡可判定:并是有限集合按分量取并加一位布尔或,包含是逐轴的集合判定。
6.2 规则
- 函数体的效果 = 体内所有调用效果的并。
- 签名未标
!io的函数,体内出现 io 效果 → 编译错误 (报错指出哪个调用引入了 io,并建议在签名加!io或消除该调用)。 - 标了
!io但体是纯的 → 允许(预留演化空间);「多余!io」的 lint 需要类型分析, 当前dawn fmt --check只做格式检查、未实现该提示(留待后续)。 - 纯函数保证:给定相同参数返回相同值、无可观测副作用。
编译器可据此折叠、消重、在 comptime 调用。具名效果也在这条保证之内:
函数值的类型带着完整的效果行,能从行里减去一个标签的只有真正应答了它的那个
with handle(§6.5),所以签名为纯的函数跑不出别人的 handler 臂。 - 函数值的效果在调用点应答,不在写下它的地方。闭包的行就是它体内发出的效果, 创建点不做改写;一次调用实例化出的行里若有没人应答的具名效果,错误报在这次调用上 (§6.5)。
panic/todo/assert不算 io——它们不返回(发散不是效果)。- 吸收禁令:效果行不得在无 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)
- 效果变量
!e无需声明,在签名中出现即引入;作用域是整条签名。 也可以在参数表里写出显式绑定者:fn map[T, U, !e](xs: List[T], f: fn(T) -> U !e) -> List[U] !e。 两种写法等价。签名自带显式绑定者时,该名字在这条签名内只解析到它;没写的名字照旧出现即引入, 同一条签名里两者可以混用。绑定者不带约束([!e: X]是错误),名字必须小写(!E是具名效果, 不是变量),!io与pure都不能当绑定者名,同名绑定者不能写两次。
pure 在行的任何位置都被拒绝,不只是绑定者位置:它读起来是「只许纯」,
当变量用却是「什么行都行」,两者相反。空行有自己的拼法 !()(§6.6)。
绑定者列表也是顺序的来源:它按写下的次序绑定,未写绑定者的名字仍按首次出现的次序引入。
- 类型声明也能绑定效果参数:
alias Mapper[T, U, !e] = fn(T) -> U !e、type Box[!e] = { f: fn(Int) -> Int !e }。只能靠绑定者:类型声明不是签名, 「出现即引入」不适用,写一个本声明没绑定的变量是编译错误——加进参数表,或写!io,或留纯。 同一声明里的多处!e是同一个变量,不同声明的!e互不相干。 具名效果不受这条限制:alias A = fn() -> Int !Ask、type Boxed = { f: fn() -> Int !Ask }都合法。标签不是变量,没有绑定者可言;它的含义是「调用这个函数值的人供 handler」, 而这正是今天证据的走向(§6.5)。 - 名义类型绑定的效果参数只走空证据。 限制的是实例化,不是书写:
type Box[!e]、type Chain[!e]、opaque type Hidden[!e]的!e只能解成不欠证据的行, 即纯、!io,或它自己(原样转发,fn store(b: Box) -> Box = b)。带具名标签、 带关联效果投影、或带别处绑定的变量的行不得经这个通道流进消费方。 理由是通道不存在:签名绑的变量有一格隐藏证据参数可走(§6.5「实现」),名义类型没有, 记录不是帧,而名义类型的身份只记名字与类型实参(下下条),没有位置逐使用点记住一条行。 要让一条真欠证据的行流动,就把变量绑在运行这个闭包的函数上 (fn f(g: fn() -> Int !e) -> Int !e),或者改用透明alias并在使用点写出效果实参(下一条)。 - 使用点可以写效果实参,只有透明
alias收:Thunk[!io]、Thunk[!Ask]、Thunk[!e]、Mapper[Int, String, !(Ask | e)]。实参位收的就是行位置能写的一切,一条也不少: 透明别名就是它的展开,Thunk[!Ask]与fn() -> Int !Ask是同一个类型的两种拼法, 内联写得出的行不会因为经过别名而写不出。 实参在使用点自己的作用域里解析,与内联写下同一条行的读法逐字相同:!e指的是消费方 签名绑的那个变量(首次出现即引入),!Ask是这里可见的那个具名效果,!T.E是这里的投影。 代换按声明顺序逐位进行,别名自己的变量被替换掉,结果按集合语义并进展开式里原有的行 (拍平、去重、io不吸收变量,见下两条):alias IoThunk[!e] = fn() -> Int !io !e上IoThunk[!Ask]是fn() -> Int !(io | Ask)。 实参与类型实参共用同一对方括号,各按自己那条列表定位(声明侧的[T, U, !e]也是这样分的), 所以效果实参写在哪两个类型实参之间都不动类型实参的对齐。 记录、变体、opaque type不收:它们的身份是「名字 + 类型实参」,没有位置记住这个行, 收下来要么两个类型不可分辨,要么可分辨却印成同一个名字。透明别名没有身份可记,它就地展开, 实参落在展开式里。 - 省略效果实参仍然合法,不是元数错误:
Mapper[Int, String]只给类型实参。 省略掉的那一格仍是声明自己的那个效果变量,由上下文求解: 把一个纯闭包存进去,它在那里就解成纯;把一个!io闭包存进去,它在那里就解成!io。 消费方要在自己的行里容得下它,而一条只由本签名绑定的!e容不下别处绑的变量。 别名的出路是把实参写出来(Mapper[Int, String, !e],!e由本签名绑定); 名义类型没有这条出路,消费方写!io(!io容得下任何行)。 !(e1 | e2)为并,规范化后存储:并按分量取(io 位布尔或,变量、投影、标签三个 集合各自取并),pure是幺元(可省去),能收成更小形状的就收成更小形状 (!(e|e)即!e)。io不吸收任何东西:!(io | Ask)就是!(io | Ask),!io !e就是!(e|io),都不会塌成!io。完整规则与它的封闭性见 §6.6。- 效果变量横跨两轴:
!e可以实例化成带标签的行。map(xs, x => ask())里list.map的!e实例化成「pure + {Ask}」,于是for循环体里发具名效果照样通得过。 - 实例化不限原子个数:一个
!e可以解成单个标签、若干标签的并(!e := !(Ask | Tell))、 另一个变量、或标签与变量的混合(!e := !Ask !e2)。没有「一个变量至多站一个标签」这条规则。 - 调用点实例化:
compose(inc, tag)若inc纯、tag为!io,则结果类型的效果并 规范化为!io;两者皆纯则规范化为纯,结果可在纯上下文调用。 - 调用点实例化:
map(xs, println)中e = io,故整个调用是 io。
6.4 逃生门:unsafe_pure(仅限 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` §九)。
- 只改效果,不放松类型:块内类型检查、重载消解一律照常;被盖的只有「效果」这一维。
- 拒绝屏蔽效果变量:块内若出现效果多态调用(
!e,如高阶map/fold),报错—— 盖成纯即撒谎,且e = io时值都定不下来。这条护栏把高阶代码逼向「纯 Dawn 递归 over 一阶 pure 原语」的正道(§6.3),故unsafe_pure只会出现在最底层一阶包装上。 - 多余即报错:块内本就纯(无 io)→ 报
redundant unsafe_pure,保证每处unsafe_pure都是载荷性的、grep unsafe_pure即完整信任清单。 - 不健全性:这是可撒谎的口子(名字带刺以示警)。缓解靠具名可 grep + 多余 lint + 两层结构把担保收敛到极少数一阶原语;编译器不验证 Java 纯度(做不到)。
- 运行期透明:codegen 直接生成内层表达式,无任何运行期标记。
- 编译期折叠(route C)需要
--comptime-ffi,默认关:const A: Int = unsafe_pure { Math.max(3, 7) }折叠为 7,但只在这个 flag 打开时。限制另有两条:只反射静态方法,边界类型限Int/Float/Bool/String/Unit。 - 纯度与许可是两件事(2026-07-27 分家):
unsafe_pure曾同时充当 route C 的许可证, 于是「我担保这个调用是纯的」被顺带读成「编译器可以在自己的进程里跑它」。前者是作者对 程序的断言,后者是对编译这份源码的那台机器的索取——受害人不同,就不该同一个记号。 这三道闸都不是沙箱:System.load(String)就是静态、String 入参、void 返回。故门由 运行编译器的人开,不由被编译的源码开。
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
}
- 效果名是 UpperCamelCase,与类型、trait 共享一个命名空间。
- 操作是普通函数签名:无体、不得再带效果注记(操作的效果就是它所属的效果本身), 当前也不得带类型参数。
- 操作名进入模块的函数命名空间:与本模块的任何顶层声明(普通函数 / trait 方法 /
另一个效果的操作)同名是重定义错误;与另一处引入的名字(含另一个效果带进来的
同名操作)同名也是错误。
pub effect的操作随use m.{Ask}一起进来, 也可以写m.ask(…)。 - 效果本身不带类型参数;
effect Yield[T]当前不支持。
拼写与传播
fn sum_three() -> Int !Ask = ask() + ask() + ask()
fn logged(x: Int) -> Int !Ask !io = {
io.println("asking")
ask() + x
}
- 具名效果写在效果位,与
!io、效果变量并列;多个注记可以叠写(!Ask !io), 也可以写成并(!(io | Ask))。 !name的判别是查表:name命中作用域内的effect声明 → 具名效果; 否则是效果变量(旧行为)。首字母大写却查不到 → 报「unknown effect」, 因为效果变量按惯例是小写。- 发出了却没人应答(既没在签名里声明、也没有 handler)→ 在没人应答的那次调用上报错,
并给出两条出路:签名加注记,或就地
with handle。直接调操作时那次调用就是操作调用; 标签是经函数值或效果多态调用带进来的,报错点就是那次调用,不是被调函数的定义处。 - 只有
pub fn main的标签必须为空——它没有调用者提供证据,「没人应答」的错误落在main的签名上。普通pub fn可以携带pub effect的标签,由调用方继续传播或安装 handler; 出现在公开面的是私有效果时,才由导出面校验拒绝(§3.3)。
with handle
fn demo() -> Unit !io = {
with handle Ask { ask() => 42 }
io.println("${sum_three()}") # 126
}
with handle E { 臂… } 是 with 语句的一种子句形态(§4.10):块的剩余部分在该
handler 的作用域内,与 with x <- f(…) 完全同构,并继承它的全部纪律——只在块内合法、
剩余部分是真闭包、return/break/continue 被拒(诊断点名 with handle)、
? 透明穿过。
- 臂形如
op(参数…) => 表达式,=>与 lambda 同记号(臂本来就是闭包),臂之间换行分隔。 每个声明的操作恰好一臂:少臂、多臂、臂名不属于该效果都是编译错误。 - 一个
handle一个效果;同一效果重复handle是内层遮蔽外层,合法。 - 允许 handle 一个块里实际没发出的效果(无害的死证据)。
handler 局部状态(格子):臂表的开头可以声明若干
var,每个是这次安装私有的 一格可变状态。格子从本次安装的臂与with handle之后的块剩余可达:臂读它写它, 块剩余读它。这是带状态的 handler 把攒下来的东西交回安装点的唯一表面,没有return臂 一类的第二条路。三条附带规定:格子必须全在臂之前(混在臂之间是编译错误,有专门诊断, 免得读者去猜声明顺序有没有意义);类型标注强制(臂表的花括号里没有可推的上下文); 这里声明的是位置而不是新写法(var与赋值语句都是 §4.1 那两条,格子只是给它们添了一个 合法的声明位置)。格子本身没有可拼写的类型,用户拼不出它。with handle Emit { var acc: List[Int] = [] # 格子:注解必须写,且必须排在臂之前 emit(n) => { acc = acc ++ [n] } } body() acc # 块剩余读它,这就是交回表面安装点之前的
var在其后碰不到(§4.10 的按值捕获纪律在这里的实例):安装点 之前声明的每个var,在with handle之后的块剩余里既不能赋值也不能读; 安装点之后声明的var完全正常。诊断点名with引入的那个闭包,并给两条出路: 先let一个快照,或者把它作为参数传进来。臂本身也是闭包,同样拒绝捕获外层的普通var、 拒绝赋值给它。例外只有本次安装的格子:上一条声明的那几个var,臂可读可写, 它们不是被捕获进来的外层绑定,而是这次安装自己的状态。var n = 1 let base = n # 先 let 化;此后再读 n 会被拒 with handle Ask { ask() => 42 } var acc = base + ask() # 安装点之后声明的 var 正常- 类型规则:设剩余闭包的效果是
(base, L)、各臂身体的效果是(base_i, L_i), 则装 handler 的这个块记(base ∪ ⋃base_i, (L ∖ {E}) ∪ ⋃L_i)。 减法就在这个节点上:剩余部分是闭包,运行它的就是这个节点,它的证据也由这个节点供, 所以能把E从行里减掉的只有它。减法与供给是同一次调用,一个没应答过E的 handler 因此拿不掉任何东西。基轴不减:!io不是handle应答得了的。 臂身体自己发的效果(含 io、含别的标签)全部并回块头上—— 臂在装 handler 的地方运行,不在发出操作的地方。 「行失去一个成员」只发生在with handle这一个语法节点上,不进 unification。 ?早退丢状态:?照旧透明地穿过with handle(§4.10),带格子也不例外。 早退时状态丢弃,格子随 handler 帧一起销毁;块剩余里读格子的那行代码本来就不会执行, 所以没有「看起来一定会跑、实际被跳过」的交回表面在骗人。这不是本语言发明的口径, 它是文献里的 local state interpretation(Wu / Schrijvers / Hinze, Effect Handlers in Scope, Haskell 2014),也就是状态 handler 装在错误边界内侧时的标准答案。
use std/io
effect Emit {
fn emit(n: Int) -> Unit
}
fn body() -> Unit !Emit = {
emit(1)
emit(2)
}
pub fn main() -> Unit !io = {
with handle Emit {
var acc: List[Int] = []
emit(n) => { acc = acc ++ [n] }
}
body()
io.println("${acc}")
}
[1, 2]
这就是格子存在的形状:body() 里散落的 emit 按发出顺序攒进 acc,而 body 的签名只说
它发 !Emit,不说攒到哪里去。
词法作用域与供给点
证据(handler 的臂)按词法解析:操作调用绑定到词法上最近的 with handle。
证据随调用进入被调方,闭包在创建点不捕获任何 handler。推论:
- handler 是调用点的,不是创建点的。在 handle 块内建的闭包逃出块外再被调用,
不带着原 handler:它的行里仍有
!E,由调用点在场的 handler 应答,那里没有就报错。 类型说的是「谁得供 handler」,与臂做什么无关;纯臂的 handler 造出的逃逸闭包也是!E。 - 格子逃不出词法域。状态格子只从本次安装的臂与块剩余可达,被任何不属于这次安装的
闭包捕获是编译错误,三种写法各有诊断:臂里手写的 lambda(被许可的是臂,不是臂里再造的
闭包)、
with x <- f(…)之后的那段块剩余(with引入的是另一个闭包,它在声明格子的with handle之外,出路是在with之前把格子读出来)、另一次安装的臂。格子本体也不能 作为值传出词法域,它没有可拼写的类型。与它相对的是普通let:handler 区域里let绑的值 照旧可以被逃逸闭包捕获,这道禁令收紧的只有格子。 - 臂身体里发本效果,绑定到外层的同效果 handler(自己不应答自己);没有外层就是 臂欠标签,照常报错。
- 重入(本档如此):臂拿到的证据是安装点之前那一层的环境,所以一次安装的格子只被
这次安装的臂访问,「读格子、调用、写格子」那个经典的丢更新形状在这一档里构造不出来:
中间那次调用无论怎么绕都回不到同一个格子。嵌套安装各有各的格子,内层攒的东西不混进外层。
这条限定在尾恢复这一档,不承诺多次
resume放开之后仍然如此(放开之后格子须随延续 复制,那是另一次裁决)。 - 臂身体里发别的效果,由安装点在场的 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 的边界。「写出来的具名效果」不再是其中任何一条的理由,各条各有各的判据。
- trait / impl 方法:方法的行是完整的行——效果变量、具名标签、关联效果投影
都可以。变量由这条签名自己引入(§6.3 的隐式引入在这里就算
「已绑定」),不合成证据,字典槽位一字不动;标签在槽位上就是一格类型精确的证据参数;
投影是一格擦除的——哪个证据记录由 impl 决定,边界上只知道有这么一格。
trait Container[C] { fn wrap(c: C, body: fn() -> Int !e) -> Int !e }里!e是 调用者的:同一个wrap一处收纯闭包、一处收!io闭包。 impl 与 trait 的效果变量按位置配对(各自签名里的引入序:行、参数从左到右、返回类型), 拼写不必相同,个数不等是错误。配对、并经这个 impl 的关联效果绑定归约之后,impl 的行必须 被 trait 的行覆盖:纯总是可以(做得比行少不算食言),无关的标签或!io不行;写出来的 标签例外,两边必须恰相等——每个标签是一格隐藏证据参数,是方法的形状而不只是承诺, 少写一个就换了方法的元数。 trait 方法的默认体照普通函数体检查:它能沿变量或擦除的证据格转发(调那个闭包), 但装不了 handler——刚性效果没有名字可供with handle点名。 - comptime / const 初始化:编译期求值不发具名效果,也装不了 handler。
- 写出来的函数类型:任何
TypeRef位置的fn(…) -> T !E都合法,包括参数、返回、let注解、alias目标、record/variant 字段、泛型实参、元组元素、trait / impl 方法的 参数类型。写出来的标签读作「调用我的人得供 handler」,这正是它的运行时含义。 效果变量(fn() -> Int !e)是另一种写法,不是迁移写法:它把行转发给自己的调用者, 收任何行的闭包。!io、关联效果投影(fn() -> Int !C.E)同样合法。 记录字段存得进带标签的闭包,字段的行就是取出来调用时要供的行。 - 函数值:带标签的具名函数可以当值传,行进入值的类型,调用点供证据。
效果操作本身不能:操作调用是「读证据字段 + 调闭包」,没有可取的函数符号,
诊断也是这么说的。写成 lambda(
() => ask())即可。 unsafe_pure只遮 io,不遮标签:标签是证据合成的输入,遮了就断参数。- 效果多态代码能转发、不能装 handler:
with handle E在语法上就点名一个具体效果, 所以 handler 的安装点永远是单态的。签名里的!e只能把行传下去,装 handler 要写出 效果的名字;同一堵墙的另一面是pub fn main的 labels 必须为空。
实现(信息性)
每个 effect E 让 lowering 合成一个普通记录类型(用户拼不出它的名字),字段是各操作的
闭包,末尾多一格 env:臂运行时所在的证据环境,在安装点填好。with handle 构造这个
记录并绑成一个局部,操作调用 = 取字段 + 调闭包,被调臂的证据格取自 env 而不是发出点。
证据有两套约定,翻译点唯一。
具名调用按行逐原子给槽。 签名里写出的每个标签给函数追加一格类型精确的隐藏证据参数, 排在字典参数之后、按效果 id 升序;每个签名内引入的效果变量追加一格擦除的; 每个关联效果投影再追加恰好一格擦除的,排在标签的证据之后、按(主体、trait、成员名) 排序,由 impl 侧的桥接还原成具体证据记录。
函数值恒一格。 不论行写了什么,函数值的运行期元数是「写出的参数 + 一格证据包」,
纯函数值也背一格空包。证据包是不可变链,节点为 (key, evidence, outer),按原子键寻址:
标签、效果变量、关联投影三条轴各自分段取键,所以两条行在一格里相遇是一次 cons、查询是按键
走链,不必重排也不会串位。调用点建包的规则:行里已定的原子(写出的标签、已归约成标签的
投影)各 cons 一个节点,压在本帧已经持有的未定环境之上;行里没有已定原子、只剩一格未定原子
时,包就是那一格,不分配。超集无害:查询按键,多一个节点只多走一步;缺键才是错误。
两套约定在 lift_fn_value 合成的 wrapper 上换手:把具名侧要的每一格按键从函数值那一格包里
取出来。高阶库函数(list.map 之流)走的是「只剩一格未定原子」那条,原样转发,不建包。
这就是并不设吸收的原因(§6.6)。 上面两套约定都从效果行读形状:具名侧读出槽的有无 与个数,函数值侧读出包里该有哪些键。于是任何删掉一个标签、一个变量、一个投影的行等式, 都会让静态的行与运行时的载体失配:签名少开一格,或者建包时少 cons 一个键,而读的那一侧 照旧按键去取,取空就是「effect evidence missing」这条编译器不变式的 panic,落在一份检查 全绿的程序上。§6.6 的吸收禁令是同一件事的静态一侧。
一个备选方案在此显式否决:让运行时在槽位缺失时降级为按标签动态查找,吸收于是只损失 性能、不损失正确性。不采用。它把「行说有的原子,载体里必有它的位置」这条不变式换成一条 回退路径,而回退路径只在恰好缺槽的程序上跑,测不到的那部分正是不健全的那部分;查找也会 从「按键走链」一套语义变成两套。不变式保持原样。
6.6 效果行的等价与规范形
一条效果行由四类原子构成:基轴的 io、效果变量(§6.3)、关联效果投影(§6.5),
以及标签轴的具名效果。pure 是没有原子的行,写作 !();凡是能写 !io 的位置都写得下它
(签名的行、函数类型的行、效果实参、impl 的关联效果绑定),与整条注记省略不写等价。
pure 不是这条行的拼法:它不是关键字,写进行里只会成为一个普通的效果变量,
而效果变量任何行都解得开,所以 !pure 是错误(§6.3)。
行的等价由且仅由以下五条规则生成。
- 并按分量取:两条行的并 = io 位取布尔或,效果变量、关联效果投影、具名标签三个集合
各自取并。于是
|交换、结合、幂等,pure是幺元。多个注记叠写与写成一个并等价:!Ask !io就是!(io | Ask)。 - 规范形取最小形状:基轴分量全空即
pure,只剩 io 即!io,无 io 且只剩一个基轴 原子即该原子本身,否则是这些基轴分量的并;标签集合为空就不带标签层,非空则是这条基轴 行加上这些标签。 - 规范形有确定次序:效果变量按引入序(签名内首次出现的次序,显式绑定者按写下的次序
排在前面),关联效果投影按(主体类型参数、trait、成员名),具名标签按效果 id。这个次序
同时是证据槽的布局次序(§6.5「实现」)。渲染是另一回事:印出来的原子按名字字典序排,
所以
!io !e印作!(e|io)。 - 投影按 impl 归约:主体定下来时,关联效果投影换成该 impl 绑定的那条行,换完按上面 重新规范化。一条 ground 的行里不再有投影。
- 两条行等价当且仅当规范形相等。
没写下的等式就是没有。 尤其是:并里没有吸收律。!(io | Ask)、!(e | io) 都是
不动的规范形,任何把它们等同于 !io 的读法都不是这五条的推论,而是新增了一条规则。
并不区分「占证据槽的效果」与「环境效果」。 io 是环境效果,自己不占证据槽
(§6.5「实现」),这不构成它可以吸收 !e 的理由:!e 可以实例化成占槽的具名效果,
而槽的有无要到实例化之后才知道,并却是在实例化之前算的。这条最容易被反向推翻成
「io 又不占槽,吸掉有什么关系」,所以写成条文而不是注释:吸收的对象是原子,不是槽。
包含是另一条关系,不参与等价。 !io 覆盖 pure、io 与效果变量(含它们的并);
具名标签与关联效果投影则按精确的集合包含判,!io 既不覆盖 !Ask 也不覆盖 !C.E。
两条关系分工明确:
- 签名按包含判。体比签名纯是允许的(§6.2 规则 3),一条签名可以承诺
!io而站在 它绑定的变量上面,证据照样到得了,因为槽由绑定者列表决定而不是由行决定。 - 函数值不按包含判。它没有绑定者列表,它的行就是调用点用来建包的全部依据,所以送进 某个位置时,目标位置的行必须逐原子容得下它的行。向上放宽仍然免费:纯闭包进任何 位置都行,多出来的键没人读;被拒的是「行里有个原子,目标没有」。
行相减是「逐原子」的唯一例外。把一个函数值送进形如 !(e | R) 的参数位时,若 e 是
被调方签名绑定的唯一效果变量,R 是这条行里具体原子(具名标签、io、关联效果投影)
的集合,则把 e 定为 S \ R 并接受,其中 S 是实参的行。写成 !(e | io) 的
参数因此收得下一个既做 io 又抛具名效果的闭包,而在此之前唯一能收下它的拼法是光秃的 !e。
这一步有一条共现前提:被调方签名里提到 e 的每一条行,都必须写下 R 的全部原子。
理由是 !(e | R) ~ !S 的所有解在含 R 的行上取值一致,e 究竟取了哪条,只可能从一条
「提到 e 却不提 R」的行上看出来。签名里没有这样的行时,e := S \ R 是唯一可观察的
答案,取它不损失主型。有这样的行时,取哪条都是替调用者做了一个它没写下的选择,故仍然拒绝,
并在诊断里点名是哪一处出现让这个选择变得可观察。
一次调用把多个函数值实参穿过同一个 e 时,各实参的余量 S_i \ R 取并(这条格上
的最小上界)并进 e 的绑定:既不是第一个实参说了算,也不是最后一个。这与光秃的 !e 参数
一直以来的做法相同,一条参数行的两种拼法不给出不同答案。
这一步有一条配套条件:e 要在每个参数未代换的声明行上找。前一个实参给 e 落下
绑定之后,代换后的行里已经没有变量可找了。
实参的行不必含有 R。S 短于 R 时,S \ R 就是 S 减去它本来就没有的原子,e
照样定为它并接受。这一步安全的理由与「向上放宽仍然免费」是同一条:调用点是按实例化后的
行建包的,不是按实参自己的行;包按键沿链查找,不按下标定位。所以短一截的闭包拿到的是个超集,
多出来的键没人读。
其余形状不变。反过来仍然拒绝:实参行里有个原子而目标位置没有,那是上面「函数值逐原子」 的那一条,本节不动它。行里有两个及以上效果变量也仍然拒绝:余量在它们之间没有主型划分,本 规范不给一条。
7. comptime
7.1 形式
const CRC_TABLE: List[Int] = comptime { crc32_table() }
fn lookup(d: Int) -> Float =
SIN_TABLE.get(d % 360).expect("table covers 0..360")
comptime { ... } 是表达式:编译期由编译器内置解释器求值,
结果作为常量嵌入产物。顶层 const 的右侧隐式处于 comptime 上下文。
7.2 约束
- comptime 代码必须是纯的(可调用任何纯函数,包括本模块与依赖模块的)。
- 结果类型必须常量可序列化——即编译器能在类初始化时把这个值重新造出来:
Int/Float/Bool/String/Unit,以及仅由这些组成的List/元组/record/ADT。 函数值不行。不透明类型与它的目标一样可序列化(§2.7),只有一个例外:std/cursor的Cursor不可以。它的Int是后端各自的字符串表示里的偏移 (JVM 是 UTF-16 码元、native 是 UTF-8 字节),而常量只折叠一次、写进两个后端 共读的 Core,所以折出来的位置至多对一个后端成立。要在编译期算位置,就持有字符串、 在用它的地方走过去。这条例外是 §11「度量永不成为可观察的值」的一个推论,不是它的 全部:渲染那条路同样被堵上了,否则const S: String = to_string(c)会绕过本条、 把度量以字符串形式折进产物(解释器按码点计数,于是那还是第三个答案)。 等游标统一度量后这条例外撤销。Map/Set暂不可以:它们是Array之上的 HAMT,而 comptime 解释器没有Array原语;List能行是因为解释器自带列表表示,不是因为它跑得动std/pvec。 - 求值有步数预算(默认 10⁸ 步,
--comptime-fuel调整),超限报错—— 保证编译必然终止。 - 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)
}
- 后缀
?作用于Result[T, E]:Ok(v)解出v,Err(e)使当前函数 立即返回Err(e)。对Option[T]同理(None提前返回None)。 ?所在函数的返回类型必须是相容的Result/Option(E类型必须一致, 没有自动错误类型转换)。?是Option/Result的表达式级传播简写:它把当前表达式的None/Err分支传播为当前函数的返回。return、break、continue是独立的显式 jump,不属于这套简写。
跨错误类型:写个本地 helper,别等语言给。 ? 要求 E 一致,所以返回
Result[_, HttpError] 的函数不能直接 ? 一个 Result[_, String]。解法是
在边界处放一个 4 行函数:
fn as_http[T](r: Result[T, String], status: Int) -> Result[T, HttpError] =
match r { Ok(v) -> Ok(v)
Err(m) -> Err(http_error(status, m)) }
之后 let rows = as_http(repo_call(...), 500)? 即可,? 接管其余部分。
曾为此加过一个 std 的
map_err,2026-07-19 撤销:dawnop-site 的 102 处跨层match里,94 处靠「?+ 上面这个 helper」就能拆(另有 8 处错误类型本就相同, 连 helper 都不需要),而map_err的全部作用只是把这个 helper 从 4 行缩成 1 行, 一个项目一次。真正解开那 102 处的是?和局部 helper,不是新增的库函数。
? 在 lambda 内从该 lambda 返回,故闭包里的桥同样可以塌。
8.2 不可恢复:panic
panic(msg):打印消息与 Dawn 层栈迹,进程以非零退出。
todo() 等价于 panic("not yet implemented") 且能通过任意类型检查
(返回类型为底类型 Never)。
后缀 !:o! 把 Option[T] 解成 T,None 则 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,于是解包是常态。
- 只作用于
Option。Result用?传播(§8.1)或match。 - 与
./[]同级、左结合:a()!.b()!即((a()!).b())!。 - 行尾可以是
!——它不是二元运算符,故不触发续行(§1.7)。x! != v中!=仍是一个 token(先按最长匹配切分),比较的是解包后的值。 - 确有话要说时仍用
expect(o, "原因")——它就是为此存在。
get/map.get 返回 Option(问询);下标 c[i] 越界/缺键 panic(断言,§4.8;语义由该类型的 Index impl 定,List/Map 如上);
Int 除零(/ 与 %)panic——是 panic 故 catch_fault 不拦(§4.3 数值边缘语义)。
9. Java 互操作
9.1 引入与调用
use java "java.nio.file.Files"
use java "java.nio.file.Path"
use java "java.lang.StringBuilder"
fn slurp(p: String) -> Option[String] !io =
Files.readString(Path.of(p).expect("valid path"))
fn build() -> String !io = {
let sb = StringBuilder.new() # 构造器统一拼写为 .new
sb.append("a")
sb.append("b")
sb.toString().expect("non-null")
}
use java "全限定名"把类引入为:一个不透明类型 + 一个静态方法命名空间。 只读静态字段可访问:Class.FIELD(常量惯例大写,如Integer.MAX_VALUE、Math.PI、枚举常量TimeUnit.SECONDS;小写字段名同样可读,如System.out)读取该字段—— 值按 §9.2 映射(引用类型包Option需!解包,int/double等基本类型加宽), 效果同一切互操作为!io。只读(无Class.FIELD = v);实例字段与非pub静态字段不可访问。.后的成员名可以是任意「词」,包括与 Dawn 关键字同形的名字(in/type/match等): Java 有名为这些的成员,而.之后关键字无歧义、一律按成员名收。故System.in(字段名in是关键字)与obj.type()(方法名type)都能直接写,无需反射绕道。- 成员名按 JVM 声明逐字匹配;大小写不决定成员种类。
Class.member读公开静态字段,Class.member(args)调公开静态方法,value.member(args)调公开实例方法——同名的字段与方法 (Java 的两个命名空间允许重名)由有无(...)后缀消歧。故Math.IEEEremainder(a, b)是 方法调用而非字段读。写错大小写不做折叠(Math.ieeeremainder(a, b)报「没有静态方法」), 静态/实例同样是匹配的一部分:静态方法不能经实例调用,实例方法也不能经类调用。 Dawn 自己的限定拼写优先于 Java 查找:m.C(args)是限定构造器,m是模块别名时整条 按 §10.3 解析(限定常量加(...)仍报不可调用)。 - 构造器统一为
Type.new(args),返回T本身(构造器不会返回 null,不包 Option); 实例方法用.method(args)。 - 所有 Java 调用的效果都是
!io,无例外(理由见 design.md D5)。 - Java 调用的返回值允许直接丢弃(语句位置或 Unit 块的尾位置)——Java API 常返回
this或状态码;「不得悄悄丢弃」规则只保护 Dawn 值。 - 返回值里出现未导入的引用类(如
Path.of的Path)时,值仍可用(自动成为 不透明类型、可继续链式调用);只有要在签名里写出类型名才需要use java导入。 - 类解析发生在编译期反射:JDK 类恒可见;第三方类可由项目
[java-deps]提供 (dawn check/doc/run/test/build),或由--cp <jars>提供 (dawn run/test/build通用,§12.1);需要运行程序的命令在编译与运行时共用同一份 classpath。 LSP 目前仅解析 JDK 类,第三方类在编辑器里报未找到但命令行可编译。 - 嵌套类用点号写:
use java "java.net.http.HttpResponse.BodyHandlers"(不是$——$在字符串里会被当插值)。解析时先整体反射,失败则从右往左把.逐个换成$重试,故任意嵌套深度都能用点号书写;绑定名取最后一段 (BodyHandlers)。嵌套类的泛型方法仍按擦除返回不透明Object(§9.2)—— 如HttpResponse.body()返回Object,取字符串体用String.valueOf(...)桥回。
9.2 类型映射
| Dawn | Java | 方向 |
|---|---|---|
Int | long(接收 int 自动加宽) | 双向 |
Float | double | 双向 |
Bool | boolean | 双向 |
String | java.lang.String | 双向 |
Unit | void | 返回 |
引入的类 T | 该类引用 | 双向 |
Java 方法返回引用类型一律为 Option[T]——null 在边界处被拦下。用 ! 解包(§8.2)
或 match 处理:
use java "java.net.URI"
use java "java.lang.StringBuilder"
pub fn main() -> Unit !io = {
let uri = URI.create("https://dawn-lang.org/spec")! # 方法:包 Option,解包
let sb = StringBuilder.new() # 构造子:不包,直接是对象
sb.append(uri.getHost()!)
println(sb.toString()!)
}
dawn-lang.org
为什么方法包、构造子不包——这不是两套随意的规则,而是各有依据:
- 构造子不包:JLS 保证
new表达式永不返回 null,包成Option是纯噪音。 - 方法包:方法可以返回 null,且编译器无从静态区分。JDK 类不携带运行期可见的
可空性注解——
URI.create(永不 null)与Map.get(真可空)反射出来的注解都是空。 既然分不出,就只能一律包:宁可让!显式承担风险,也不把 null 放进 Dawn。
基本类型返回值不包 Option;short/byte/int 返回自动加宽为 Int,float 加宽为
Float。char 出入参当前不支持;数组走不透明直通(§9.5)。Option 实参传 null
同样不支持(Dawn 侧目前不能把 null 传给 Java)。
9.3 重载消解
按"实参个数 + 静态类型"打分选唯一候选(精确匹配 long/double 优于收窄到
int/float,String 优于 CharSequence/Object);并列最高分或无候选都是
编译错误(错误信息列出候选签名)。函数值实参只匹配函数式接口形参(§9.4);
Dawn List 实参可匹配 List/Collection/Iterable 形参(§9.6);数组实参精确匹配
优于宽化到 Object(§9.5)。
变长参数照 JLS 分两相:先不打包试一轮(相位 1),全部失败才按变参打包 (相位 2);相位优先于分数——分数是逐参求和、随实参个数增长,不分相位会让打包 候选反超精确匹配。可变部分内联铺开,与 Java 写法一致:
use java "java.nio.file.Path"
use java "java.util.List"
use java "java.lang.String"
pub fn main() -> Unit !io = {
let p = Path.of("a", "b")! # 可变部分 1 段:打包成 String[1]
let q = Path.of("a", "b", "c")! # 同一相位,String[2]
let l = List.of("a", "b")! # 相位 1 胜:选 of(E, E)
let m = List.of("a", "b", "c")! # 相位 2:打包成 E[]
let e = List.of()! # 不传可变部分 = 打包 0 个 = 空数组
println("${p.toString()!} ${q.toString()!}")
println("${String.join(",", l)!} ${String.join(",", m)!} ${to_string(e.size())}")
}
a/b a/b/c
a,b a,b,c 0
可变部分同样收 Java 引用(BodyPublishers.concat(head, file, tail)!)。尾部实参逐个对
数组分量类型打分,规则同普通形参,故 SAM 转换(§9.4)与 List 桥
(§9.6)在可变部分内同样可用。传一个现成数组当可变部分(如 String[])走相位 1,
原样传入不重新打包。注意标量不装箱(§9.2),故 Object... 收 String 与 Java 引用,
收不了 Int/Float/Bool;char 出入参不支持,char... 随之不支持。
9.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()
}
- Java 形参是函数式接口(interface 且恰有一个抽象方法,
Object的公共方法不计)时, 实参可传 Dawn 函数值——lambda、命名函数、构造器值均可。仅限接口;单抽象方法的 抽象类不支持。实现会生成一个持有 DawnFnN的普通 adapter class,并在构建期 写入 classfile;native-image 不需要运行时动态类生成配置。 - 匹配:SAM 方法签名按 §9.2 映射成 Dawn 函数类型后做常规匹配;lambda 的参数类型
可从形参播种(与泛型实参推导同一机制)。重载打分时函数值只匹配函数式接口形参。
Dawn 不追踪 Java 泛型实参,泛型 SAM(
Predicate/Function这类)的参数按擦除后 的类型进入 Dawn(通常是不透明Object,只能原样传递);具体类型的 SAM (Runnable、HttpHandler)才有完整体验。 - 行必须不欠证据:可传出的只有纯函数与
!io函数。带具名效果标签、含效果变量、 含关联效果投影的行一律在转换点拒绝,是编译错误而非运行期故障。理由在 §6:证据由 调用点交付,而 Java 从没有 Dawn 栈帧的地方进入回调,没有调用点可交。效果变量也拒收, 因为它可以实例化成带标签的行,而转换只在这里检查一次。 出口:把
with handle E { ... }写进函数值体内,让跨界的行收敛为纯或!io; 诊断会指出这个改法。with handle Log { log(m) => println(m) } Thread.new(() => log("tick")) # 拒绝:行是 !Log Thread.new(() => { # 通过:行是 !io with handle Log { log(m) => println(m) } log("tick") })- 调用时机仍不受追踪:效果系统不管 Java 侧何时调用,Java 可能在任意线程、任意时刻
(包括本次调用返回之后)调用该函数值。这不破坏纯度契约:Java 代码只可能在 Dawn 的
!io调用之下、或无 Dawn 栈的 Java 线程上运行,纯函数与!io函数的签名承诺都未被违反。 - 参数的 null 边界:回调的引用类型参数不包
Option,以T直达;桥接层逐参 检查,Java 传入 null 立即 panic(消息指明回调边界)。与返回值包Option(§9.2) 互补:返回位置的 null 是常态故进类型,回调参数的 null 属病态故 fail-fast。 - 返回值收窄:SAM 方法要
int而 Dawn 函数返回Int时做检查性收窄,超出 范围 panic,不静默截断;要float时按 IEEE 规则收窄Float(可能损失精度, 这是浮点语义而非溢出)。 - Dawn 函数在回调中 panic,以
dawn.rt.PanicError(Error的子类)传给 Java 调用方,不捕获不包装。
9.5 数组:不透明直通;byte[] = 一等 Bytes
数组值与未导入的引用类同待遇(§9.1):可接收、持有、传参——重载打分按数组类型
精确匹配,或宽化到 Object;返回位置照 §9.2 包 Option。但数组(除 byte[],见下)
不可命名(签名里写不出该类型)、不可创建、不可索引;要长度用
use java "java.lang.reflect.Array" 的 Array.getLength(a)。
byte[] 是唯一例外:它就是一等类型 Bytes(§9.5.1)。Java 方法返回的具体 byte[]
(如 readAllBytes/toByteArray/Base64.decode/MessageDigest.digest)照 §9.2 落成
Option[Bytes];Bytes 可写进签名、存进 record、切片/索引/拼接/按内容比较;反向传给
Java byte[] 形参(OutputStream.write、MessageDigest.isEqual 等)直接匹配。
use java "java.nio.file.Files"
use java "java.nio.file.Path"
fn slurp(p: String) -> String !io = {
let bytes: Bytes = Files.readAllBytes(Path.of(p).expect("path")).expect("readable")
decode(bytes, "UTF-8")
}
9.5.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.len、bytes.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 也返回 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.6 List 桥接:Dawn List 直达集合形参
Java 形参声明为 java.util.List / java.util.Collection / java.lang.Iterable 时,
实参可传 Dawn List[T]。零拷贝:桥接处套一层不可变视图
(Collections.unmodifiableList),Java 侧的变异方法抛
UnsupportedOperationException——同 Scala asJava / Clojure 持久集合的约定。
- 元素类型
T限:Int/Float/Bool/String/ 已导入或不透明的引用类。 元素是List/Map/Set/ADT/元组/record/函数值时拒绝(编译错误)——嵌套容器 零拷贝会泄漏内层可变性;当前不做深包装。 - 元素按 §9.2 的装箱表示直达(
Int→java.lang.Long)。泛型擦除意味着期待List<Integer>的 API 会在取用时ClassCastException;当前不额外修复,选 API 时留意。 - 方向仅 Dawn → Java;Java 返回的集合仍是不透明引用 +
Option(§9.2),可链式调用。Map/Set桥接当前未提供。
9.7 限制
不能继承 Java 类;不能以命名类形式实现 Java 接口——函数值经 SAM 转换(§9.4)
传出是唯一路径。只读静态字段可访问(Class.FIELD,§9.1)——枚举常量与静态常量直接读
(TimeUnit.SECONDS、Integer.MAX_VALUE);写静态字段、实例字段仍不支持。数组不可创建/索引/命名(§9.5);
Map/Set 不桥接、Java 集合不反向转换为 Dawn 值(§9.6);Option 实参传 null
不支持(§9.2)。
9.8 外部失败屏障:catch_fault
这个内建到 v0.30.0 为止叫
java_try,v0.31.0 改名。它拦的是 fault——外部世界 造成的失败——而这个分类自 native 有了失败种类之后就是两个后端共用的 (`native-backend-plan.md` §14.9),和 Java 无关了;名字比理由多活了一阵。 用旧名字会得到「java_tryis not a builtin; renamed tocatch_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_fault[T, !e](f: fn() -> T !e) -> Result[T, ForeignError] !io。被护闭包的行 是一个效果参数!e:闭包可为纯函数,可为!io,也可带标签或效果变量,只要那些 效果在屏障之外有 handler 答。屏障自己的行仍是!io,因为它把接住的失败当成值交 回去,而观察失败的人不纯(`docs/audit/error-model-design.md` §7.3、§7.4)。 - 只拦
java.lang.Exception及其子类;Error不拦——Dawn 的 panic (dawn.rt.PanicError是Error子类)原样穿透,panic 仍然是 bug、不可恢复。 Err载荷是ForeignError——一个 prelude record,字段与取值见 §9.8.1。它到 v0.32.0 为止是一句渲染好的字符串(Throwable.toString()),本节也曾建议 「需要区分异常种类时按前缀匹配字符串」;那条建议已被撤销,kind是它的替代物。- 边界之内失败照常传播:
catch_fault包住整段复合调用即可,无需逐调用包裹。
配套的 catch_panic[T, !e](f: fn() -> T !e) -> Result[T, ForeignError] !io(同一形状,
同一理由)拦的是Dawn panic(PanicError)与 Exception 两类——不是任意 Throwable:
VirtualMachineError(堆耗尽、栈溢出)穿透,资源耗尽不是一个值。它用于监督边界——
服务器的单个请求、任务 runner 的单次执行:一个请求 panic 应变成 500 并记录,而非掀翻
整条连接或进程。它与 catch_fault 分工明确:catch_fault 处理预期外部失败、放
panic 穿透;catch_panic 是隔离点。普通业务失败仍走 Result,别拿 catch_panic
当常规错误处理。
这条分工与后端无关。 JVM 从类层次白拿它(
Error对Exception);native 没有异常,一切失败走同一条longjmp,所以失败带一个种类、handler 记住自己 收不收 panic。判据是同一条:语言自己定义的失败是 panic(panic、expect、 越界下标、除零、非法码点),外部世界造成的失败不是(io 原语——量下来也只有 这一类)。两个后端的实测比对在scripts/spike-native/catch_kinds.dawn; 在它写出来之前 native 的catch_fault拦下了本该穿透的每一个 panic。
9.8.1 载荷 ForeignError
「按前缀匹配字符串」曾是本节的建议,现已撤销:它把控制流建在一句可以被重构、被 本地化、被换一版 JDK 改掉的文本上。载荷是一个 prelude record:
type ForeignError = { kind: String, message: String, cause: Option[String] }
kind是后端自己给这类失败起的名字,而且是个名字不是一句渲染:JVM 上是 二进制名(getClass().getName(),如java.lang.NumberFormatException、dawn.rt.PanicError),native 上是运行时的失败种类("panic"/"fault")。- 按
kind分流的代码是后端相关的代码。 可移植的匹配只有Ok/Err这一层。 取值表与理由在docs/runtime-intrinsics-design.md§12.4。 message是失败自己说的话(JVM 的getMessage(),无则空串);cause是底下那层 失败的渲染,没有则None。不带栈:渲染栈的代价要付在每一次屏障上。
载荷契约(对每个后端):
message没有长度上限,逐字节等于失败发出时的那个 String;语言自己发出的失败 (panic(m))两后端逐字节相同。设上限就得选一个数字,而任何数字都会被某条合法消息 越过;截断还得处理字符边界——所以是「没有截断条款」而不是「上限较大」。message是良构 UTF-8。这是 String 不变式(§4.8)的直接推论,单独写出来是因为 它曾被违反:按字节截断的载荷把切点落在字符中间。- 一个失败的载荷属于将要接住它的那个屏障:从 raise 到该屏障造出
ForeignError(或bracket交给下一个屏障)为止,其它任何屏障读不到也写不到它。嵌套捕获因此 不影响正在传播的失败(§9.8.2 保证 2 的展开)。 - panic/fault 这个二分是可观察的——由哪个屏障收得下体现,不由
kind字符串体现 (kind的取值仍是后端自有)。
屏障只有这一对(拦失败的只有这一对;§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
第三个参数叫 body:use 是关键字,bracket(r, close, use: f) 这个调用写不出来,
所以那个拼写从来没有调用方,只在渲染出的签名里出现。
let f = FileOutputStream.new(path) # 取资源:普通代码,在调用之前
bracket(f, s => s.close(), s => write_all(s, bytes))
资源是个值,不是 acquire 闭包。 Haskell 的 bracket 收 thunk 是为了堵住
asynchronous exception 的窗口——别的线程或定时器可能在「取到」和「装上 handler」之间打断它。
Dawn 没有这种东西:失败只从程序自己调用的代码里发出,而实参求值与本内建装 handler 之间
不跑调用方的任何代码,所以 thunk 堵不到任何窗口(Koka 的 finally 同理,也是直接收资源)。
取资源因此是调用之前的普通代码——那里失败无需释放,因为还没取到。
它在最后是给 with 留的路(§4.10,2026-07-31 落地):那个糖把「剩下的块」当
最后一个实参附加,把它放在中间的原语就得改拼写。资源提前取之后,那种站点一个
lambda 都不用写:
with f <- bracket(open(path), close)
...块剩下的部分就是 body...
三条保证:
release每条路径恰好跑一次——body正常返回、panic、fault,三条都跑,且只跑一次。- 原失败原样继续传播:
kind/message逐字不变,panic 仍是 panic、fault 仍是 fault。 所以catch_fault依然不拦一个穿过 bracket 的 panic(native 侧靠 re-raise 复原那个 种类位,不是重新推断;两个后端的实测比对在scripts/spike-native/bracket.dawn与bracket_fatal.dawn)。release内部自己接住的失败不影响正在传播的那个—— release 里 raise 并咽下一个新失败是合法代码,穿越中的失败原样出去 (scripts/spike-native/bracket_release_fails.dawn)。release自己逃逸一个 失败(raise 而不接)则顶掉原失败、无 suppressed 链:这是两后端的现状,写在这里 使它成为裁决而非巧合。 bracket不拦任何东西,故返回B而非Result——护与拦是两件正交的事 (Haskellbracket、Kotlinuse、Kokafinally、Godefer无一返回 Result)。 要把失败拿成值就写catch_fault(() => bracket(...)),两个原语各做一件事。- 效果行是变量
!e:release、use与整个调用共用同一行,bracket自己不加任何 效果。所以纯资源的bracket是纯的,!io的是!io的,带标签的把标签原样传出去。 §9.8 那对屏障同样绑一个效果参数(catch_fault[T, !e]),所以「被跑的闭包行是自由的」 这一点两边一样。剩下的真差别是这一行落在谁的签名上:bracket自己的行就是那个变量, 屏障自己的行钉死!io,因为它们把接住的失败当成值交回去。理由见 `docs/audit/error-model-design.md` §7.3、§7.4。
它不给
defer那样的面语法:受保护的区间恒为一次闭包调用,所以return/?/break在语言层面就跨不出去,编译器也就不欠一套逃逸改写。 判据与被推翻的旧结论见docs/core-move2-design.md§2.6 与 §6。
10. 模块系统
10.1 文件与模块路径
一个 .dawn 文件 = 一个模块。模块路径 = 相对模块根的路径去掉扩展名:
<root>/json/lexer.dawn → 模块 json/lexer。路径每段须匹配 [a-z_][a-z0-9_]*
(与文件名一致),否则编译错误。
模块根的确定:
- 目录模式
dawn check|doc|run|test|build <dir>:根 =<dir>/src,入口 =<dir>/src/main.dawn(缺失则报错并给出预期路径)。 - 文件模式
dawn check|doc|run|test|build <file.dawn>:从该文件所在目录向上找最近的名为src的祖先目录作为根;找不到则根 = 文件所在目录。LSP 用同一条启发式,故单独打开 一个子模块文件也能解析它相对根的use。
目录约定即工程定义:模块根、入口、模块路径全部由目录结构决定,不需要清单文件。
项目可选带一个 dawn.toml,只承载目录约定表达不了的东西——工程身份与依赖。
没有它的项目按上述规则照常工作。schema 1 的内容:
schema = 1 # 必须是第一个 key
name = "backend_dawn" # 工程身份([a-z_][a-z0-9_]*)
[java-deps] # Maven 依赖,`use java` 用得到
sqlite = "org.xerial:sqlite-jdbc:3.36.0.3" # 精确坐标;禁 SNAPSHOT、禁版本区间
[deps] # Dawn 源码包:别名 = 本地目录
web = "../packages/web"
[deps.json] # 或远端归档(zip / tar.gz)
url = "https://github.com/dawnop/dawn-lang/archive/refs/tags/v0.7.0.zip"
version = "1.0.0" # 严格 x.y.z;版本求解 = MVS
hash = "d1:<sha256>" # 解包文件树的内容哈希,即包身份
subdir = "packages/json" # 归档内的包根(可选)
[deps] 的别名只是本方源码的拼写(use <别名>/<模块>);包的身份是它自己
manifest 里的 name——类名命名空间、版本求解、全程序一名一份都按真名进行,
别名引入会在装载时规范化为真名。dawn add <坐标|url|路径> 可代写这些条目
(抓取并计算 hash,保留手写格式)。
dawn check|doc|run|test|build 会拉取 [java-deps](含各依赖包声明的,取并集),供
编译期 use java 使用;check/doc 的每个 target 使用独立 classpath,
run/test/build 则把它与 --cp 合并后同时用于编译和运行。dawn build 另把
[java-deps] 复制进 jar 同级的 lib/。
仓库地址走 $DAWN_MAVEN_MIRROR,不进 manifest。
manifest 永远是数据,不是代码——不存在可执行的 build.dawn。理由与完整设计见
`package-design.md`。
10.2 引入
use json/lexer # 整模块引入;别名 = 末段 lexer,限定访问 lexer.next(...)
use json/lexer as jl # 显式别名,限定访问 jl.next(...)
use json/value.{Json, render} # 选择性引入,非限定使用
use java "java.lang.Math" # Java 互操作(§9),形式不变
- 一个模块的整模块引入与选择性引入各至多一次,两者可并存——前者绑别名、 后者绑名字,是两件事。同一形式出现两次是错误(两条选择性引入即使括号内名字不同也算)。
use可出现在顶层任意位置(与use java一致),dawn fmt不重排。as重命名:整模块引入可用use a/b/c as name显式指定别名(默认别名 = 末段)。as是上下文关键字(只在整模块路径之后特殊,不是保留字,仍可作普通标识符);只用于 整模块引入,选择性引入不需要。两个整模块引入若末段同名,用不同as别名即可并存 (否则末段同名 → 错误)。
10.3 名字解析(消歧规则)
- 整模块引入的别名与本模块顶层声明、局部绑定、参数同一命名空间:
声明任何与模块别名同名的顶层 fn/type/const、局部或参数都是编译错误
("
lexershadows the imported modulejson/lexer")。由此lexer.next(x)永不歧义——lexer要么是一个绑定(走 §4 的 UFCS 点调用),要么是模块别名(限定访问),不可能两者兼是。 - 限定访问支持表达式位置的
alias.fn(args)(调用一个 pub 函数),以及类型位置的alias.T[...](LANG-06,2026-07-30):类型位置的小写名只可能是模块别名,故无歧义; 可指向导出的 ADT/record 或pub alias(后者按声明方的解析展开)。 模式位置同样支持限定构造器m.C(..)/m.Pt { .. }:模式里的小写名后跟.大写名只可能是模块别名。限定常量m.NAME与限定构造器m.C(..)/m.C(表达式位置) 亦可用(同日):本模块可以另有同名的常量或构造器,两者互不干扰——Core 里常量的身份是 (声明模块, 名字) 而不是名字;限定构造器与不带限定的写法进的是同一段检查,故字段名、 arity、spread、泛型实例化的行为完全一致。 - 选择性引入一个
type同时引入其全部构造器(与pub type导出构造器+字段的规则一致)。 - 选择性引入的名字与本模块顶层声明或其他引入冲突 → 错误。与 prelude 名冲突:
选择性引入可以遮蔽 prelude 名(你逐字要来的名字,意图明确),包括 prelude
trait 的方法名;顶层 fn / trait 方法 / 效果操作也可以遮蔽 builtin、std 与
prelude trait 方法(Rust 式)——解析序是本模块声明 → std → 内建。
遮蔽只作用于这个拼写:被遮蔽的拼写在该模块不可达,这是声明者自己的选择。
运算符与糖(
==、${...}、for..in)按 trait 找 impl、不经名字,因此 不受遮蔽影响——遮蔽show不会改变${x}的输出,遮蔽iter_next不会 改变for..in的行为。 但内建与 prelude 的类型 / trait 名(Map/Option/Ord…)仍不可重定义。
10.4 可见性
所有声明默认模块私有;pub 导出 fn/type/alias/const/trait/effect
(pub type 连带构造器与字段,见 §3.3)。
访问或引入非 pub 项 → 错误(`parse` is private to module json/parser,附 hint:加 pub)。
导出的声明内部也不得泄漏模块外无法命名的私有 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 编译单元与求值顺序
- 目录模式加载
src/下全部.dawn文件(不止use闭包):未被引用的模块也要通过 类型检查(防 bit-rot),其 test 块也被dawn test执行。 use依赖图禁止成环,报错打印环路(json/a → json/b → json/a)。- 类型检查与 comptime 求值按依赖拓扑序进行;跨模块引用的
const值在使用方求值前已就绪。 - 类型同一性:同一
type声明在整个程序中是同一个类型(每个文件只解析/检查一次)。 - 入口:main 模块的
pub fn main() -> Unit !io。
10.6 捆绑标准库与 prelude
标准库以 Dawn 源码随编译器捆绑,组织为真模块(`stdlib-naming.md`):
std/str、std/fmt、std/bytes、std/io、std/list、std/map、std/set、std/cursor、
std/char。
另有两个内部模块 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.len / bytes.len),
由限定或选择性引入消歧。
prelude 是其中隐式可用、无需 use 的高频核:List/Option/Result 的构造器、
println/print、map/filter/fold、sort 族(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_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 / parse_int_radix 的接受语言是这段
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_radix 用 radix 产生式:rdigit ∈ 0-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_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]) 由字符组装;str.len 是码点数,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
用 RFC 4648 section 4 的标准字母表并以 = 补齐,to_base64_url 用 section 5 的
url/文件名安全字母表且
不补 =;两个解码器各只认自己的字母表(猜字母表会把拼错的输入变成错的字节),padding
可有可无,但末组的空余低位必须为零——否则同一串字节会有多个拼写。这一族的解码器都属判据 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.size(b: Buf):bytes.len 已经是成品
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,会失败的一律回 Result[T, ForeignError](§9.8.1)——
结构化载荷而非屏障渲染好的那句话,理由见
`audit/error-model-design.md`。另有以下规范性行为:
io.write_file(path, content)自动创建缺失的父目录。Ok不带值io.list_dir(path)的条目名按码点序排序。排序在 std 层做(list.sort走语言自己的Ord[String]),io_list_names原语不承诺顺序——各后端不再各排各的。path 不是目录时Err,kind是"io.not_a_directory"——它与io.run的"io.no_program"、io.delete的"io.invalid_delete_path"是 std 自己铸的三个 kind,其余都是后端给的io.delete(path)回Result[DeleteOutcome, ForeignError]:删掉文件或空目录是Ok(Deleted),路径不存在是Ok(NotFound);非空目录、权限等其他 host refusal 一律 是Err,且从不递归删除。空串与以/结尾的 path 在 std 层直接拒绝,kind为"io.invalid_delete_path",不会交给后端自行规范化;含 U+0000 的 path 同样是Err, 绝不能把 NUL 前的前缀当成目标io.exists(path)、io.is_dir(path)、io.is_symlink(path)都是 Bool 查询:路径不存在、 类型不匹配或 host path 无效都回false。特别地,含 U+0000 的 path 不得 fault,也不得把 NUL 前缀拿去查询;三者都直接回falseio.getenv(name)在变量未设置或 name 不能由 host 环境 API 表示时回None。特别地,含 U+0000 的 name 不得 fault,也不得查询 NUL 前缀;它直接回Noneio.stdin_ready(timeout_ms)只答一件事:此刻是否至少有一字节可读。 输入结束不算就绪——写端已关和「连着但静默」给同一个false,两者的区别由io.read_stdin报告,它仍是唯一的读者。故只靠它驱动的循环会在输入结束后空转: 没活干的时候必须去做那次阻塞读(selfhost/src/lsp/server.dawn的读循环是这个形状)。timeout_ms是上界不是下界:提早回false一律合法(常规文件到达末尾会立刻回), 因为拿到false的调用方唯一能做的就是别等了;true才是不许猜的那个。 取这个形状是因为它是两个后端都不起线程就能实现的唯一一个:「读不会阻塞」那种读法要 在输入结束时答true,而 JVM 分不出那个状态与静默(available()两边都是 0), 起后台读线程又会替继承了 stdin 的子进程把字节吃掉(实测见 `docs/audit/lsp-robustness-design.md` §2.2.1)读进来的字节不是 UTF-8 时:操作系统给的字节没有任何东西保证它是 UTF-8,故每个把字节 变成
String的读取原语都必须表态。分两类,且两个后端一致:io.read_file失败(Err,即屏障接得住的 fault)。读文本读回来的却不是盘上那段 文本,正是这条规则要挡的事;输入本来就不是文本时该用io.read_file的字节孪生io.read_bytes,它一个字节都不看io.read_line/io.cwd/io.getenv/io.list_dir/io.temp_dir/args替换:非法序列换成 U+FFFD,规则逐字沿用bytes.decode_utf8_lossy(一个非法序列一个 U+FFFD,不是一个字节一个)。文件名不是 UTF-8 仍然是个文件名,拒绝它等于让程序连目录 都列不了- 由此得一条不变式:
String里没有非良构的 UTF-8。这不是修辞——它挡的是 overlong 编码顺流而下、被下游按码点走的原语当成真字符 (`stdlib-impl-notes.md`)
数学(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.c | C 翻译单元。隐藏子命令:这是 JVM 工具链上的 C 后端入口,dawnc 的自举与差分都经它 |
C 后端驱动 dawnc(单文件静态可执行程序,随 release 发布;不需要 JVM,也不需要
本仓库):
| 命令 | 产物 |
|---|---|
dawnc check <target>... | 只做类型检查,聚合全部 target 的诊断 |
dawnc emitc <target> [-o out.c] | C 翻译单元(配 runtime/c/ 的运行时一起编) |
dawnc build <target> [-o out] | 前一步 + 调 cc($CC 可覆盖),独立可执行文件 |
dawnc run [--std <dir>] <target> [-- <program-args>...] | 同上,编完直接执行 |
dawnc test <target> | 编译含 test 块的变体并执行 |
dawnc fmt / doc / add / lsp | 与 dawn 的同名子命令输出逐字节一致(scripts/native-cli-diff.sh 把这四件钉在 JVM 的字节上) |
dawnc version | 版本号(自报 (native),故不与 dawn --version 逐字相同) |
两个 lsp 子命令共用同一份 stdio framing。头部从首字节到 CRLF CRLF 最多
8192 bytes:终止符恰好落在第 8192 byte 合法,到达上限仍未完成则立即拒绝。body 最多
67108864 bytes。每个非空 header 行必须是 1*tchar ":" field-value;tchar 是 ASCII
字母、数字,以及 ! # $ % & ' * + - . ^ _ | ~ 和反引号,所以无冒号、空字段名、字段名中的
空格/括号等都属于 framing failure。语法正确的未知 header 可忽略。字段名 Content-Length 仅按 ASCII
大小写不敏感比较;字段值只能是两端
可有 SP/HTAB 的一个或多个 ASCII 十进制数字。空值、符号、小数、指数、下划线、Unicode
数字、Int 溢出与超出 body 上限都非法;0 合法。重复字段仅在每次独立解析所得数值相同
时合法,故前导零不构成冲突;任一副本非法或数值冲突都拒绝。
body 必须是严格 UTF-8。完整且有界的 frame 若 UTF-8 或 JSON body 解析失败,server 回
-32700、id: null,随后继续读下一帧;替换式 UTF-8 decoding 不得把非法 wire bytes
修成合法 JSON。其他 framing failure(畸形/部分 header、部分 body、缺失/非法/冲突/超限
长度、header 超限)统一只回一次下面的 parse error,并关闭读循环;失败后的字节不得再
解释成新帧。零 header byte 的 clean EOF 静默退出。header 语法与长度必须在调用底层
stdin read 之前完成上述校验。
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}
两个驱动各自解析 argv,但 target 基数是一份共同契约(TOOL-04):
| 子命令 | target / selector 基数 |
|---|---|
check | 1..N 个 target |
test | 恰一个 target,或 --stdlib;两者异或 |
doc | 恰在“一个 target / --stdlib / --builtins”中选择一项 |
build | 恰一个 target |
emitc | 恰一个 target(JVM 入口拼作隐藏命令 __emitc) |
fmt | 1..N 个 target |
缺参、多 target 或 selector 冲突属于用法错误:在开始加载 target 或生成后端产物前退出 2;
两个驱动对同一错误使用相同诊断字节。check 与 fmt 的 N 没有人为上限;写两个 target
不是“最大为二”,而是证明它们确实是批处理命令。
run 的 argv 使用显式双名字空间(TOOL-03):
dawn run [compiler-options] <target> [-- <program-args>...]
compiler option 只在 target 前解析。没有程序参数时可省略 --;run target 与
run target -- 都向程序传空 argv。target 后若还有 token,第一个必须是 --,否则 stdout
为空、stderr 恰为下列一行并退出 2:
error: usage: dawn run [compiler-options] <target> [-- <program-args>...]
分隔符自身不转发;其后的 token 不再解释,逐字原样进入程序的 args(),包括空串、--、
--comptime-ffi 与 -o。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> 换标准库源。
参数可为单文件或工程目录(§10.1):目录模式加载 src/ 全部模块,入口
src/main.dawn;单文件模式向上找 src 祖先为根。jar 收全部模块类,Main-Class = 入口
模块类 main。
第三方 jar:--cp <jars>(run 的 target 前、test/build 通用;路径分隔符分隔、可重复)。
编译期 use java 解析与运行期加载共用这份 classpath。build 把各 jar 记入 manifest
的 Class-Path(相对产物目录,jar 挪走要一起挪),产物仍 java -jar 直接跑;
build --native 改以 -cp 形式调 native-image(第三方库的反射/JNI 是否过
native-image,责任在库,见 §12.3)。
--cp 本身不做依赖解析:它要什么就挂什么,传递依赖得自己列全。需要依赖树的库走
[java-deps](§10.1 的 dawn.toml)——那条路径经 coursier 解析 Maven 传递依赖。本节曾写「Dawn
无依赖解析——只接受单 jar、零传递依赖的库」,那是 [java-deps] 出现之前的事实,
与 §10.1 直接冲突。
保证:同一程序在三种产物下行为一致——JVM 上跑的字节码、--native 出的 native-image
二进制、C 后端出的可执行文件(除启动时间与内存占用)。带 use java 的程序只有前两种
产物,那是它自己声明的边界,不是不一致。
12.2 字节码映射
| Dawn 构造 | JVM 实现 |
|---|---|
模块 json/lexer | 一个类,内部名 json/lexer(包 json、类 lexer),函数为 static 方法 |
| ADT/record | 类名带模块前缀:json/lexer$Token、构造器 json/lexer$Token$Num |
| 跨模块调用 | 对方模块类上的 invokestatic;构造器/字段照常(类公开) |
| ADT | abstract class + final 子类;无载荷构造器为单例 |
| record | final class + 字段(不依赖 Java record,兼容旧字节码目标) |
match | instanceof 链 + 字段读取(不用 indy、不用 pattern switch) |
| lambda/闭包 | 每个 closure 生成一个实现 FnN 的普通类,捕获值存入 final 字段;SAM 转换另生成持有该 FnN 的 adapter class |
| 泛型 | 擦除 + 装箱 |
| 结构相等类型 | ADT/record/元组仍生成配套 equals 与 hashCode,但那是给 Java 调用方看的——Dawn 的 ==/hash 是 lowering 按结构展开的 Core 函数(§4.3),Map/Set 经字典走它。有 impl Eq/impl Hash 时这两个方法转发到该 impl |
Int/Float/Bool | 原生 long/double/boolean,仅泛型位置装箱 |
Unit | Ldawn/rt/Unit;——单例引用,占一个槽位,形参/字段/捕获位与别的引用无异 |
Never | Dawn 静态调用使用返回描述符 V。擦除后的 FnN.apply 调用返回 Object,调用方先用 POP 丢弃它再终止。SAM adapter 调 bridge 时按 SAM 返回描述符处理结果:void 不产生栈值,单槽结果用 POP 丢弃,双槽结果用 POP2 丢弃;随后生成 aconst_null; athrow。Never 没有形参、字段或其他存储表示 |
panic | 抛 dawn.rt.PanicError(Error 子类,故 catch_fault 不拦;只有隔离点 catch_panic 拦,见 §9.8) |
运行时支持类(dawn/rt/Lists、Strings、Io、Show、Maps、Tuple*、Fn* 等)
每个程序生成一份,被全部模块类共享。
12.3 提前编译:native-image 契约与 C 后端
两条路都能得到不依赖 JVM 的可执行文件,但它们在编译栈的哪一层分岔,决定了各自的约束。
dawn build --native(GraalVM native-image):走完整个 JVM 后端,拿 jar 去做封闭世界
分析。语言构造保证不产生:反射调用、自定义 indy bootstrap、动态类加载、
JNI(Java 互操作走普通 invoke)。因此 --native 构建不需要 reachability 配置。
若引入的 Java 库自身用反射,责任在库——错误信息会提示这超出 Dawn 的保证范围。
dawnc(C 后端):在 Core 之后分岔,不经过字节码,因此上面那份契约对它无意义——
没有类要被分析,也没有 native-image 参与。代价是它没有 Java:use java 直接拒绝
(§12.1)。产物是 cc 编出来的普通可执行文件,运行时在 runtime/c/。
12.4 尾调用
自递归尾调用保证编译为循环(不长栈)——顶层函数与局部命名函数(§3.1)皆然。 互递归尾调用不保证。判定规则:函数体内对自身的调用处于尾位置(返回位置、 match/if 分支的尾位置、块的末表达式)。
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)
}