为什么要函数式编程?


为什么需要函数?

1
2
3
4
5
6
7
8
9
int main() {
std::vector<int> a = {1, 2, 3, 4};
int s = 0;
for (int i = 0; i < a.size(); i++) {
s += a[i];
}
fmt::println("sum = {}", s);
return 0;
}

这是一个计算数组求和的简单程序。

但是,他只能计算数组 a 的求和,无法复用。

如果我们有另一个数组 b 也需要求和的话,就得把整个求和的 for 循环重新写一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main() {
std::vector<int> a = {1, 2, 3, 4};
int s = 0;
for (int i = 0; i < a.size(); i++) {
s += a[i];
}
fmt::println("sum of a = {}", s);
std::vector<int> b = {5, 6, 7, 8};
s = 0;
for (int i = 0; i < a.size(); i++) {
s += b[i];
}
fmt::println("sum of b = {}", s);
return 0;
}

这就出现了程序设计的大忌:代码重复

在软件设计中,也有一个类似的概念:DRY(Don’t Repeat Yourself)。

例如,你有吹空调的需求,和充手机的需求。你为了满足这两个需求,购买了两台发电机,分别为空调和手机供电。第二天,你又产生了玩电脑需求,于是你又购买一台发电机,专为电脑供电……真是浪费!

重复的代码不仅影响代码的可读性,也增加了维护代码的成本。

  • 看起来乱糟糟的,信息密度低,让人一眼看不出代码在干什么的功能
  • 很容易写错,看走眼,难调试
  • 复制粘贴过程中,容易漏改,比如这里的 s += b[i] 可能写成 s += a[i] 而自己不发现
  • 改起来不方便,当我们的需求变更时,需要多处修改,比如当我需要改为计算乘积时,需要把两个地方都改成 s *=
  • 改了以后可能漏改一部分,留下 Bug 隐患
  • 敏捷开发需要反复修改代码,比如你正在调试 +=-= 的区别,看结果变化,如果一次切换需要改多处,就影响了调试速度

狂想:没有函数的世界?

如果你还是喜欢“一本道”写法的话,不妨想想看,完全不用任何标准库和第三方库的函数和类,把 fmt::printlnstd::vector 这些函数全部拆解成一个个系统调用。那这整个程序会有多难写?

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
53
54
55
56
57
58
59
60
61
int main() {
#ifdef _WIN32
int *a = (int *)VirtualAlloc(NULL, 4096, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
#else
int *a = (int *)mmap(NULL, 4 * sizeof(int), PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
#endif
a[0] = 1;
a[1] = 2;
a[2] = 3;
a[3] = 4;
int s = 0;
for (int i = 0; i < 4; i++) {
s += a[i];
}
char buffer[64];
buffer[0] = 's';
buffer[1] = 'u';
buffer[2] = 'm';
buffer[3] = ' ';
buffer[4] = '=';
buffer[5] = ' '; // 例如,如果要修改此处的提示文本,甚至需要修改后面的 len 变量...
int len = 6;
int x = s;
do {
buffer[len++] = '0' + x % 10;
x /= 10;
} while (x);
buffer[len++] = '\n';
#ifdef _WIN32
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), buffer, len, NULL, NULL);
#else
write(1, buffer, len);
#endif
int *b = (int *)a;
b[0] = 4;
b[1] = 5;
b[2] = 6;
b[3] = 7;
int s = 0;
for (int i = 0; i < 4; i++) {
s += b[i];
}
len = 6;
x = s;
do {
buffer[len++] = '0' + x % 10;
x /= 10;
} while (x);
buffer[len++] = '\n';
#ifdef _WIN32
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), buffer, len, NULL, NULL);
#else
write(1, buffer, len);
#endif
#ifdef _WIN32
VirtualFree(a, 0, MEM_RELEASE);
#else
munmap(a);
#endif
return 0;
}

不仅完全没有可读性、可维护性,甚至都没有可移植性。

除非你只写应付导师的“一次性”程序,一旦要实现复杂的业务需求,不可避免的要自己封装函数或类。网上所有鼓吹“不封装”“设计模式是面子工程”的反智言论,都是没有做过大型项目的。

设计模式追求的是“可改”而不是“可读”!

