0%

你所理解的函数式可能不是我们所推崇的函数式

我时常在项目中听到一些经验稍欠缺的开发人员在Code Review时这么讲:

这里为了方便测试我抽取了一个纯函数,这个函数包含了主要的业务逻辑,测试覆盖率也比较高,我们可以认为这一部分质量不错。
使用这个函数的地方由于集成度高不好测试,我们就不做自动化测试了。

他的代码可能写成下面这样:

1
2
3
4
5
6
7
8
9
10
// some_file.ts
function someEasyToTestMethod() {
...
}

class A {
someMethod() {
someEasyToTestMethod();
}
}

咋一看似乎也在理,主要的逻辑有测试覆盖了,质量有保障了,集成点的测试由于代价比较高就不通过自动化测试覆盖而是通过手工测试覆盖。但是在我看来这里还存在不少问题:

  1. 为了测试方便而抽取函数,这反映出我们的代码不是用TDD驱动出来的,里面可能存在不好的设计
  2. 函数抽取的目的是为了方便测试,而不是为了让设计更好或可读性更好或复用性更好,这样的函数可能破坏了封装暴露了实现细节
  3. 放松了集成点的测试覆盖,可能导致更多的代码被放到这些所谓的集成点里面去,最终导致越来越多的代码没有测试
  4. 函数抽取可能过于随意,从而导致命名不易读,带有大量的参数等坏味道出现

函数式近两年不断兴起,渐渐成为了一种大家推崇的编程范式,不少人就觉得一旦我抽取函数了,那我就是在实践函数式了。将简单的函数抽取说成函数式编程似乎将自己的编程水平拔高了一大截,而且其他人可能还不好在Code Review时指出真正正确的优化方式或重构方式。但函数式真的是这么简单吗?它的优势在哪里?与过程式的区别是什么?这些问题如果回答不清楚,那我们自称函数式编程就只能是自己骗自己了。

回顾我自己的软件开发历程,有一个阶段其实我的想法跟上面的很相似。之前我们用Java语言开发,在需要复用代码的时候,我也是第一时间想到抽取一个静态函数放到某个Util类中。当然这种方式不是不可以,但是这样的复用方式实在不怎么高明。如果这也算一种抽象,那这种抽象级别也是比较低的,作为专业的开发者,我们需要多走一步,从设计层面去思考更多的东西。

真正的函数式编程是什么样的?

关于这个问题的答案我们可以在网上找到很多,也有很多关于函数式编程讨论的书籍(推荐我司大牛 Neal Ford 编写的《函数式编程思维》),总结起来我们可以认为函数式之所以能流行起来,关键在于其所倡导的状态管理方式和函数式接口抽象。

过程式编程一般通过全局的变量来管理状态,我们现在都知道全局的状态是不好的,因为一旦状态多了,我们甚至很难给其一个独立的名字,更不用说可能导致的大量代码随意读写全局状态而产生的强耦合和高复杂度了。面向对象编程范式通过封装将状态管理限制到一小块代码中来解决状态管理问题。而函数式编程范式推崇的是直接去掉状态,从而无需复杂的状态管理逻辑,这点从我们所熟悉的纯函数(无副作用且引用透明)、不可变对象、流式风格等等特性中就能感受到。由于无状态的特性,函数式在并行执行方面有着天然的优势,我们无需再用各种锁机制谨慎的去协调线程间状态访问了。

当然函数式范式的好处还不止于此,它还给我们提供了一整套新的抽象,让我们无需编写各种循环结构,而是将这些操作抽象为一些更小的基于流的过滤(filter)、转换(map)、聚合(reduce, fold)等等操作。这些操作比起我们去理解for循环while循环要容易很多。同时在函数式编程范式中,函数是一等公民,也就是说我们可以将函数作为一个普通的变量来使用,从而我们可以实现如高阶函数、柯里化等。函数式范式还包括了闭包、尾递归优化、惰性求值、确定性、缓存结果等等概念和应用,这里就不列举了,大家如果不熟悉可以去参考相关的资料。

在了解了函数式之后,再来审视一下我们的代码,它真正是符合函数式范式吗?我们真的是在实践函数式吗?

