0%

Clean Architecture 阅读笔记(Chapter 3-6 三种编程范式)

Chapter 3 Paradigm Overview

这一章介绍了三种编程范式structured programming, object-oriented programming以及functional programming,并对三者进行了总结:

  • Structured programming imposes discipline on direct transfer of control.
  • Object-oriented programming imposes discipline on indirect transfer of control.
  • Functional programming imposes discipline upon assignment.

同时作者指出,编程范式的作用是remove capacity from the programmer而不是增加。换句话说就是对写代码起约束作用,由于这三种编程范式的总和已经接近于去除了我们所有的能力,所以几乎没有可能出现新的编程范式了。

Chapter 4 Structured Programming

这一章具体介绍了Structured Programming,这一范式源于Dijkstra对于编程规范的探求。在试图以数学推导来证明程序的正确性时,他发现程序中的某些goto语句大大影响了我们把程序解耦到更小的单位的能力,而另一些goto又可以对应为简单的selection和iteration control structure。这一发现后被Bohm和Jacopini证实所有程序都可以被三种结构所构成:sequence,selection以及iteration。

Dijkstra指出

  • 对于sequential statements,可以通过简单的枚举来证明其正确性;
  • 对于selection,每一条分支都可以通过枚举证明正确性,从而证明selection的正确性;
  • 对于iteration,则可采用数学归纳法证明,即首先证明起始条件下的正确性,再在假设n正确的前提下证明n+1正确,以此类推可最终证明整个iteration的正确性。

在structured programming中,大的模块可以不断被分解成更小的模块。但是对于每一段程序,我们基本不可能对其进行完整的数学证明来证明其正确性,而是取而代之地以科学的方法(scientific method)证明其正确性。对于科学理论来说,它们是可以被证伪(falsifiable)但不能被证明的(provable)。从数学的角度来看,对于一段程序,测试只能证明其不正确(当测试失败时)但不能证明其正确。但从科学的角度来看,如果有足够的测试显示没有测试失败,我们就可以认为对于我们的目的,这段程序是正确的。当然,这一证明方法的前提是这段代码是可测试的。而当程序中不加限制的goto影响了程序的可测试性时,我们就很难采取这种方法来证明其正确性。

总而言之,structured programming要求我们把程序分解成许多个可以通过科学方法来证明的小函数。当对于函数进行充足的测试后发现没有失败时,我们可以认为这些小函数足够正确。

Chapter 5 Object-Oriented Programming

这一章作者问了一个问题,究竟什么是OO(Object-Oriented)?“数据和函数的集合”,这个回答显得太过简单;“一种对于现实世界建模的方法”,这并没有真正说明OO是什么;“封装,继承,多态”,点出了OO是这三者的混合(至少来说一门OO语言需要包含这三种功能)。

封装

作者指出,OO语言的确提供了简单有效的封装,但是事实上封装早在C这类的非OO语言中出现了。举例如下

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
// point.h
struct Point;
struct Point* makePoint(double x, double y);
double distance (struct Point *p1, struct Point *p2);

// point.c
# include "point.c"
# include <stdlib.h>
# include <math.h>

struct Point {
double x, y;
};

struct Point* makePoint(double x, double y) {
struct Point* p = malloc(sizeof(struct Point));
p->x = x;
p->y = y;
return p;
}

double distance(struct Point* p1, struct Point* p2) {
double dx = p1->x - p2->x;
double dy = p1->y - p2->y;
return sqrt(dx*dx - dy*dy);
}

在使用point.h时我们可以通过makePoint来制点,可以通过distance来计算两点间的距离,但是我们不会知道Point内部的具体实现。这个例子展现了C中完美的封装实现。而到了C++这一OO语言中,由于C++编译器必须知道每个类的实例所占的空间,point.h变成了如下的样子:
1
2
3
4
5
6
7
8
9
class Point {
public:
Point(double x, double y);
double distance(const Point& p) const;

private:
double x;
double y;
}

类的内部变量x和y出现在了头文件中,同时当我们需要改变这些成员名字时,我们需要在对应的point.cc中做相应的改动并重新编译。这实际在某种程度上违背了封装的理念。作者也指出,在其他例如Java和C#的语言中,这种头文件和具体实现分离的模式甚至被放弃了,这进一步削弱了封装(个人觉得Java的interface也是类似于头文件与实现分离的方式?作者这里只是一笔提过Java,可能是打算到多态的部分时再具体讨论interface。但的确相比于C中的封装要弱得多)。

由此,我们可以看出,尽管OO是基于程序员们都会自觉地规避与class内部成员进行交互的前提,但OO并没有加强封装,甚至相对于C削弱了对于封装的限制。

