函数式编程思想:耦合和组合,第2部分

标签: 函数 编程 思想 | 发表时间:2011-10-25 12:36 | 作者:Elaine.Ye TONY
出处:http://www.yeeyan.org

译者 Elaine.Ye

在上一部分内容中,我说明了代码重用的不同做法。在面向对象的版本中,我提取出了重复的方法,把他们和一个受保护(protected)域一起移到一个超类中。在函数式版本中,我把纯函数(不会带来边际效应的那些函数)提取出来放到了它们自己的类中,通过提供参数值来调用它们。我改变了重用机制,把经由继承的受保护域改成方法参数。这些构成面向对象语言的功能(比如说继承)有着明显的好处,但它们无意中也带来了一些副作用。正如一些读者在评论中准确指出的那样,正是基于这一原因,许多经验丰富的OOP开发者已经不再通过继承来共享状态。但是如果面向对象对于你来说已是一种根深蒂固的范式了的话,有时就不太容易看到其他的做法。

在这部分内容中,我比较了基于语言机制的耦合和使用了可移植代码的组合,可移植代码作为提取可重用代码的一种方式——这同时还用来揭示代码重用一个关键性的理念差异。首先,我会重温一个经典的问题:如何在继承存在的情况下编写一个正确的equals()方法。

重写equals()方法

Joshua Bloch所著Effective Java一书包括了一节内容是关于如何编写正确的equals()和hashCode()方法的(参见参考资料)。复杂性来自于相等语义和继承之间的交互,用Java编写的equals()方法必须遵循Object.equals()的Javadoc指定的特性:

1. 它是自反的:对于任何非空的引用值x,x.equals(x)应该返回true。

2. 它是对称的:对于任何非空的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)返回true。

3. 它是传递的:对于任何非空的引用值x、y和z,如果x.euqals(y)返回true且y.equals(z)返回true,则x.equals(z)应该返回true。

4. 它是前后一致的:对于任何非空的引用值x和y,x.equals(y)的多次调用始终返回true或者始终返回false,提供给对象进行相等比较的信息没有被修改。

5. 对于任何非空引用值x,x.equals(null)应该返回false。

在Bloch的例子中,它创建了两个类——Point和ColorPoint——并试着创建一个对于两个类来说都能正确工作的equals()方法。试图忽略继承类中的额外域会破坏对称性,试图把它算计在内又破坏了传递性。Josh Bloch为这一问题提供了一个可怕的预后:

根本不存在这样的一种方式,即在继承一个可实例化类并加入某方面内容的同时又保持了equals的契约不变。

在不需要考虑继承后的可变域时,实现相等性则要容易得多。加入诸如继承一类的耦合机制制造出了一些微妙的差别和错误陷阱。(原来有一种保留了继承的方法可用来解决这一问题,但要增加一个额外的依赖方法成本,请参阅补充栏:继承和canEqual()。)

回忆一下这一系列的前两部分内容开篇引用的Michael Feathers的话:

面向对象编程通过封装变动部分把代码变成易懂的,函数式编程则是通过最小化变动部分来把代码变成易懂的。

equals()的难以实现说明了Feathers的变动部分的这一概念,继承是一种耦合机制:它使用可见性、方法派发等这一类明确定义好的规则把两个实体绑定在一起。在像Java这样的语言中,多态也和继承绑在一起,正是这些耦合点使Java成为了一种面向对象的语言。但也让变动部分带来了一系列的后果,特别是在语言层面上。直升机是出了名的难飞,由于控制要用到飞行员的四肢。移动一个控制杆就会影响到其他的控制杆的操作,因此飞行员必须善于处理每个控制给其他控制带来的副作用。语言的各个组成部分就像是直升飞机的控制操作:你不能随时添加(或修改)他们,这会影响到所有的其他部分。

继承对于面向对象语言来说是如此自然的组成部分,以致于大部分的开发者都忽略了这样的一个事实:即就其本质来说,这是一种耦合机制。当有异样的事情出现或导致不能运行时,你只会去学习一些(有时是晦涩难懂的)规则来减轻问题,然后继续。然而,这些隐式的耦合规则影响了你思考代码的基础方面的方式,比如说如何实现重用性、可扩展性和相等性等。

如果Bloch就这样让这一相等性问题悬而未决的话,那么Effective Java一书估计不会如此成功。相反,他利用这一机会来重新介绍了早先在prefer composition to inheritance一书中提出的一个很好的建议,Bloch对这一equals()问题的解决方案是使用组合来代替耦合。这种做法完全避开了继承,让ColorPoint 拥有一个到Point实例的引用,而不是让它成为一个point类型。

补充栏:继承和canEqual()

