我们先从一个简单的问题开始。
为什么我们要把代码提取到一个函数中呢?
1 | const f = () => { |
有的人说是为了避免重复。
代码有重复,就意味着代码中有些部分是相同一致的,
我们就不得不手工维护这种一致性,重复的代码必须被同时修改或者删除。
如果写到一个函数中,那么只需修改一处即可。
有的人说是为了可复用。
当前开发工作中的某些功能,未来可能还会被用到,
所以,为了避免重复劳动,将这些功能提取出来,为了以后可以直接使用。
然而,将重复代码提取到一个地方进行引用,会建立更多的依赖,
我们也不能简单的修改一个被多处使用的函数,函数的某些调用者可能并不需要这种变更。
此外,代码复用也未必会按照我们的计划发生,
为了满足新项目的需要,我们常常不得不对它再进行调整,
接着,老项目也得进行相应的修改,
过度设计得不偿失。
因此,只考虑消除重复和可复用性,并不能让代码更好。
我们还要进行其他几个方面的考虑。
理解复杂性
可能我们已经意识到了,
写代码最难的地方,并不是要实现功能,
而是要思考如何组织代码,如何管理它们。
代码具有双重作用,不但要实现软件所提供的功能,还要承载软件的复杂性。
如果不对这些复杂性进行管控,就没有人能理解软件,也无法维护它。
所以,写代码和阅读代码,实际上是一个知识的表述和理解过程。
提取功能模块的目的,并不仅仅是为了消除重复,或者让它可复用,
而是为了让软件系统更容易被理解。
区分表述方式和外部接口
一块逻辑完整的功能,当做一个整体来看待,
会提高了表述单元的粒度,简化主流程,降低构建大型软件时的心智负担。
只被调用一次的函数仍然被提取出来,实际上是为了表述问题方便,
它能提高我们看待问题的层次。
1 | const main = () => { |
然而,如果因此意外的建立了依赖,就是危险的,
逻辑上互相依赖的功能,和逻辑上是一个整体但是为了表述方便而拆分成多个部分的功能,是截然不同的。
一旦建立了依赖,就得时刻考虑修改所产生的外在影响。
我们无法轻易修改一个被多处引用的函数。
1 | const main = () => { |
封装是一个避免产生过多依赖的办法,它可以很好的区分表述和依赖,
我们将某些函数设置为私有函数,是为了表述方便,
而将另外一些函数设置为功能接口,是为了建立依赖。
当私有函数进行调整的时候,我们是换了一种表述方式,
而当功能接口进行调整的时候,我们就得考虑对外影响了。
需要注意的是,封装是和面向对象无关的,
一个模块可以不具有内部状态,但是仍然可以最小化它的功能接口。
封装思想会指导我们,先从系统各模块之间的依赖角度考虑问题,而后再考虑具体实现。
1 | const f1 = () => { }; //为了表述问题方便 |
用抽象指导具体
抽象是一种为了消除个体差异从而将不同的事物统一看待的方法。
因此,抽象不可避免的会丢失一些细节信息。
然而,抽象是一种分析问题的方式,不是一种解决方案。
随着软件的演变,人们通常会采取不同的归类方式重新看待问题,
这样所得的抽象概念往往是易变的,且与原来不同。
比如,前端轮询发送Ajax请求,和用js实现一个倒计时,是相似的,
它们都是以固定的时长执行一个任务,无论是同步的还是异步的。
这时候,建立一个任务执行者,用它来执行这些任务是合理的。
1 | taskExecutor({ |
如果增加了新场景,那么任务执行者可能就不适用了,
例如,在node中异步读取一个文件夹下的所有文件。
通过建立新的抽象,我们可以将它们三个统一看待,
即,它们三个都是在考虑,如何递归的执行一个任务,无论是同步的或者异步的。
于是我们可以建立一个递归执行者,
上述任务执行者只是当前递归执行者的一个具体应用,任务执行者可以用递归执行者实现出来。
1 | recursion(init, (current, next) => { |
在具体工作中,不同的抽象给出了不同适用范围的解决方案,
上述递归执行者的适用范围更广,但却不一定总是最好的,
抽象思维指导了我们如何解决问题,但并不意味着我们必须这样解决。
例如,以上三个问题都可以看做执行一个任务,
1 | const execute = fn => fn(); |
如此这样的抽象,对于解决问题来说,意义就不明显了,
并没有给我们带来任何便利和额外的价值。
抓住数学模型和语言结构
程序员自己发明的抽象通常是不稳定的,
这种抽象完全取决于当前项目中我们具体处理的问题,
一旦问题场景发生了变化,抽象概念也会发生变化。
因此,有经验的程序员极少发明新的概念,而是使用已有的成熟概念。
有两种成熟的抽象概念值得借鉴,
其一是问题背后的数学模型,其二是代码本身所具有的语言结构。
所谓数学模型,就是使用数学概念和语言对问题进行分析和描述。
这些数学概念一般而言都是通用的,不存在理解上的歧义性,
我们只需要把问题向它们靠拢即可,不用自己来发明。
所谓语言结构,指的是当前所使用的编程语言所提供的那些抽象方式,
例如,函数,类,模块,数据类型,高阶函数,泛型,多态性,等等。
使用这些内置的语言结构来书写代码,会避免代码晦涩难懂。
因此,好的代码,总是能清晰的看到背后的数学,并且使用了足够丰富的语言结构。
将数据结构从代码中分离,就是这样的一个例子,
1 | const graph = new Graph; |
以上代码,使用了一个称之为图的数据结构,并且,使用了类来表示它。
数据结构是稳定的,对图进行的所有操作都是固定的,很少发生变化,大家都能理解发生了什么,
此外,使用类进行表示,有助于把所有操作图状态的函数放在一起,并且可以避免对外建立无谓的依赖。
值得一提的是,数学不只是数据结构,编程语言也不只有类这么一个概念。
例如,字符串经过词法分析,会得到一系列token,
在数学上,token可以视为与之相应的有穷自动机所接受的子串,
在语言结构上,返回token的过程可以用generator来表示。
不考虑数学,或者从不使用高级语言结构,就不得不自创一些难懂的概念,
这和平铺直叙的书写代码一样难以维护。
结语
写代码除了是一个实现功能的过程,还是一个管理复杂性的过程,
管理复杂性,涉及到代码的组织和管理。
涉及到我们如何向别人把软件系统表述清楚,
涉及到我们如何管理模块之间的依赖关系,
涉及到我们如何抽象的看待事物。
仔细的斟酌和考量才会写出好代码,不假思索一定会带来灾难,
我想这应该是一名专业的程序员与门外汉的根本区别吧。