Scala:一种统治DSL的语言(II)


/**
 * 谨献给我最爱的Yoyo
 *
 * 原文出处:https://scalerablog.wordpress.com/2016/05/30/scala-one-language-to-rule-them-all-ii/
 * @author dogstar.huang <chanzonghuang@gmail.com> 2017-02-10
 */

你不会让一个门外汉来控制全新的380空中客车。如此一个强大的工具要求使用它的人员是有经过培训的;当Scala用于铸造新的能量环时,我的意思是,领域特定语言,Scala也类似是这样。经过前面的一些理论和大量动手的学习(learning by doing)我们已经掌握了一些理论,现在是时候通过从头开始构造一个DSL来进一步学习。

我们领域特定语言的目标

我们全新的DSL打算用于服务教学练习。然而,除了这个目的这外,它还需要有一个目标。那就是(或者是其中一个):

  • 一个治理的过程或系统。
  • 作为另一种语言的代理。

在我们的示例里,将采用第二种。

AWK简介

你是在开玩笑吗?这还需要介绍吗?
让你的unix终端来告诉我:

man awk | head -n 6  

GAWK(1) Utility Commands GAWK(1)

NAME
gawk – pattern scanning and processing language

好了,维基百科看起来更详细点:

AWK is an interpreted programming language designed for text processing and typically used as a data extraction and reporting tool. It is a standard feature of most Unix-like operating systems.

The AWK language is a data-driven scripting language consisting of a set of actions to be taken against streams of textual data – either run directly on files or used as part of a pipeline

考虑一下这里的两个英雄:

在这幅画了,Gnu有一个你看不见的带子,并且它藏着一个很强大的武器:AWK。

它非常强大,因为它可以让很多运行在(并且过去用于构建)GNU/Linux/BSD发布版本上脚本,能够对来自其他命令输出的数据进行转换、过滤、聚合等操作。让我们来看一个示例:

这里,通过lsmod命令输出生成的结果通过管道流给AWK,然后AWK会处理每一行,把每行第二列的值提取出来并累积到一个叫做total的字符计数器里。这样的话,当输出结束后,total将会打印出Linux内核模块使用了多少KB的内存总量。

无从下手?

AWK的应用数不胜数,就像一旦你掌握了AWK后能够节省的时间一样。然而,对于很多人来说,它更像是这样。。。

。。。而不是一个帮助工具。它那1475行man使用手册的说明可不是那么容易读得懂的。

所以引导用户穿过AWK程序的组成,看起来非常有帮助。这些话能给你敲响警钟吗?

领域特定语言简单而又简练,这意味着在描述动作、实体和应用领域内的关系过程中,它们会引导用户。

是的!Scala内部DSL可以用于构建这样一个工具!

着手Scalawk领域特写语言构建

在编程语言里,最重要的事情是名字。。。
Donald Erving Knuth

首先,如果我们把权威的观点作为一个有效的观点,那么应该先给我们的DSL一个名字。

简单起见:通过使用Scala内部DSL结合AWK,

Scala + AWK = Scalawk

到目前为止,一切都好!

你可以从Github上面克隆Scalawk的源代码

git clone https://github.com/pfcoperez/scalawk.git

分而治之(Divide & Conquer)

在上一章节我们认同了通过使用状态机模型是设计DSL最安全的方式。这些机器很容易分解成为:

  • 状态(他们中的一个是机器初始状态)
  • 转换
    • 输入触发转换
    • 转换副作用:
      • 状态机改变当前状态
      • 除了状态转换还能产生一些输出
  • 机器母表(Machine alphabet):机器输入实体。

通过绘画状态机图表,与设计一个互动引导没有什么不同,我们会在此DSL创造的过程中完成这整个有创造性的流程。剩下的工作,就只是优雅的Scala样板了。

与Scalawk进行交互的全部可能的用户都体现在了前面这张图表里,例如:

这种状态机模型和分解,导致了以下Scala包结构:

我该如何学会不再担忧,并且爱上Scala提供的构建块?

状态、转换和辅助的元素作为实体,包含在上面列出的包里。事实上,他们只是Scala中的对象、类、方法和物质。