Programming Scala一书中,作者提供了一种机制,即使是在继承存在的情况下,也支持相等性(参见参考资料)。Bloch认为问题的根源在于父类对子类没有足够的“了解”,不能确定它们是否应该加入到相等性的比较中。为了解决这一问题,把一个canEqual()方法加入到基类中,如果想要进行相等性比较的话,就重写子类的该方法。这种做法允许当前类(经由canEqual())决定两种类型的相等是否是合理的和有意义的。

这一机制解决了该问题,但带来的损害却是在父类和子类之间添加了另一个耦合点:canEqual()方法。

组合和继承


组合——以传递的参数加上第一类函数(first-class function)的格式——经常作为一种重用机制出现在函数式编程库中。函数式语言在一个比面向对象语言要粗粒度的层面上实现重用,其使用参数化行为来提取通用的体系结构。面向对象系统由对象组成,这些对象通过给其他对象发送消息(或更具体的说,在对象上执行方法)来进行通信。图1展示了一个面向对象的系统:

图1. 面向对象的系统


在找出类及其相应消息的一个有用集合后,提取出重用类的图,如图2所示:

图2. 提取出图的有用部分


不用奇怪,软件工程领域中最受欢迎的书之一Design Patterns: Elements of Reusable Object-Oriented Software(参见参考资料),它的模式编目正是图2所示的抽取类型。经由模式的重用是如此普遍,许多其他的书也都是按照这样的提取来分类编目(并为之提供不同的名称)的。设计模式运动是软件开发领域的一大幸事,因为其提供了统一的命名和样本模型。不过,从根本上来说,经由设计模式的重用是细粒度的:一个解决方案(比如说享元(Flyweight)模式)和另一个(备忘录(Memento)模式)是正交的。设计模式解决的每种问题都是非常具体的,这使得模式变得非常的有用,你常常能够找到适合解决当前问题的模式——但只是狭义上的有用,因为它是如此的特定于问题。

函数式编程者也希望重用代码,但他们使用的是不同的构建块,而不是试图在结构之间创建熟知的一些关系(耦合),函数式编程尝试提取粗粒度的重用机制——部分基于范畴论(category theory),这是数学的一个分支,其定义了对象类型之间的关系(态射(morphism))(参见参考资料)。大多数的应用都使用元素列表来处理事情,因此函数式方法是围绕着列表加上语境化可移植的代码来构建重用机制的。函数式语言依赖于第一类函数(可出现在任何其他语言构造可出现的地方),把它作为参数和返回值。图3说明了这一概念。

图3. 凭借粗粒度机制加上可移植代码的重用


在图3中,齿轮箱代表了一般化地处理一些基础数据结构的抽象,黄色箱则代表了可移植的代码,把数据封装在其中。

通用的构建块


在这一系列的第二部分内容中,我使用Functional Java库构建了一个数字分类器例子(参见参考资料)。

折叠(fold)

数字分类器的一个方法在所有收集到的因子上执行了求和操作,该方法如清单1所示:

清单1. 来自函数式的数字分类器的sum()方法

public int sum(List< Integer> factors) {
    return factors.foldLeft(fj.function.Integers.add, 0);
}  

起初并不明显,清单1中的一行代码如何能够执行一个求和操作呢?在一系列的通用列表转换操作中,该例子是一种特定的类型,这些列表转换操作被称作风化变质作用(catamorphism)——从一种形式转换为另一种形式(参见参考资料)。在这一例子中,折叠操作引用了一个组合了列表中的每个元素和下一个元素的转换,累加出整个列表的一个结果。左折叠(fold left)向左折叠列表,以一个初始值为开始,依次合并列表中的每个元素,以此来产生一个最终的结果,图4说明了一个折叠操作:

图4. 折叠操作

因为加法是可交换的,所以进行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类有着折叠操作的正确结构:其创建了一个接收两个整型参数(这是两个在彼此之上进行折叠的值)和一个返回类型的方法。清单2中的例子对奇数求和,做法是如果第二个数字是奇数的话就对两个数值求和,否则的话只返回第一个数值。

过滤(filtering)

列表上的另一种常见操作是过滤:通过基于某些用户定义的条件过滤子项来创建一个更小的列表。图5说明了过滤:

图5. 过滤列表


在过滤时,有可能会产生比该例子最初的列表要小的另一个列表,这取决于过滤条件。在数字分类器例子中,我使用过滤操作来确定数字的因子,如清单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(),其接受一个指定了过滤条件的代码块。方法的最后一行是方法的返回值,在这个例子中该值是一个因子列表。

映射(mapping)

映射操作通过把函数应用在每个元素上来把一个集合转换成一个新的集合。如图6所示:

图6. 把函数映射到集合上


