[DSL翻译]通过 JetBrains MPS 的棱镜看代码

155 阅读15分钟

本文由 简悦 SimpRead 转码, 原文地址 medium.com

我将开始一系列文章,来解释 JetBrains MPS,这是一种强大的工具,用于定义和实现领域特定语言(DSL)以及为其创建集成开发环境(IDE)。

我将开始一系列文章,来解释 JetBrains MPS——一种强大的工具,用于定义和实现领域特定语言以及相应的集成开发环境(IDE)。我希望能够找到一种方法,将 MPS 介绍给那些对抽象语法树元编程模型到模型转换以及其他类似“高深概念”完全没有了解的人。

我将选择从“中间”开始讲解,而不是先讨论什么是领域特定语言,甚至也不是从解析(parsing)讲起。我更倾向于专注于一些小的代码片段展开讨论。在这篇首篇文章中,我将带大家从语言开发者的视角来看待它们——更确切地说,如同通过 MPS 的棱镜来观察它们一样(这是我的一种 隐喻表述)。

免责声明

这绝不是关于 JetBrains MPS 的教程,而是对其背后的某些核心理念的一种解释。为了便于理解,这篇文章中的许多内容都进行了(过度)简化。如果您需要更详细的信息,我建议阅读 Sergey Dmitriev 的论文《面向语言编程:下一代编程范式》,以及 Martin Fowler 的文章《语言工作台实战——MPS》。

The prism of aspects.

What is JetBrains MPS?

JetBrains MPS 是一种“语言工作台”(Language Workbench),根据 Wikipedia 的定义:

一种软件开发工具,用于定义、重用和组合领域特定语言(DSL),并为它们创建集成开发环境(IDE)。

为了帮助理解,让我们来看一个例子:Scratch。Scratch 是一种可视化编程语言,在其中,代码并不是以传统的文本形式表示的,而是通过图形化的“积木块”,这些块中有占位符来组织逻辑结构。

Example of code in Scratch.

Scratch 有用于表示条件语句、循环、事件(以及许多其他类型块)的代码块,本质上它是一门功能完整的编程语言。

Fragments of the palette in Scratch.

可用的代码块根据其“语义”被分类为多个组。

那么,如果我们想创建我们自己的“Scratch”,使用自定义类型的代码块呢?例如,我们可能希望定义一个像 SQL 中那样的用于 SELECT — FROM 语句的代码块,或者其他特定领域的代码块。这正是 Blockly 所允许我们做的事情。

在Blockly中,可以定义自定义的代码块,然后用这些代码块来构建程序。图片来源:developers.google.com/blockly/ima…

以下是重点:

  • Scratch 是一种带有集成开发环境(IDE)的可视化编程语言。
  • Blockly 同样是一种带有集成开发环境(IDE)的可视化编程语言,但它还是一个元工具(meta-tool):它允许我们定义可视化语言及其对应的IDE

现在我们可以将 JetBrains MPS 想象成一个功能强大的 Blockly :-)

与 Scratch 和 Blockly 类似,在 MPS 中,代码不是文本,虽然它可能看起来感觉起来像文本。具体细节将在后续内容中进行讨论。

备注

如果你在想为什么需要关注非文本形式的代码表示,简单答案是:为了能够组合语言,即使这些语言具有互相“冲突”的语法。想象一下,在 Blockly 中出现两个语义不同但外观完全相同的代码块。这对 Blockly 来说并不是问题,因为 IDE 知道用户在“绘制代码”时选择的是哪个代码块——它可通过代码块在调色板中的位置来区分。同样的原理也适用于 MPS。

现在暂时忘记那些精美的视觉化内容,让我们专注于传统的 Java 代码。

Structure & Editor

这里是一个简单的 Java if 语句:

if (x > y * 2 + 100 && ! myList.contains(200) || this.isVisible()) {
   myList.add(x);
   myList.add(y);
}
else {
   System.out.println("Condition not met!");
}

当你看到这段代码时,你会想到什么? 我敢说你会尝试理解它的作用,即你会关注它的语义。实际上,if 检查 x 是否大于某个值 并且 myList 不包含某个值 或者 **this** 是否可见。我知道,这段代码意义可能不太明确。

