函数式思维: 函数设计模式,第 3 部分

解释器模式和扩展语言

Gang of Four 的解释器设计模式 (Interpreter design pattern) 鼓励在一个语言的基础上构建一个新的语言来实现扩展。大多数函数式语言都能够让您以多种方式(如操作符重载和模式匹配)对语言进行扩展。尽管 Java™ 不支持这些技术,下一代 JVM 语言均支持这些技术,但其具体实现细则有所不同。在本文中,Neal Ford 将探讨 Groovy、Scala 和 Clojure 如何通过以 Java 无法支持的方式来实现函数式扩展,从而实现解释器设计模式的目的。

Neal Ford, 软件架构师, ThoughtWorks Inc.

Neal FordNeal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2012 年 6 月 11 日

关于本系列

本系列的目标是重新调整您对函数式思维的认识,帮助您以全新的方式看待常见问题,并提升您的日常编码能力。本系列文章将探讨函数式编程概念、允许在 Java 语言中进行函数式编程的框架、在 JVM 上运行的函数编程语言,以及语言设计的未来方向。本系列面向那些了解 Java 及其抽象工作原理,但对函数语言不甚了解的开发人员。

在本期 函数式思维 的文章中,我将继续研究 Gang of Four (GoF) 设计模式(参阅 参考资料)的函数式替代解决方案。在本文中,我将研究最少人了解,但却是最强大的模式之一:解释器 (Interpreter)

解释器的定义是:

给定一个语言,定义其语法表示,以及一个使用该表示来解释语言中的句子的解释器。

换句话说,如果您正在使用的语言不适用于解决问题,那么用它来构建一个适用的语言。关于该方法的一个很好的示例出现在 Web 框架中,如 Grails 和 Ruby on Rails(参阅 参考资料),它们扩展了自己的基础语言(分别是 Groovy 和 Ruby),使编写 Web 应用程序变得更容易。

这种模式最少人了解,因为构建一种新的语言并不常见,需要专业的技能和惯用语法。它是最强大的 设计模式,因为它鼓励您针对正在解决的问题扩展自己的编程语言。这在 Lisp(因此 Clojure 也同样)世界是一个普遍的特质,但在主流语言中不太常见。

当使用禁止对语言本身进行扩展的语言(如 Java)时,开发人员往往将自己的思维塑造成该语言的语法;这是您的惟一选择。然而,当您渐渐习惯使用允许轻松扩展的语言时,您就会开始将语言折向解决问题的方向,而不是其他折衷的方式。

Java 缺乏直观的语言扩展机制,除非您求助于面向方面的编程。然而,下一代的 JVM 语言(Groovy、Scala 和 Clojure)(参阅 参考资料)均支持以多种方式进行扩展。通过这样做,它们可以达到解释器设计模式的目的。首先,我将展示如何使用这三种语言实现操作符重载,然后演示 Groovy 和 Scala 如何让您扩展现有的类。

操作符重载(operator overloading)

操作符重载 是函数式语言的一个常见特性,能够重定义操作符(如 +-*)配合新的类型工作,并表现出新的行为。操作符重载的缺失是 Java 形成时期的一个有意识的决定,但现在几乎每一个现代语言都具备这个特性,包括在 JVM 上 Java 的天然接班人。

Groovy

Groovy 尝试更新 Java 的语法,使其跟上潮流,同时保留其自然语义。因此,Groovy 通过将操作符自动映射到方法名称实现操作符重载。例如,如果您想重载 Integer+ 操作符,那么您要重写 Integer 类的 plus() 方法。完整的映射列表已在线提供(参阅 参考资料);表 1 显示了列表的一部分:

表 1. Groovy 的操作符/方法映射列表的一部分
操作符方法
x + yx.plus(y)
x * yx.multiply(y)
x / yx.div(y)
x ** yx.power(y)

