演化架构与紧急设计: 语言、表达性与设计:第 1 部分
 

2009-10-09 作者:Neal Ford 来源:IBM

 
本文内容包括:
发现和积累惯用模式的能力对于紧急设计至关重要。对于设计而言同样十分重要的是代码的表达性。在本系列文章中,Neal Ford 分两部分介绍了表达性和模式的交集,通过惯用模式和正式设计模式阐释了这些概念。他用动态语言为 JVM 重构了一些经典的四人组(Gang of Four)模式,以说明表达性更好的语言如何使您看到被透明度不佳的语言遮挡的设计元素。

紧急设计的一个主要推动者是发现和积累惯用模式的能力:在代码库中大量重复的流程、结构和惯例。但是,有时您并不能发现这些模式。在 演化架构与紧急设计 系列的 第 1 部分,我讨论了阻碍这些模式可见性的问题,比如过度的一般性。构建多层应用程序对于将问题分别处理,支持可扩展性和分区可能十分有效,但它隐藏了惯用模式,因为现在必须在多个层中查找它们。一个优秀的设计员和架构师需要培养洞察这些模式的眼光。

另一个阻碍积累模式(harvesting patterns)的因素是语言本身的表达性。例如,很难从汇编语言中积累模式,因为该语言的特点是抵触表达性。即使您对汇编语言的了解不亚于对母语的了解,在您编写代码的过程中仍然有无法逾越的障碍,使您无法获得完整的视角。例如,将变量传入和传出寄存器,而不能创建命名良好的变量和方法,这就意味着要将大量时间花在处理语言中固有的开销上。

与汇编语言相比,Java™ 语言有了进一步的发展,但是计算机语言的表达性差异很大。某些语言比其他语言表达性好,因此它们能更容易且更高效地发现模式。因此,本文 — 两部分中的第 1 部分 — 针对 JVM (Groovy) 使用动态语言展示一些四人组模式的其他实现方法。

重温设计模式

软件开发领域的一本影响深远的图书是 Eric Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的 Design Patterns: Elements of Reusable Object-Oriented Software(见 参考资料)。该书包含两部分:软件开发中的常见问题说明及其解决方案示例。第一部分是一个宝贵的常见问题目录,但是模式的实现不可避免地出现了对某一特定语言的偏向。虽然样例实现貌似采用了 C++ 和 Smalltalk,但是几乎没有利用 Smalltalk 的高级语言特性。在很多方面,这些实现突出了 C++ 的局限以及解决该语言中固有问题所需的迂回方法。

四人组的图书现在在术语方面仍有价值,但其实现已经过时了。这些实现解决的很多问题现在在结构上都有了更简洁的方法(通过构建相互影响类的分层结构),语言也更具威力和表达性。

自四人组图书出版以来还发生了另一个有趣的变化。很多语言已经将模式包含在语言本身里。例如,Java 语言在 JDK 1.1 和 1.2 之间改变了集合迭代的样式,用 Iterator 接口取代了 Enumerator,以便使 Java 语言中的迭代器更接近四人组的迭代器模式。语言趋向于包含模式和其他常见惯例,它们作为语言抽象自身的一部分出现。

前几个例子展示了更为现代的基于 Java 的语言包含了四人组模式,该语言中直接包含了迭代器和命令模式。

迭代器模式

四人组的书将迭代器模式定义为:

提供一种方式访问聚合对象的元素,从而无需公开其底层表示法。

迭代器模式是最先添加到 Java 语言中的模式,它是以 Iterator 接口和实现的方式添加的。Groovy 将这一技术进一步发展,添加了内部迭代器作为集合 API 的一部分。这样,您可以使用 each 方法和代码块在集合中轻松迭代,如清单 1 所示。该清单展示了内部迭代器(也称为 push 迭代器,因为它将每个元素依次推到代码块中)。

清单 1. Groovy each 操作符
 
				
def numbers = [1,2,3,4]

numbers.each { n ->
  println n
}

Groovy 支持各种集合的迭代工作,包括哈希,如清单 2 所示:

清单 2. 在哈希中迭代
 
				
def months = [Mar:31, Apr:30, May:31]

