跨越边界: 延迟绑定 javarubysmalltalk设计模式程序语言
简介: 静态类型语言(如 Java™ 语言和 C)可以在编译时把方法调用绑定到其实现中。这项策略让这类语言可以执行更丰富的语法和类型检查。比起不具有此项编译时检查功能的动态类型语言来说,静态类型语言更加稳定且具有更佳的性能。然而静态类型语言存在一个严重的局限性:前期绑定。一些动态类型语言(如 Ruby、Smalltalk 和 Self)允许延迟绑定,它们可以实现另一个层次的编程功能。
几年前,我有幸教我的大女儿学滑雪。滑雪学校提供的工具里有一条绳子,用这条绳把雪橇的尖端绑在一起。利用这根绳,初学滑雪的人能够轻易地实现较为理想的滑雪动作,如转弯、减速和停止。最初,这些滑雪者十分依赖于这条绳子。我女儿还发誓说她离开这条绳就不滑雪。当然,她这样说是因为她刚刚开始学所以对整个过程不了解。这没关系。因为我知道将来她最终会在滑雪时迫使自己冲破这一束缚。
作为一名 Java 开发人员,我也有过类似的经历。我喜爱静态类型提供的安全性。我曾和 Dave Thomas 就静态类型检查的优点这个问题进行过辩论,但我却不能被说服。如果没有最初的这条“安全之绳”,滑雪会是一种截然不同的体验。对于许多人来说,静态类型也是一种依赖,这和雪橇上的束带没有什么区别。Dave 认为我只是还没有足够的经历来理解动态语言的好处罢了。当我认识到 Ruby 和 Smalltalk 的妙处之后,我开始明白对动态类型我的确了解得不够,但这也加深了我对它的理解(参阅之前的 跨越边界 系列,获取对静态类型策略和动态类型策略的总体比较)。对我来说它最大的好处是延迟绑定。本文使用 Ruby、Smalltalk 和一个叫做 Self 的 Smalltalk 的派生语言的编程例子探讨了延迟绑定的好处。
编程语言能够将对函数(或在面向对象语言中的方法)的声明从其调用中分离出来。可以声明一个方法并使用单独的语法调用这个方法,但最终系统需要将这两者绑到一起。将调用和实现绑到一起的过程叫做绑定。前期先绑定到类型再绑定到实现,还是后期先绑定到类型再绑定到实现,这对一门给定语言的编程体验来说有着显著的影响。大多数面向对象的语言都在后期绑定到实现,从而允许多态性 ,该功能让您能够将许多不同的子类型表示为一种类型。Java 代码和 C 主要在前期的一个编译的步骤里绑定到一种类型。使用此策略,编译器就有足够的信息可以捕获许多不同类型的 bug,比如说方法参数或返回值之间类型的不兼容。例如,清单 1 中的代码就会产生几个编译错误:
清单 1. 编译时绑定
... x = 1.0 + anObject.methodThatReturnsString(); anObject.methodRequiringIntParameter(aString); ... |
通过编译时捕获错误,前期绑定有助于在提早检查出问题。对这种协助的价值存在很多质疑。很明显,类型检查并不足以证明代码的正确性。必需经过测试,而这种测试所发现的问题也常常与类型有关。但这种协助是有代价的。使用前期绑定,编译器必需对目标对象的结构作出如下假设:
- 必须有一个预定义好的有限方法列表。
- 想要调用的方法必须在编译时存在。
- 必须绑定到已知的类型、接口或公共超类。
- 绑定目标的方法和属性必须是固定的。
这些假设所带来的限制在最初也许并不明显,但如果您再深入研究一下,很快就能发现后期绑定的益处,其中一些在跨越边界 系列中已经介绍过:
- Ruby on Rails 的持久性框架 Active Record 含有不具有编译时属性的对象。建立了一个数据库连接后,Rails 在运行时为数据库中的每一行添加一个属性到该模型。Active Record 用户并不总需要随着数据模型的改变而改变模型对象。正是后期绑定让这一切成为可能。
- 单元测试员们必须经常编写代码,这些代码可以将测试实现(如 stub 或 mock)和产品实现绑定到相同的方法调用。Java 开发人员常使用叫做依赖性注入的设计模式来解决此问题,该模式常需要复杂的框架,如 EJB 或 Spring。这些框架虽然有很多优势,但也带来了相当大的复杂性。面向方面的编程或有着依赖性查找的对象工厂也能解决该问题,但也同样会增加复杂性。动态语言中的测试框架(如 Smalltalk 和 Ruby)不需要依赖性注入框架,因为它们能够选择在运行时绑定到所需的实现。它们常常能够用一小段代码实现相同的目标。
- 整个 Smalltalk 语言是构建在延迟绑定的前提之下的。Smalltalk 开发人员在一个叫做 image 的持续运行的应用程序之上构建。因为它总在运行,对任何类中方法的任何添加、删除或更新操作都发生在运行时。延迟绑定让 Smalltalk 应用程序在整个开发周期中可以持续运行。
连续区间
静态或动态只是连续区间中的点。一些语言高度静态。Java 语言比 C 或 C++ 更为动态。连续区间中的每个点都有一套自已的折衷方式。Java 语言有许多有助于延迟绑定的功能,这些功能都以相对较高的复杂度为代价。反射、依赖性注入以及 XML 配置都可用于延迟绑定和减少耦合。一直以来,Java 语言都是通过添加功能(如面向方面编程)来使其更为动态的。您也许会认为 Java 开发人员拥有了所需的一切。但还有一类语言 —— 如 Smalltalk、Self 和 Ruby —— 要比 Java 还要动态且允许用更佳的方式来延迟绑定。
这些语言可以提供 Java 语言所没有的技术,如覆盖在方法丢失时发生的行为。请记住,Java 语言需要存在用于编译时绑定的方法。其他语言允许打开的类,这些类能够基于开发人员需求进行改变。如果您曾长久关注框架的发展,就会发现对延迟绑定的需求在日益增长,这种需求导致了 Java 语言中出现了许多很不自然的捆绑,它们使这门语言变得复杂且模糊。而其他语言则持观望态度,等着我们去构建这类框架来实现更高的抽象级别以及更高的效率。对于您来说,好处很明显:可以获得一门更易表达且更高效的语言。
为了理解连续区间中的点,可以看一下反射的情况。使用 Java 语言,可以在运行时装载类,通过反射找到一个方法,为该方法验证正确的参数设置,然后执行该方法。要实现这些功能很可能需要编写很多代码行。但为延迟绑定所做出的这些努力常常会得不偿失,所以大多数 Java 应用程序开发人员不会使用此项技术。Ruby、Smalltalk 和 Self 都使用一种原操作(如 Ruby 中的object.send(method_name)
)来完成此功能。该技术改变着这些语言中编程的本质,这样的例子随处可见。
对类型策略和绑定策略越是深入研究就越会发现:等到运行时再绑定到调用或类型会根本性地改变编程的过程,从而开启一个全新的可能世界。没错,您会发现这样不那么安全。但您也会发现:重复少了、功能强大了并且在减少代码行的同时有了更大的灵活性。为了理解这一切是如何运行的,下面将快速介绍一下 Smalltalk、Self 和 Ruby。首先介绍延迟绑定调用方法,然后介绍一些可以在运行时改变类定义的可用技术。
延迟调用
在静态语言中,编译器在编译时直接将调用绑定到实现。动态语言则有些不同。Ruby、Smalltalk 和 Self 依赖于消息传送来延迟绑定。客户机使用消息传送来指定目标对象、消息和参数集。这完全是一个运行机制。所以动态语言有效地添加了一级间接寻址。它们将消息名绑定到一个对象上,而不是从调用绑定到类型再到实现。然后,将该对象绑定到一个名称或标记,并使用该名称或标记在运行时查找相关的实现。它是这样工作的:
- 客户机向目标对象发送一条消息。
- 该消息有一个名称和零个或多个参数。
- 该目标(可以是类或对象)查找是否有与此消息同名的方法。
- 如果有,目标对象调用该方法。
- 如果没有,目标对象向父对象发送一条消息。父对象可能是一个超类(Smalltalk)、一个父对象(Self)或一个模块(Ruby)。
- 如果在任何父对象中都没找到该方法,会调用一个错误捕捉方法。
上述所有步骤都发生在运行时 。这意味着在执行该消息的语句前,既不需要目标方法,也不需要实现。在 Smalltalk 中,一切皆是对象,且大多数行为都利用消息传送。甚至于控制结构都依赖它。Smalltalk 有三种消息:一元消息(无参数)、二元消息(带固定的参数集)和关键字消息(带已命名的参数)。例如:
-
7 sin
将一元消息sin
发送给目标对象7
。 -
3 + 4
是一条二元消息。它将带参数4
的+
消息发送给对象3
。 -
array at: 1 put "value".
是一条关键字消息。该代码将消息at: put:
发送到array
对象,将value
放置在数组中1
的位置。 -
condition ifTrue: [doSomething] ifFalse: [doSomethingElse].
是一条关键字消息。括号中的代码叫做闭包或代码块 。这个代码样例将:ifTrue :ifFalse
消息发送到condition
对象。如果条件为真,Smalltalk 执行[doSomething]
代码块;否则执行[doSomethingElse]
代码块。
Ruby 支持消息传送和直接方法调用。Ruby 中的消息传送看起来有些许不同,但前提是一致的。在清单 2 中,定义了带 speak
方法和sleep
方法的 Dog
类。直接调用 speak
方法并通过 send
方法按 name
调用 sleep
方法。
清单 2. 在 Ruby 中用两种方式调用方法
irb(main):001:0> class Dog irb(main):002:1> def speak irb(main):003:2> puts "Arf" irb(main):004:2> end irb(main):005:1> def sleep irb(main):006:2> puts "Zzz" irb(main):007:2> end irb(main):008:1> end => nil irb(main):009:0> dog = Dog.new => #<Dog:0x34fa9c> irb(main):010:0> dog.speak Arf => nil irb(main):011:0> message = "sleep" => "sleep" irb(main):012:0> dog.send message Zzz |
如果您是一名 Java 程序员,这些都不是什么新鲜内容。您当然可以通过 Java 的反射 API 处理后期绑定。只是需要更多的努力来实现同样的功能。但用任意字符串(可以通过编程进行修改)调用方法确实打开了语言中一项额外的功能。最为重要的是,能够轻易地调用在运行时过后所添加的任意行为。
Self 语言将消息传送这一概念发挥到极致。在 Self 中,一切皆是对象。通过消息传送专门地调用所有 Self 行为。Self 不含类(通过复制其他对象创建新对象)也不含变量(只有带方法和对象的已命名的 slot)。Self 使用消息传送来调用已命名的 slot 和方法。其他大多数面向对象的语言通过允许直接访问实例数据弱化了封装的价值,但 Self 通过加强用于访问方法和实例数据的相同协议克服了这一不足。发送一条消息调用一个 slot。如果一个 slot 有一个对象,该对象返回其值。Self 对访问属性和访问方法不作区别。这项简化措施让 Self 成为了一门简单但功能却很强大的编程语言。像 Smalltalk 一样,Self 用消息传送来表示控制结构,Self 依赖于一个一直运行的 image。Self 中的对象都有一个父对象以及包含其他对象或方法的 slot。从 Self 对消息传送的严重依赖以及 Self 应用程序一直运行这一概念可以看出:延迟绑定是 Self 的中心课题。
Self 中的消息传送和 Smalltalk 中的几乎一样。在 Smalltalk 中 count <- 3
将数字 3
赋给一个叫做 count
的变量。但 Self 没有变量,也没有赋值。需要发送消息 object count: 3
将 object
的 count
slot 的值设置为 3
。要检索 count
的值,只需简单地使用 object count
。
当您将 method_missing
加入进来后,消息传送就有了另一层次的意义。请记住,这项功能对于动态语言来说是开放的,可对于在编译时绑定到类型的语言来说却是彻底关闭的。前期绑定的好处(强迫该方法必须存在)原来也是其核心的弱点。Ruby 让您能够越过method_missing
行为来调用在运行时也许不存在的方法的行为。Active Record 将类和表关联起来,并按照数据库中的每一列给每个类动态地添加一个属性。Active Record 也为每个列或列的集合自动添加寻找程序!例如,映射到 people
数据库表的 Person
类具有first_name
和 last_name
列,person.find_by_first_name_and_last_name
是一个合法的 Active Record 语句,尽管这样的方法并不存在。 Active Record 越过 method_missing
并在运行时解析该方法名以确定该方法名是否有效。结果就会产生一个相当有效的框架用于包装数据库表,该框架通过后期绑定而获得了极大的简化。但到目前为止,我还只是探讨了有关调用的内容。现在可以讨论一下有关添加行为的话题了。
在运行时添加行为
所有这三种语言(Self、Smalltalk 和 Ruby)都使在运行时添加行为变得十分简单。使用 Self 和 Smalltalk,对现有类所做的任何更改都是通过定义一个运行时修改来实现的。当添加一个方法时,也有效地修改了一个活动的类。在 Self 中添加或删除 slot 很简单:只需要发送 _add_slot
消息。类似地,在 Smalltalk 中,可以通过调用相应的消息(在一些地方称作 compile
)来添加方法或属性。在这两种情况下,都可以直接在 image 中修改类的单个副本。接下来我要对 Ruby 中对类添加行为进行稍微深入一点的探讨。
Ruby 框架常使用在运行时通过几种不同的机制修改类的技术。最简单的是打开类。可以打开任何的 Ruby 类,并通过重命名、添加或删除方法或属性来更改它。假设您想要在 Ruby 中扩展数字来简化柱状图的实现过程。您可以打开 Fixnum
类,并添加一个方法来打印出相应长度的柱,如清单 3 所示。
清单 3. 扩展 Fixnum
irb(main):001:0> 7.class => Fixnum irb(main):002:0> class Fixnum irb(main):003:1> def bar irb(main):004:2> puts('-'*self) irb(main):005:2> end irb(main):006:1> end => nil irb(main):007:0> 7 => 7 irb(main):008:0> 7.bar ------- => nil irb(main):009:0> [6, 8, 2, 4, 9].each {|i| i.bar} ------ -------- -- ---- --------- => [6, 8, 2, 4, 9] |
这个例子展示了当您在运行程序时知道了该行为后该如何扩展 Ruby 类。当想要基于未知规则给一个类添加任意行为时,则需要使用一项不同的技术。可以在类的上下文中估计字符串。以清单 4 为例。清单 3 扩展了 Fixnum
类,该类被认为是静态的。在清单 4 中,该类根本不需要被认为是静态的。对 Dog
的扩展让您可以添加任意行为到 dog
或任何其他类中。该类打开自己,添加进一个具有您指定的名称的方法,并添加您指定给该类的行为:
清单 4. 可扩展的类
class Dog def self.extend(method_name, method_body) class_eval(%Q[ def #{method_name} #{method_body} end ]) end def speak puts "Arf" end end dog = Dog.new Dog.extend("sneeze", "puts 'Achoo!'") dog.speak dog.sneeze |
清单 4 中的 extend
方法还需要进一步加以说明。这里大概地解释一下。Ruby 打开一个类并添加一个带有您提供给 Dog
类的名称及主体的方法。首先,定义一个叫做 self.extend
的方法。self.
意味着 extend
是一个类方法,即在整个类上操作的方法,比如 new
方法。其次,调用 class_eval
。这个方法打开类并在打开的类上执行下列字符串。接下来,所有在 %[
和 ]
之间的代码都被解释为单个的字符串。最后,Ruby 按 #{
和 }
之间的变量替代了该值。
现状和超越
在清单 4 的例子中,从内部扩展了 Dog
。采用相同的技术,可以用相同的方式扩展任意的 Ruby 类。现在,延迟绑定全部的能量就凸现了出来。可以为一个普通的类扩展任意的功能并调用类的新行为,尽管当编写原始类时它们根本不存在。
反射的功能也不能不提。Self、Ruby 和 Smalltalk 通常都进行反射。其消息传送功能允许不迫使用户访问物理的方法就调用方法,正如在 Java 语言中那样。class.methods
提供 Ruby 中一组方法名的数组,而 class methods
可以返回 Smalltalk 中的方法集。使用这些功能及类似的特性几乎可以找到在类或对象上进行快速自检所需的一切。
到目前为止,我主要探讨了关于绑定到方法的内容,但延迟绑定的内容要多得多。看一下清单 5 中所示的 Ruby 的方法定义。
清单 5. 添加两个数字
def add(x, y) x+y end |
如果采用类似的 Java 方法,就需要键入参数。这个 Ruby 方法返回两个数字的和。使用该方法惟一的要求是:第一个对象实现 +
而第二个对象和第一个要兼容。该方法的客户机能够确定对象是否兼容。
类似的 Java 方法只适用于单一类型的参数。而这个 Ruby 方法能够服务于浮点型、整型、字符串型以及任何支持 +
方法的类型。延迟绑定可以让单个的方法具有真正的多态性。该设计也是可扩展的,因为它能够支持当前系统尚未实现的类型。例如,此方法可以轻易地支持虚数。
用 Java 语言延迟绑定
Java 社区对静态类型检查的迷恋程度令人惊讶,Java 程序员们正在不遗余力地寻找延迟绑定的方式。有些方法是成功的。诸如 Spring 等框架的存在主要是为了延迟绑定,它有助于减缓客户机和服务之间的耦合。面向方面的编程通过提供能够扩展类的功能(甚至可以超出其当前的功能)的服务来实现延迟绑定。像 Hibernate 这样的框架也可以延迟绑定,通过反射、代理和其他工具在运行时将持久性功能添加到纯粹、普通的 Java 对象(POJO)中。现在有很多关于如何用 POJO 编程的流行书籍可供开发人员参考,这些书籍大多会使用愈加复杂的技术(比反射还要先进),而这些技术主要是为了打开类并延迟绑定,从而有效地回避了静态类型。
在其他地方,延迟绑定的方法就不那么成功。依赖于 XML 来延迟绑定的部署描述符有很多问题。对 XML 的过分依赖和我们对语言中的动态行为的强烈渴望有很大关系,因为这些语言常常有点太过静态,绑定得有点太早,并且有点太受限制。
现在已经有一些语言和技术可以为 Java 程序员们极想解决的这几类问题提供解决方案,例如透明的持久性、为可测试性减少耦合、更加丰富的插件模型等。只要看看推动 Java 持久性框架发展的元程序设计,并同 Active Record、Gemstone 或 Og(动态语言中的持久性框架)中类似的解决方案对比一下,一切就一目了然了(参见 参考资料)。现在延迟绑定变得越来越重要,并且推动该过程的那些思想和做法在其它语言中也甚为高效。当您需要进行元程序设计时,请打开您的工具箱,加入几种允许延迟绑定的语言。不要害怕跨越边界!