使用 Lingo 设计、构建和集成您自己的领域特定语言。
领域特定语言 (DSL) 是小型、专注的语言,适用领域较窄。DSL 针对其目标领域量身定制,以便领域专家能够根据其知识和背景将想法形式化。
这使得 DSL 成为强大的工具,与通用语言相比,它在目标领域更具表现力,并且通过提供概念来减轻用户的认知负担,可用于提高程序员的效率。
这篇文章讨论了领域特定语言(DSL)的概念,以及如何使用Go语言开发一个微语言框架Lingo来构建DSL。
DSL的定义和重要性:
通过一个例子展示了使用通用编程语言(如Ruby)和特定领域语言(如AWK)解决特定问题(如汇总CSV文件中的银行账户余额)的差异。强调DSL在提高程序员效率和减少认知负担方面的优势。
考虑在 CSV 文件中汇总不同银行账户余额的问题。下面的示例提供了一个示例 CSV 文件,其中第一列包含账户持有人的姓名,第二列包含账户余额。
name, balance |
您可以使用通用语言(例如Ruby)来解决汇总余额的问题,如下面的代码片段所示。除了下面的代码不太健壮之外,它还包含大量与当前问题(即汇总帐户余额)无关的样板代码。
#!/usr/bin/env ruby |
下面是解决相同问题的AWK 脚本示例。AWK 是一种专门为解决文本处理相关问题而设计的 DSL。
#!/usr/bin/awk -f |
Ruby 程序的大小为 208 个字符,而 AWK 程序的大小为 56 个字符。AWK 程序大约比 Ruby 程序小 4 倍。此外,AWK 实现更稳定,不易出现 CSV 文件中可能出现的故障(例如,空换行符、格式错误的数据字段)。大小方面的显著差异表明,DSL 更专注于解决特定问题,可以减轻用户编写样板代码的负担,并将语言的重点缩小到手头的问题上,从而提高用户的工作效率。
大多数软件开发人员经常使用的某些流行 DSL 包括 用于模式匹配的正则表达式、用于文本转换的 AWK 或 用于与数据库交互的标准查询语言。
设计领域特定语言时的挑战
原型设计、设计和开发 DSL 是一个具有挑战性的过程。根据我们的经验,这是一个探索周期,在这个周期中,你要不断地为想法制作原型,将它们融入语言中,在现实中尝试它们,收集反馈,并根据反馈改进 DSL。
在设计 DSL 时,有许多组件需要实现和改进。从非常高的层次来看,有两个主要组件:语言词法分析器/解析器和语言处理器。词法分析器/解析器是根据语言定义接受输入的组件,语言定义通常通过语言语法来指定。解析/词法分析阶段会生成语法树,然后将其传递到语言处理器。语言处理器会评估语法树。在我们之前看到的示例中,我们运行了 Ruby 和 AWK 解释器,并提供脚本和 CSV 文件作为输入;两个解释器都评估了脚本,并最终得出了所有帐户余额的总和。
诸如解析器生成器之类的工具可以通过代码生成显著减少词法分析器/解析器的开发工作量。诸如JetBrains MPS或 Xtext之类的复杂 DSL 框架也提供了有助于在 IDE 中实现自定义语言支持的功能。但是,即使存在,对构建语言处理器的支持通常也仅限于为 DSL 开发人员必须填写的语言组件生成占位符函数或样板代码。此外,这种大型而强大的 DSL 框架通常具有相当陡峭的学习曲线,因此它们可能更适合更复杂的 DSL,而不是小型、易于嵌入、专注的语言(我们称之为微语言)。
在某些情况下,可能值得考虑通过仅依靠标准数据交换格式(例如 或 作为配置手段)来解决这些问题.toml。.yaml与 .json解析器生成器类似,使用这种格式可以减轻解析器开发工作的一些负担。但是,这种方法在实际语言处理器的实现方面无济于事。此外,大多数标准数据交换格式本质上仅限于以简单概念(例如列表、字典、字符串和数字)的形式表示数据。这种限制会很快导致配置文件臃肿,如以下示例所示。
想象一下开发一个使用乘法*、加法对整数进行运算的计算器+。当在下面的示例中使用像 YAML 这样的数据描述语言时,你可以看到,即使是像这样的小简单术语也1 + 2 * 3 + 5 很难推理,并且通过添加更多术语,配置文件会很快变得臃肿。
term: |
这篇博文主要关注微语言的设计。核心思想是提供一个简单、可扩展的语言核心,可以使用自定义类型和自定义函数轻松扩展;语言可以在不触及解析器或语言处理器的情况下发展。相反,DSL 设计人员只需关注应该集成到 DSL 中的概念,方法是实现接口并将它们“挂接”到核心语言实现中。
Lingo:Go 的微语言框架
在 GitLab,Go 是我们的主要编程语言之一,我们开发的一些工具需要自己的、小型的、可嵌入的 DSL,以便用户能够正确配置和与它们交互。
最初,我们尝试集成现有的、可嵌入和可扩展的语言实现。我们唯一的条件是它们必须能够原生嵌入到 Go 应用程序中。我们探索了几个很棒的免费开源 (FOSS) 项目,例如go-lua (用 Go 实现的 Lua VM)、go-yeagi (提供 Go 解释器,可将其用作脚本语言)或go-zygomys(用 Go 编写的 LISP 解释器)。然而,这些包本质上是集成通用语言的模块,可以在其上构建 DSL。这些模块最终变得相当复杂。相比之下,我们希望获得基本支持,以便将 DSL 原生设计、实现、嵌入和发展到灵活、小巧、简单/易于掌握、发展和适应的 Go 应用程序中。
我们正在寻找具有以下属性的微语言框架:
- 稳定性:对 DSL 的更改既不需要对核心词法分析器/解析器实现进行任何更改,也不需要对语言处理器实现进行任何更改。
- 灵活性/可组合性:新的 DSL 概念(数据类型、功能)可以通过简单的插件机制进行集成。
- 简单性:语言框架应具有足够的功能来提供足够强大的基础,以实现和发展自定义 DSL。此外,微语言框架的整个实现应采用纯 Go 语言,以便轻松嵌入 Go 应用程序中。
Lingo 为基于符号表达式(S 表达式)构建 DSL 提供了基础,即以嵌套列表形式提供的表达式(f ...),其中f可以被视为代表函数符号的占位符。使用这种格式,我们之前看到的数学术语可以写成 S 表达式(+ 1 (* 2 3) 5)。
S 表达式用途广泛,由于其统一性而易于处理。此外,它们还可以用来以一致的方式表示代码和数据。
关于稳定性、灵活性和可组合性, Lingo 提供了一种简单的插件机制,可以添加新函数和类型,而无需触及核心解析器或语言处理器。从 S 表达式解析器的角度来看,实际函数符号与 S 表达式解析基本上无关。语言处理器只是评估 S 表达式并将执行分派给接口实现。这些实现由基于函数符号的插件提供。
至于简单性,Lingo 代码库大约有 3K 行纯 Go 代码,包括词法分析器/解析器、代码转换引擎和解释器/求值器。小巧的规模应该可以理解整个实现。
对 Lingo 本身的技术细节感兴趣的读者可以查看 README.md , 其中解释了实现细节和使用的理论基础。这篇博文重点介绍如何使用 Lingo 从头开始构建 DSL。
更多点击标题