函数式编程思想:耦合和组合,第1部分
总是在某种特定抽象(比如说面向对象)中进行编码工作,这使得很难看清楚何时这一抽象会把你引向一种并非最好的解决方案上。作为这一系列的两篇文章中的头 一篇,本文探讨了用于代码重用的面向对象编程思想的一些影响,并把它们与一些更函数化的可选方法,比如说组合,进行比较。
面向对象编程通过封装变动部分把代码变成易懂的,函数式编程则是通过最小化变动部分来把代码变成易懂的。——Michael Feathers,Working with Legacy Code一书的作者,经由Twitter
每天都以某种特定的抽象来进行编码工作,这种抽象会逐渐渗透到你的大脑中,影响到你解决问题的方式。这一文章系列的目标之一是说明如何以一种函数方式看待典型的问题。就本文和下一篇文章来说,我通过重构和随之带来的抽象影响来解决代码的重用问题。
面向对象的目标之一是使封装和状态操作更加容易,因此,其抽象倾向于使用状态来解决常见的问题,而这意味会用到多个类和交互——这就是前面引述 Michael Feathers的话中所说的“变动部分”。函数式编程尝试通过把各部分组合在一起而不是把结构耦合在一起来最小化变动的部分,这是一个微妙的概念,对于 其经验主要体现在面向对象语言方面的开发者来说,不太容易体会到。
经由结构的代码重用
命令式的(特别是)面向对象的编程风格使用结构和消息来作为构建块。若要重用面向对象的代码,你需要把对象代码提取到另一个类中,然后使用继承来访问它。
无意导致的代码重复
为 了说明代码的重用及其影响,我重提之前的文章用来说明代码结构和风格的一个数字分类器版本,该分类器确定一个正数是富余的(abundant)、完美的 (perfect)还是欠缺的(deficient),如果数字因子的总和大于数字的两倍,它就是富余的,如果总和等于数字的两倍,它就是完美的,否则 (如果总和小于数字的两倍)就是欠缺的。
你还可以编写这样的代码,使用正数的因子来确定它是否是一个素数(定义是,一个大于1的整数,它的因子只有1和它自身)。因为这两个问题都依赖于数字的因子,因此它们是用于重构从而也是用于说明代码重用风格的很好的可选案例。
清单1给出了使用命令式风格编写的数字分类器:
清单1. 命令式的数字分类器
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import static java.lang.Math.sqrt;
public class ClassifierAlpha {
private int number;
public ClassifierAlpha(int number) {
this.number = number;
}
public boolean isFactor(int potential_factor) {
return number % potential_factor == 0;
}
public Set factors() {
HashSet factors = new HashSet();
for (int i = 1; i <= sqrt(number); i++)
if (isFactor(i)) {
factors.add(i);
factors.add(number / i);
}
return factors;
}
static public int sum(Set factors) {
Iterator it = factors.iterator();
int sum = 0;
while (it.hasNext())
sum += (Integer) it.next();
return sum;
}
public boolean isPerfect() {
return sum(factors()) - number == number;
}
public boolean isAbundant() {
return sum(factors()) - number > number;
}
public boolean isDeficient() {
return sum(factors()) - number < number;
}
}
我在第一部分内容中已讨论了这一代码的推导过程,因此我现在就不再重复了。该例子在这里的目标是说明代码的重用,因此我给出了清单2中的代码,该部分代码检测素数:
清单2. 素数测试,以命令方式来编写
import java.util.HashSet;
import java.util.Set;
import static java.lang.Math.sqrt;
public class PrimeAlpha {
private int number;
public PrimeAlpha(int number) {
this.number = number;
}
public boolean isPrime() {
Set primeSet = new HashSet() {{
add(1); add(number);}};
return number > 1 &&
factors().equals(primeSet);
}
public boolean isFactor(int potential_factor) {
return number % potential_factor == 0;
}
public Set factors() {
HashSet factors = new HashSet();
for (int i = 1; i <= sqrt(number); i++)
if (isFactor(i)) {
factors.add(i);
factors.add(number / i);
}
return factors;
}
}
清单2中出现了几个值得注意的事项,首先是isPrime()方法中的初始化代码有些不同寻常,这是一个实例初始化器的例子(若要了解更多关于实例初始化——一种附带了函数式编程的Java技术——这一方面的内容,请参阅“Evolutionary architecture and emergent design: Leveraging reusable code, Part 2”。)
清单2中令人感兴趣的其他部分是isFactor()和factors()方法。可以注意到,它们与(清单1的)ClassifierAlpha类中的相应部分相同,这是分开独立实现两个解决方案的自然结果,这让你意识到你用到了相同的功能。
通过重构来消除重复
这一类重复的解决方法是使用单个的Factors类来重构代码,如清单3所示:
清单3. 一般重构后的因子提取代码
import java.util.Set;
import static java.lang.Math.sqrt;
import java.util.HashSet;
public class FactorsBeta {
protected int number;
public FactorsBeta(int number) {
this.number = number;
}
public boolean isFactor(int potential_factor) {
return number % potential_factor == 0;
}
public Set getFactors() {
HashSet factors = new HashSet();
for (int i = 1; i <= sqrt(number); i++)
if (isFactor(i)) {
factors.add(i);
factors.add(number / i);
}
return factors;
}
}
清 单3中的代码是使用提取超类(Extract Superclass)这一重构做法的结果,需要注意的是,因为两个提取出来的方法都使用了number这一成员变量,因此它也被放到了超类中。在执行这 一重构时,IDE询问我想要如何处理访问(访问器对、保护范围等等),我选择了protected(受保护)这一作用域,这一选择把number加入了类 中,并创建了一个构造函数来设置它的值。
一旦我孤立并删除了重复的代码,数字分类器和素数测试器两者就都变得简单多了。清单4给出了重构后的数字分类器:
清单4. 重构后简化了的数字分类器
import java.util.Iterator;
import java.util.Set;
public class ClassifierBeta extends FactorsBeta {
public ClassifierBeta(int number) {
super(number);
}
public int sum() {
Iterator it = getFactors().iterator();
int sum = 0;
while (it.hasNext())
sum += (Integer) it.next();
return sum;
}
public boolean isPerfect() {
return sum() - number == number;
}
public boolean isAbundant() {
return sum() - number > number;
}
public boolean isDeficient() {
return sum() - number < number;
}
}
清单5给出了重构后的素数测试器
清单5. 重构后简化了的素数测试器
import java.util.HashSet;
import java.util.Set;
public class PrimeBeta extends FactorsBeta {
public PrimeBeta(int number) {
super(number);
}
public boolean isPrime() {
Set primeSet = new HashSet() {{
add(1); add(number);}};
return getFactors().equals(primeSet);
}
}
无论在重构时为number成员选择的访问选项是哪一种,你在考虑这一问题时都必须要处理类之间的网络关系。通常这是一件好事,因为其允许你独立出问题的某些部分,但在修改父类时也会带来不利的后果。
这 是一个通过耦合(coupling)来重用代码的例子:通过number域这一共享状态和超类的getFactors()方法来把两个元素(在本例中是 类)捆绑在一起。换句话说,这种做法起作用是因为利用了内置在语言中的耦合规则。面向对象定义了耦合的交互方式(比如说,你通过继承访问成员变量的方 式),因此你拥有了关于事情如何交互的一些预定义好的风格——这没有什么问题,因为你可以以一种一致的方式来推理行为。不要误解我——我并非是在暗示使用 继承是一个糟糕的主意,相反,我的意思是,它在面向对象的语言中被过度使用,结果取代了另一种有着更好特性的抽象。
经由组合的代码重用
在这一系列的第二部分内容中,我给出了一个用Java编写的数字分类器的函数式版本,如清单6所示:
清单6. 数字分类器的一个更加函数化的版本
public class FClassifier {
static public boolean isFactor(int number, int potential_factor) {
return number % potential_factor == 0;
}
static public Set factors(int number) {
HashSet factors = new HashSet();
for (int i = 1; i <= sqrt(number); i++)
if (isFactor(number, i)) {
factors.add(i);
factors.add(number / i);
}
return factors;
}
public static int sumOfFactors(int number) {
Iterator it = factors(number).iterator();
int sum = 0;
while (it.hasNext())
sum += it.next();
return sum;
}
public static boolean isPerfect(int number) {
return sumOfFactors(number) - number == number;
}
public static boolean isAbundant(int number) {
return sumOfFactors(number) - number > number;
}
public static boolean isDeficient(int number) {
return sumOfFactors(number) - number < number;
}
}
我也有素数测试器的一个函数式版本(使用了纯粹的函数,没有共享状态),该版本的 isPrime()方法如清单7所示。其余部分代码与清单6中的相同命名方法的代码一样。
清单7. 素数测试器的函数式版本
public static boolean isPrime(int number) {
Set factors = factors(number);
return number > 1 &&
factors.size() == 2 &&
factors.contains(1) &&
factors.contains(number);
}
就像我在命令式版本中所做的那样,我把重复的代码提取到它自己的Factors类中,基于可读性,我把factors方法的名称改为of,如图8所示:
清单8 函数式的重构后的Factors类
import java.util.HashSet;
import java.util.Set;
import static java.lang.Math.sqrt;
public class Factors {
static public boolean isFactor(int number, int potential_factor) {
return number % potential_factor == 0;
}
static public Set of(int number) {
HashSet factors = new HashSet();
for (int i = 1; i <= sqrt(number); i++)
if (isFactor(number, i)) {
factors.add(i);
factors.add(number / i);
}
return factors;
}
}
因为函数式版本中所有状态都是作为参数传递的,因此提取出来的这部分内容没有共享状态。一旦提取了该类之后,我就可以重构函数式的分类器和素数测试器来使用它了。清单9给出了重构后的分类器:
清单9. 重构后的数字分类器
public class FClassifier {
public static int sumOfFactors(int number) {
Iterator it = Factors.of(number).iterator();
int sum = 0;
while (it.hasNext())
sum += it.next();
return sum;
}
public static boolean isPerfect(int number) {
return sumOfFactors(number) - number == number;
}
public static boolean isAbundant(int number) {
return sumOfFactors(number) - number > number;
}
public static boolean isDeficient(int number) {
return sumOfFactors(number) - number < number;
}
}
清单10给出了重构后的素数测试器:
清单10. 重构后的素数测试器
import java.util.Set;
public class FPrime {
public static boolean isPrime(int number) {
Set factors = Factors.of(number);
return number > 1 &&
factors.size() == 2 &&
factors.contains(1) &&
factors.contains(number);
}
}
可以注意到,我并未使用任何特殊的库或是语言来把第二个版本变得更加的函数化,相反,我通过使用组合而不是耦合式的代码重用做到了这一点。清单9和清单10都用到了Factors类,但它的使用完全是包含在了单独方法的内部之中。
耦 合和组合之间的区别很细微但很重要,在一个像这样的简单例子中,你可以看到显露出来的代码结构骨架。但是,当你最终重构的是一个大型的代码库时,耦合就显 得无处不在了,因为这是面向对象语言中的重用机制之一。繁复的耦合结构的难以理解性损害到了面向对象语言的重用性,把有效的重用局限在了诸如对象-关系映 射和构件库一类已明确定义的技术领域上,当我们在写少量的明显结构化的Java代码时(比如说你在业务应用中编写的代码),这种层面的重用我们就用不上 了。
你可以通过这样的做法来改进命令式的版本,即在重构期间会告之哪些内容由IDE提供,先客气地拒绝,然后使用组合来替代。
结束语
作 为一个更函数化的编程者来进行思考,这意味着以不同的方式来思考编码的各个方面。代码重用显然是开发的一个目标,命令式抽象倾向于以不同于函数式编程者的 方式来解决该问题。这部分内容对比了代码重用的两种方式:经由继承的耦合方式和经由参数的组合方式。下一部分内容会继续探讨这一重要的分歧。