java

位置:IT落伍者 >> java >> 浏览文章

面向Java开发人员的Scala指南: 构建计算器,第1 部分


发布日期:2020年09月02日
 
面向Java开发人员的Scala指南: 构建计算器,第1 部分

摘要特定于领域的语言已经成为一个热门话题很多函数性语言之所以受欢迎主要是因为它们可以用于构建特定于领域的语言鑒于此在 面向 Java? 开发人员的 Scala 指南 系列的第 篇文章中Ted Neward 着手构建一个简单的计算器 DSL以此来展示函数性语言的构建 外部 DSL 的强大功能他研究了 Scala 的一个新的特性case 类并重新审视一个功能强大的特性模式匹配

上个月的文章发表后我又收到了一些抱怨/评论说我迄今为止在本系列中所用的示例都没涉及到什么实质性的问题当然在学习一个新语言的初期使用一些小例子是很合理的而读者想要看到一些更 现实的 示例从而了解语言的深层领域和强大功能以及其优势这也是理所当然的因此在这个月的文章中我们来分两部分练习构建特定于领域的语言(DSL)— 本文以一个小的计算器语言为例

关于本系列

Ted Neward 将和您一起深入探讨 Scala 编程语言在这个新的 developerWorks 系列 中您将深入了解 Sacla并在实践中看到 Scala 的语言功能进行比较时Scala 代码和 Java 代码将放在一起展示但(您将发现)Scala 中的许多内容与您在 Java 编程中发现的任何内容都没有直接关联而这正是 Scala 的魅力所在!如果用 Java 代码就能够实现的话又何必再学习 Scala 呢?

特定于领域的语言

可能您无法(或没有时间)承受来自于您的项目经理给您的压力那么让我直接了当地说吧特定于领域的语言无非就是尝试(再一次)将一个应用程序的功能放在它该属于的地方 — 用户的手中

通过定义一个新的用户可以理解并直接使用的文本语言程序员成功摆脱了不停地处理 UI 请求和功能增强的麻烦而且这样还可以使用户能够自己创建脚本以及其他的工具用来给他们所构建的应用程序创建新的行为虽然这个例子可能有点冒险(或许会惹来几封抱怨的电子邮件)但我还是要说DSL 的最成功的例子就是 Microsoft® Office Excel 语言用于表达电子表格单元格的各种计算和内容甚至有些人认为 SQL 本身就是 DSL但这次是一个旨在与关系数据库相交互的语言(想象一下如果程序员要通过传统 API read()/write() 调用来从 Oracle 中获取数据的话那将会是什么样子)

这里构建的 DSL 是一个简单的计算器语言用于获取并计算数学表达式其实这里的目标是要创建一个小型语言这个语言能够允许用户来输入相对简单的代数表达式然后这个代码来为它求值并产生结果为了尽量简单明了该语言不会支持很多功能完善的计算器所支持的特性但我不也不想把它的用途限定在教学上 — 该语言一定要具备足够的可扩展性以使读者无需彻底改变该语言就能够将它用作一个功能更强大的语言的核心这意味着该语言一定要可以被轻易地扩展并要尽量保持封装性用起来不会有任何的阻碍

关于 DSL 的更多信息

DSL 这个主题的涉及面很广它的丰富性和广泛性不是本文的一个段落可以描述得了的想要了解更多 DSL 信息的读者可以查阅本文末尾列出的 Martin Fowler 的 正在进展中的图书特别要注意关于 内部外部 DSL 之间的讨论Scala 以其灵活的语法和强大的功能而成为最强有力的构建内部和外部 DSL 的语言

换句话说(最终的)目标是要允许客户机编写代码以达到如下的目的

清单 计算器 DSL目标

// This is Java using the CalculatorString s = (( * ) + );double result = comtednewardcalcdslCalculatorevaluate(s);Systemoutprintln(We got + result); // Should be

我们不会在一篇文章完成所有的论述但是我们在本篇文章中可以学习到一部分内容在下一篇文章完成全部内容

从实现和设计的角度看可以从构建一个基于字符串的解析器来着手构建某种可以 挑选每个字符并动态计算 的解析器这的确极具诱惑力但是这只适用于较简单的语言而且其扩展性不是很好如果语言的目标是实现简单的扩展性那么在深入研究实现之前让我们先花点时间想一想如何设计语言

根据那些基本的编译理论中最精华的部分您可以得知一个语言处理器(包括解释器和编译器)的基本运算至少由两个阶段组成

● 解析器用于获取输入的文本并将其转换成 Abstract Syntax Tree(AST)

● 代码生成器(在编译器的情况下)用于获取 AST 并从中生成所需字节码或是求值器(在解释器的情况下)用于获取 AST 并计算它在 AST 里面所发现的内容