很多设计模式教材片面强调可读性,仿佛设计模式就是为了“优雅”“高大上”“美学”?使得很多人认为,“我这个是自己的项目,不用美化给领导看”而拒绝设计模式。实际上设计模式的主要价值在于方便后续修改

例如 B 站以前只支持上传普通视频,现在叔叔突然提出:要支持互动视频,充电视频,视频合集,还废除了视频分 p,还要支持上传短视频,竖屏开关等……每一个叔叔的要求,都需要大量程序员修改代码,无论涉及前端还是后端

与建筑、绘画等领域不同,一次交付完毕就可以几乎永久使用。而软件开发是一个持续的过程,每次需求变更,都导致代码需要修改。开发人员几乎需要一直围绕着软件代码,不断的修改。调查表明,程序员 90% 的时间花在改代码上,写代码只占 10%。

软件就像生物,要不断进化,软件不更新不维护了等于死。如果一个软件逐渐变得臃肿难以修改,无法适应新需求,那他就像已经失去进化能力的生物种群,如《三体》世界观中“安顿”到澳大利亚保留区里“绝育”的人类,被淘汰只是时间问题。

如果我们能在写代码阶段,就把程序准备得易于后续修改,那就可以在后续 90% 的改代码阶段省下无数时间。

如何让代码易于修改?前人总结出一系列常用的写法,这类写法有助于让后续修改更容易,各自适用于不同的场合,这就是设计模式。

提升可维护性最基础的一点,就是避免重复!

当你有很多地方出现重复的代码时,一旦需要涉及修改这部分逻辑时,就需要到每一个出现了这个逻辑的代码中,去逐一修改。

例如你的名字,在出生证,身份证,学生证,毕业证,房产证,驾驶证,各种地方都出现了。那么你要改名的话,所有这些证件都需要重新印刷!如果能把他们合并成一个“统一证”,那么只需要修改“统一证”上的名字就行了。

可以理解为这些证在使用的时候是到一个固定的点位去获取名字,而不是硬编码写死,我们微服务的Nacos这点实现的就非常好。通过这样的操作,我们就不需要全部重新修改一遍了。

不过,现实中并没有频繁改名字的需求,这说明:

  • 对于不常修改的东西,可以容忍一定的重复。
  • 越是未来有可能修改的,就越需要设计模式降重!

例如数学常数 PI = 3.1415926535897,这辈子都不可能出现修改的需求,那写死也没关系。如果要把 PI 定义成宏,只是出于“记不住”“写起来太长了”“复制粘贴麻烦”。所以对于 PI 这种不会修改的东西,降重只是增加可读性,而不是可修改性

但是,不要想当然!需求的千变万化总是超出你的想象。

例如你做了一个“愤怒的小鸟”游戏,需要用到重力加速度 g = 9.8,你想当然认为 g 以后不可能修改。老板也信誓旦旦向你保证:“没事,重力加速度不会改变。”你就写死在代码里了。

没想到,“愤怒的小鸟”老板突然要求你加入“月球章”关卡,在这些关卡中,重力加速度是 g = 1.6。

如果你一开始就已经把 g 提取出来,定义为常量:

1
2
3
4
5
6
7
8
struct Level {
const double g = 9.8;

void physics_sim() {
bird.v = g * t; // 假装这里是物理仿真程序
pig.v = g * t; // 假装这里是物理仿真程序
}
};

那么要支持月球关卡,只需修改一处就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Level {
double g;

Level(Chapter chapter) {
if (chapter == ChapterMoon) {
g = 1.6;
} else {
g = 9.8;
}
}

void physics_sim() {
bird.v = g * t; // 无需任何修改,自动适应了新的非常数 g
pig.v = g * t; // 无需任何修改,自动适应了新的非常数 g
}
};

小彭老师之前做 zeno 时,询问要不要把渲染管线节点化,方便用户动态编程?张猩猩就是信誓旦旦道:“渲染是一个高度成熟领域,不会有多少修改需求的。”小彭老师遂写死了渲染管线,专为性能极度优化,几个月后,张猩猩羞答答找到小彭老师:“小彭老师,那个,渲染,能不能改成节点啊……”。这个故事告诉我们,甲方的信誓旦旦放的一个屁都不能信。

用函数封装

函数就是来帮你解决代码重复问题的!要领:

