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

习惯于使用面向对象构建块(继承、多态等)的编程者可能会对这一方法的缺点及其他的可选做法视而不见,函数式编程使用不同的构建块来实现重用,其基于的是 更一般化的概念,比如说列表转换和可移植代码。函数式编程思想的这一部分内容比较了作为重用机制的经由继承的耦合和组合,指出了命令式编程和函数式编程之 间的主要区别之一。
 
在上一部分内容中,我说明了代码重用的不同做法。在面向对象的版本中,我提取出了重复的方法,把他们和一个受保护(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上的动态语言的函数式功能。 
posted @ 2017-06-11 22:39  会说话的帆船  阅读(511)  评论(0编辑  收藏  举报