months.each { 
  println it
}  

Groovy 还实现了方便的默认行为,为名为 it 的迭代自动提供参数,您可以在代码块中引用。

而且 Groovy 支持外部迭代器(也称为 pull 迭代器,因为必须请求集合中的下一项),如清单 3 所示。这与内置到 Java 语言自身中的迭代器是完全一样的。

清单 3. pull 迭代器
 
				
iterator = numbers.iterator()
while (iterator.hasNext()) {
  println iterator.next()
}

因为迭代器太常见了,所以它根本就算不上是正式的模式;它只是一种语言的特性。这在计算机语言自身的紧急设计中非常常见。

命令模式

四人组的书将命令模式定义为:

将请求压缩为对象,使您能用不同的请求,队列或日志请求,参数化客户机,并支持可撤销的操作。

Java 语言中这种模式的常见实现是创建 Command 类,该类包含 execute() 方法。命令设计模式作为代码块出现在 Groovy 中,是定义在独立的花括号({})中的任何东西。不强制您创建新类和相应方法,您可以通过调用其 call() 方法或通过在包含代码块(带有或没有参数)的变量名后放置括号来执行代码块。清单 4 显示了示例:

清单 4. Groovy 中带有代码块的命令模式
 
				
def count = 0
def commands = []

1.upto(10) { i ->
    commands.add { count++ }
}

println "count is initially ${count}"
commands.each { cmd ->
    cmd()
}
println "did all commands, count is ${count}"

支持撤销

在类似匿名内部类的机制中使用代码块的一个优势是其简洁性。因为指定可撤销的操作是一个常见需求,所以这个语法变得很重要。查看清单 5 中的 Groovy 代码,它显示了如何使用代码块和命令设计模式配合支持可撤销的操作:

清单 5. 使用代码块支持可撤销的操作
 
				
class Command {
    def cmd, uncmd
    
    Command(doCommand, undoCommand) {
        cmd = doCommand
        uncmd = undoCommand
    }
    
    def doCommand() {
        cmd()
    }
    
    def undoCommand() {
        uncmd()
    }
}

def count = 0
def commands = []
1.upto(10) { i ->
    commands.add(new Command({count++}, {count--}))
}
println "count is initially ${count}"
commands.each { c -> c.doCommand() }
commands.reverseEach { c -> c.undoCommand() }
println "undid all commands, count is ${count}"
commands.each { c -> c.doCommand() }
println "redid all command, count is ${count}" 

将代码块作为参数传入比较麻烦,但它支持简洁但仍然可读的 commands.add(new Command({count++}, {count--})) 语法。

代码块、表达性和惯用模式

尽管代码块和匿名内部类之间的不同看似只是语义不同,但实际上它对代码的可读性以及积累惯用模式的容易程度会有很大影响。考虑一下这里我称为 Unit of Work 的惯用模式示例。首先是 Java 版本(使用匿名内部类),如清单 6 所示:

清单 6. 使用匿名内部类的 Unit of Work 模式
 
				
public void wrapInTransaction(Command c) throws SQLException {
    setupDataInfrastructure();
    try {
        c.execute();
        completeTransaction();
    } catch (RuntimeException ex) {
        rollbackTransaction();
        throw ex;
    } finally {
        cleanUp();
    }
}

public void addOrderFrom(final ShoppingCart cart, final String userName,
                         final Order order) throws SQLException {
    wrapInTransaction(new Command() {
        public void execute() throws SQLException{
            add(order, userKeyBasedOn(userName));
            addLineItemsFrom(cart, order.getOrderKey());
        }
    });                
}

现在看一下用 Groovy 编写的同样的示例,如清单 7 所示,它利用了代码块提供的更简洁的语法:

清单 7. 使用代码块实现 Unit of Work 模式
 
				
public class OrderDbClosure {
   def wrapInTransaction(command) {
     setupDataInfrastructure()
     try {
       command()
       completeTransaction()
     } catch (RuntimeException ex) {
       rollbackTransaction()
       throw ex
     } finally {
       cleanUp()
     }
   }
   