把共同的部分提取出来,把不同的部分作为参数传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void sum(std::vector<int> const &v) {
int s = 0;
for (int i = 0; i < v.size(); i++) {
s += v[i];
}
fmt::println("sum of v = {}", s);
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
sum(a);
std::vector<int> b = {5, 6, 7, 8};
sum(b);
return 0;
}

这样 main 函数里就可以只关心要求和的数组,而不用关心求和具体是如何实现的了。事后我们可以随时把 sum 的内容偷偷换掉,换成并行的算法,main 也不用知道。这就是封装,可以把重复的公共部分抽取出来,方便以后修改代码

sum 函数相当于,当需要吹空调时,插上空调插座。当需要给手机充电时,插上手机充电器。你不需要关心插座里的电哪里来,“国家电网”会替你想办法解决,想办法优化,想办法升级到绿色能源。你只需要吹着空调给你正在开发的手机 App 优化就行了,大大减轻程序员心智负担。

要封装,但不要耦合

但是!这段代码仍然有个问题,我们把 sum 求和的结果,直接在 sum 里打印了出来。sum 里写死了,求完和之后只能直接打印,调用者 main 根本无法控制。

这是一种错误的封装,或者说,封装过头了。

你把手机充电器 (fmt::println) 焊死在了插座 (sum) 上,现在这个插座只能给手机充电 (用于直接打印) 了,不能给笔记本电脑充电 (求和结果不直接用于打印) 了!尽管通过更换充电线 (参数 v),还可以支持支持安卓 (a) 和苹果 (b) 两种手机的充电,但这样焊死的插座已经和笔记本电脑无缘了。

每个函数应该职责单一,别一心多用

很明显,“打印”和“求和”是两个独立的操作,不应该焊死在一块。

sum 函数的本职工作是“数组求和”,不应该附赠打印功能

sum 计算出求和结果后,直接 return 即可

如何处理这个结果,是调用者 main 的事,正如“国家电网”不会管你用他提供的电来吹空调还是玩游戏一样,只要不妨碍到其他居民的正常用电。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int sum(std::vector<int> const &v) {
int s = 0;
for (int i = 0; i < v.size(); i++) {
s += v[i];
}
return s;
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
fmt::println("sum of a = {}", sum(a));
std::vector<int> b = {5, 6, 7, 8};
fmt::println("sum of b = {}", sum(b));
return 0;
}

这就是设计模式所说的职责单一原则

二次封装

假设我们要计算一个数组的平均值,可以再定义个函数 average,他可以基于 sum 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int sum(std::vector<int> const &v) {
int s = 0;
for (int i = 0; i < v.size(); i++) {
s += v[i];
}
return s;
}

double average(std::vector<int> const &v) {
return (double)sum(v) / v.size();
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
fmt::println("average of a = {}", average(a));
std::vector<int> b = {5, 6, 7, 8};
fmt::println("average of b = {}", average(b));
return 0;
}

进一步封装一个打印数组所有统计学信息的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void print_statistics(std::vector<int> const &v) {
if (v.empty()) {
fmt::println("this is empty...");
} else {
fmt::println("sum: {}", sum(v));
fmt::println("average: {}", average(v));
fmt::println("min: {}", min(v));
fmt::println("max: {}", max(v));
}
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
print_statistics(a);
std::vector<int> b = {5, 6, 7, 8};
print_statistics(b);
return 0;
}

暴露 API 时,要同时提供底层的 API 和高层封装的 API。用户如果想要控制更多细节可以调用底层 API,想要省事的用户可以调用高层封装好的 API。

高层封装 API 应当可以完全通过调用底层 API 实现,提供高层 API 只是方便初级用户使用和理解

例如 libcurl 就提供了 curl_easycurl_multi 两套 API。

1
2
- `curl_multi` 提供了超详细的参数,把每个操作分拆成多步,方便用户插手细节,满足高级用户的定制化需求,但太过复杂,难以学习。
- `curl_easy` 是对 `curl_multi` 的再封装,提供了更简单的 API,但是对具体细节就难以操控了,适合初学者上手。

Linus 的最佳实践:每个函数不要超过 3 层嵌套,一行不要超过 80 字符,每个函数体不要超过 24 行

Linux 内核为什么坚持使用 8 缩进为代码风格

