技术分享 | 领域特定编程语言的设计探索

5 月 29 日,由极客邦旗下 InfoQ 中国主办的全球软件开发大会 QCon 2021 北京站正式拉开帷幕。大会为期三天,华为编程语言实验室架构师徐潇作为其中“软件研发方法”专题的出品人,于今日联合多位讲师为大家带来软件开发技术和编程语言技术相关的精彩议题。

技术分享 | 领域特定编程语言的设计探索_第1张图片


徐潇 华为编程语言首席架构师,领域编程语言高级专家,拥有软件领域十余年工作经验。当前主要担任编程语言 eDSL 相关能力的设计工作。

技术分享 | 领域特定编程语言的设计探索_第2张图片

以下为徐潇分享的“领域特定编程语言的设计探索”原文:

领域特定编程语言,简称 DSL,作为在软件开发中不可或缺的一环,在很多领域都承担了很重要的作用。当前出现一个趋势,DSL 的一个分类 —— 内部 DSL(简称 eDSL)—— 在某些领域取代了原有的外部 DSL 或者通用语言,其背后一个客观因素是,现代编程语言的发展,有意识的设计用于实现内部 DSL 的特性,使开发者可以构建面向特定领域的扩展。

我们今天来介绍在内部 DSL 设计上的一些探索和发现,主要分以下三部分:首先我们会介绍 DSL 和内部 DSL 的定义,同时讨论一下实现内部 DSL 有哪些惯用的“套路”;然后我们从三个业界案例出发,看看他们背后的动机以及是实现方案;最后我们有一些下一步的思考。

目录

DSL 的介绍/Introduction of DSL

# 什么是 DSL

# 什么是 eDSL

# 语言的“工具箱”

案例分析/Case Study

# Pulumi

# Chisel

# SwiftUI

下一步的思考/Further Thought


DSL 的介绍/Introduction of DSL

# 什么是 DSL

DSL 是一种针对特定问题的编程语言,它含有建模所需的语法和语义,在与问题域相同的抽象层次上进行建模。如何理解这个定义呢?我们知道与 DSL 相对的是GPL(General Purpose Language,即通用编程语言),GPL 与 DSL 的对比,我喜欢用这两张与建筑材料有关的图来类比:GPL 就像水泥,用水泥可以构建出任意形状的建筑物或中间件,圆的,方的,直的,弯的;而 DSL 像是提前用水泥做好的“预制件”,我们可以直接使用“预制件”,而不用每次都从“搅拌水泥”开始,但“预制件”的用途的特定的,比如像图中的“预制件”更适合做管道,而无法比如做个楼板。

技术分享 | 领域特定编程语言的设计探索_第3张图片

所以 DSL 具有以下特征:

  • 有限的表达能力,你只能用它解决特定领域的问题

  • 使用它提供的抽象,而不需关心底层细节

  • 提供充分的表达能力,可以使不懂编程的用户理解程序的意图

从实现方式上,DSL 分为内部 DSL 和外部 DSL,我们今天主要关注内部 DSL,即 eDSL。

什么是 eDSL

即 embedded DSL,它将一种现有的通用编程语言作为宿主语言,基于宿主语言的基础设施(比如语法,编译器,工具等)建立专门面向特定领域的语义;相比之下,外部 DSL 具有完全独立的语法设计、编译器实现,以及工具配套。

eDSL 的优点是:

  • 可复用宿主语言的语言特性,表达力强

  • 可复用宿主语言配套设施(库生态,编译工具,开发环境等),构建门槛低

  • 无缝嵌入到宿主语言工程中,可以方便的穿越 “Domain” 进行交互

在后面介绍的案例里我们会看到这些优点的具体呈现。这里只说一个“反面的”案例,曾经做过一个项目,选择外部 DSL 的方案,基本上站在这 3 点的对立面,我们在人力短缺的情况下,花了几个月时间才勉强把语言的编译器的基础框架搭起来,而这段时间我们本该用于结合客户需求更好的打磨我们提供的语法和抽象,另外由于其缺乏一些周边能力的配套,导致虽然我们设计的 DSL 有一些亮点,但无法支持真正的商用。当然语言工作台(Language workbench)可以帮助你更快的构建 DSL,但是那种方案有很多局限性,不在这里展开。