但是如果我们以更抽象的方式来看这段代码呢? 首先,它是一个if语句。

那么,一个if语句“包含”什么呢?

它包含:

  • 一个条件:
x > y * 2 + 100 && ! myList.contains(200) || this.isVisible()
  • 如果条件满足时要执行的语句:
myList.add(x);
myList.add(y);
  • 如果条件不满足时要执行的语句:
System.out.println("Condition not met!");

让我们将其可视化。

如果 condition 成立时要执行的语句被记为 true_block,而当 condition 不成立时要执行的语句被记为 false_block

那么,可以说,每个元素的“类别”(或者说“类型”)是什么呢?

很简单,condition 是一个表达式(expression),true_block 包含的是语句(statements),false_block 也同样包含语句

一个 if 语句总会有且只有一个 condition,但在 true_blockfalse_block 中可以有语句,也可以没有语句。

Voilà!现在我们已经得到了 if 语句的结构。这种结构通常被称为抽象语法(abstract syntax)。

类似 "if statement" 这样的东西被称为一个概念(concept)。其他的例子包括 "while statement"(while 语句)、"variable declaration"(变量声明)、"expression"(表达式)等等。这些概念就像你在 Blockly 中看到的块(block)的种类类别类型

当我们定义好了概念 "if statement" 的结构之后,我们可以开始考虑这种概念在代码中的外观应该是什么样子。我们可以随意选择这种表示方式(称为具体语法,concrete syntax),无论是类似 Java 的大括号语法,还是 Pascal 式的 beginend 语法。这在实际实现中并不重要,因为在 MPS 中,这只是结构的一种美化打印(pretty printing)。

不过这里,我们还是选择使用 Java 的语法样式。

让我们暂时想象一下,我们有一张表格,每一个包含 if 的 Java 表示的“单词”被放在一个独立的单元格中。

在这张表格中,我们可以区分出以下四种类型的单元格:

  1. 包含关键字的单元格(如 **if****else**)。
  2. 包含特殊符号的单元格(如 **(****)****{****}** 以及空格)。
  3. 表示缩进的单元格。
  4. 包含概念结构中元素的单元格,例如条件(condition)、true_blockfalse_block

各类单元格的视觉样式如下:

  • 包含关键字和特殊符号的单元格的背景是白色。
  • 表示缩进的单元格是灰色的。
  • 包含概念结构中元素的单元格是棕色的。

关键字和特殊符号的单元格是“冻结”的:这些内容不可被修改。的确,在 Java 中,关键字 **if** 应始终拼写为 **if**,条件也必须用括号括起来,等等。

程序员唯一能修改的内容是三个占位符(placeholders),分别对应 condition(条件)、true_block(条件成立时执行的语句块)和 false_block(条件不成立时执行的语句块)。

只要我们遵循以上规则,代码将始终是语法正确的。这种机制实际上和 Scratch 非常相似。

在 Scratch 中,"if" 不可能拼错,也不会漏掉 "then"。

这基本上就是 MPS 中 投影式编辑 的工作原理。代码并不是以文本形式表示的,而是一个结构的半图形化美丽打印。但这种美丽打印是可编辑的。你可以把它看作是“比较少可视化的 Scratch”。

多重投影

如果有人更喜欢 Visual Basic 风格的 if 语句语法呢?没问题!在 MPS 中,我们可以为单个概念定义多种语法(称为投影)。

此外,当我们编辑代码时,可以在不同的投影之间自由切换。

编辑结构

编程时,所有的关键词、括号、大括号和缩进其实并不重要。这和 Scratch 非常相似。真正重要的是我们在概念结构中描述的元素:condition(条件)、true_block(真分支)和 false_block(假分支)。

当程序员编写或修改一个 if 语句时,他们实际上是在改变其对应概念的结构。这种操作会从编辑器向结构产生“反馈”。关键词、括号和缩进是无法被修改的——即使可以修改,也没有什么实际意义。