拥有 AST 就能够在某种程度上优化结果树如果意识到这一点的话那么上述区别的原因就变得更加显而易见了对于计算器我们可能要仔细检查表达式找出可以截去表达式的整个片段的位置诸如在乘法表达式中运算数为 的位置(它表明无论其他运算数是多少运算结果都会是

您要做的第一件事是为计算器语言定义该 AST幸运的是Scala 有 case 类一种提供了丰富数据使用了非常薄的封装的类它们所具有的一些特性使它们很适合构建 AST

case 类

在深入到 AST 定义之前让我先简要概述一下什么是 case 类case 类是使 scala 程序员得以使用某些假设的默认值来创建一个类的一种便捷机制例如当编写如下内容时

清单 对 person 使用 case 类

case class Person(first:String last:String age:Int)

{

}

Scala 编译器不仅仅可以按照我们对它的期望生成预期的构造函数 — Scala 编译器还可以生成常规意义上的 equals()toString() 和 hashCode() 实现事实上这种 case 类很普通(即它没有其他的成员)因此 case 类声明后面的大括号的内容是可选的

清单 世界上最短的类清单

case class Person(first:String last:String age:Int)

这一点通过我们的老朋友 javap 很容易得以验证

清单 神圣的代码生成器Batman!

C:\Projects\Exploration\Scala>javap Person

Compiled from casescala

public class Person extends javalangObject implements scalaScalaObjectscala

ProductjavaioSerializable{

public Person(javalangString javalangString int);

public javalangObject productElement(int);

public int productArity();

public javalangString productPrefix();

public boolean equals(javalangObject);

public javalangString toString();

public int hashCode();

public int $tag();

public int age();

public javalangString last();

public javalangString first();

}

如您所见伴随 case 类发生了很多传统类通常不会引发的事情这是因为 case 类是要与 Scala 的模式匹配(在 集合类型 中曾简短分析过)结合使用的

使用 case 类与使用传统类有些不同这是因为通常它们都不是通过传统的 new 语法构造而成的事实上它们通常是通过一种名称与类相同的工厂方法来创建的

清单 没有使用 new 语法?

object App

{

def main(args : Array[String]) : Unit =

{

val ted = Person(Ted Neward )

}

}

case 类本身可能并不比传统类有趣或者有多么的与众不同但是在使用它们时会有一个很重要的差别与引用等式相比case 类生成的代码更喜欢按位(bitwise)等式因此下面的代码对 Java 程序员来说有些有趣的惊喜

清单 这不是以前的类

object App

{

def main(args : Array[String]) : Unit =

{

val ted = Person(Ted Neward )

val ted = Person(Ted Neward )

val amanda = Person(Amanda Laucher )

Systemoutprintln(ted == amanda: +

(if (ted == amanda) Yes else No))

Systemoutprintln(ted == ted: +

(if (ted == ted) Yes else No))

Systemoutprintln(ted == ted: +

(if (ted == ted) Yes else No))

}

}

/*

C:\Projects\Exploration\Scala>scala App

ted == amanda: No

ted == ted: Yes

ted == ted: Yes

*/

case 类的真正价值体现在模式匹配中本系列的读者可以回顾一下模式匹配(参见 本系列的第二篇文章关于 Scala 中的各种控制构造)模式匹配类似 Java 的 switch/case只不过它的本领和功能更加强大模式匹配不仅能够检查匹配构造的值从而执行值匹配还可以针对局部通配符(类似局部 默认值 的东西)匹配值case 还可以包括对测试匹配的保护来自匹配标准的值还可以绑定于局部变量甚至符合匹配标准的类型本身也可以进行匹配

有了 case 类模式匹配具备了更强大的功能如清单 所示

清单 这也不是以前的 switch

case class Person(first:String last:String age:Int);

object App

{

def main(args : Array[String]) : Unit =

{

val ted = Person(Ted Neward )

val amanda = Person(Amanda Laucher )

Systemoutprintln(process(ted))

Systemoutprintln(process(amanda))

}

def process(p : Person) =

{

Processing + p + reveals that +

(p match

{

case Person(_ _ a) if a > =>

theyre certainly old

case Person(_ Neward _) =>

they come from good genes

case Person(first last ageInYears) if ageInYears > =>

first + + last + is + ageInYears + years old

case _ =>

I have no idea what to do with this person

})

}

}

/*

C:\Projects\Exploration\Scala>scala App

Processing Person(TedNeward) reveals that theyre certainly old

Processing Person(AmandaLaucher) reveals that Amanda Laucher is years old

*/

清单 中发生了很多操作下面就让我们先慢慢了解发生了什么然后回到计算器看看如何应用它们

首先整个 match 表达式被包裹在圆括号中这并非模式匹配语法的要求但之所以会这样是因为我把模式匹配表达式的结果根据其前面的前缀串联了起来(切记函数性语言里面的任何东西都是一个表达式)

其次第一个 case 表达式里面有两个通配符(带下划线的字符就是通配符)这意味着该匹配将会为符合匹配的 Person 中那两个字段获取任何值但是它引入了一个局部变量 apage 中的值会绑定在这个局部变量上这个 case 只有在同时提供的起保护作用的表达式(跟在它后边的 if 表达式)成功时才会成功但只有第一个 Person 会这样第二个就不会了第二个 case 表达式在 Person 的 firstName 部分使用了一个通配符但在 lastName 部分使用常量字符串 Neward 来匹配在 age 部分使用通配符来匹配

由于第一个 Person 已经通过前面的 case 匹配了而且第二个 Person 没有姓 Neward所以该匹配不会为任何一个 Person 而被触发(但是Person(Michael Neward ) 会由于第一个 case 中的 guard 子句失败而转到第二个 case)

第三个示例展示了模式匹配的一个常见用途有时称之为提取在这个提取过程中匹配对象 p 中的值为了能够在 case 块内使用而被提取到局部变量中(第一个最后一个和 ageInYears)最后的 case 表达式是普通 case 的默认值它只有在其他 case 表达式均未成功的情况下才会被触发

简要了解了 case 类和模式匹配之后接下来让我们回到创建计算器 AST 的任务上

计算器 AST

首先计算器的 AST 一定要有一个公用基类型因为数学表达式通常都由子表达式组成通过 + ( * 就可以很容易地看到这一点在这个例子中子表达式 * 将会是 + 运算的右侧运算数

事实上这个表达式提供了三种 AST 类型

● 基表达式

● 承载常量值的 Number 类型

● 承载运算和两个运算数的 BinaryOperator

想一下算数中还允许将一元运算符用作求负运算符(减号)将值从正数转换为负数因此我们可以引入下列基本 AST

清单 计算器 AST(src/calcscala)

package comtednewardcalcdsl

{

private[calcdsl] abstract class Expr

private[calcdsl] case class Number(value : Double) extends Expr

private[calcdsl] case class UnaryOp(operator : String arg : Expr) extends Expr

private[calcdsl] case class BinaryOp(operator : String left : Expr right : Expr)

extends Expr

}

注意包声明将所有这些内容放在一个包(comtednewardcalcdsl)中以及每一个类前面的访问修饰符声明表明该包可以由该包中的其他成员或子包访问之所以要注意这个是因为需要拥有一系列可以测试这个代码的 JUnit 测试计算器的实际客户机并不一定非要看到 AST因此要将单元测试编写成 comtednewardcalcdsl 的一个子包

清单 计算器测试(testsrc/calctestscala)

package comtednewardcalcdsltest

{

class CalcTest

{

import orgjunit_ Assert_

@Test def ASTTest =

{

val n = Number()

assertEquals( nvalue)

}

@Test def equalityTest =

{

val binop = BinaryOp(+ Number() Number())

assertEquals(Number() binopleft)

assertEquals(Number() binopright)

assertEquals(+ binopoperator)

}

}

}

到目前为止还不错我们已经有了 AST

再想一想我们用了四行 Scala 代码构建了一个类型分层结构表示一个具有任意深度的数学表达式集合(当然这些数学表达式很简单但仍然很有用)与 Scala 能够使对象编程更简单更具表达力相比这不算什么(不用担心真正强大的功能还在后面)

接下来我们需要一个求值函数它将会获取 AST并求出它的数字值有了模式匹配的强大功能编写这样的函数简直轻而易举

清单 计算器(src/calcscala)

package comtednewardcalcdsl

{

//

object Calc

{

def evaluate(e : Expr) : Double =

{

e match {

case Number(x) => x

case UnaryOp( x) => (evaluate(x))

case BinaryOp(+ x x) => (evaluate(x) + evaluate(x))

case BinaryOp( x x) => (evaluate(x) evaluate(x))

case BinaryOp(* x x) => (evaluate(x) * evaluate(x))

case BinaryOp(/ x x) => (evaluate(x) / evaluate(x))

}

}

}

}

注意 evaluate() 返回了一个 Double它意味着模式匹配中的每一个 case 都必须被求值成一个 Double 值这个并不难数字仅仅返回它们的包含的值但对于剩余的 case(有两种运算符)我们还必须在执行必要运算(求负加法减法等)前计算运算数正如常在函数性语言中所看到的会使用到递归所以我们只需要在执行整体运算前对每一个运算数调用 evaluate() 就可以了

大多数忠实于面向对象的编程人员会认为在各种运算符本身以外 执行运算的想法根本就是错误的 — 这个想法显然大大违背了封装和多态性的原则坦白说这个甚至不值得讨论这很显然违背 了封装原则至少在传统意义上是这样的

在这里我们需要考虑的一个更大的问题是我们到底从哪里封装代码?要记住 AST 类在包外是不可见的还有就是客户机(最终)只会传入它们想求值的表达式的一个字符串表示只有单元测试在直接与 AST case 类合作

但这并不是说所有的封装都没有用了或过时了事实上恰好相反它试图说服我们在对象领域所熟悉的方法之外还有很多其他的设计方法也很奏效不要忘了 Scala 兼具对象和函数性有时候 Expr 需要在自身及其子类上附加其他行为(例如实现良好输出的 toString 方法)在这种情况下可以很轻松地将这些方法添加到 Expr函数性和面向对象的结合提供了另一种选择无论是函数性编程人员还是对象编程人员都不会忽略到另一半的设计方法并且会考虑如何结合两者来达到一些有趣的效果

从设计的角度看有些其他的选择是有问题的例如使用字符串来承载运算符就有可能出现小的输入错误最终会导致结果不正确在生产代码中可能会使用(也许必须使用)枚举而非字符串使用字符串的话就意味着我们可能潜在地 开放 了运算符允许调用出更复杂的函数(诸如 abssincostan 等)乃至用户定义的函数这些函数是基于枚举的方法很难支持的

对所有设计和实现的来说都不存在一个适当的决策方法只能承担后果后果自负

但是这里可以使用一个有趣的小技巧某些数学表达式可以简化因而(潜在地)优化了表达式的求值(因此展示了 AST 的有用性)

● 任何加上 的运算数都可以被简化成非零运算数

● 任何乘以 的运算数都可以被简化成非零运算数

● 任何乘以 的运算数都可以被简化成零

不止这些因此我们引入了一个在求值前执行的步骤叫做 simplify()使用它执行这些具体的简化工作

清单 计算器(src/calcscala)

def simplify(e : Expr) : Expr =

{

e match {

// Double negation returns the original value

case UnaryOp( UnaryOp( x)) => x

// Positive returns the original value

case UnaryOp(+ x) => x

// Multiplying x by returns the original value

case BinaryOp(* x Number()) => x

// Multiplying by x returns the original value

case BinaryOp(* Number() x) => x

// Multiplying x by returns zero

case BinaryOp(* x Number()) => Number()

// Multiplying by x returns zero

case BinaryOp(* Number() x) => Number()

// Dividing x by returns the original value

case BinaryOp(/ x Number()) => x

// Adding x to returns the original value

case BinaryOp(+ x Number()) => x

// Adding to x returns the original value

case BinaryOp(+ Number() x) => x

// Anything else cannot (yet) be simplified

case _ => e

}

}

还是要注意如何使用模式匹配的常量匹配和变量绑定特性从而使得编写这些表达式可以易如反掌对 evaluate() 惟一一个更改的地方就是包含了在求值前先简化的调用

清单 计算器(src/calcscala)

def evaluate(e : Expr) : Double =

{

simplify(e) match {

case Number(x) => x

case UnaryOp( x) => (evaluate(x))

case BinaryOp(+ x x) => (evaluate(x) + evaluate(x))

case BinaryOp( x x) => (evaluate(x) evaluate(x))

case BinaryOp(* x x) => (evaluate(x) * evaluate(x))

case BinaryOp(/ x x) => (evaluate(x) / evaluate(x))

}

}

还可以再进一步简化注意一下它是如何实现只简化树的最底层的?如果我们有一个包含 BinaryOp(* Number( Number()) 和 Number() 的 BinaryOp 的话那么内部的 BinaryOp 就可以被简化成 Number(但外部的 BinaryOp 也会如此这是因为此时外部 BinaryOp 的其中一个运算数是零

我突然犯了作家的职业病了所以我想将它留予读者来定义其实是想增加点趣味性罢了如果读者愿意将他们的实现发给我的话我将会把它放在下一篇文章的代码分析中将会有两个测试单元来测试这种情况并会立刻失败您的任务(如果您选择接受它的话)是使这些测试 — 以及其他任何测试只要该测试采取了任意程度的 BinaryOp 和 UnaryOp 嵌套 — 通过

结束语

显然我还没有说完还有分析的工作要做但是计算器 AST 已经成形我们无需作出大的变动就可以添加其他的运算运行 AST 也无需大量的代码(按照 Gang of Four 的 Visitor 模式)而且我们已经有了一些执行计算本身的工作代码(如果客户机愿意为我们构建用于求值的代码的话)

更重要的是您已经看到了 case 类是如何与模式匹配合作使得创建 AST 并对其求值变得轻而易举这是 Scala 代码(以及大多数函数性语言)很常用的设计而且如果您准备认真地研究这个环境的话这是您应当掌握的内容之一

               

上一篇:Java实现简单的缓存机制原理

下一篇:Java面向对象的排列组合算法