在数字分类器例子中,我在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中的代码首先以目标数字的平方根为上限来收集因子的列表,把它存放在变量factors中。然后把一个新的集合追加到factors上——由factors列表上的map()函数生成——应用这一代码来生成对称(大于平方根的匹配因子)的列表。最后的nub()方法确保列表中不存在重复的因子。

一同往常,Groovy版本更简单,如清单6所示,因为灵活的类型和代码块是该语言的一等公民。

清单6. Groovy的优化版本的factors

def factorsOfOptimized(number) {
  def factors = (1..(Math.sqrt(number))).findAll { i -> isFactor(number, i) }
  factors + factors.collect({ i -> number / i})
}

虽然方法的名称不同,但清单6中的代码执行的任务与清单5中的代码相同:取得1到平方根的一个数值范围,过滤出因子,然后使用产生对称因子的函数来映射列表中的每个值,把产生的列表追加到该列表上。

重写函数式的完善做法


有了可用的高阶函数,确定数字是否为完美的整个问题就压缩成了寥寥几行代码,如清单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()是如此频繁的出现,给我留下了深刻的印象。一旦开始习惯于把这些工具置于你的工具箱中,就会发现自己一次又一次地求助于它们。

结束语


学习像函数式编程这样一种新范式的挑战之一是要学习新的构建块,并要“看明白”它们是哪些问题的潜在解决方案。在函数式编程中,你拥有的抽象要少得多,但每一种都是泛型(有着经由第一类函数加入的特定性)。因为函数式编程严重依赖传递的参数和组合,所以关于变动部分之间的交互,你要了解的规则更少,这会让你的工作变得更容易一些。

函数式编程通过抽象出体系的可经由高阶函数定制的通用部分来实现代码重用。本文重点说明了面向对象语言中固有的耦合机制引入的一些困难,这些困难带出了对类的通用模式图的讨论,讨论得出的结果是可重用的代码,这属于设计模式领域。接着我说明了基于范畴论的粗粒度机制如何允许你利用语言设计者编写(和调试)的代码来解决问题,在每种情况中,解决方法都很简洁且是声明性的,这例证了通过组合参数和功能来创建通用行为的代码重用。

在下一部分内容中,我会进一步深入探究Groovy和JRuby这两种JVM上的动态语言的函数式功能。

参考资料


学习资料

1. Effect Java(Joshua Bloc,Addison-Wesley,2001)Bloch的关于如何正确使用Java语言的一本开创性的著作。

2. Programming in Scala, 1st ed.(Martin Odersky、Lex Spoon和Bill Venners):本书网上有提供,非常棒的第二版也在分布在各处的书店中出售。

3. Design Patterns: Elements of Reusable Object-Oriented Software(Erich Gamma等人所著,Addison-Wesley,1994):四人组关于设计模式的经典著作。

4. 范畴论(category theory):范畴论是数学的一个分支,以一种抽象方式涵盖 特定数学概念的属性。

5. 风化变质作用(catamorphism):一个风化变质作用表示了从一个代数到另一个代数的唯一映射。

6. “语言设计者的笔记:以不伤害为首要原则”(Brian Goetz,developerWorks,2011年7月):了解lambda表达式背后的设计考虑,这是在Java SE 8中生效的新语言功能。Lambda表达是是函数字面量(function literal)——是包含了一个递延计算的表达式,该计算可被当成值对待,在晚些时候再调用。

7. 浏览technology bookstore来查找一些关于这些和另外一些技术主题的书籍。

8. developerWorks Java technology zone:可以找到几百篇关于Java编程的各个方面的文章。

获取产品和技术

1. Functional Java:Functional Java是一个框架,其为Java加入了许多的函数式语言构造。

2. 下载IBM产品评估版本或是浏览 IBM SOA Sandbox中的在线使用,动手操作DB2®, Lotus®, Rational®, Tivoli®, and WebSphere®方面的应用开发工具和中间件产品。

讨论

1. 加入developerWorks社区

关于作者


Neal Ford是一家全球性的IT咨询公司ThoughtWorks的软件架构师和Meme Wrangler。他还设计并编写了一些应用、教材、杂志文章、课件和视频/DVD演示,他是一些跨多种技术的书籍的作者或是编辑,其中包括了最近出版的这本The Productive Programmer。他的工作重点是设计和构建大型的企业级应用,他还是全球范围的开发者大会上的一位国际知名的演讲者。你可以看看他的网站。          

  

译者注:标题配图来自developerWorks网站

相关 [函数 编程 思想] 推荐:

函数式编程思想:不变性