当然 eDSL 也有缺点:

  • 语法设计受宿主语言能力的约束,可能会有“噪音”

    噪音可以简单理解为,站在对问题描述或者建模的角度,某些语法是没有领域意义的,但为了遵守宿主语言的语法规范,使代码可以通过编译器的编译,而必须写的代码,通常是一些标点符号,关键字之类

  • DSL 的运行环境需要支持宿主语言的运行

在软件开发中(如下图所示),通常是库/框架/平台的开发者会定义 eDSL,面向下游用户提供的开发方式更具有领域特征,更友好,使用门槛更低;同时我们也看到,当下游开发者的开发方式混合了 DSL 与 GPL 的使用,就是说不能单纯的使用 DSL 来完成其业务实现时,采用 eDSL 方案是一个合理的选择

技术分享 | 领域特定编程语言的设计探索_第4张图片

从 DSL 实现角度,与外部 DSL 相比,其相同点是都要构建面向领域的语义模型,我把它理解为 DSL 的“灵魂”;而不同点是,外部 DSL 需要实现一个解析器,而内部 DSL 主要依赖宿主语言提供的语法和特性,这篇文章我们主要关注点是宿主语言的能力如何支撑我们去构建一门 eDSL。大家都是各自领域的专家,站在各自领域的角度可以去构建出语义模型,那么剩下的问题是,宿主语言通常提供什么公共的能力,让我们可以利用去描述我们的语义模型

技术分享 | 领域特定编程语言的设计探索_第5张图片

# 语言的“工具箱

我们先看看工具箱里有什么工具可以用呢?(包括但不限于)

  • 链式调用

  • 命名参数/默认参数

  • 注解

  • 语法“糖”

  • 操作符重载

  • 自定义操作符

  • 属性机制

  • 类型推导

  • 面向对象OO

  • 类型扩展

  • Collection literals

通常在构建一门 eDSL 的时候,可能会有的需求如下所示,我们可以结合需求和方法来讨论一下可行的解决方案:

> 定义领域对象

我们通常采用面向对象的方式,用类、接口等抽象,用继承、组合、实现等方法去建立一套领域对象系统,有时候对于无法修改的类型,采用类型扩展的机制进行封装,让它融入到这个对象系统里来。

自定义语法

创造一种不同于宿主语言的语法,通常使用宏和自定义操作符的方法,当然建议慎用自定义语法,它本身会增加开发者的学习成本,同时会要求开发工具进行适配。

声明式范式

在 DSL 中,声明式范式是一个非常重要的特性,它让开发者只需要描述 “What”,而不需要关注 “How”;从实现角度,可以采用链式调用、或者嵌套调用结合命名参数和默认参数、或者 Collection literals(比如 List 和 Map)达到“声明式”的效果,甚至可以用去创造一种新声明式语法;同时我们也看到另外一种声明式,即依赖关系的声明式表达,通常可以用操作符重载和自定义操作符的方式去表达不同领域对象之间的关系;语法糖发挥降低噪音的作用;而类型扩展可以让我们定义出类似“1.px”这样带有单位的效果。

> 操作语法树

有时我们希望在编译期改变语法树,目的可能是为了实现自定义的语义,或者进行模板代码的生成,通常我们会用宏和注解这样的元编程的手段。

> 隐藏重复代码

这些重复代码可能是些模板代码,那么我们自然想到用 OO 去实现设计模式中的模板方法模式;也可以采用前面提到宏和注解可以实现代码生成;同时属性机制可以把特定的对数据的读写模式封装在 Getter/Setter 中,向访问者隐藏。

> 降低“噪音”

通常会利用一些语法糖,比如省略标点(,;),省略关键字(new,return,this),简化的 Lambda,以及尾随闭包属性机制使我们可以把代码逻辑放在属性的 Getter() 方法中,有时候可以帮助我们去掉冗余的括号;而类型推到可以减少类型标注。