因为高缩进可以避免程序员写出嵌套层数太深的代码,当他写出太深嵌套时,巨大的 8 缩进会让代码变得非常偏右,写不下多少空间。从而让程序员自己红着脸“对不起,我把单个函数写太深了”然后赶紧拆分出多个函数来。

此外,他还规定了单一一个函数必须在终端宽度 80 x 24 中显示得下,否则就需要拆分成多个函数重写,这配合 8 缩进,有效的限制了嵌套的层数,迫使程序员不得不重新思考,更解耦的写法出来。

为什么需要函数式?

你产生了两个需求,分别封装了两个函数:

  • sum 求所有元素的和
  • product 求所有元素的积
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int sum(std::vector<int> const &v) {
int ret = v[0];
for (int i = 1; i < v.size(); i++) {
ret += v[i];
}
return ret;
}

int product(std::vector<int> const &v) {
int ret = v[0];
for (int i = 1; i < v.size(); i++) {
ret *= v[i];
}
return ret;
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
fmt::println("sum: {}", sum(a));
fmt::println("product: {}", product(a));
return 0;
}

注意到 sumproduct 的内容几乎如出一辙,唯一的区别在于:

  • sum 的循环体为 +=
  • product 的循环体为 *=

这种函数体内有部分代码重复,但又有特定部分不同,难以抽离。

该怎么复用这重复的部分代码呢?