- pippo - 译言-每日精品译文推荐
来源Functional thinking: Immutability. 面向对象的编程通过封装可变动的部分来构造出可让人读懂的代码,函数式编程则是通过最小化可变动的部分来构造出可让人读懂的代码. ——Michael Feathers,Working with Legacy Code一书的作者,经由Twitter在这部分内容中,我讨论的是函数式编程的基石之一:不变性(immutability ).

函数式编程思想:耦合和组合,第2部分

- TONY - 译言-电脑/网络/数码科技
在上一部分内容中,我说明了代码重用的不同做法. 在面向对象的版本中,我提取出了重复的方法,把他们和一个受保护(protected)域一起移到一个超类中. 在函数式版本中,我把纯函数(不会带来边际效应的那些函数)提取出来放到了它们自己的类中,通过提供参数值来调用它们. 我改变了重用机制,把经由继承的受保护域改成方法参数.

学会像函数式编程者那样思考——函数式编程思想:以函数的方式来思考,第1部分

- xf - 译言-每日精品译文推荐
来源Learning to think like a functional programmer——Functional thinking: Thinking functionally, Part 1. 让我们先来扯一下这样的一个话题,你是一个伐木工,在这森林中,你有着一把最好的斧头,这使得你成为了营地中最具生产效率的樵夫.

函数式编程初探

- - 博客 - 伯乐在线
诞生50多年之后, 函数式编程(functional programming)开始获得越来越多的关注. 不仅最古老的函数式语言Lisp重获青春,而且新的函数式语言 层出不穷,比如Erlang、clojure、Scala、F#等等. 目前最当红的Python、Ruby、Javascript,对函数式编程的支持都 很强,就连老牌的面向对象的Java、面向过程的PHP,都忙不迭地加入对匿名函数的支持.

Dart中的函数式编程

- - 技术改变世界 创新驱动中国 - 《程序员》官网
JavaScript的一个特点是它支持函数式编程. 因为Dart的目标是让人感觉熟悉,现在让我们看看在Dart语言中函数式编程是什么样的. 我们先从一个传统的例子开始,计算斐波纳契数列. 在JavaScript中,大概像下面这样写:. 探索一个语言的函数式编程特性,斐波纳契数列是个很好的例子. 不仅因为它是一个函数,也因为它的递归性质可以展示函数的调用.

【外刊IT评论网】如何学会函数式编程

- iBeyond - 外刊IT评论网
本文是从 How to get started with functional programming 这篇文章翻译而来. 上周末,有人问我,如何学会函数式编程. 我的回答是:用你现在使用的编程语言写纯正函数. 纯函数唯一的输入是它的参数,唯一的输出是它的返回值. 如果你以前从未接触过这个概念,你会以为所有的函数都是纯正的.

Clojure 1.3发布,基于JVM的函数式编程语言

- bamerl - ITeye资讯频道
Clojure日前发布了 1.3 版本. Clojure是一个在JVM平台运行的动态函数式编程语言,在JVM平台运行的时候,会被编译为JVM的字节码进行运算,能调用Java的类库,支持并发,与Scala类似. Leinigen或是Maven用户现在可以设置依赖:.    该版本中包含了许多重大的特性和性能改进,比如增强了原生支持、改进了defrecord和deftype、改进了异常报告、可以通过Maven进行编辑和部署,以及绑定Conveyance等.

浅谈编程解决实际问题的常见思想

- - 博客园_知识库
  现实生活中有很多问题,人为不好解决,但利用计算机速度快,不出错的特性,可以很方便的解决这些问题,下面简单说说我在程序设计中解决实际问题的一些常见思想,高手可以忽略掉,我也是无聊了随便写写而已.    1、枚举最优解时的情况.   有很多问题初看很棘手,但经过仔细的分析,可以得出一些显然的结论.

【外刊IT评论网】函数式编程很难,这正是你要学习它的原因

- ZB - 外刊IT评论
本文是从 Functional Programming Is Hard,That's Why It's Good 这篇文章翻译而来. 很奇怪不是,很少有人每天都使用函数式编程语言. 如果你用Scala,Haskell,Erlang,F#或某个Lisp方言来编程,很可能没有公司会花钱聘你. 这个行业里的绝大部分人都是使用像Python,Ruby,Java或C#等面向对象的编程语言——它们用起来很顺手.

剧情函数库

- SourBell - 学而时嘻之
(《新知客》,2010年10月). 著名物理学家徐一鸿先生在《可怕的对称》这本书中谈到对称性群的时候提到一个很有意思的笑话. 有一个客人随他的朋友参加一个笑话俱乐部的聚会. 另一个站起来叫道,“S—5”,引得所有的人都笑了起来. 这个迷惑不解的客人问道,这是怎么回事. 他的朋友解释道:“所有可能的笑话,当然不能计细小的差别,都已经被归类编上号了,我们心里都知道这些编号指的是什么.