作为一个操作符重载的示例,我将在 Groovy 和 Scala 中都创建一个 ComplexNumber 类。复数 是一个数学概念,由一个实数虚数 部分组成,一般写法是,例如 3 + 4i。复数在许多科学领域中都很常用,包括工程学、物理学、电磁学和混沌理论。开发人员在编写这些领域的应用程序时,大大受益于能够创建反映其问题域的操作符。(有关复数的更多信息,请参阅 参考资料。)

清单 1 中显示了一个 Groovy ComplexNumber 类:

清单 1. Groovy 中的 ComplexNumber
package complexnums

class ComplexNumber {
   def real, imaginary

  public ComplexNumber(real, imaginary) {
    this.real = real
    this.imaginary = imaginary
  }

  def plus(rhs) {
    new ComplexNumber(this.real + rhs.real, this.imaginary + rhs.imaginary)
  }
  
  def multiply(rhs) {
    new ComplexNumber(
        real * rhs.real - imaginary * rhs.imaginary,
        real * rhs.imaginary + imaginary * rhs.real)
  }

  String toString() {
    real.toString() + ((imaginary < 0 ? "" : "+") + imaginary + "i").toString()
  }
}

清单 1 中,我创建一个类,保存实数和虚数部分,并且我创建重载的 plus()multiply() 操作符。两个复数的相加是非常直观的:plus() 操作符将两个数各自的实数和虚数分别进行相加,并产生结果。两个复数的相乘需要以下公式:

(x + yi)(u + vi) = (xu - yv) + (xv + yu)i

清单 1 中的 multiply() 操作符复制该公式。它将两个数字的实数部分相乘,然后减去虚数部分相乘的积,再加上实数和虚数分别彼此相乘的积。

清单 2 测试复数运算符:

清单 2. 测试复数运算符
package complexnums

import org.junit.Test
import static org.junit.Assert.assertTrue
import org.junit.Before

class ComplexNumberTest {
  def x, y

  @Before void setup() {
    x = new ComplexNumber(3, 2)
    y = new ComplexNumber(1, 4)
  }

  @Test void plus_test() {
    def z = x + y;
    assertTrue 3 + 1 == z.real
    assertTrue 2 + 4 == z.imaginary
  }
  
  @Test void multiply_test() {
    def z = x * y
    assertTrue(-5  == z.real)
    assertTrue 14 == z.imaginary
  }
}

清单 2 中,plus_test()multiply_test() 方法对重载操作符的使用(两者都以该领域专家使用的相同符号代表)与类似的内置类型用法没什么区别。

Scala(和 Clojure)

Scala 通过放弃操作符和方法之间的区别来实现操作符重载:操作符仅仅是具有特殊名称的方法。因此,要使用 Scala 重写乘法运算,您要重写 * 方法。在清单 3 中,我用 Scala 创建复数。

清单 3. Scala 中的复数
class ComplexNumber(val real:Int, val imaginary:Int) {
    def +(operand:ComplexNumber):ComplexNumber = {
        new ComplexNumber(real + operand.real, imaginary + operand.imaginary)
    }
 
    def *(operand:ComplexNumber):ComplexNumber = {
        new ComplexNumber(real * operand.real - imaginary * operand.imaginary,
            real * operand.imaginary + imaginary * operand.real)
    }

    override def toString() = {
        real + (if (imaginary < 0) "" else "+") + imaginary + "i"
    }
}

清单 3 中的类包括熟悉的 realimaginary 成员,以及 +* 操作符/方法。如清单 4 所示,我可以自然地使用 ComplexNumber

清单 4. 在 Scala 中使用复数
val c1 = new ComplexNumber(3, 2)
val c2 = new ComplexNumber(1, 4)
val c3 = c1 + c2
assert(c3.real == 4)
assert(c3.imaginary == 6)

val res = c1 + c2 * c3
 
printf("(%s) + (%s) * (%s) = %s\n", c1, c2, c3, res)
assert(res.real == -17)
assert(res.imaginary == 24)