面向对象还是主流

在知道了函数式的好处之后,有人可能会心潮澎湃,只要一写代码就想要用函数式,不用就不舒服。其实面向对象仍然是当前编程范式的主流。我们欣赏函数式,但不代表我们可以不学习面向对象。事实上,面向对象对于现实世界的抽象和我们理解现实的方式是一致的,这会让我们很容易理解面向对象写出来的代码。同时面向对象的范式可以在很大程度上通过封装隐藏复杂度,让我们在阅读代码的时候始终处于同一个抽象层级进行思考,不容易被繁琐的细节所干扰。当前绝大部分流行的库,甚至绝大部分新出现的流行的库,也都是基于面向对象构建的。

事实上函数式其实也有其缺点,比如,函数式可能会遇到下面这些问题:

  1. 可能无意间引入一些低性能的操作,如在本应该使用List.find的地方用List.filter去实现,则会导致性能降低
  2. 多个流组合处理或者存在过多的流式逻辑时,可能导致难以理解的逻辑、大量新的类型或者随处可见的Tuple
  3. 在需要管理可变状态时几乎无能为力,比如UI程序就很难完全使用函数式实现

而我们采用命令式的方式结合面向对象的思想,上面的问题解决起来是可以得心应手的。

总结起来,我们可以认为,在编写复杂软件时,还是需要以面向对象范式为主,但是在细节实现的地方考虑结合函数式的思想,充分利用函数式的优势。

几个实践的例子

以面向对象范式为主,以函数式思想为辅的编码方式在我经历过的项目中经常使用,我们在项目中确实也深刻的感受到了它带来的好处。这里分享几个例子供大家参考。

在一个最近的客户项目上,我们用到了一个名为多边形的核心领域对象,在这个对象上面我们需要提供很多变换操作。比如多边形缩放、多边形简化、求多边形的最小外接凸多边形等等。借鉴函数式的思想,我们将其设计为了一个不可变对象,每经历一次变换,我们就返回一个新的多边形,而不是修改原多边形的状态。基于这样的设计,我们可以非常安全的进行各种各样的变换,完全不用担心调用者会由于状态改变而产生出乎意料的bug。我们的多边形定义成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Polygon(object):

def __init__(self, points: Union[List[List[float]], List[Tuple[float, float]], np.ndarray]):
self._points = np.array(points)

def expand_from_center(self) -> 'Polygon':
points = ...
return Polygon(points)

def convex_hull(self) -> 'Polygon':
points = ...
return Polygon(points)

def simplify(self, tolerance: float) -> 'Polygon':
points = ...
return Polygon(points)

def minimum_rotated_rectangle(self) -> 'Polygon':
points = ...
return Polygon(points)

...