我们接下来看看,在实际的案例中,具体是怎么应用的。

案例分析/Case Study

# Pulumi

Pulumi 是一个开源的云开发平台,提供“基础设施即代码”的能力;如下图所示,它允许开发者用一套语言完成应用的代码和基础设施的代码,然后可以进行测试、提交、构建、部署,其后端对接不同云的提供商(provider)。

技术分享 | 领域特定编程语言的设计探索_第6张图片

Pulumi 面向开发者提供了一套基于多种语言构建 eDSL ,支持 TypeScript,JavaScript,Python,Go,C# ,有文章称其为云原生编程语言,它通过提供这种开发方式期望给开发者带来以下好处:

  • 熟悉(Familiarity):让开发者可以选择自己熟悉的语言进行开发,而不需要学习新的语言

  • 抽象( Abstraction ):基于宿主语言提供的抽象函数、类、模块等,通过“小”的抽象构建“大”的抽象

  • 可共享和重用(Sharing and reuse):基于构建的抽象,支持不同代码粒度抽象的重用

  • 表达力(Expressiveness):使用语言的循环、条件等能力来描述领域逻辑

  • 工具(Toolability):复用宿主语言的开发工具(IDE、格式化、静态分析、调试等)

  • 生产力(Productivity):基于以上的优点,最终呈现为生产力的提升

  • 互通:由于 Pulumi 代码包含了应用相关的代码和基础设施配置的代码,两个不同“上下文”之间可以非常方便的互通,由Pulumi在最终打包构建时进行“拆分”

在作者的 blog 中,他提供了一个数据,把原来用 25000 行 YAML 文件实现的功能,改写为 500 行的 TypeScript。

"My favorite success story so far has been taking 25,000 lines of a customer’s AWS CloudFormation YAML files …… and replacing them all with 500 lines of TypeScript and a single continuously deployed architecture using Pulumi."

参考:http://joeduffyblog.com/2018/06/18/hello-pulumi/

Pulumi 的核心抽象是云对象模型,以 AWS 的 SDK 为例,Pulumi 提供了 ec2、lambda、s3、dynamodb、sqs、apigateway、iot 等等对象,开发者可创建并进行参数配置。如下面例子所示:

技术分享 | 领域特定编程语言的设计探索_第7张图片

以上代码是 TypeScript 的版本,在第 1 行和第 12 行,分别创建了 ec2 的 Security Group 和一个 ec2 的实例,并在第 14 行通过引用了创建的 Group 实例的 id,将 ec2 实例加入到该 Group。从代码可以看到,Pulumi 主要通过“函数嵌套调用” 的方式来构建“声明式”范式,另外在参数为列表类型时,采用 Collection literals,比如第 2 行和第 14 行。

由于其提供多语言的支持,因此需要借助不同语言的能力来提供“相似”的表现力,比如我们对比一下 TypeScript 和 C# 的版本,图中黄色框的部分,TypeScript 版本使用匿名对象,而 C# 采用对象初始化器,后者是一个 .Net 3.0 开始新增的特性。

技术分享 | 领域特定编程语言的设计探索_第8张图片

前面我们讲了 Pulumi 采用 eDSL 方案的好处,但同时我们也看到,这种选择使其不得不与不同语言绑定,变成一个非语言中立的方案,对于单个开发者来说,可以采用自己熟悉的语言;但不同开发者之间,可能由于语言选择的不同导致无法共享代码,造成生态上的“割裂”

# Chisel

Chisel,全称是 Constructing Hardware In a Scala Embedded Language,是 UC Berkeley 开发的一种开源硬件构造语言。它是构建在 Scala 语言之上的领域专用语言(DSL),支持高度参数化的硬件生成器。开发者编写的 Chisel 代码,会被编译到其中间表示 FIRRTL,最后通过代码生成的方式生成 C++ 代码,进一步编译成 CA 级仿真器,或者也可以直接编译成 Verilog 代码。

技术分享 | 领域特定编程语言的设计探索_第9张图片

