函数式思维: 耦合和组合,第 2 部分

面向对象构建块 VS 函数式构建块

习惯使用面向对象(继承、多态性等)构建块的程序员会越发对于该构建块的缺点和替代方案视而不见。函数式编程是使用不同的构建块来实现重用,它基于更通用的概念,比如列表转换和可移植代码。函数式思维 系列文章的这一期将比较通过继承进行的耦合与作为重用机制的组合,并指出命令式编程和函数式编程的主要区别。

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

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



2011 年 12 月 01 日

关于本系列

本系列旨在重新调整您的函数式思维定势,帮助您以新的方式来思考常见的问题,并找出改进您日常的编码工作的方法。探讨了函数式编程概念、充许使用 Java 语言进行函数式编程的框架、在 JVM 上运行的函数式编程语言以及语言设计的一些未来学习方向。本系列面向那些了解 Java 语言及其抽象工作原理但又没有或只有少许关于函数式语言使用经验的开发人员。

在本系列的 上一期,我详细阐述了各种代码重用的技术。在面向对象版本中,我提取了复制的方法,将其连同 protected 字段移到一个超类中。在函数式版本中,我提取了纯函数(即没有副作用的函数)并将其放入它们各自的类中,通过提供参数值来调用它们。我将重用机制从 protected field via inheritance 更改为method parameters。由面向对象语言组成的功能(比如继承)具有明显的益处,但是同时也具有疏忽的副作用。正如一些读者所精确评论的,许多经验丰富的 OOP 开发人员正因为这个原因学会了 通过继承共享状态。如果面向对象已根植于您脑中,那么您就很难看到其他的替代方案。

在这一期中,我将比较通过语言机制进行的耦合与联同可移植代码作为提取可重用代码的方法的组合,这也可用于揭开代码重用的一个主要哲学差异。首先,我将重温一个典型的问题:如何在存在继承的情况下编写一个合适的 equals() 方法。

重温 equals() 方法

在 Joshua Bloch 的 Effective Java 一书中,其中有一节描写了如何巧妙编写合适的 equals()hashCode() 方法(参见 参考资料)。同等语义和继承之间的交互会产生一些复杂的情况。Java 中的 equals() 方法必须符合 Javadoc 为 Object.equals() 所指定的如下特征:

  • 具有反射性:对于任何非空的参考值 x,x.equals(x) 应该返回真值。
  • 具有对称性:对于任何非空的参考值 x 和 y,当且仅当 y.equals(x) 返回真值是,x.equals(y) 也应该返回真值。
  • 具有传递性:对于任何非空的参考值 x 、y 和 z,如果 x.equals(y) 返回真值并且 y.equals(z) 也返回真值,那么 x.equals(z) 也应该返回真值。
  • 具有一致性:对于任何非空的参考值 x 和 y,多重调用 x.equals(y) 会一致返回真值或一致返回假值,前提是在对象的等同性比较中使用的信息未经过修改。
  • 对于任何非空的参考值 x ,x.equals(null) 应该返回假值。

在此示例中,Bloch 创建了两个类:PointColorPoint,并试图创建一个 equals() 方法来适用于这两个类。设法忽略继承类中的额外字段则会破坏对称性,试图解释说明它会破坏传递性。Josh Bloch 提出了一个难解的问题:

没有一种方法可以扩展可实例化类并在保存等同性契约时添加某个方面。(There is simply no way to extend an instantiable class and add an aspect while preserving the equals contract. )