初始状态

我们已经知道,状态就是对象。或者说他们是类的实例或者单例对象。另一方面,我们也知道正确实现状态机的方式是把这些状态变成不可变的,让转换专注负责于对新生成的状态。

初始状态不是由任何转换生成的,它之所在存在是因为它来自程序的启动。那是作为它单例本质一个很好的标识,这通过没有其他初始状态能够存在这一事实从而得以完全确认:

object lines extends ToCommandWithSeparator  

从DSL用户所站的角度,这个初始状态应该只是一个标识了语言中短语开始的一个词。这是另一个支撑单例对象方式的原因。

初始状态需要转换成下一个状态,这就是为什么lines继承于ToCommandWithSeparator。不要急,记住,ToCommandWithSeparator转换集合物质(transition set trait)

临时与最终状态

状态即对象。。。真的只是这样吗?!肯定不止!这里有不同类型的状态,除此之外,很多状态非常相似并且可以根据模板来构建。让我们来回顾一些技巧与技艺。

非初始状态的顶级分类应该是这个:暂时的最终的。因此,前者不能用于生成一个结果而后者可以。在Scalawk具体的场景里,这意味着临时状态不能产生有效的AWK程序,但最终状态可以。

在Scalawk里,任何能够生成有效AWK代码的实体都应该混入AwkElement

trait AwkElement {  
  def toAwk: String
}

这样做,我们就把toAwk方法添加到了那个实体,这个实体指向来自客户端代码中索要AWK的代码。

尽管各有各的不同,几乎所有的状态都共享可能组成AWK命令的一个通用属性集合:

  • 命令行选项,例如:令牌分隔符(Token separator)
  • 初始程序:在开始行处理前由AWK运行的指令。例如,初始化一个计算数值。
  • 行程序:针对每一行AWK输入所执行的指令。例如,打印一行;添加一个值到初始程序初始的累加器。
  • 结束程序:在全部的行都被处理后所执行的指令,那就是说,在行程序使用每一个单一输入作为它自己的输入运行后。例如,打印计数器的值。

在每一个状态,这些字段有可能是空的,也有可能不是,并且当需要一个最终状态来产生一个AWK程序时,他们将会用于生成字符值结果。

abstract class AwkCommand protected(  
  private[scalawk] val commandOptions: Seq[String] = Seq.empty,
  private[scalawk] val linePresentation: Seq[AwkExpression] = Seq.empty,
  private[scalawk] val lineProgram: Seq[SideEffectStatement] = Seq.empty,
  private[scalawk] val initialProgram: Seq[SideEffectStatement] = Seq.empty,
  private[scalawk] val endProgram: Seq[SideEffectStatement] = Seq.empty
) {
  def this(prev: AwkCommand) = this(
    prev.commandOptions,
    prev.linePresentation,
    prev.lineProgram,
    prev.initialProgram,
    prev.endProgram
  )
}

到目前为止,我们知道了非初始状态:

  • 当然,为了包含结果的构建块以及为这些字段传播先前的状态上下文:那么他们应该继承于AwkCommand抽象类
  • 更多的是,为了添加或修改来自先前状态AwkCommand属性的某些信息:那么他们应该重载AwkCommand属性。
  • 可选地,可以转换成另一个状态:如果是这样,他们应该有一个返回目标状态类型的值的方法或者混入某个转换家庭的特质。

你可能会想:为什么AwkCommand是一个抽象类而不是一个特质?
好吧,AwkCommand的目标是为持续性提供一个可重用的代码。那就是,它提供了从一个状态(前一个参数)来构建另一个状态的构造器。这样,状态代码减少到只有他们的转换和要重载的AwkCommand属性,但也只有这些属性的信息才要在新的状态里发生改变

显而易见,在一个类层次结构里提供一个构造器的唯一方式就是提供一个类,如果这个类不能被实例化:把它变成抽象。

class CommandWithLineProgram(  
                              statements: Seq[SideEffectStatement]
                            )(prev: AwkCommand) extends AwkCommand(prev)
  with ToSolidCommand {

  override private[scalawk]val lineProgram: Seq[SideEffectStatement] = statements

}

