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

会说话的帆船 会说话的帆船     2022-09-04     743

关键词:

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

函数式编程思想:以函数的方式思考,第3部分

...ealFord发布:2011-07-0611:23:24挑错|查看译者版本|收藏本文在函数式编程思想的第一部分和第二部分中,我考察了一些函数式编程的主题,研究了这些主题如何与Java?及其相关语言产生关联。本篇文章继续这一探索过程,给出来自前... 查看详情

《javascript函数式编程思想》——部分应用和复合

第5章 部分应用和复合一等值的函数,是函数式编程的基石。部分应用和复合,则是函数式编程的重要特征。采用命令式编程时,每当我们感觉需要抽象出一个新的功能时,就会定义一个函数。在函数式编程中... 查看详情

《javascript函数式编程思想》

...快速发展,JavaScript向各个领域渗透的势头仍然强劲。函数式编程的思想和语言原来仅仅在计算机学术圈中流行,近年来它的魅力越来越多地被主流软件开发行业认识到,Scala、Closure等语言的出现,C#、Java等语言中... 查看详情

《javascript函数式编程思想》——副作用和不变性

第6章 副作用和不变性6.1 副作用6.2 纯函数6.2.1 外部变量6.2.2 实现6.2.3 函数内部的副作用6.2.4 闭包6.3 不变性6.3.1 哲学上的不变性与身份6.3.2 简单类型和复合类型6.3.3 值类型和引用类型6.3.4 可变类型和不可变类型6.3.5 可变... 查看详情

精通高级rxjava2响应式编程思想

...的内容,并提前布置预先需要储备的知识。第2章响应式编程思想概述—概念与案例讲解本章节主要阐释响应式编程思想,先做一个概念性的介绍,之后会以生活中的实例和代码实例相结合的方式来讲解。第3章RxJava基本元素—源... 查看详情

onjava8第十三章函数式编程

...2.1递归3方法引用3.1Runnable接口3.2未绑定的方法引用3.3构造函数引用4函数式接口4.1多参数函数式接口4.2缺少基本类型的函数5高阶函数6闭包6.1作为闭包的内部类7函数组合8柯里化和部分求值9纯函数式编程10本章小结函数式编程的中... 查看详情

《javascript函数式编程思想》——列表

第8章 列表函数式编程与列表处理有很深的渊源。列表是最基础,也是使用最普遍的复合数据类型。作为最早出现的函数式编程语言之一,Lisp【注:它的名称就来源于“列表处理器”(LIStProcessor)】用函数参... 查看详情

《javascript函数式编程思想》——类型系统

...?用户自定义的各种类型与它们又有什么关系?函数也是类型吗?强类型和弱类型意味着什么?它们的区别和类型转换有关吗?静态类型语言中的变量为什么有固定类型而动态类型则没有?多态性就是后期... 查看详情

《javascript函数式编程思想》——名称

第1章 名称一般对函数式编程的介绍都会从一等值和纯函数等概念开始,本书却准备在那之前先花些篇章讨论两个通常未得到足够重视的主题:名称和类型系统。前者包括名称绑定、作用域和闭包等内容,后者包括类... 查看详情

kotlin函数式编程思想fpinkotlin

Kotlin函数式编程思想:FPinKotlin函数式编程特性闭包和高阶函数函数编程支持函数作为第一类对象,有时称为闭包或者仿函数(functor)对象。实质上,闭包是起函数的作用并可以像对象一样操作的对象。与此类似,FP语言... 查看详情

《javascript函数式编程思想》——从面向对象到函数式编程

第9章 从面向对象到函数式编程假如本书的写作时间倒退回十年前,书名可能会变成JavaScript面向对象编程思想。自上世纪90年代兴起的面向对象编程思想随Java的繁荣达于顶点,在JavaScript从一门只被用来编写零星的简单的... 查看详情

这些电子书新上架

关注微信公众号【异步图书】每周送书本周上新Haskell函数式编程入门(第2版)第1卷作者:张淞,刘长生分类:软件开发>编程语言>函数式语言这是一本讲解纯函数式编程语言Haskell的书,同时也是一本通过Haskell来讲解函数式... 查看详情

《javascript函数式编程思想》——函数是一等值

第4章 函数是一等值在函数式编程的标准或特点中,“函数是一等值”是最基本和重要的,也是最为人所知的,所有介绍函数式编程的书籍和文章都会优先介绍这一点,以至于“一等值”几乎成为函数的专属头衔&... 查看详情

函数式编程思想

对于函数式编程来说,其只关心,定义输入数据和输出数据相关的关系,数学表达式里面其实是在做一种映射(mapping),输入的数据和输出的数据关系是什么样的,是用函数来定义的。http://www.yxtvg.com/toutiao/5413179/20180212a04ro500.ht... 查看详情

用函数式编程,从0开发3d引擎和编辑器:函数式编程准备(代码片段)

大家好,本文介绍了本系列涉及到的函数式编程的主要知识点,为正式开发做好了准备。函数式编程的优点1.粒度小相比面向对象编程以类为单位,函数式编程以函数为单位,粒度更小。正所谓:我只想要一个香蕉,而面向对象... 查看详情

javascript函数式编程

第1章JavaScript函数式编程简介11.1JavaScript案例11.2开始函数式编程41.2.1为什么函数式编程很重要41.2.2以函数为抽象单元71.2.3封装和隐藏91.2.4以函数为行为单位101.2.5数据抽象141.2.6函数式JavaScript初试171.2.7加速191.3Underscore示例221.4总结2... 查看详情

reactivecocoa响应式函数编程

简介ReactiveCocoa(简称为RAC),RAC具有函数响应式编程特性,由MattDiephouse开源的一个应用于iOS和OSX的新框架。为什么使用RAC?因为RAC具有高聚合低耦合的思想所以使用RAC会让代码更简洁,逻辑更清晰。如何在项目中添加RAC?方法1.可... 查看详情

函数响应式编程(frp)思想

...让你的代码像数学一样简洁,业务像流水一样清晰流畅。函数响应式编程响应式编程思想为体,函数式编程思想为用。响应式编程例如,在命令式编程环境中,a:=b+c表示将表达式的结果赋给a,而之后改变b或c的值不会影响a。但... 查看详情