继承

在之前作者指出,OO并没有给予更好的封装(应该是仅针对严格性,OO语言相对于C的确提供了更为方便的封装)。那么继承呢?实际上在C中,我们也能看到继承的例子,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// namedPoint.h
struct NamedPoint;

struct NamedPoint* NamedPoint(double x, double y, char* name);
void setName(struct NamedPoint* np, char* name);
char* getName(struct NamedPoint* np);

// namedPoint.c

...
struct NamedPoint {
double x, y;
char* name;
}
...

在前文Point的基础上,我们又定义了一种NamedPoint类,在实际使用中,我们可以对其进行如下操作
1
2
3
4
5
printf("distance=%f\n",
distance(
(struct Point*) origin,
(struct Point*) upperRight
));

由此看出,NamedPoint可以被认为是Point的超集,即我们认为说NamedPoint实际上继承了Point(尽管C中没有正式的继承)。另外这其实也是C++中对于继承的实现方法。

由此,我们可以总结说OO并没有在继承上提供一些全新的概念,但是它也的确让继承变得更为方便。然而这仍然没有为“OO是什么”这一问题提供一个很好的答案。

多态

在C中有没有多态呢,其实也是有的。例如

1
2
3
4
5
6
7
#include <stdio.h>

void copy() {
int c;
while ((c==getchar()) != EOF)
putchar(c);
}

函数getchar从STDIN中进行读操作,而putchar对STDOUT进行写操作,但是我们并不知道STDIN和STDOUT具体是什么设备。实际上这里就涉及了多态,这两个函数实际的行为需要视具体的设备而定。而由于C中并没有interface的定义,这里的原理是Unix操作系统规定了所有设备的驱动必须提供5个标准函数open, close, read, write和seek。在实际运行中,只需要将函数指针指向设备驱动的具体实现即可。

由此看出早在OO语言出现之前,程序员们已经通过使用函数的指针在实际上实现了多态。但是由于对函数指针的使用是极其危险的,这一思想在OO出现之前几乎仅仅在操作系统层面出现,而没有大规模地扩展到应用层面开发。我们说OO的出现虽然没有带来全新的概念,但由于它带来了更为简单安全的多态,才使得这一思想得到大规模地运用。这也照应了我们开头所提到的“Object-oriented programming imposes discipline on indirect transfer of control”。

如何理解这一说法呢,这是因为多态给予了我们对依赖进行反转的能力。在OO带来简单安全的多态之前,一段程序会通过high-lvel function调用mid-level function,而mid-level又会调用low-level,这是一个很典型的direct transfer of control,以high-level和mid-level间的关系为例,high-level function牢牢地依赖于mid-level function。但当我们在两者之间插入一个interface时,情况就不一样了,在编写high-level function时,我们不再需要考虑mid-level的实现。相反地变成了在编写mid-level时,我们需要依照high-level中的interface来编写程序。Mid-level成为了high-level的一个插件,而不是依赖。

这就是OO最重要的一点,我们可以对于任意的代码依赖进行反转,从而在各个模块之间实现解耦。比如对于一个程序,其中的业务逻辑会调用UI和数据库实现,这是一个紧耦合的状态。但是有了依赖反转后,我们可以简单地把UI和数据库作为业务逻辑的插件,使得这三个部分可以作为独立的元件分别开发和部署而不互相影响。综上,对于这一章开头“OO是什么“的问题,我们可以回答说OO是一种能力,一种通过运用多态来获得对代码依赖关系的绝对控制的能力。

Chapter 6 Functional Programming

Functional Programming的概念最早可以追溯到Alonzo Church在1930年代发明的Lambda算子。第三章之前提过“Functional programming imposes discipline upon assignment”,这是因为在纯粹的函数式编程中,所谓的变量(variable)都是不可变的(immutable)。变量不可变的优点在于从根源上解决了并发编程中的各种问题(如race condition,deadlock等等),因为这些问题的根本原因就是对于可变变量的读写时的冲突。但是这种不可变是可以实现的吗。在存储和计算资源是无限的情况下,毫无疑问这是可以实现的,但是当没有无限的资源时,我们仍可以通过一定的妥协来实现这种不可变。

一种方法是把组件们分成可变和不可变两类。让不可变的组件依赖于可变组件并将可变组件的状态记录在transaction memory中以防止concurrent updates和race condition的出现。对于这种结构,架构师们所期望的是尽可能地把代码从可变的部分提取出来处理成不可变的组件。

另一种是event sourcing,即不存储状态,只存储所有的transaction,当需要查询状态时,只需要将所有的transaction全都计算一遍得到状态即可。