小型语言
小型语言(little language)是为特定领域设计的专用语言。通过把代码当作一等数据,用递归数据类型表达语法,再配合解析器与访问者模式,我们可以在宿主语言中嵌入一个优雅的子语言。
把代码当作数据
在 6.102 中,一个 Formula 值可以存入变量、传给函数、像普通数据一样操作。这种「代码即数据」的思想是构建语言抽象的基石。
一等值(first-class value):可以存入变量、作为参数传递、作为返回值、被函数操作的语言实体。
用语言解决问题
一个语言比一个单纯的程序更灵活:它允许用户用领域术语描述问题,而不必关心实现细节。领域特定语言(DSL)分两类:
- 外部 DSL:自定义语法,如正则表达式、ParserLib 文法。
- 内部 DSL:嵌入在宿主语言中,利用一等函数等特性。
音乐语言
本课以一个音乐语言为例:设计一种能生成和播放音乐的 DSL。它展示了从数据类型定义到解析器再到播放器的完整流程。
播放 MIDI 音乐
SequencePlayer 接口提供 addNote 和 start,MidiSequencePlayer 是它的一个实现,负责把音符变成真实的 MIDI 输出。
音乐数据类型
音乐由三种变体构成:
\[ \texttt{Music} \;=\; \texttt{Note}(\textit{duration}\!: \texttt{number},\ \textit{pitch}\!: \texttt{Pitch},\ \textit{instrument}\!: \texttt{Instrument}) \;+\; \texttt{Rest}(\textit{duration}\!: \texttt{number}) \;+\; \texttt{Concat}(\textit{first}\!: \texttt{Music},\ \textit{second}\!: \texttt{Music}) \]interface Music {
play(player: SequencePlayer, beat: number): number;
}
class Note implements Music { /* ... */ }
class Rest implements Music { /* ... */ }
class Concat implements Music { /* ... */ }
组合子模式
基本对象(音符、休止)与组合体(拼接)属于同一类型,客户端可以用统一的方式处理它们。这就是组合子模式(composite pattern)。
音乐记谱法
为了让人能直接书写音乐,我们引入简化的 abc 记谱法。它把音符写成字母,用数字表示时值,用 | 表示小节。
实现解析器
notes 函数把 abc 记谱字符串解析成 Music 对象。它使用递归下降:每个非终结符对应一个解析函数,彼此递归调用。
function notes(input: string): Music {
// 递归下降:解析音符序列,遇到 '|' 分段
// 返回 Concat(Note(...), Concat(...))
}
实现播放器
play 函数把 Music 对象交给 SequencePlayer,按节拍依次播放每个音符。
递归类型上的函数:解释器模式
操作在接口上声明,在每个变体中实现——这正是我们熟悉的解释器模式。
解释器模式的缺点
- 代码分散在各个变体类中,难以一眼看到某个操作的完整逻辑。
- 新增一个操作,必须修改所有变体类。
模式匹配
函数式语言中,递归类型的操作常写成模式匹配(pattern matching):每个分支对应一种变体。TypeScript 没有原生模式匹配,但可辨识联合 + 条件判断可以模拟它。
访问者模式
为了克服解释器模式的缺点,我们引入访问者模式(visitor pattern):
- 访问者实现一个「按类型分派」的
switch。 - 它通过双重分派(double dispatch)工作:每个变体调用访问者的
onVariant方法。 - 新增操作只需新增一个访问者,无需修改变体类。
interface MusicVisitor<R> {
onNote(n: Note): R;
onRest(r: Rest): R;
onConcat(c: Concat): R;
}
interface Music {
accept<R>(v: MusicVisitor<R>): R;
}
class DurationVisitor implements MusicVisitor<number> {
onNote(n: Note) { return n.duration; }
onRest(r: Rest) { return r.duration; }
onConcat(c: Concat) { return c.first.accept(this) + c.second.accept(this); }
}
为什么用访问者?
- 解释器模式:代码按「变体」分组(列优先)。
- 访问者模式:代码按「操作」分组(行优先)。
当操作频繁新增而变体相对稳定时,访问者模式更合适。
关键要点
- DSL小型语言为特定领域设计,分外部 DSL 与内部 DSL。
- 组合子基本对象与组合体属于同一类型,统一处理。
- 解析递归下降把记谱字符串翻译成 Music 对象。
- 解释器操作分散在变体中,新增操作需改所有类。
- 访问者双重分派按操作分组,新增操作更容易。