   def addOrderFrom(cart, userName, order) {
     wrapInTransaction {
       add order, userKeyBasedOn(userName)
       addLineItemsFrom cart, order.getOrderKey()
     }
   }
}

尽管 清单 7 中定义 wrapInTransaction() 的代码与 清单 6 中的代码类似,但是调用代码更加简洁。Java 版本需要创建很多语法来实现匿名内部类;这些语法将我要表达的意思弄得含混不清。看到 设计元素要经过的语法越多,实现现在的模式就变得越困难。Groovy 版本只要最少的语法就可以实现模式,只留下相关内容。

战略模式

四人组的书籍将战略模式定义为:

定义了一系列算法,封装每个算法并使其可互换。战略使算法随着使用它的客户机的不同而独立地进行变化。

Java 语言中战略模式的传统实现需要一个接口来定义算法的语义以及提供实现的具体类。战略的 Java 实现如清单 8 所示,该战略是数字的乘法运算:

清单 8. Java 语言的乘法战略
 
				
public interface Calc {
    public int product(int x, int y);
}

public class CalcByMult implements Calc {
    public int product(int x, int y) {
        return x * y;
    }
}

public class CalcByAdds implements Calc {
    public int product(int x, int y) {
        int result = 0;
        for (int i = 1; i <= y; i++)
            result += x;
        return result;
    }
}

Java 语言迫使您创建结构来解决问题。事实上,四人组解决方案对创建结构来实现模式解决方案存在着严重的偏见 — 您是否注意到了每个模式都包含一个展示解决方案的 UML 图表?但是构建结构并不总是解决问题的最清楚或最简洁的方式。看看清单 9,它用 Groovy 实现了同样的模式:

清单 9. 用 Groovy 实现的乘法战略
 
				
interface Calc {
    def product(n, m)
}

def multiplicationStrategies = [
    { n, m -> n * m } as Calc,
    { n, m -> def result = 0
      n.times { result += m }
      result 
    } as Calc
]

def sampleData = [
    [3, 4, 12],
    [5, -5, -25]
]

sampleData.each{ data ->
    multiplicationStrategies.each{ calc ->
        assert data[2] == calc.product(data[0], data[1])
    }
}   

在 Groovy 示例中,您不需要显式地创建额外的类来实现定义该调用语义的接口。Groovy 中强大的 as 操作符接收一个代码块并生成一个新类来实现该接口,您可以像调用实现该接口的具体类一样调用它。这样,在本例中,动态定义战略的所有代码块仍可作为正式具体类,实现 Calc 接口。

解释器模式

四人组解释器模式是一种特殊的情况。其定义为:

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

该模式本质上就是 “免交罚款出狱” 模式。它得到了 Philip Greenspun 的正式认可和支持,也就是 Greenspun's Tenth Rule(见 参考资料):

任何足够复杂的 C 或 Fortran 程序都包含一个特别的非正式指定的、充斥着 bug 的缓慢实现,用于实现 Common Lisp 的部分功能。

他的意思是您用较弱的语言构建越来越复杂的软件时,您实际是在实现来自更为强大的语言(比如 Lisp)的特别功能,一次一个功能,却没能实现它。解释器模式承认您的基本语言或许不能胜任目前的任务,那么在这种情况下,最佳的解决方案是使用该语言在其之上构建一个更好的语言。

该模式展示了四人组书籍中想法的老化。四人组主张放弃核心语言并在其上构建全新的语言,创建您自己的 lexer、解析器、语法等等。但是该模式的中间阶段在过去的几年中已经发展成了主流(虽然从 Lisp 开始它就出现了):通过在其上构建特定于域的语言(DSL)使您的语言更具表达性。

在 Java 语言之上构建 DSL 非常困难,因为该语言语法非常严格,并且它几乎没有语言级扩展点。在 Groovy 和 Ruby 之类的语言中构建 DSL 很常见,因为这些语法能够扩展并且包容性更好。

清单 10 显示了一个示例应用程序,这是用 Groovy 编写的一个小菜谱 DSL:

清单 10. 用 Groovy 编写的菜谱 DSL
 
				
def recipe = new Recipe("Spicy Bread")
recipe.add 1.gram.of("Nutmeg")
recipe.add 2.lbs.of("Flour")
println recipe

清单 10 中比较有意思的代码行是中间几行,它定义了菜谱的原料。Groovy 使您能将新方法添加到任何类(包括 java.lang.Integer,这是 Groovy 对待数字字面值的方式)。这是我们能够调用数字值上方法的方式。要向现有类添加新方法,可以使用叫做 ExpandoMetaClass 的 Groovy 机制,如清单 11 所示:

清单 11. 通过 ExpandoMetaClassInteger 添加方法
 
				
  Integer.metaClass.getGram { ->
    delegate
  }
  Integer.metaClass.getGrams {-> delegate.gram }

  Integer.metaClass.getPound { ->
    delegate * 453.29
  }

  Integer.metaClass.getPounds {-> delegate.pound }
  Integer.metaClass.getLb {-> delegate.pound }
  Integer.metaClass.getLbs {-> delegate.pound }

清单 11 中,我在 Integer 的元类上定义了一个新属性,叫做 getGram(它使我能从 Groovy 中调用它并且不用 get 前缀)。在属性定义中,delegate 指的是实例 Integer 的值;我使 DSL 中所有度量单位都是克,这样它会返回整数值。DSL 的目标之一是流畅性,因此我还定义了一个复数版本的 gram 属性,叫做 getGrams,使 DSL 代码更可读。我还需要支持磅作为度量单位,因此我还定义了一个 pound 属性家族。

新属性处理 DSL 的第一部分,of 方法是惟一剩余的部分。of 也是添加到 Integer 的方法,它出现在清单 12 中。该方法接受单个参数,指定原料名称,设置数量并返回新创建的原料对象。

清单 12. 添加到 Integer 中的 of 方法
 
				
  Integer.metaClass.of { name ->
    def ingredient = new Ingredient(name);
    ingredient.quantity = delegate
    ingredient
  }

清单 10 的代码中存在的微妙之处被 清单 12 中的代码公开了。尽管 DSL 的第一行(recipe.add 1.gram.of("Nutmeg"))现在工作得很好,第二行因为我定义的 of 方法不再适用而失败了。一旦对 of 的调用出现在样例行 recipe.add 2.lbs.of("Flour") 中,调用类型就从 Integer 变成了 BigDecimal(Groovy 对于浮点数字的默认格式)。这是怎么回事呢?在对 pounds 的调用中,返回类型现在是浮点数字 (2 * 453.29)。因此我需要将额外的 of 方法附加到 BigDecimal,如 清单 13 所示:

清单 13. 添加到 BigDecimalof 方法
 
				
  BigDecimal.metaClass.of { name ->
    def ingredient = new Ingredient(name);
    ingredient.quantity = delegate
    ingredient
  }    

令人惊讶地是,这个问题在 DSL 实现中经常出现。很多 DSL 需要表示量的东西:1 周、2 磅、6 美元。向 Integer 添加方法允许您创建更具表达性的代码,因为您可以使用真实的数字表示数值。DSL 中很多代码行经常以数量开始,调用一些中间方法来完成工作并最终返回有趣的最后一个类型的实例。在 清单 10 中,数量开始方法调用,开始是 Integer,然后是 BigDecimal,最后返回 Ingredient。DSL 旨在创建更精简的代码,去除了无用的冗长的语法。删除语法有助于改善可读性,从而使您更容易发现隐藏在代码中的设计元素,这些设计元素往往为必要但又杂乱的语法所遮挡。

结束语

在本文中,我介绍了语言的表达性,它会影响代码的可读性和发现(从而积累)代码中惯用模式(要发现和重用的真正设计元素)的能力。我介绍了如何用更具表达性的语言(比如 Groovy)实现几个四人组模式。在第 2 部分中,我将继续讨论表达性与语言的交集,更好地表达正式设计模式的方式,以及某些语言如何为您赋予在表达性不好的语言中根本不存在的能力。

参考资料

学习 获得产品和技术 讨论

火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织