到目前为止,我们从两个“角度”看待了同一个概念——“if 语句”:一个是它的结构,另一个是它的表示。这些被称为语言的方面,在 MPS 中,这两个具体的方面分别被称为(毫不意外地)Structure(结构)Editor(编辑器)

在接下来的部分中,我将讨论更多的方面。

Intentions Aspect

我们先从编辑体验开始:对于一种语言的用户来说,编辑体验至关重要,尤其当语言不是基于文本时。

Intentions(意图)方面允许我们定义快速修复(quick fixes)或意图(intentions),这些功能会在 IDE 中显示给用户。

Example of intentions in IDEA.

对于我们的概念“if语句”,我们可以定义以下意图:

  • 否定条件(例如,从x==3变为x!=3,或者从x>10变为!(x>10),等等)
  • 移除带有false_blockelse子句(这同时也会移除花括号)
  • 交换true_blockfalse_block

让我们再看一下Scratch。假设在“绘制”代码时,我们选择了一个没有else子句的if语句。我们如何在之后添加else子句呢?有关讨论参见此处

在JetBrains MPS中,这类问题可以通过一种语言的Actions方面来解决。它允许指定哪些编辑操作可以改变概念的结构,从而影响其显示投影

例如,当我们希望给if语句添加一个else子句时,我们只需在true_block闭合的花括号后面键入关键字**else**。请记住:MPS中的代码不是纯文本,而是一种具有类文本编辑体验的半图形化投影,因此“仅仅键入**else**”并不能算作有效操作。

在“}”之后键入关键字“else”将会修改概念的结构,并添加一个带有对应语法的**false_block**

这可以对应于类似于Scratch中的操作:假设关键字**else**在一个工具面板中可用,你将它拖动到现有ifthen块的底部,现有代码块将会转变为ifthenelse块。(再次强调,在这种情况下,不能“仅仅键入**else**”,无论是在Scratch中还是在MPS里,这都同样适用。)

Behavior

接下来,让我们重复思考关于概念结构的练习。这次,我们的概念将是“变量声明”。为了简化,我们仅考虑整数变量声明。

因此,一个变量声明具有以下结构:

  • 声明变量的名称
  • 可能的初始值(这个初始值当然也可以是一个算术表达式)

这里有一个小细节:元素name是一个原始(内置)类型,而init_value是一个我们需要定义的expression概念的实例。为了清晰起见,让我们将“原始”元素放在左边,“我们定义的”元素放在右边。

实际上,name是一个概念的属性(property),而init_value是一个概念的子元素(child)。它们之间的区别如下:从某种意义上来说,初始化表达式是“嵌入”在变量声明中的,它是一个不同的概念(即概念“expression”),由我们从“变量声明”概念的定义里“调用”。另一方面,变量的名称是变量“拥有”的某种东西。因此,我们分别有了 子元素(children)属性(properties) 这两个概念。

对于子元素,我们可以指定它们的基数(cardinality)。在这个例子中,init_value可以存在(初始化值)也可以不存在(没有初始化值),因此其基数是0..1。而属性总是存在的,因此对它们而言指定基数是没有意义的。这里我们就不详细展开了。

现在,让我们定义整数变量声明的语法——这里我们稍微偏离传统语法,使用如下的表示方式:

number x equals 10 * 20

接下来是该语法使用细胞(cells)表示时的投影结构(如前所述)。

当程序员在代码中键入**number**时,会自动生成一个带有两个占位符的代码片段:

number _____ equals ______

这与Scratch非常相似,只不过在Scratch中,你会“绘制”一个变量声明,而在MPS中,你仍然是通过键入完成的——更准确地说,是键入它的前导关键字(在我们的例子中,这个前导关键字是**number**;在MPS中,这个前导关键字被称为概念的别名(alias of a concept))。

行为方面使得可以“定制”概念的结构。例如,我们可以设定,当一个变量声明被添加到代码中时,其占位符name会自动预填为abc

现在,当程序员键入**number**时,他们将会得到:

number abc equals ______

只剩下一个占位符(用于初始化值)需要填充。

至此,我们正在逐渐接近最初称为“令人害怕的东西”的那部分内容。

