Kotlin 17:DSL 嵌套作用域怎样限制误写
任务草稿有 ID、正文和若干标签。普通函数写成 makeTaskDraft(id, text, labels),参数一目了然;改成 taskDraft { ... label { ... } } 后,在最内层 label 块里写 text = "overwritten" 到底会修改标签,还是悄悄修改外层任务正文?此前 14 的作用域函数已经说明接收者与返回值可能不是同一回事,这里让两个接收者同时进入作用域,再用编译反例确认 @DslMarker 的保护范围。
本文实验仍在 Kotlin/JVM 2.2.21、JDK 21、Gradle 8.14 上运行。语法依据是 官方类型安全构造器的作用域控制 和 带接收者函数字面量;具体允许或拒绝哪条语句,以冻结编译器的原始输出为准。
普通构造函数已经解决什么
本章的产物只是值 TaskDraft(id: TaskId, text: String, labels: List<String>)。makeTaskDraft 在构造前检查正文非空、每个标签 trim 后非空,并归一化正文和标签;16 的 TaskId 在实例创建时拒绝空白 ID。现有存储接口、任务工作者和 CLI 都没有因新增这个值而运行。
1 | |
调用方既能看出参数顺序,也能直接定位异常来自哪项校验。函数的返回类型是草稿,不是 SubmissionDecision.Accepted;一次函数正常返回更不能证明后续持久化或后台任务完成。构造结果的 labels 声明成 List<String> 也不是“一切 List 永远不可变”的语言保证,此处 map 新建了列表结构、字符串不可变,但本文不把 DTO 复制推广成所有嵌套对象的深拷贝。
加上带接收者的 lambda,哪一个 this 在工作
taskDraft(block: TaskDraftBuilder.() -> Unit) 创建构造器,用 apply(block) 执行用户提供的 lambda,再调用 build()。外层有 id、text 和 label(block);内层 label 又创建一个 TaskLabelBuilder,其 value 表示单个标签。两个构造器都标上 @TaskDraftDsl:
1 | |
外层 id 暂时是可空属性,只在 build() 时要求存在。内部标签在添加时还没有归一化,最终由普通 makeTaskDraft 统一验证,避免 DSL 和普通函数维护两套规则。省略创建器或增删标签的附加功能,是为了让实验聚焦接收者,而不是用 DSL 把一页配置伪装成语言能力。
标记挡得住误用,却挡不住显式修改
将 text = "overwritten" 写进 label { ... } 的独立负例,冻结编译器退出 1,实际诊断:
1 | |
隐式 text 属于外层 TaskDraftBuilder,内层 TaskLabelBuilder 没有它;两者同属标记 DSL,编译器不再把隐藏的外层接收者直接拿来解析这个名字。这比让作者记住每一个隐式 this 属于谁更可靠,但官方文档也明确了标记解决的是隐式接收者可见性,不是封禁所有跨层访问。
正常测试中,在内层显式写 this@taskDraft.text = " alpha " 仍能修改外层正文;最后 DSL 结果与 makeTaskDraft(TaskId("task-1"), " alpha ", listOf(" urgent ")) 相等,正文为 alpha、标签为 [urgent]。因此 @DslMarker 不是访问控制、深不可变约束或并发隔离;显式限定外层接收者后仍可改变构造中的草稿。需要禁止此类修改时,必须收窄 API/可见性或改变构造方式,不能把标记当业务权限体系。
什么时候不用 DSL
两种写法的测试还分别覆盖缺 ID、空白正文与标签,以及普通函数的空标签拒绝。只构造一条固定形状的任务时,普通函数的三个显式参数更容易在代码评审中看清,并且仍使用同一校验逻辑。若真有层级很多的配置,接收者型 lambda 可以减少重复参数,但层级越深,隐式名字带来的误读风险越高;本例不把“短了几行”当成正确性证明。
在仓库运行 bash examples/kotlin-language/verification/17.sh 可复跑 00–17 十八组测试、旧 CLI 正反和单独的 DSL 错误编译。详细诊断位于 writing-plans/kotlin/verification/17/rejected.txt。下载含 Wrapper 与累计工程/原始输出的快照(SHA-256校验)在同名素材目录提供解包复跑入口。旧 submit CLI 不调用 taskDraft;通过它的回归只能说明此前解析分支没有被改坏。
练习:既然 @DslMarker 使内层 text = ... 编译失败,为什么测试里的 this@taskDraft.text = ... 能运行?前者使用被限制的隐式外层接收者,后者明确点名外层;标记不能把存在的对象变成不可访问。若不需要标签嵌套,能否直接使用普通 makeTaskDraft?可以,它与 DSL 走同一验证函数,而且参数来源更直观。
[PATTERN] 嵌套 DSL 要分别检查隐式能看见谁、显式仍可改谁、最终由谁校验。@DslMarker 解决第一项,收紧构造 API 解决第二项,共用普通校验入口解决第三项;三者不互相替代。
| 路径 | 本篇观察 | 未证实 |
|---|---|---|
普通 makeTaskDraft |
构造并校验归一化值 | 已提交或持久化 |
内层 text = ... |
DSL 标记下编译拒绝 | 对外层对象无任何访问 |
this@taskDraft.text = ... |
显式修改外层并得到等价草稿 | DSL 具备权限边界 |
下一篇讨论任务元数据的注解目标与运行期反射;当前工程没有反射扫描容器,也没有插件生成代码。