我们要把 sumproduct 合并成一个函数 generic_sum。然后通过函数参数,把差异部分(0、+=“注入”到两个函数原本不同地方。

枚举的糟糕用法

如何表示我这个函数是要做求和 += 还是求积 *=

让我们定义枚举:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum Mode {
ADD, // 求和操作
MUL, // 求积操作
};

int generic_sum(std::vector<int> const &v, Mode mode) {
int ret = v[0];
for (int i = 1; i < v.size(); i++) {
if (mode == ADD) { // 函数内判断枚举,决定要做什么操作
ret += v[i];
} else if (mode == MUL) {
ret *= v[i];
}
}
return ret;
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
fmt::println("sum: {}", generic_sum(a, ADD)); // 用户指定他想要的操作
fmt::println("product: {}", generic_sum(a, MUL));
return 0;
}

然而,如果用户现在想要求数组的最大值呢?

枚举中还没有实现最大值的操作……要支持,就得手忙脚乱地去修改 generic_sum 函数和 Mode 枚举原本的定义,真麻烦!

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
enum Mode {
ADD,
MUL,
MAX, // ***改***
};

int generic_sum(std::vector<int> const &v, Mode mode) {
int ret = v[0];
for (int i = 1; i < v.size(); i++) {
if (mode == ADD) {
ret += v[i];
} else if (mode == MUL) {
ret *= v[i];
} else if (mode == MAX) { // ***改***
ret = std::max(ret, v[i]); // ***改***
}
}
return ret;
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
generic_sum(a, MAX); // ***改***
return 0;
}

我用 // ***改*** 指示了所有需要改动的地方。

为了增加一个求最大值的操作,就需要三处分散在各地的改动!

不仅如此,还容易抄漏,抄错,比如 MAX 不小心打错成 MUL 了,自己却没发现,留下 BUG 隐患。

这样写代码的方式,心智负担极大,整天就提心吊胆着东一块,西一块的散装代码,担心着有没有哪个地方写错写漏,严重妨碍了开发效率。

并且写出来的代码也不能适应需求的变化:假如我需要支持 MIN 呢?又得改三个地方!这违背了设计模式的开闭原则

  • 开闭原则: 对扩展开放,对修改封闭。指的是软件在适应需求变化时,应尽量通过扩展代码来实现变化,而不是通过修改已有代码来实现变化。

使用枚举和 if-else 实现多态,难以扩展,还要一直去修改原函数的底层实现,就违背了开闭原则

函数式编程光荣救场

如果我们可以“注入”代码就好了!能否把一段“代码”作为 generic_sum 函数的参数呢?

代码,实际上就是函数,注入代码就是注入函数。我们先定义出三个不同操作对应的函数:

1
2
3
4
5
6
7
8
9
10
11
int add(int a, int b) {
return a + b;
}

int mul(int a, int b) {
return a * b;
}

int max(int a, int b) {
return std::max(a, b);
}

然后,把这三个小函数,作为另一个大函数 generic_sum 的参数就行!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int generic_sum(std::vector<int> const &v, auto op) {
int ret = v[0];
for (int i = 1; i < v.size(); i++) {
// 函数作者无需了解用户指定的“操作”具体是什么
// 只需要调用这一“操作”,得到结果就行
ret = op(ret, v[i]);
}
return ret;
}

int main() {
std::vector<int> a = {1, 2, 3, 4};
// 用户无需关心函数的具体实现是什么
// 只需随心所欲指定他的“操作”作为参数
generic_sum(a, add);
generic_sum(a, product);
generic_sum(a, max);
return 0;
}

责任明确了,我们成功把一部分细节从 generic_sum 中进一步抽离。

  • 库作者 generic_sum 不必了解 main 的操作具体是什么,他只负责利用这个操作求“和”。
  • 库用户 main 不必了解 generic_sum 如何实现操作累加,他只管注入“如何操作”的代码,以函数的形式

依赖注入原则

函数对象 op 作为参数传入,让 generic_sum 内部去调用,就像往 generic_sum 体内“注入”了一段自定义代码一样。

这可以让 generic_sum 在不修改本体的情况下,通过修改“注入”部分,轻松扩展,满足开闭原则

更准确的说,这体现的是设计模式所要求的依赖注入原则

  • 依赖注入原则: 一个封装好的函数或类,应该尽量依赖于抽象接口,而不是依赖于具体实现。这可以提高程序的灵活性和可扩展性。

四大编程范式都各自发展出了依赖注入原则的解决方案:

  • 面向过程编程范式中,函数指针就是那个抽象接口。
  • 面向对象编程范式中,虚函数就是那个抽象接口。
  • 函数式编程范式中,函数对象就是那个抽象接口。
  • 模板元编程范式中,模板参数就是那个抽象接口。

同样是把抽象接口作为参数,同样解决可扩展问题。

函数指针贴近底层硬件,虚函数方便整合多个接口,函数对象轻量级、随地取用,模板元有助高性能优化,不同的编程范式殊途同归。

低耦合,高内聚

依赖注入原则可以减少代码之间的耦合度,大大提高代码的灵活性和可扩展性。

  • 耦合度: 指的是一个模块、类、函数和其他模块、类、函数之间的关联程度。耦合度越低,越容易进行单元测试、重构、复用和扩展

高耦合度的典型是“牵一发而动全身”。低耦合的典范是蚯蚓,因为蚯蚓可以在任意断面切开,还能活下来,看来蚯蚓的身体设计非常“模块化”呢。

通常来说,软件应当追求低耦合度,适度解耦的软件能更快适应需求变化。但过度的低耦合也会导致代码过于分散,不易阅读和修改,甚至可能起到反效果。

若你解耦后,每次需求变化要改动的地方变少了,那就是合理的解耦。若你过分解耦,代码东一块西一块,以至于需求变化时需要到处改,比不解耦时浪费的时间还要多,那就是解耦过度。

完全零耦合的程序每个函数互不联系,就像把蚯蚓拆散成一个个独立的细胞一样。连初始需求“活着”都实现不了,谈何适应需求变化?所以解耦也切勿矫枉过正。

为了避免解耦矫枉过正,人们又提出了内聚的概念,并规定解耦的前提是:不耽误内聚。耽误到内聚的解耦,就只会起到降低可维护性的反效果了。

  • 内聚: 指的是同一个模块、类、函数内部各个元素之间的关联程度。内聚度越高,功能越独立,越方便集中维护。

例如,人的心脏专门负责泵血,肝脏只负责解毒,这就是高内聚的人体器官。若人的心脏还要兼职解毒,肝脏还兼职泵血,看似好像是增加了“万一心脏坏掉”的冗余性,实际上把“泵血”这一功能拆散到各地,无法“集中力量泵大血”了。

人类的大脑和 CPU 一样,也有“缓存局域性 (cache-locality)”的限制:**不能同时在很多个主题之间快速切换,无论是时间上的还是空间上的割裂 (cache-miss)**,都会干扰程序员思维的连贯性,从而增大心智负担。

好的软件要保持低耦合,同时高内聚。

就像“民主集中制”一样,既要监督防止大权独揽,又要集中力量办一个人办不成的大事。

节选自:小彭老师带你学函数式编程 - ✝️小彭大典✝️ (parallel101.github.io)


文章作者: zoloy
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 zoloy !
评论
  目录