本文由 简悦 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_block 和 false_block 中可以有语句,也可以没有语句。
Voilà!现在我们已经得到了 if 语句的结构。这种结构通常被称为抽象语法(abstract syntax)。
类似 "if statement" 这样的东西被称为一个概念(concept)。其他的例子包括 "while statement"(while 语句)、"variable declaration"(变量声明)、"expression"(表达式)等等。这些概念就像你在 Blockly 中看到的块(block)的种类、类别或类型。
当我们定义好了概念 "if statement" 的结构之后,我们可以开始考虑这种概念在代码中的外观应该是什么样子。我们可以随意选择这种表示方式(称为具体语法,concrete syntax),无论是类似 Java 的大括号语法,还是 Pascal 式的 begin — end 语法。这在实际实现中并不重要,因为在 MPS 中,这只是结构的一种美化打印(pretty printing)。
不过这里,我们还是选择使用 Java 的语法样式。
让我们暂时想象一下,我们有一张表格,每一个包含 if 的 Java 表示的“单词”被放在一个独立的单元格中。
在这张表格中,我们可以区分出以下四种类型的单元格:
- 包含关键字的单元格(如
**if**和**else**)。 - 包含特殊符号的单元格(如
**(**,**)**,**{**,**}**以及空格)。 - 表示缩进的单元格。
- 包含概念结构中元素的单元格,例如条件(
condition)、true_block和false_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_block的else子句(这同时也会移除花括号) - 交换
true_block和false_block
让我们再看一下Scratch。假设在“绘制”代码时,我们选择了一个没有else子句的if语句。我们如何在之后添加else子句呢?有关讨论参见此处。
在JetBrains MPS中,这类问题可以通过一种语言的Actions方面来解决。它允许指定哪些编辑操作可以改变概念的结构,从而影响其显示投影。
例如,当我们希望给if语句添加一个else子句时,我们只需在true_block闭合的花括号后面键入关键字**else**。请记住:MPS中的代码不是纯文本,而是一种具有类文本编辑体验的半图形化投影,因此“仅仅键入**else**”并不能算作有效操作。
在“}”之后键入关键字“else”将会修改概念的结构,并添加一个带有对应语法的**false_block**。
这可以对应于类似于Scratch中的操作:假设关键字**else**在一个工具面板中可用,你将它拖动到现有if—then块的底部,现有代码块将会转变为if—then—else块。(再次强调,在这种情况下,不能“仅仅键入**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 中的语言方面展开的讨论。