如果您不需要关心继承的可变字段,那么实现等同性会较为简单。添加耦合机制,比如继承,来创建细微的差别和陷阱(事实证明还有一个可解决保留继承问题的方法,但其代价是要额外添加一个相关方法。请参见侧栏中的 继承和 canEqual()

继承和 canEqual()

Programming Scala 中,作者提供了一个机制,甚至允许在存在继承的情况下实现等同性(参见 参考资料)。Bloch 所讨论的问题的根源在于父类不能足够 “了解” 子类以确定他们是否参与等同性比较。要解决此问题,您可以对基类添加一个 canEqual() 方法并为您想要进行等同性比较的子类覆盖它。这样可使当前类(通过 canEqual())决定对此两种类型执行等同性比较是否合理和明智。

该机制解决了此问题,但是其代价是要通过 canEqual() 方法在父类与子类之间添加另一个耦合点。

回想起本系列先前两期文章中 Michael Feathers 所提到的话:

面向对象的编程通过封装 移动部件来让代码变得易于理解,而函数式编程则通过尽量减少 移动部件来使代码变得易于理解。

实现 equals() 中碰到的难点阐明了 Feathers 提到的移动部件 的暗喻。继承是一个耦合机制:它使用关于可见性、方法调度等定义明确的规则绑定两个实体。在 Java 之类的语言中,多态性也绑定到继承。正是那些耦合点使 Java 成为一种面向对象的语言。但是对移动部件的支持会产生一系列的后果,特别是在语言层上。众所周知直升飞机很难飞起来,是因为飞机员的四肢都有操纵装置。移动其中一个就会影响到其他三个,所以飞行员必须熟练于处理每一个操纵装置对其他三个操纵装置所产生的不良影响。语言部件就像直升飞机操纵装置一样:您不能轻易地对其进行添加(或更改),因为会对其他的部件产生影响。

继承是面向对象语言的一个自然部件,许多开发人员忽视了这样的一个事实,继承的核心是一个耦合机制。当奇怪的东西发生中断或无法运行时,您只需了解规则(有时晦涩难懂)来缓解该问题,再继续进行其他操作。然而,那些隐性的耦合规则会影响您思考代码基本方面的方式,比如如何实现重用、扩展和等同性。

假如 Bloch 没有解决等同性的问题,那么 Effective Java 一书可能就不会那么成功了。相反,他借此机会,重新引用了早期书藉 prefer composition to inheritance 中的一些好的建议。Bloch 对于 equals() 问题所采取的解决方案是组合 而非耦合。它完全避开了继承,使 ColorPoint 拥有 一个对 Point 实例的引用,而不是变成一种耦合点。


组合和继承

组合(由于其传递参数的形式加上一等函数(first-class)函数)经常在函数式编程库中作为重用机制出现。相比于面向对象语言,函数式语言在一个较粗粒度级别上实现重用,使用参数化行为提取常用的实现功能。面向对象系统是由对象所组成的,这些对象通过向其他对象发送消息(或更具体的来说,对其他对象执行方法)来进行通信。图 1 显示了一个面向对象的系统:

图1. 面向对象系统
面向对象系统的图:类为圆形,圆形之间的箭头指明它们之间发送的消息

当您发现了一个有用的类集合以及其相应的消息,您可以提取这些类的图形以供重用,如图 2 所示:

图 2. 提取有用的图形部分
图表显示提取图 1 中的一组圆形和箭头(类和消息)的子集并放入一个较小的子集中。

软件工程领域中最畅销的书之一是 Design Patterns: Elements of Reusable Object-Oriented Software (参见 参考资料),这不足为奇,此书中目录所列的正好是 图 2 中所显示的提取。通过模式实现重用变得非常普遍,其他许多书藉也将此提取列入目录中(并给予独特名称)。设计模式由于提供了命名法和大量范例而为软件开发界带来了巨大的帮助。但是,从根本上来说通过模式实现重用属于细粒度操作:一个解决方案是(比如享元(Flyweight)模式)与另一个解决方案(备忘录模式)正交。通过设计模式解决的每个问题都具有较高的针对性,这使模式变得有用,因为您可以经常找到与您当前问题相匹配的模式,但是用途比较窄,因为它只是针对于这个问题。

函数式程序员也需要可重用的代码,但是他们会使用不同的构建块。函数式程序员不是在结构之间尝试创建众所周知的关系(耦合),而是试着提取粗粒度的重用机制,部分基于范畴论(category theory),这是数学的一个分支,它定义了对象类型之间的关系(态射 morphism)(参见 参考资料)。大多数的应用程序都是凭元素列表处理事情的,所以函数式方法是在列表和语境化可移植代码的理念基础上构建重用机制。函数式语言依赖于一等函数(可在任何其他语言结构可能出现的地方出现的函数)作为参数和返回值。图 3 阐明了此概念:

图 3. 通过粗粒度机制实现的重用以及可移植代码
图表代表通过粗粒度机制实现的重用以及可移植代码

图 3 中,齿轮箱代表处理一些基础数据结构的抽象,而黄色箱代表可移植代码,将数据封装在里面。


公共构建块

在本系列文章的 第 2 部分 中,我使用 Functional Java 库构造了一个数字分类器示例(参见 参考资料)。该示例使用了三种不同的构建块,但是没有相应的说明。现在我要对那些构建块进行研究。

fold 运算

数字分类器使用的方法之一是对所有聚集的因子执行合计,该方法如清单 1 所示:

清单 1. 函数式数字分类器中的 sum() 方法
public int sum(List<Integer> factors) {
    return factors.foldLeft(fj.function.Integers.add, 0);
}

首先,清单 1 中的这行代码是如何执行合计操作的,并不是很明显。这个示例是列表转换系列中的一个特定类型,称为 catamorphisms,即从一个形式到另一个形式的转换(参见 参考资料)。在这种情况下,fold 运算指的是结合列表每个元素与下一个元素的转换,为整个列表提供一个累计的结果。fold left 将从左侧方向折叠列表,以一个种子值开始并轮流与列表中的元素结合以生成一个最终结果。图 4 阐明了一个 fold 运算:

图 4. fold 运算
图中显示对列表中的元素 3、5、7、3 和 1 执行一个 fold 运算(加法)。最终的结果为 19。

因为加法是交替的,所以无论您执行的是 foldLeft() 还是 foldRight() 都无关紧要。但是有一些运算(包括减法和除法)比较关注顺序,所以此对称的 foldRight() 方法专用于处理那些情况。

清单 1 使用了 Functional Java 所提供的 add 枚举;它包含了最常用的数学运算。但是,如果您需要使用更细化的条件,请查看清单 2 中的示例:

清单 2. 带有用户提供的条件的 foldLeft()
static public int addOnlyOddNumbersIn(List<Integer> numbers) {
    return numbers.foldLeft(new F2<Integer, Integer, Integer>() {
        public Integer f(Integer i1, Integer i2) {
            return (!(i2 % 2 == 0)) ? i1 + i2 : i1;
        }
    }, 0);
}

因为 Java 还没有 lambda 块形式的一等函数(参见 参考资料)。这迫使 Functional Java 凑合使用泛型。内部类 F2 拥有 fold 运算的正确结构:它会创建一个方法来接受两个整数参数(两个相互折叠的值)和返回类型。清单 2 中的示例对奇数进行合计,如果第二个数是奇数,会返回两个数的总和,否则只返回第一个数字。

过滤

列表中另一个常用的运算是过滤:根据某些用户定义的条件过滤列表中的项以创建一个较小的列表。过滤如图 5 所示:

图 5. 过滤一个列表
图中显示过滤列表中 1 到 12 的整数以生成一个较小的列表子集。

在执行过滤时,您会根据过滤条件生成另一个可能比原始示例规模较小的列表(或集合)。在数字分类器示例中,我使用过滤运算来确定一个数字因子,如清单 3 所示:

清单 3. 使用过滤运算来确定因子
public boolean isFactor(int number, int potential_factor) {
    return number % potential_factor == 0;
}

public List<Integer> factorsOf(final int number) {
    return range(1, number + 1)
            .filter(new F<Integer, Boolean>() {
                public Boolean f(final Integer i) {
                    return isFactor(number, i);
                }
            });
}

清单 3 中的代码创建了范围从 1 到目标数字的数字(作为一个 列表),然后再应用 filter() 方法,使用(清单上方定义的)isFactor() 方法来消除不是目标数字因子的数字。

清单 3 中显示的相同函数可以使用具有闭包特征的语言更简洁地实现。如清单 4 所示的 Groovy 版本:

清单 4. Groovy 版本的过滤运算
def isFactor(number, potential) {
  number % potential == 0;
}

def factorsOf(number) {
  (1..number).findAll { i -> isFactor(number, i) }
}

Groovy 版本的 filter()findAll(),它可接受一个指明过滤条件的代码块。此方法的最后一行是方法的返回值,它是本例子中的因子列表。

映射

map 运算会对每个元素应用一个函数来将一个集合转换成一个新的集合,如图 6 所示:

图 6. 将一个函数映射到一个集合
图中显示将一个函数映射到一个集合。input (i) 集合的元素为 1、2 和 4。对这些元素中的每个元素应用 28/i 函数可生成带有元素 28、14 和 7 的新集合

在数字分类器示例中,我在优化版本的 factorsOf() 方法中使用映射,如清单 5 所示:

清单 5. 采用 Functional Java 的 map() 方法的优化的因子查找方法
public List<Integer> factorsOfOptimized(final int number) {
    final List<Integer> factors = range(1, (int) round(sqrt(number) + 1))
            .filter(new F<Integer, Boolean>() {
                public Boolean f(final Integer i) {
                    return isFactor(number, i);
                }
            });
    return factors.append(factors.map(new F<Integer, Integer>() {
        public Integer f(final Integer i) {
            return number / i;
        }
    }))
   .nub();
}

清单 5 中的代码首先将该因子列表收集到目标数字的平方根,将其保存在 factor 变量中。然后我将一个新的集合附加至由 factor 列表中的 map() 函数生成的 factor 上,应用此代码生成对称的(平方根上方匹配的因子)列表。最后的 nub() 方法能确保该列表中不存在重复值。

Groovy 版本通常比较简单,如清单 6 所示,因为其灵活的类型和代码块都是一等公民:

清单 6. Groovy 优化的因子
def factorsOfOptimized(number) {
  def factors = (1..(Math.sqrt(number))).findAll { i -> isFactor(number, i) }
  factors + factors.collect({ i -> number / i})
}

此方法命名迥异,但是 清单 6 中的代码执行了与 清单 5 中代码相同的任务:获取范围从 1 到平方根的数字,过滤出因子,然后再通过生成对称因子的函数映象每一个列表值,将列表添加至原来的列表中。


重温函数式解决方法

由于高阶函数的可用性,确定数字是否完全的整个问题在 Groovy 中只需几行代码即可完成,如清单 7 所示:

清单 7. Groovy 完全数查找器
def factorsOf(number) {
  (1..number).findAll { i -> isFactor(number, i) }
}

def isPerfect(number) {
    factorsOf(number).inject(0, {i, j -> i + j}) == 2 * number
}

当然这是一个关于数字分类的设想示例,所以很难在各种类型的代码中泛化。然而,有些项目采用了支持这些抽象的语言(不管它们是否为函数式语言),在其中我观察到了编码风格的一个重大改变。我最先在 Ruby on Rails 项目中观察到这一点。Ruby 拥有这些使用闭包块的相同列表操作方法,我不清楚 collect()map()inject() 多久出现一次。一旦您习惯于工具栏中的这些工具,就会发现您会反复地求助于它们。


结束语

了解一个诸如函数式编程的新范例的一大挑战是了解其新的构建块,并将这些构建块看作是问题的潜在解决方案。在函数式编程中,您拥有更少的抽象,但是每个抽象都具有通用性(通过一等函数添加特性)。由于函数式编程主要依赖于传递的参数和组合,所以您不用了解太多关于移动部件交互的规则,这使您的工作更加轻松。

函数式编程通过提取通用的功能部分来实现代码重用,可通过高阶函数进行定制。本文主要关注面向对象语言中固有的耦合机制所带来的一些难题,并讨论了生成可重用代码的常用方法。这是属于设计模式的领域。之后,我还展示了基于范畴论的粗粒度的机制,它可以让您利用语言设计者所写(和调试)的代码来解决问题。每一个例子中的解决方案都即简单又具有很强的说明性,通过组合参数和功能来创建通用行为,进而阐述代码重用。

在下一期,我将深入研究 JVM 中几种动态语言的函数特性:Groovy 和 JRuby。

参考资料

学习

获得产品和技术

讨论

  • 加入 developerWorks 中文社区。查看开发人员推动的博客、论坛、组和维基,并与其他 developerWorks 用户交流。

条评论

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=777704
ArticleTitle=函数式思维: 耦合和组合,第 2 部分
publish-date=12012011