CommandWithLineProgram不是非最终状态,所以它没有混入AwkElement物质。

//This is the first state which can be used to obtain an AWK command string `toAwk`
class SolidCommand(val lineResult: Seq[AwkExpression], prevSt: AwkCommand) extends AwkCommand(prevSt)  
  with AwkElement
  with ToCommandWithLastAction {
 ...
 ...
 ...
}

相反,SolidCommand不是,所以需要提供一个对toAwk方法的实现:

// AWK程序部分

// 开始
protected def beginBlock: String = programToBlock(initialProgram)

// 每一行
protected def eachLineActionBlock: String =  
programToBlock(lineProgram ++ linePresentation.headOption.map(_ => Print(linePresentation)))


// 结束
protected def endBlock: String = programToBlock(endProgram)

protected def programToBlock(program: Seq[SideEffectStatement]) =  
{program.map(_.toAwk) mkString "; "} +
program.headOption.map(_ => "; ").getOrElse("")

protected def optionsBlock: String =  
{commandOptions mkString " "} + commandOptions.headOption.map(_ => " ").getOrElse("")

override def toAwk: String =  
s"""|awk ${optionsBlock}'  
|${identifyBlock("BEGIN", beginBlock)}
|${identifyBlock("", eachLineActionBlock)}
|${identifyBlock("END", endBlock)}'""".stripMargin.replace("\n", "")

// 辅助方法
private[this] def identifyBlock(blockName: String, blockAwkCode: String): String =  
blockAwkCode.headOption.map(_ => s"$blockName{$blockAwkCode}").getOrElse("")  

这个类层次结构使得代码得以再利用,例如,SolidCommandWithLastAction几乎就是SolidCommand的副本并且没有什么可以阻止我们扩展它从而定义SolidCommandWithLastAction:

class SolidCommandWithLastAction(lastAction: Seq[SideEffectStatement])(prevSt: SolidCommand)  
extends SolidCommand(prevSt) {...}  

这时候,你应该可以开始探索这个代码仓库同时把代码里的状态类和图表中的每个状态关联起来。以防万一,下面表格收集了这些关联:

图表节点 实体 是否最终状态? 实体类型
init 单例对象
command CommandWithSeparator
with line program CommandWithLineProgram
with initial program CommandWithInitialProgram
solid command SolidCommand
with last action SolidCommandWithLastAction

转换

状态之间的转换是最容易的部分,他们就像返回新状态的方法那样简单。得益于Scala中缀表达式,他们创造了自动语言表达的错觉,至少在某种程度上是。。。

有些状态可能共享转换,所以创建包含他们的物质看起来是个不错的主意。通过使用混入,状态因此可以使用他们作为乐高模块来构建他们自己的转换集。

这有两个特殊的场景需要特别注意:转换组空输入转换

转换组。。。

。。。是由多个转换组成的,通常一起呈现或者是同一转换的不同版本。这些通常在遵循To模式的相同特质中定义。

trait ToCommandWithSeparator {

  def splitBy(separator: String) = new CommandWithSeparator(separator)
  def splitBy(separator: Regex) =  new CommandWithSeparator(separator)

}

上面的例子是同一个转换有两个版本清晰的案例:一个接收一个输入字符串而另一个接收一个正则表达式。

注意!!!与抽象状态机相关,这个状态机的输入是方法名和它的参数。

空输入转换

考虑以下转换,从我们的状态机中提取的:
当输入是空的字符串时,状态机可以从一个状态移到另一个状态。它可能看起来怪异但得益于隐式转换,它可以使用我们的状态机对象模型来完成。

通过只是尝试访问其中一个有Source实例的Target方法,从一个状态(Source)到另一个状态(Target)的隐式转换,会被SDL的用户感觉是空转换。它就像听起来那么简单。

更多的是,仅仅是通过在Source或者Target class/trait里定义隐式转换,它就可以在空转换发生的地方的段落作用域内有效。不需要导入的意思是:针对用户的绝对转换(ABSOLUTELY TRANSPARENCY)。

所以,以下代码:

object ToCommandWithSeparator {  
  implicit def toCommandWithSep(x: ToCommandWithSeparator) = new CommandWithSeparator()
}

