本文实践环境:
Operating System: CentOS Linux release 8.4.2105
Kernel: 4.18.0-305
Architecture: x86-64
刚学习完《Clean Architecture》第 2 部分编程范式相关内容,为提高学习留存率,加深理解,故将相关内容进行简单总结。
三大编程范式梳理:
范式 | 描述 | 限制 | 代表语言 | 提出时间 | 提出人 | 备注 |
---|---|---|---|---|---|---|
structured programming | 对程序控制权的直接转移进行了限制和规范 | 限制使用 goto 语句,不让程序随意跳转 | C | 1968 | Edsger Wybe Dijkstra | |
object-oriented programming | 对程序控制权的间接转移进行了限制和规范 | 限制使用指针,用多态来代替 | Java | 1966 | Ole Johan Dahl & Kriste Nygaard | |
functional programming | 对程序中的赋值语句进行了限制和规范 | 限制使用赋值语句 | LISP | 1958 | John Mccarthy | 作者发明了 LISP 语言 |
从上面的表格中可以看到一个比较有意思的事情,编程范式的提出时间与编程范式的普及正好相反。回顾过去几十年的发展历史,结构化编程最先为大众所接受,然后是面向对象编程,最后是现在慢慢为大家推崇的函数式编程。我个人对这个发展趋势的原因分析是:程序规模的不断膨胀,以及得益于摩尔定律而发展起来的硬件(如 CPU,内存,磁盘等)性能的大幅度提升。
structured programming
1、goto 是有害的
结构化编程是相对于非结构化编程而言的。比如,对于 C 这类高级语言来说,汇编语言就是非结构化的。如果用汇编语言进行编程,你面对的将是各种寄存器和内存地址,以及各种指令:比如 add, push, pop, mov, cmp, jmp等等。由于没有 if/else 这类分支结构,所以代码中都是通过比较指令进行判断,然后再通过跳转指令跳到对应的地方执行。
可以想象得到,这样编写出来的代码一旦程序规模扩大,就会失控(代码随意跳来跳去),换个新人接手代码,不但不好维护,阅读起来都非常费力。于是,Dijkstra 在 1968 年提出了《Go To Statement Considered Harmful》,并且被最终证明是对的。
2、功能分解是最佳实践之一
在 Dijkstra 研究结构化编程之时,Bohm 和 Jocopini 证明了人们可以用顺序结构、分支结构、循环结构这三种结构构造出任何程序。Dijkstra 使用枚举法证明了顺序结构的正确性、分支结构的可推导性,而循环结构的正确性则稍有不同,它是通过数学归纳法证明的。
结构化编程通过拆分,可以将一个大程序自上而下拆分成一个个小单元,如此一来,再复杂的问题也能解决。以此为基础,便出现了后面的结构化分析和结构化设计等工作方式。而我们平常工作时,经常需要进行任务分解,也是同一个道理。
3、测试只能证明 bug 存在,不能证明没有 bug
科学研究与数学有所不同,数学可以被证明,但科学只能被证伪,因为在某个特殊的场景下,科学定律可能就不成立了。但在没有遇到这个特殊场景之前,如果科学定律都被证明是正确的,那么我们就认为它在当下是对的。
同样,一段程序可以由一个测试来证明其错误性,却不能被证明它没有其他缺陷。即程序与科学定律类似,可以被证伪,却不能被证明。如果分解后的每个小程序都证明没有错误,则认为对应的大程序暂时是正确的。但如果程序中无节制的使用 goto 语句,那么写多少测试用例也无法证明它当下是正确的,因为 goto 语句的某些用法会导致某个模块无法被递归拆分成更小的、可证明的单元。这就是为什么要限制使用 goto 语句的原因。
object-oriented programming
1、封装
封装不能完全说是面向对象编程的必要条件,因为相对于 C 语言这类结构化编程语言来说,C++,Java,C# 的封装特性是被逐渐虚弱了,而不是增强。
怎么理解?先看一个简单的程序:
// point.h
struct Point;
struct Point* makePoint(double x, double y);
double distance(struct Point *p1, struct Point *p2);
// point.c
#include "point.h"
#include <math.h>
#include <stdlib.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);
}
另外一个程序通过 #include "point.h"
即可使用 makePoint()
和 distance()
两个函数,但是对于 Point 结构体成员的访问权限,它是没有的。通过这种头文件与实现文件分离的方式,完美的封装了 Point
结构体的内部细节以及函数的实现细节。
接着再看 C++ 版本的写法:
// point.h
class Point{
public:
Point(double x, double y);
double distance(const Point& p) const;
private:
double x;
double y;
};
// point.cpp
#include "point.h"
#include <math.h>
Point::Point(double x, double y): x(x), y(y){}
double Point::distance(const Point& p) const{
double dx = x-p.x;
double dy = y-p.y;
return sqrt(dx*dx + dy*dy);
}
与前面的 C 语言相比,C++ 的封装性削弱了,因为头文件中暴露了类的成员变量 x 和 y。而之所以需要在头文件中暴露成员变量,却是因为 C++ 语言特性:C++ 编译器必须要知道每个类实例的大小。
最后我们再看看用 Java 写这段代码的样子:
package com.learn.core.blog;
public class Point {
private double x;
private double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double distance(Point p) {
double dx = x - p.x;
double dy = y - p.y;
return Math.sqrt(dx * dx + dy * dy);
}
}
很明显,Java 在 C++ 的基础上直接将头文件中类的声明给干掉了,面向对象的封装性进一步被削弱了,因为我们已经无法区分一个类的声明和定义了。
2、继承
继承的主要作用是:让我们可以在某个作用域内对外部定义的某一组变量与函数进行覆盖。
在面向对象编程语言发明之前,继承就已经存在了,只是实现起来有些投机取巧,不像如今的继承这样方便使用。是怎样投机取巧的呢?下面笔者便把书中的例子演示一遍,你一看便明白了。
在 point.h 和 point.c 的基础上,新增 namedPoint.h 和 namedPoint.c 两个文件如下:
// namedPoint.h
struct NamedPoint;
struct NamedPoint* makeNamedPoint(double x, double y, char* name);
void setName(struct NamedPoint* np, char* name);
char* getName(struct NamedPoint* np);
// namedPoint.c
#include "namedPoint.h"
#include <stdlib.h>
struct NamedPoint {
double x, y;
char* name;
};
struct NamedPoint* makeNamedPoint(double x, double y, char* name) {
struct NamedPoint* p = malloc(sizeof(struct NamedPoint));
p->x = x;
p->y = y;
p->name = name;
return p;
}
void setName(struct NamedPoint* np, char* name) {
np->name = name;
}
char* getName(struct NamedPoint* np) {
return np->name;
}
下面是 main 函数入口的代码:
// main.c
#include "point.h"
#include "namedPoint.h"
#include <stdio.h>
int main(int ac, char** av){
struct NamedPoint* origin = makeNamedPoint(0.0, 0.0, "origin");
struct NamedPoint* upperRight = makeNamedPoint(3.0, 4.0, "upperRight");
double distanceValue = distance((struct Point*) origin, (struct Point*) upperRight);
printf("distanceValue=%f\n", distanceValue);
}
注意上面调用 distance()
函数的代码,将 NamedPoint
类型的变量强制转换为 Point
类型:
double distanceValue = distance((struct Point*) origin, (struct Point*) upperRight);
之所以可以这样伪装,是因为 NamedPoint
是 Point
结构体的一个超集,同时两者共同成员的顺序也是一样的。这便是所谓的投机取巧(我们可以将其理解为 NamedPoint
继承自 Point
)。
编译运行结果如下:
➜ chapter5-inheritance$ ll
total 44
-rw-rw-r-- 1 demonlee demonlee 377 Sep 29 22:54 main.c
-rw-rw-r-- 1 demonlee demonlee 137 Sep 29 23:20 Makefile
-rwxrwxr-x 1 demonlee demonlee 17776 Oct 1 21:42 namedPoint
-rw-rw-r-- 1 demonlee demonlee 433 Sep 29 22:40 namedPoint.c
-rw-rw-r-- 1 demonlee demonlee 174 Sep 29 22:36 namedPoint.h
-rw-rw-r-- 1 demonlee demonlee 386 Sep 29 22:29 point.c
-rw-rw-r-- 1 demonlee demonlee 113 Sep 29 22:28 point.h
➜ chapter5-inheritance$
➜ chapter5-inheritance$ cat Makefile
objs=point.o namedPoint.o main.o
all: namedPoint
namedPoint: ${objs}
gcc -o $@ $? -lm
echo "### $@ created ###\n"
clean:
rm -f *.o
➜ chapter5-inheritance$
➜ chapter5-inheritance$ make all
cc -c -o point.o point.c
cc -c -o namedPoint.o namedPoint.c
cc -c -o main.o main.c
gcc -o namedPoint point.o namedPoint.o main.o -lm
echo "### namedPoint created ###\n"
### namedPoint created ###\n
➜ chapter5-inheritance$
➜ chapter5-inheritance$ ./namedPoint
distanceValue=5.000000
➜ chapter5-inheritance$
3、多态
如你想象,在面向对象编程语言发明之前,多态也是支持的,比如 UNIX 操作系统中每个 IO 设备都要提供 open、close、read、write 和 seek 5个标准函数。
struct FILE {
void (*open)(char* name, int mode);
void (*close)();
int (*read)();
void (*write)(char);
void (*seek)(long index, int mode);
}
不同的设备都会实现这几个函数,所以当切换设备时,业务上层代码可以做到不用改动就能用了,即程序与设备无关,这也就是控制逻辑与业务逻辑分离的好处。
为什么UNIX操作系统会将IO设备设计成插件形式呢?因为自20世纪50年代末期以来,我们学到了一个重要经验:程序应该与设备无关。这个经验从何而来呢?因为一度所有程序都是设备相关的,但是后来我们发现自己其实真正需要的是在不同的设备上实现同样的功能。
上面的 FILE
结构体有 5 个函数指针,不同的设备会对这个结构体赋不同的函数指针,所以说多态其实就是函数指针的一种应用。但是用函数指针的坏处就是风险高,结构体中的值可以被修改,这样就必须依靠人为约定,一旦有人不小心犯错,就会失控。
面向对象编程语言中将这种一个接口多个不同实现中的函数指针赋值给隐藏了,下沉到运行时中去实现。简单来说会有一张虚拟函数表,不同的子类有不同的虚拟表,虚拟表中记录了每个函数对应的函数指针。
在面向对象的多态出现之前,软件源代码中的依赖与程序控制流的方向一致,即 B 模块调用 A 模块,B 模块调用 C 模块,那么 A <-- B --> C (B 依赖 A,B 依赖 C)。也就是,系统行为决定了控制流,而控制流则决定了源代码依赖关系。
有了多态之后,通过引入一个中间抽象层(接口),让上层依赖这个接口,而接口的实现有各种情况,与上层无关,只与接口关联,如下图所示:
此时,实际的控制流(代码实际调用)的方向,与具体业务实现的方向是相反的,这就是所谓的依赖反转。
functional programming
1、What
函数式编程中的函数不是软件中的函数,而是来自数学中的函数,比如:
f(x)=2x+1
数学中的函数有一个特点,就是无论调用多少次,相同的入参,永远都会获得相同的结果。简单一点说,函数式编程中的函数无副作用,天生就是幂等的,但又不止步于此。比如,函数式编程语言中的变量是不可变的。在这里,我们将这种函数称之为纯函数。
面向对象编程中,类或对象是一等公民,而函数式编程中函数是一等公民。
2、Why
那为啥要不可变呢?很简单:在当前多处理器架构的服务器上跑的程序,所有的竞争问题、死锁问题等,都是由可变变量导致的。如果可以做到变量不可变,函数不可变,那么这些问题通通没有了,复杂度立即便降低了一个档次。假设我们忽略存储器与处理器在速度上的限制,不可变性的实现是可行的。
3、How
如何实现不可变性?答案是:分离关注点。
将一个服务中的内部组件进行拆分,一种是可变的,一种是不可变的。不可变的部分用纯函数的方式来实现,不改变任何状态。而这些不可变组件通过与一个或多个非函数式组件通信的方式来修改变量状态。
软件架构师需要花大力气将大部分处理逻辑归并到不可变组件中,将可变组件的数量降至最低,最后用合适的机制来保护可变量。
另外一个可行的解决方案是事件溯源,也就是只记录事务操作日志,而不记录最终的计算结果。如果要结果呢?那就将之前的事务日志数据都拿出来,按照时间顺序从头到尾计算一遍。如此一来,也就不用维护任何可变变量了,CRUD 中的 U(更新)和 D(删除)没了,变成了 CR。
是不是觉得不可思议,可我们用的源代码管理工具(Svn,Git等),不正是按照这种方式来工作的吗。只要要足够大的存储能力和计算能力,我们完全可以使用这种方式来实现函数式编程。
总结
- 三大编程范式是对程序员提出的限制,限制就是规则,从而降低程序故障或问题出现的概率,同时提高编程的效率。
- 结构化编程从微观上解决程序的实现逻辑,但因为是微观,所以抽象程度不高,搞不定大规模的软件系统。
- 面向对象编程便克服了结构化编程的不足,从更高的宏观层面,对物理世界进行建模,然后再按照一定的规则进行组织。比如,以多态为手段来对源代码中的依赖关系进行控制,构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层实现性组件便可以被编译成插件,实现独立开发和部署。
- 函数式编程之所以限制赋值操作,是为了提高程序的不可变性,防止自己定义的变量被别人随意赋值更改。除了不可变性,函数式编程中还有组合性,在后续的学习中,笔者还会进行相关梳理和总结。
参考资料
- Clean Architecture,by Robert C. Martin
- 软件设计之美,by 郑晔