通过统一操作符和方法,Scala 使操作符重载变成一件小事。Clojure 使用相同的机制来重载操作符。例如,以下 Clojure 代码定义了一个重载的 ** 操作符:

(defn ** [x y] (Math/pow x y))

扩展类

类似于操作符重载,下一代的 JVM 语言允许您扩展类(包括核心 Java 类),扩展的方式在 Java 语言本身是不可能实现的。这些设施通常用于构建领域特定的语言 (DSL)。虽然 GOF 从来没有考虑过 DSL(因为它们与当时流行的语言没有共同点),DSL 却体现了解释器设计模式的初衷。

通过将计量单位和其他修饰符添加给 Integer 等核心类,您可以(就像添加操作符一样)更紧密地对现实问题进行建模。Groovy 和 Scala 都支持这样做,但它们使用不同的机制。

Groovy 的 Expando 和类别类

Groovy 包括两种对现有类添加方法的机制:ExpandoMetaClass类别。(在 函数式思维:函数设计模式,第 2 部分 中,我在适配器模式的上下文中详细介绍过 ExpandoMetaClass。)

比方说,您的公司由于离奇的遗留原因,需要以浪(furlongs,英国的计量单位)/每两周而不是以英里/每小时 (MPH) 的方法来表达速度,开发人员发现自己经常要执行这种转换。使用 Groovy 的  ExpandoMetaClass,您可以添加一个 FF 属性给处理转换的 Integer ,如清单 5 所示:

清单 5. 使用 ExpandoMetaClass 添加一个浪/两周的计量单位给 Integer
static {
  Integer.metaClass.getFF { ->
    delegate * 2688
  }
}

@Test void test_conversion_with_expando() {
  assertTrue 1.FF == 2688
}

ExpandoMetaClass 的替代方法是,创建一个类别 包装器类,这是从 Objective-C 借来的概念。在清单 6 中,我添加了一个(小写) ff 属性给 Integer

清单 6. 通过一个类别类添加计量单位
class FFCategory {
  static Integer getFf(Integer self) {
    self * 2688
  }
}

@Test void test_conversion_with_category() {
  use(FFCategory) {
    assertTrue 1.ff == 2688
  }
}

一个类别类是一个带有一组静态方法集合的普通类。每个方法接受至少一个参数;第一个参数是这种方法增强的类型。例如,在 清单 6 中, FFCategory 类拥有一个 getFf() 方法,它接受一个 Integer 参数。当这个类别类与 use 关键字一起使用时,代码块内所有相应类型都被增强。在单元测试中,我可以在代码块内引用 ff 属性(记住,Groovy 自动将 get 方法转换为属性引用),如在 清单 6 的底部所示。

有两种机制可供选择,让您可以更准确地控制增强的范围。例如,如果整个系统使用 MPH 作为速度的默认单位,但也需要频繁转换为浪/每两周,那么使用 ExpandoMetaClass 进行全局修改将是适当的。

您可能对重新开放核心 JVM 类的有效性持怀疑态度,担心会产生广泛深远的影响。类别类让您限制潜在危险性增强的范围。以下是一个来自真实世界的开源项目示例,它极好地利用了这一机制。

easyb 项目(参阅 参考资料)让您可以编写测试,以验证正接受测试的类的各个方面。请研究清单 7 所示的 easyb 测试代码片段:

清单 7. easyb 测试一个 queue
it "should dequeue items in same order enqueued", {
    [1..5].each {val ->
        queue.enqueue(val)
    }
    [1..5].each {val ->
        queue.dequeue().shouldBe(val)
    }
}

queue 类不包括 shouldBe() 方法,这是我在测试的验证阶段所调用的方法。easyb 框架已为我添加了该方法;清单 8 中所显示的在 easyb 源代码中的 it() 方法定义,演示了该过程:

清单 8. easyb 的 it() 方法定义
def it(spec, closure) {
  stepStack.startStep(BehaviorStepType.IT, spec)
  closure.delegate = new EnsuringDelegate()
  try {
    if (beforeIt != null) {
      beforeIt()
    }
    listener.gotResult(new Result(Result.SUCCEEDED))
    use(categories) {
      closure()
    }
    if (afterIt != null) {
      afterIt()
    }
  } catch (Throwable ex) {
    listener.gotResult(new Result(ex))
  } finally {
    stepStack.stopStep()
  }
}

class BehaviorCategory {
  // ...

  static void shouldBe(Object self, value) {
    shouldBe(self, value, null)
  }

  //...
}

清单 8中,it() 方法接受了一个 spec (描述测试的一个字符串)和一个代表测试的主体的闭包块。在方法的中间,闭包会在 BehaviorCategory 块内执行,该块出现在清单的底部。BehaviorCategory 增强 Object,允许 Java 世界中的任何 实例验证其值。

通过允许选择性增强驻留在层次结构顶层的 Object,Groovy 的开放类机制可以轻松地实现为任何实例验证结果,但它限制了对 use 块主体的修改。

Scala 的隐式转换

Scala 使用隐式转换 来模拟现有类的增强。隐式转换不会对类添加方法,但允许语言自动将一个对象转换成拥有所需方法的相应类型。例如,我不能将 isBlank() 方法添加到 String 类中,但我可以创建一个隐式转换,将 String 自动转换为拥有这种方法的类。

作为一个示例,我想将 append() 方法添加到 Array,这让我可以轻松地将 Person 实例添加到适当类型的数组,如清单 9 所示:

清单 9.将一个方法添加到 Array 中,以增加人员
case class Person (firstName: String, lastName: String) {}

class PersonWrapper(a: Array[Person]) {
  def append(other: Person) = {
    a ++ Array(other)
  }
  def +(other: Person) = {
    a ++ Array(other)
  }
}
    
implicit def listWrapper(a: Array[Person]) = new PersonWrapper(a)

清单 9中,我创建一个简单的 Person 类,它带有若干个属性。为了使 Array[Person](在 Scala 中,一般使用 [ ] 而不是 < > 作为分隔符)Person 可知,我创建一个 PersonWrapper 类,它包括所需的 append() 方法。在清单的底部,我创建一个隐式转换,当我在数组上调用 append() 方法时,隐式转换会自动将一个 Array[Person] 转换为 PersonWrapper。清单 10 测试该转换:

清单 10. 测试对现有类的自然扩展
val p1 = new Person("John", "Doe")
var people = Array[Person]()
people = people.append(p1)

清单 9中,我也为 PersonWrapper 类添加了一个 + 方法。清单 11 显示了我如何使用操作符的这个漂亮直观的版本:

清单 11. 修改语言以增强可读性
people = people + new Person("Fred", "Smith")
for (p <- people)
  printf("%s, %s\n", p.lastName, p.firstName)

Scala 实际上并未对原始的类添加一个方法,但它通过自动转换成一个合适的类型,提供了这样做的外观。使用 Groovy 等语言进行元编程所需要的相同工作在 Scala 中也需要,以避免过多使用隐式转换而产生由相互关联的类所组成的令人费解的网。但是,在正确使用时,隐式转换可以帮助您编写表达非常清晰的代码。


结束语

来自 GoF 的原始解释器设计模式建议创建一个新语言,但其基础语言并不支持我们今天所掌握的良好扩展机制。下一代 Java 语言都通过使用多种技术来支持语言级别的可扩展性。在本期文章中,我演示了操作符重载如何在 Groovy、Scala 和 Clojure 中工作,并研究了在 Groovy 和 Scala 中的类扩展。

在下期文章中,我将展示 Scala 风格的模式匹配和泛型的组合如何取代一些传统的设计模式。该讨论的中心是一个在函数式错误处理中也起着作用的概念,这一概念将是我们下期文章的主题。

参考资料

学习

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=820636
ArticleTitle=函数式思维: 函数设计模式,第 3 部分
publish-date=06112012