其选择 eDSL 方案的动机,是希望利用 Scala 的现代语言能力,例如面向对象,类型推断,函数式编程,泛型等,提供强大的表现力,简化了开发;提供参数化的模块生成器概念,让一个模块可以被多个设计复用。在其网站公开的 Demo 中,通过编写 20.5k 行的 Chisel 代码,最终生成了 420k 的 Verilog 代码,生产力提升近 20 倍。

Chisel 的核心抽象是硬件构造原语,基于 Scala 构造硬件设计相关的抽象,与已有 HDL(比如 Verilog)相似,比如 Module,IO,Wire,Reg,数据类型以及相应的各种操作,我们看一个例子:

技术分享 | 领域特定编程语言的设计探索_第10张图片

这段代码实现了一个多路选择器,Mux2 是一个 Module 的子类,而 Bundle 类似于 C 语言的结构体,Input 和 Output 表示不同的 io port 类型,1.W 表示这个 port是 1 bit 的位宽,每个 Module 的子类需要包含叫 io 的成员变量(第 10 行),从 io 的初始化显示该 Module 有三个输入:sel,in0 和 in1,一个输出:out,从第 11 行可以看到该 Module 基于 sel 的输入决定把 in0 或者 in1 传给输出 out。

我们再看看除了提供这些抽象,Chisel 的实现用到的其他 Scala 特性,首先 Chisel 的数据类型不同与 Scala 的基本类型,其具有特定的领域语义,需要提供方法让开发者可以很方便的把一个 Scala 基本类型转换成 Chisel 类型:

技术分享 | 领域特定编程语言的设计探索_第11张图片

Chisel 在 Scala 的 String 类型和整数类型扩展了“asUInt”、“asSInt”、“W”等方法,它使用的是 Scala 的 implicit 扩展的特性,其实现为:

技术分享 | 领域特定编程语言的设计探索_第12张图片

另外,Chisel 针对领域对象的操作,构建一种“透明”的效果,即使通用的运算符具有领域语义,该实现利用了 Scala 的操作符重载和自定义操作符的特性,如下图中的“&”、“|”、“~”都是使用操作符重载,而“:=”、“<>”都是使用自定义操作符的特性。

技术分享 | 领域特定编程语言的设计探索_第13张图片

这里的操作符不是立即运算获得结果的操作,而是建立运算的关系。如前面介绍声明式范式的时候,我们提到的另外一种的“声明式”,可以应用于很多需要表达依赖关系的地方,比如并发任务的编排,数据驱动的响应式编程,serverless 里云函数的编排等等。

# SwiftUI

最后我们看看UI领域的SwiftUI。SwiftUI是苹果在 WWDC2019 发布的 UI 框架,“Better apps. Less code.”,从广义上 SwiftUI 包含三部分:

  • 基于Swift构建的声明式UI(eDSL)

  • 结合Xcode构建UI预览能力

  • 统一的多端一致的渲染能力

这里主要关注 eDSL 的部分,通过选择 eDSL 的方案,可以带来以下收益:

  • 声明式的表达

  • 代码复用

  • 状态管理

  • 复用Swift的类型系统

下面是一个例子,VStack 表示其子组件按列进行布局,其子组件包含一个 Text 组件和一个 Button 组件,根据状态的动态变化,显示的第一个子组件会发生变化,SwiftUI 构建的声明式 UI 范式,使开发者比较容易的将代码与 UI 布局建立联系

技术分享 | 领域特定编程语言的设计探索_第14张图片

从实现角度,为了达成这种“UI as Code”的效果,有以下的 Swift 特性对 SwiftUI 的构建提供支持:

  • 命名参数

  • 链式调用

  • Function builder

  • 尾随闭包(后续增强为多重尾闭包)

  • 属性包装器(property wrappers)

  • Dynamic Member Lookup

  • Key Path

  • 省略new/return

  • 简化枚举

  • 反射

  • 不透明返回类型