。。。使用在下面图表中的描述的转换:

-- 如果ToCommandWithSeparator是一个转换家族特质,那它同等名称的伴生对象不都是那转换家族的伴生对象吗?我们设置的隐式转换不是应该定义在Source或者Target伴生对象里并且在某个状态类里吗?

-- 完全正确!而且如果没有混入到某个状态类的定义,ToCommandWithSeparator的命运又会怎样呢?

在Scala里,在一个特质伴生对象定义的隐式转换也可以适用于继承或者混入此特质的类,并且在类有效的地方他们可以在任意作用域内有效。这个特性,除了相当有用外,看起来也是相当合理的:混入/继承特质的类可以当作为此特质的某种类型,一种子类型,所以任何它的实例也是给定特质的类型并且它期望是任何应用于那种类型的转换也都能应用到实例上。

拿特质T和S为例,两者都有伴生对象,并且分别定义了隐式转换成D和E:

case class E(x: Int)  
case class D(x: Int)

trait T  
object T { implicit def toD(from: T): D = D(1) }

trait S  
object S { implicit def toE(from: S): E = E(2) }  

把他们两者混入类C的定义中。。。

case class C() extends T with S  

。。。然后检查一个C的实例是怎样隐式转换成E或D的实例。

scala> val c: C = C()  
c: C = C()

scala> val d: D = c  
d: D = D(1)  
scala> val e: E = c  
e: E = E(2)  

表达式

大部分Scalawk转换输入符合转换名称 + 基本类型值的模式。然而,其中有一些接收表达式,标识符或句子。这些是在AWK程序里用于表达指令和值而特别创建的类型。所以他们不应该是如何创建一个SDL通用引导的一部分。此外,他们背后的创建在很多Scala内部DSL里是不通用的,所以我们只是简短说明一下。

Scalawk中的标识符(内部标识)

某些SDL表达式,例如arePresentedAs,需要产生对AWK变量的指向,用其他一些DSL表达式来声明。你可以使用字符串来表示这些内部标识。但是需要用双引号把我们的DSL标识包起来无疑是给用户体验泼了一盆冷水,使得用户觉得她/他实际上是在使用Scala而不是从零开始的领域语言。

Scala提供了一种对于相同字符串获取唯一对象的机制。那恰恰是一个好的DSL标识所需要的。

如果有人写了:

'counter  

。。。她/他将可以得到一个Symbol实例的引用。Symbol类有一个名字属性,你可以重写用于获取实例的字符串。

用户只要写了'counter,那么DSL开发人员就能取得这个字符串counter并且在这里使用它来表示内部的AWK变量。

句子

通过把带ad-hoc类的内部标识和隐式转换结合,表达赋值语句,甚至是代数运算并不困难。

's := 's + 1  

这篇文章到这里已经很长很长了,在暗示了这么多以及经过前面第一章的技巧,实际上理解构建这种类型表达式的代码是很容易的。这些代码位于entities包下。把这个包当作是Scalawk里的一种嵌入式DSL,是的,DSL嵌入在DSL中,而DSL又嵌入在Scala中。

最后的一些思考

开发内部DSL不是一件简单的事。如果强制回退到宿主语言构造,用户会很容易从使用一种为他们量身定做的语言的美梦中惊醒过来。没有人喜欢被提醒他/她与别人没有什么不一样。

当试图忠诚地重现状态机时,你会遇到很多陷阱,放弃这个模型的诱惑是巨大的。相信我,是很复杂,但如果你离开了状态机这条路,在森林里的食人魔会比你想象得更快就把你的头拿下来当早餐。Scala已经证明它可以为状态机这条路的所有坑提供解决方案,在这条路之外你可以随便踩杭。

作为最后一点小建议,我建议你去买一块白板/黑板,一本纸质笔记本和一支好笔。。。可以随心所欲想画就画。

以下是早期设计Scalawk的两张图:

思考!涂画!思考!再涂画!那么相比于这个家伙,你是一位很优秀的DSL架构师。。。

。。。那么你的尼奥(们)就不会那么快醒。

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州