TextGen

假设我们已经在语言中定义了想要的所有概念。现在,我们希望将用我们语言编写的代码:

number carPower equals 10 * 20

转换为比如说Java的代码:

private int carPower = 10 * 20;

在MPS中,有两种方法可以实现这个目标。第一种方法是生成一个包含目标输出的字符串。

这本质上是一种字符串插值(或者如果你愿意的话,可以称之为模板表达式):我们有一些“固定”的部分(例如,**private int**)以及一些可变的部分(name属性的以及init_value)。

关于 **init_value**技巧

如果我们语言中的表达式语法与Java不同,例如,在我们的语言中我们可以将30+20-50写成30 **plus** 20 **minus** 50,那么我们也需要为init_value生成代码(即:“翻译”或“编译”init_value)。在这篇文章中我们不会详细讨论这一点。

无论如何,现在我们已经生成了所需的字符串:

private int carPower = 10 * 20;

这是Java代码吗? 答案既是“是”,又是“不是”。

Yes, but no.

那个字符串看起来像Java代码,但实际上,我们完全可以生成像下面这样的无意义内容:

private variable of type integer carPower := 10 * 20 ;;;

换句话说,当我们生成代码时,我们不会检查生成的内容是否合理:最终,它只是一个字符串。

显然,MPS(Meta Programming System)提供了一种方法来解决这个问题。

在代码生成的第二种方法中,我们可以指定一个正确的目标代码示例。

是的,我们确实在生成的代码中写了 **x** **0**就像它们是硬编码的一样,而此时我们完全忽略了 **name** **init_value**现在这已经是一个Java 代码片段(不再只是看起来像代码的文本!),而MPS可以确保它在语法上是有效的。我们不可能跳过某些部分或拼写错误——否则,MPS会报错,并阻止我们进一步操作。

接下来,我们可以将name“绑定”(或者说“链接”)到x,并将init_value绑定到0

再次提醒,这里同样适用关于init_value的技巧(参见上文)。

我们将 name 关联到 x,将 init_value 关联到 0。在 MPS 中,这看起来会有所不同,但核心思想仍然相同。

我们刚刚看到的是一个模型到模型(model-to-model)转换的例子。在此,我们不会深入探讨更多细节。

Types & scopes

现在,让我们再次重复这个(已经熟悉的)练习:思考“赋值语句”概念的结构。

它包含:

  • 左部分(也就是被赋值的地方)
  • 右部分(正在被赋的值)

A couple of different syntaxes for an assignment statement.

左部分是一个变量,右部分是一个表达式

TypeSystem(类型系统)方面允许我们指定左部分和右部分的类型应该相同。

这是一个简化的例子。事实上,右侧部分的类型应该是左侧部分的子类型。此外,TypeSystem方面的功能不仅仅局限于检查类型,它实际上能做得更多

最后,还有Constraints(约束)方面。这个方面允许对概念的结构进行更精细的调整。例如,可以指定可见性范围(scopes):在我们的例子中,我们可能会希望赋值语句左侧的变量仅限于全局变量或局部变量(而不是类的字段,假设我们的语言有类的概念)。

当程序员编写赋值语句时,赋值语句左部分的自动完成功能将只会建议局部变量和全局变量。

其他方面

在这篇文章中,我们没有提到 MPS 允许为语言指定的几个其他方面,例如 DataFlow(数据流)方面。值得注意的是,在 MPS 中甚至可以定义自定义语言方面!

以下是本文中涉及到的方面的一个图表。

其中包含三个主要“支柱”:

  • 结构(Structure):是语言开发者的核心部分,辅以 行为(Behaviour)约束(Constraints)类型系统(TypeSystem)
  • 编辑器(Editor):是语言用户的核心部分,并与编辑的动作(Actions) 和用户友好的意图(Intentions) 相关联。
  • 代码生成方面:文本生成(TextGen)生成器(Generator)——两个共享相同目标但性质完全不同的方面——是 “计算机” 部分的核心。

感谢芬兰图尔库大学 (University of Turku)就 JetBrains MPS 中的语言方面展开的讨论。