我们分成 4 类来大致说明一下这些特性的用途:

  1. 关于声明式范式,命名参数 + 链式调用,用于 UI 组件配置,Function builder + 尾随闭包,用于构建 UI 子组件列表的效果。如下图所示在 VStack 的最后一个参数,是一个 Lambda,被 @ViewBuilder 修饰。首先因为是最后一个参数,所以在调用时,该 Lambda 不需要包裹在函数调用的 () 中,而 ViewBuilder 是 Function builder 的一个实现,改变了 Swift 的语义,将一列表达式包裹成一个 TupleView,在第 4 点展示的类型信息里我们可以看到这个变化;

    技术分享 | 领域特定编程语言的设计探索_第15张图片

  2. 关于状态管理,SwiftUI 是一个 MVVM 模型,UI 只能被动响应状态的变化,而无法主动被改变。其背后机制是观察者模式,状态是被观察者,而 UI 是观察者,通常需要建立被观察者与观察者的关系,同时当被观察者发生变化时,知会观察者。在 SwiftUI 中,这些逻辑都被类似 @State 这样的注解隐藏了,其使用的是 Swift 的 Property Wrappers 机制,或者叫 Property Delegates机制;同时针对复杂类型的状态管理,SwiftUI 使用了 Dynamic Member Lookup 和 Key Path 特性,结合了 Swift 的 Combine 框架,这里不做展开。

  3. 语法糖,比如省略关键字 new 和 return,简化的枚举(比如例子中 “.title” 和 “.yellow”),减少了语法“噪音”;

  4. SwiftUI 利用反射和类型系统,获取 UI 组件树的静态类型信息,用于指导底层渲染,如下图所示,左边的代码生成右边的类型信息。从类型信息里可以看到,VStack 的子组件列表被转变为 TupleView,5-10 行的条件表达式被转变为包含 2 个 Text 的 _ConditionalContent,即不管动态 counter 状态的值为何,其静态信息是不变的,而这种静态信息会成为每个 UI 组件的唯一标识,有利于框架在渲染时进行 UI 树的 Diff 操作。同时在此基础上,支持不透明返回类型,即左边第 3 行中的 “Some View”,使开发者避免手写复杂的返回类型,把复杂度交给编译器去推导。

    技术分享 | 领域特定编程语言的设计探索_第16张图片

下一步的思考/Further Thought

当前 eDSL 的出现,离不开现代编程语言相关特性的发展,比如 Scala、Kotlin、Swift、Rust 等都有不同程度的布局。在此基础上,我们还需要有什么进一步的思考:

eDSL 不仅仅是 API 的“美化”?

  • 从软件开发的角度,不仅仅提供了领域抽象,也提供对开发的约束;

  • 同时SwiftUI让我们看到,也可以从程序中提取出一些信息,反馈给底层框架,形成上下的协同。

如何控制 eDSL 与其宿主语言的边界?

  • 虽然 eDSL 让领域相关的逻辑可以与通用逻辑“无缝”互通,但根据“关注点分离”原则,我们只是想借用宿主语言的能力,但并不想让两边的代码混在一起,由于可能引入不同的“上下文”,实际上增加了开发者的心智负担,大家还是希望“凯撒的归凯撒,上帝的归上帝”,因此在设计时需要考虑如何设计双方的边界。

如何平衡宿主原生语法与自定义语法?

  • 宿主语法可能会产生“噪音”,相比之下自定义语法可能更简洁,噪音更少,更“领域”;

  • 但是自定义的语法可能让IDE或者其他的工具不认识,无法提供很好的支持;

  • 另外自定义语法,需要让开发者如同学习新的语言,有一定学习门槛;

  • 所以需要评估收益,谨慎使用自定义语法。

eDSL 的领域语义如何与宿主语言绑定?

  • 当我们使用 eDSL 时,如果编译出错,其报错信息依然是宿主语言的信息;

  • 如何实现领域语义与宿主语言的绑定,包括告警报错、调试调优工具等都能感知其绑定的领域语义?并且该能力需要成为一种可以向开发者开放的能力,是需要继续探索的。

技术分享 | 领域特定编程语言的设计探索_第17张图片

你可能感兴趣的:(技术文章,编程语言)