0%

谈谈代码的模块化

我们总是说要写模块化的代码。但是到底什么是模块化的代码?怎样写模块化的代码?这两个问题不解决,模块化就不接地气、无法落地。

这篇谈谈我对代码模块化的一些思考。

模块化是什么?

代码分割

可能在很多人的理解中,要「模块化」就要分割代码。于是他们会把项目分成很多个文件,甚至分割成很多个版本仓库(repository)。但实际上,这种分割只是手段,而不是目的。如果把这种手段错误地当做是目的,那可能不会带来什么好处,反而会带来很多麻烦。

例如说,我曾经参与过一个项目的开发。对于运行在网络通信框架上的业务逻辑插件,人们为之写了一个基类,命名为 PluginBase。而后,人们把它单独拉出去,作为一个单独的版本仓库。在实际编写插件时,再通过二进制依赖的方式,将 PluginBase 的共享对象(.so)动态链接到进程中去。这种做法其实完全没必要,就属于典型的错把手段当目的。PluginBasePluginFooBar 在逻辑上的结合是很紧密的——基类和派生类,因此将它们分开不合适。打个比方,在开发 PluginFooBar 时,我们可能发现之前在设计 PluginBase 时不够完善,需要再添加一个接口。此时,我们就需要:

  • 修改 PluginBase 的代码;
  • 编译之后发版;
  • PluginFooBar 的版本仓库中修改依赖配置;
  • 编译 PluginFooBar
  • 将新版本的 libPluginBase.solibPluginFooBar.so 都拷贝到执行环境。

而如果我们能发现 PluginBasePluginFooBar 在逻辑上结合很紧密,而不把它们分开在两个版本仓库中,这样的修改就简单多了:

  • 修改 PluginBase 的代码;
  • 编译;
  • 将新版本的 libPlugins.so 拷贝到执行环境。

按逻辑分块

如此我们可以发现,所谓模块化,分割是手段而非目的。那么究竟要怎么模块化呢?上面的讨论已经提到了一点——跟逻辑相关。这里继续讨论。

要搞清楚模块化,首先要搞清楚什么是模块。模块其实是一个逻辑层面的定义。它是说:如果一个东西,它有定义良好的输入和输出,那么它就是一个模块。再详细说一点,这里所谓的「定义良好」有两个方面需要定义:一方面指输入和输出的格式,一方面指输入和输出的含义。

因此,只要一个东西的行为,在逻辑上满足这个阐述,那么它就是一个模块。例如一块电路板是一个模块;因为它接受固定格式的输入,根据固定逻辑产出输出。又例如一个定义良好的函数是一个模块;因为它接受固定格式的输入(函数参数),根据固定逻辑产出输出。

回到上一小节举的例子。PluginBase 在逻辑上并没有良好的输入和输出——它是基类,通常实际功能由子类完成。因此,强行把它提出去单独管理是不合适的。

怎样写模块化的代码?

具体到写代码时,模块化这个命题主要就落在如何设计函数上了——毕竟,在代码的世界里,函数是最小的模块单元。这里提炼一些实践中的经验。

单一职责

设计函数时,最好让函数的职责足够简单,只有一个。

为什么这么说呢?我们可以考虑一下,如果一个函数既可以做这个,又可以做那个,这种函数需要怎样设计?显然,函数需要根据某些变量的值,或者某种条件,来选择走哪个逻辑分支。比如可能有如下代码:

1
2
3
4
5
6
7
8
9
10
11
// bool flag;
void foo() {
if (flag == true) {
bar_a();
baz_a();
} else {
bar_b();
baz_b();
}
qux();
}

变量 flag 用来表示影响函数行为的外部因素。函数 foo 内,根据 flag 取值的不同,会走不同的分支。这种设计看起来不错。比如,有人可能会认为它节省了代码行数,写起来爽快。但是实际上这个函数的行为某种程度上就不是良定义的了——它取决于一个外部变量。试想,一个刚接手这段代码的人,调用 foo 函数,TA 就必须不断去追踪 flag 变量代表的外部环境。如果你觉得追踪某个变量还算好的话,可以试想一下如果这个变量代表「打印机有没有连上」这种在代码中完全不可控的外部因素会怎样。

对于这种代码,就不如把两种不同情形分开,写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// bool flag;

void foo_a() {
bar_a();
baz_a();
quz();
}
void foo_b() {
bar_b();
baz_b();
quz();
}

/*
if (flag == true) {
foo_a();
} else {
foo_b();
}
*/

如此一来,foo_afoo_b 的职责都很明确,接手维护的人再也不会疑惑函数的行为了。

输入决定(input dominated)

设计函数时,尽可能使函数的行为完全由其输入参数决定。特别地,尽可能不要让函数的行为受到全局变量、类的成员变量的取值影响。某种意义上,这和「可重入」的概念比较像。

这一点应该比较好理解。如果一个函数的行为取决于入参之外的因素,那么相当于这个函数在参数之外还有其他输入。显然,这和「输入良定义」是矛盾的。特别地,如果一个类的成员函数的行为依赖类的成员变量的取值,那么使用这个成员函数时,我们就不得不胆战心惊,逐个推演其他的成员函数有可能在某些情况下修改这些成员变量的值。

自我限制

这部分完全是经验之谈。

写代码的时候,可以做一些自我限制。例如说:不写超过 50 行的代码;不超过 5 行的通用功能也要拉出去做工具函数。

人总是爱偷懒的。因此,写代码写 high 的时候,往往就会忘记很多最佳实践。因此,给自己做一些自我限制,有一些强迫症还是很有必要的。据我自己的经验,超过 50 行的代码,往往就会把逻辑写得像面条一样,同时输入和输出的定义就不那么明朗了。因此 50 行虽然没有什么别的意义,但是仅仅将它作为是一个强迫症式的自我限制也是不错的。当然,你可以根据你的习惯,调整这个限制的大小。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。