代码的组织和管理方式

我们先从一个简单的问题开始。


为什么我们要把代码提取到一个函数中呢?

1
2
3
const f = () => {
...
};

有的人说是为了避免重复

代码有重复,就意味着代码中有些部分是相同一致的,

我们就不得不手工维护这种一致性,重复的代码必须被同时修改或者删除。

如果写到一个函数中,那么只需修改一处即可。


有的人说是为了可复用

当前开发工作中的某些功能,未来可能还会被用到,

所以,为了避免重复劳动,将这些功能提取出来,为了以后可以直接使用。


然而,将重复代码提取到一个地方进行引用,会建立更多的依赖

我们也不能简单的修改一个被多处使用的函数,函数的某些调用者可能并不需要这种变更。


此外,代码复用也未必会按照我们的计划发生,

为了满足新项目的需要,我们常常不得不对它再进行调整,

接着,老项目也得进行相应的修改,

过度设计得不偿失。


因此,只考虑消除重复和可复用性,并不能让代码更好。

我们还要进行其他几个方面的考虑。


理解复杂性

可能我们已经意识到了,

写代码最难的地方,并不是要实现功能,

而是要思考如何组织代码,如何管理它们。


代码具有双重作用,不但要实现软件所提供的功能,还要承载软件的复杂性。

如果不对这些复杂性进行管控,就没有人能理解软件,也无法维护它。


所以,写代码和阅读代码,实际上是一个知识的表述和理解过程。

提取功能模块的目的,并不仅仅是为了消除重复,或者让它可复用,

而是为了让软件系统更容易被理解。


区分表述方式和外部接口

一块逻辑完整的功能,当做一个整体来看待,

会提高了表述单元的粒度,简化主流程,降低构建大型软件时的心智负担。


只被调用一次的函数仍然被提取出来,实际上是为了表述问题方便,

它能提高我们看待问题的层次。

1
2
3
4
5
const main = () => {
task1();
task2();
...
};

然而,如果因此意外的建立了依赖,就是危险的,

逻辑上互相依赖的功能,和逻辑上是一个整体但是为了表述方便而拆分成多个部分的功能,是截然不同的。

一旦建立了依赖,就得时刻考虑修改所产生的外在影响。

我们无法轻易修改一个被多处引用的函数。

1
2
3
4
5
6
7
8
9
10
const main = () => {
task1(); //危险
task2();
...
};

const other = () => {
task1(); //危险
...
};

封装是一个避免产生过多依赖的办法,它可以很好的区分表述和依赖,

我们将某些函数设置为私有函数,是为了表述方便,

而将另外一些函数设置为功能接口,是为了建立依赖。


当私有函数进行调整的时候,我们是换了一种表述方式,

而当功能接口进行调整的时候,我们就得考虑对外影响了。


需要注意的是,封装是和面向对象无关的,

一个模块可以不具有内部状态,但是仍然可以最小化它的功能接口。

封装思想会指导我们,先从系统各模块之间的依赖角度考虑问题,而后再考虑具体实现。

1
2
3
4
const f1 = () => { };    //为了表述问题方便
const f2 = () => { }; //被依赖

export default f2;

用抽象指导具体

抽象是一种为了消除个体差异从而将不同的事物统一看待的方法。

因此,抽象不可避免的会丢失一些细节信息。


然而,抽象是一种分析问题的方式,不是一种解决方案。

随着软件的演变,人们通常会采取不同的归类方式重新看待问题,

这样所得的抽象概念往往是易变的,且与原来不同。


比如,前端轮询发送Ajax请求,和用js实现一个倒计时,是相似的,

它们都是以固定的时长执行一个任务,无论是同步的还是异步的。

这时候,建立一个任务执行者,用它来执行这些任务是合理的。

1
2
3
4
5
6
7
taskExecutor({
interval: 500,
execute: next => {
...
next();
}
});

如果增加了新场景,那么任务执行者可能就不适用了,

例如,在node中异步读取一个文件夹下的所有文件。


通过建立新的抽象,我们可以将它们三个统一看待,

即,它们三个都是在考虑,如何递归的执行一个任务,无论是同步的或者异步的。


于是我们可以建立一个递归执行者,

上述任务执行者只是当前递归执行者的一个具体应用,任务执行者可以用递归执行者实现出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
recursion(init, (current, next) => {
...
next(value);
});

const taskExecutor = ({ interval, execute }) => {
recursion(null, (_, next) => {
execute(() => {
setTimeout(() => {
next();
}, interval);
});
});
};

在具体工作中,不同的抽象给出了不同适用范围的解决方案,

上述递归执行者的适用范围更广,但却不一定总是最好的,

抽象思维指导了我们如何解决问题,但并不意味着我们必须这样解决。


例如,以上三个问题都可以看做执行一个任务,

1
2
3
const execute = fn => fn();

execute(task);

如此这样的抽象,对于解决问题来说,意义就不明显了,

并没有给我们带来任何便利和额外的价值。


抓住数学模型和语言结构

程序员自己发明的抽象通常是不稳定的,

这种抽象完全取决于当前项目中我们具体处理的问题,

一旦问题场景发生了变化,抽象概念也会发生变化。

因此,有经验的程序员极少发明新的概念,而是使用已有的成熟概念。


有两种成熟的抽象概念值得借鉴,

其一是问题背后的数学模型,其二是代码本身所具有的语言结构。


所谓数学模型,就是使用数学概念和语言对问题进行分析和描述。

这些数学概念一般而言都是通用的,不存在理解上的歧义性,

我们只需要把问题向它们靠拢即可,不用自己来发明。


所谓语言结构,指的是当前所使用的编程语言所提供的那些抽象方式,

例如,函数,类,模块,数据类型,高阶函数,泛型,多态性,等等。

使用这些内置的语言结构来书写代码,会避免代码晦涩难懂。


因此,好的代码,总是能清晰的看到背后的数学,并且使用了足够丰富的语言结构。


将数据结构从代码中分离,就是这样的一个例子,

1
2
3
4
5
const graph = new Graph;

graph.addEdges(vertex, vertices);
graph.findSuccessors(vertex);
graph.findPredecessors(vertex);

以上代码,使用了一个称之为图的数据结构,并且,使用了类来表示它。


数据结构是稳定的,对图进行的所有操作都是固定的,很少发生变化,大家都能理解发生了什么,

此外,使用类进行表示,有助于把所有操作图状态的函数放在一起,并且可以避免对外建立无谓的依赖。

值得一提的是,数学不只是数据结构,编程语言也不只有类这么一个概念。


例如,字符串经过词法分析,会得到一系列token,

在数学上,token可以视为与之相应的有穷自动机所接受的子串,

在语言结构上,返回token的过程可以用generator来表示。


不考虑数学,或者从不使用高级语言结构,就不得不自创一些难懂的概念,

这和平铺直叙的书写代码一样难以维护。


结语

写代码除了是一个实现功能的过程,还是一个管理复杂性的过程,

管理复杂性,涉及到代码的组织和管理。


涉及到我们如何向别人把软件系统表述清楚,

涉及到我们如何管理模块之间的依赖关系,

涉及到我们如何抽象的看待事物。


仔细的斟酌和考量才会写出好代码,不假思索一定会带来灾难,

我想这应该是一名专业的程序员与门外汉的根本区别吧。