在另一个项目上,我们需要把对一批数据进行类似SQL的聚合操作,利用函数式的代码风格,可以完全避免for循环,让代码更容易理解。比如要实现查询select field1, field2, field3, count(*) from some_table group by field1, field2, field3,我们可以将代码写成这样(为了方便演示没有考虑变量访问控制等问题):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class AggregationTest {

public void aggregate(List<SomeTable> dataList) {
List<SomeAggregated> someAggregatedList =
dataList.stream()
.collect(Collectors.groupingBy(
SomeTable::groupingKeyOfField1Field2Field3, HashMap::new, Collectors.counting()))
.entrySet()
.stream()
.map(entry -> new SomeAggregated(entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}

@Value
@AllArgsConstructor
static class SomeTable {
String field1;
String field2;
String field3;
String field4;

SomeAggregatedKey groupingKeyOfField1Field2Field3() {
return new SomeAggregatedKey(field1, field2, field3);
}
}

@Value
@AllArgsConstructor
static class SomeAggregatedKey {
String field1;
String field2;
String field3;
}

@Value
@AllArgsConstructor
static class SomeAggregated {
String field1;
String field2;
String field3;
Long field4Count;

SomeAggregated(SomeAggregatedKey key, Long count) {
field1 = key.field1;
field2 = key.field2;
field3 = key.field3;
field4Count = count;
}
}

}

另一个经常使用的技巧在实现纯函数时非常有效。函数式的思想告诉我们,纯函数具有确定性,每次输入同一个数据,总是能得到相同的输出结果。这是个非常好的特性,对于简化代码、增加可读性并降低理解难度帮助巨大。纯函数要求函数的实现是引用透明的,即函数的运行不依赖任何外部变量。一个常常被大家所忽略的例子是代码new Date()不是引用透明的,因为它会读取当前系统时间。为了实现纯函数,我们可以将代码写成下面这样(为了方便演示没有考虑变量访问控制等问题):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class PureFunctionTest {
public static class A {
interface CurrentDateGetter {
Date currentDate();
}

CurrentDateGetter currentDateGetter = Date::new;

public A() {
}

public A(CurrentDateGetter currentDateGetter) {
this.currentDateGetter = currentDateGetter;
}

public void someMethod() {
Date date = currentDateGetter.currentDate();
// do things with date
}
}

public static class ATest {
@Test
public void should_do_something() {
A a = new A(() -> date("2019-01-01 00:00:00"));
a.someMethod();
// assert ...
}

private Date date(String dateStr) {
try {
return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(dateStr);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
}
}

上面的代码提供了一个可选的构造器参数,使得外部可以控制如何获取当前时间,只要新建A的实例的时候传入了这个参数,构造出来的A的实例就是满足纯函数条件的实例(假设A的实现中的其他部分满足纯函数条件),因为它的输出完全依赖于输入,具有引用透明的特性。有了纯函数的特性,我们在测试时就方便得多了,在ATest中,我们可以轻易的构造想要的时间测试数据。

几点建议

回到文章开头的例子,针对这个具体的场景,当我们想抽取函数的时候,我们要如何做呢?下面是几点小建议,希望能有所启发。

抽取方法的几个小建议

在抽取函数时,我们首先要问的一个问题是:真的有必要抽取函数吗?一个典型的差的理由就是为了方便测试。一些好的理由应当是:

  1. 降低当前类或者函数的复杂度
  2. 将方法归类到某一个模块或类下面,增加内聚性,降低耦合性
  3. 提高代码的可复用性
  4. 更符合整体的架构和设计
  5. 提高代码的可读性

当然还有很多合理的理由,大家可以根据实际情况权衡。

当我们发现真的要抽取函数的时候,我们要思考函数放到什么地方是合适的。

如果过于随意的把某一个函数直接放在某一个模块里面成为一个公开可访问的接口,这其实是在暴露模块的内部实现,破坏封装。而且多一个API,系统内就多了一个需要管理的东西,系统的复杂度上升了,易理解度降低了。

一个比较合理的做法是,我们需要去阅读现有的代码,权衡一下放到哪里是合适的。如果我们发现有这样一个地方,那就顺理成章放进去。如果我们发现没有这样的地方,我们可以思考一下这样几个问题:1. 将来有什么地方能复用这个逻辑吗? 2. 按照现在的架构和设计,这个函数应该放到哪里呢? 3. 是不是可以抽取一个类或模块来增加可读性? 4. 如果抽取类或模块,是不是将来可能有其他的逻辑可以归类到它上面? 当我们的系统已经很复杂的时候,如果我们要抽取类或模块,我们可能要更谨慎一些,奥卡姆剃刀原则告诉我们,“如无必要,勿增实体”,如果有不增加对象的方式来解决问题,就别增加对象。但也别过于谨慎,导致生硬的将方法归类到关系不大的现有模块中。

编写测试的几个小建议

我们不能为了测试而抽取函数,测试应该用于优化设计,而不是限制设计表达的灵活性(所以更好的方式应该是采用TDD测试驱动设计的方式进行开发)。上面例子反映出的一个问题就是不知道如何写好测试。这里有几点小建议。

  1. 如果我们在某一个框架下开发,弄清框架的工作原理,按照框架的工作方式相应去调用函数。也就是说我们要模拟框架的工作方式来测试。

比如在Angular框架下写测试,我们可以参考这里的文档。第一步是弄清Angular的工作方式,比如如何实例化对象,如何传入和传出参数,如何依次调用生命周期方法等。第二步是模拟框架的工作方式实例化测试对象,并依次调用方法,或者传参,比如@Input是其他模块提供的输入,我们可以通过comp.input = ...的方式模拟传值,比如@Output是我们需要输出的东西,我们可以据此验证我们的输出是否正确。

  1. 适当降低对于UI的单元测试,将尽量少的逻辑放到UI层。

对于前端开发而言,现在都流行使用类MVVM的架构,视图层的功能蜕化为仅仅根据ViewModel进行简单且很傻的渲染,业务逻辑都放在ViewModel或服务层。而现在多数框架,无论是移动应用还是Web框架,都提供了强大的模板引擎,我们现在编写视图实际上是在使用这些框架规定的DSL编写代码。这些DSL代码,我们可以认为功能简单无需单元测试,它的复杂的渲染过程是框架的代码帮我们保证正确性的。另一方面的原因是如果我们要写单元测试,我们势必会引入框架的渲染过程,而这通常会导致运行缓慢的测试。还有一个理由是UI一般都变更太快,不够稳定,建立单元测试可能会导致维护成本太高。当然我们还是有必要建立集成测试去一定程度上覆盖这些DSL代码的。由于没有单元测试覆盖这些DSL代码,为了我们对于代码更有信心,我们就需要将尽量少的逻辑放到这样的视图层。基于这样的考虑,我个人会更推荐摒弃使用类似jest的snapshot testing机制进行的测试。

我们博客大赛里面有很多更系统的关于如何写前端测试的介绍,比如这篇《#博客大赛# React 单元测试策略及落地》,大家在邮件内搜索就能找到,推荐大家读一下。

  1. Mock外部依赖,选择性的mock内部依赖

测试不好写的其中一个重要原因就是我们可能随意的代码中引入对于外部系统的依赖,比如我们可能无意间发起了一个http请求,读取了一个外部文件等等。没有了这些依赖,我们的程序无法完成其功能。在单元测试中,如果这些依赖引用杂乱无章的出现在任意的代码中,那么我们的测试将很难编写。其实当我们发现测试不好写的时候,通常都是我们的设计出了问题,好的设计应该是易于测试的。这里的好的设计应该是什么样的呢?其实很简单,通常我们可以将一组外部依赖抽象为一个独立的接口,然后具体调用外部系统的地方就只是这个接口的某个实现而已。当有了这样的设计时,在测试中我们只需要针对性的创建一个测试替身就好了。

内部的其他模块依赖,我们要怎么测试呢?一种方式是类似上面的做法,抽象一个接口就搞定。但有时候我们会发现这样不好操作,比如我们可能想要调用某个实体类上面的一个计算方法,这个时候难道为了mock这个方法,我们要将这个实体抽象为一个接口?还有时候,我们的代码里面会直接实例化一个工具类来使用,这同样也给测试造成了麻烦,因为这个时候工具类依赖实际上是我们要测试的对象的具体实现的一部分,而我们应该针对接口测试而不是实现。这个时候我的建议就是不mock,直接将这些调用当做内部实现进行测试。需要注意的一点是我们可以适当放松对于这些依赖实现的逻辑进行测试,因为我们常常应该在这些依赖自己的测试中去关注这些细节。这样做的一个额外的好处是让我们对于代码更有信心了,因为顺便测试到了集成点。不好的地方就是,当依赖的地方逻辑有修改可能导致这里的测试失败。

  1. 设计一层防腐层

我们可以结合依赖的工具或框架,自行设计一层抽象接口,在业务代码中使用自己的接口,而不是使用那些工具或框架中的接口。这等同于在我们的代码域和第三方库域之间做了一层防腐层,可以有效的防止我们的代码腐化。同时因为在防腐层里面我们会将外部依赖的数据结构转换为领域对象,从而一定程度上屏蔽了外部依赖的复杂度,正是由于外部依赖更简单,我们的测试替身也将更容易创建,即我们的代码也更容易测试了。

当我们熟练掌握了上面几点技巧,我相信测试将不再成为抽取函数的理由。

欢迎关注我的其它发布渠道