导言:
这篇文章的内容以及题材都是来自 《clean code》一书,内容主要是自己读书的一些收获,总结和感想。人总是健忘的,因此做些记录是好的。《clean code》一书主要讲的是如何写好程序,再具体一些就是 “程序员修养”。相比于高深难懂的算法,书中讲述的这些编写代码的技巧和认知对我们日常工作往往帮助更大,成败都藏在细节里。随着读书的跟进,我会用一系列的文章来记录并总结书中的内容,也欢迎交流探讨
命名
计算机科学中最难的两件事 - 命名 和 缓存失效。后者和我们的主题无关,我们先不讨论,如果单单看前者的话,你可能会问,为什么命名这么简单,没有任何技术含量的东西会和 “最难” 联系上?我个人的理解是,当只有短短不到一百行的程序,命名确实不是什么难题。但是如果当代码量大到一定规模的话,这又会是另一个话题。往往在工作中,特别是你在大公司工作,我们有时会花大量的时间去看别人写的代码,不管你是看一个模块,一个类,一个函数,甚至一个变量,首先你看到的绝对是它的名字。你去代码库中搜索,搜索关键词也绝对是你要找的这个东西的名字。好的命名能够让你直接看到名字,就能猜到这个东西的用途和目的,甚至都不需要读代码,就能够把整个代码架构理清楚。人的大脑对一些比较符合思维逻辑的东西记忆比较快,试想一下,如果你们公司代码库里面的变量或是函数的名称全都是类似 a, b, c, d 一样的毫无意义的字符,想想你会怎样去开发或是编写业务逻辑?
说了这么多,我们应该知道命名的重要性了,那么就来看看,给变量或是模块命名我们需要注意什么,以及这里面有哪些技巧。
使用有意义、无错误、无歧义的命名
衡量一个命名有无意义,最简单的方法就是去思考一个问题,“如果要让别人知道这个命名所代表的变量或是模块做了什么,你需不需要加注释?” 如果你还需要仅仅为一个名称加注释的话,那大概率上讲,这个名称是毫无意义的。名称必须有自述性,注释不可能跟名称走,当变量或者模块被到处 import 的话,你就知道好名称比好注释的意义大的多。
另外一个问题就是要避免错误,这个很简单,就是名称描述的东西必须是对的。比如说你用 accountList 代表的是一个类似 map 的数据结构,那么这就不对了,这会造成他人理解和使用上的错误。
当一个名称可以表示两个东西的时候,或者一个东西可以被两个名称表示的话,那么这就会有歧义了。如果我问你,accountData 和 accountInfo 这两个名称表示的是什么,正常人都会觉得它们表示的是一个东西,如果这两个命名同时出现的话,就会造成歧义。你需要知道的是,名称和事物必须是一对一的关系,如果名称不同,那么它代表的事物也绝对不同,并且事物的差异要能很好的体现在名称上,不光是业内人士懂,普通大众看了至少能够知道两个名称表示的是两个不同的东西。
使用发音连贯的命名
不知道你们有没有这个感觉,就是我们看到一个名称,总是习惯将其读出来,当然我指的是在大脑里面默读,这其实能够帮助我们记忆和更好的认知这个名称。试想一下,对于一个叫做 accda 的名称,你要怎样将其读出来。使用发音可以辅助我们去思考一个命名,这就像学习语言一样,说出来,才能够让自己影响更深。
使用正确类型和结构的名称
对于变量,类,包这些对事物进行封装的结构,我们要使用 名词 对其命名,而对于方法,函数一类表示行为的结构,我们要使用 动词 对其进行命名,这也是符合人类认知的。另外就是,一般说来,名称的长短和其使用范围相挂钩,比如在一个 for 循环中,我们完全可以使用 i 来命名循环中的递增变量(注意不要使用 l,O 一类容易混淆的字符),因为这个范围很小,但是如果是全局变量,为了搜索方便,有时我们甚至会使用全大写的短语来命名,例如 WORK_DAYS_PEEK_HOURS。
选择固定的词进行命名
比如 fetch,get,obtain 都可以表示 “获取” 这个动词,add, append, push 都可以表示 “加” 这个动词。具体用哪一个?其实这里在命名中用哪个都可以,但是选择用了一个,就一直用下去,否则的话,会造成歧义,getAddress 和 fetchAddress 同时出现在你面前的时候,你会非常的疑惑,或者有时你看到 get,有时你又看到 fetch,这会让你怀疑这两个意思一样的词会不会表示不一样的东西,被迫去阅读更多的代码,这很影响效率。另外有些词是公司或者类库规定的,那就按照规定走,但是这些必须在编写代码之前都问清楚,否则,在给变量或者函数命名的时候会出问题。
使用解决方案来命名
有些时候我们会遇到一个问题就是,到底是使用需要解决的问题来给变量命名,还是使用解决方案,这里推荐使用解决方案来命名。因为问题是一个比较宽的概念,可能整个文件,整个类库都在解决同一个问题,而且阅读代码的人应该是先知道问题是什么了之后,才会去看代码的,代码主要就是去解决这个问题的,为问题提供一个解决方案。而且解决方案中也可以使用一些计算机的专有名词,比如 AccoutVistor 表示的就是 visitor pattern,这个有计算机基础的人都不会陌生。
另外需要强调一点的是,命名往往不会是孤立存在的,它会和上下文挂钩。这就好比写文章,相同的短语放在不同的位置,不同的语境就会产生不同的意思。我们提倡命名尽量直接清晰,同时尽量不要让名称所表示的变量或者函数处在复杂或者不清晰的上下文中,避免产生误导。
函数
讲完了命名,我们再来看看函数。函数可以说是程序里面的最小组织单元,一说到组织,基本上就要提到结构。写好函数,只需要保证一点就好,那就是 小,而且只做一件事情,你可以看看书中的原话:
Function should do one thing. They should do it well. They should do it only
如果函数只做一件事,那么基本上可以保证函数足够小,差不多不到 10 行,当然这个数字主要看人,很多人觉得函数 100 多行都算短,但是文章建议的长度就是 10 行以下。很多人,包括我都会对这个小,或者是只做一件事产生疑惑,我总结了一下,基本上会有下面两个常见的问题:
- 怎么样才算是只做一件事?
- 函数是用来解决问题的,问题会有子问题,怎么样保证小?
除此之外,书中还说到了函数的各个方面,比如输入参数,返回值,错误处理,还有就是函数的命名(和之前的联系上了),我们一起来看看
怎样拆分函数
你如果想要保证每个函数的大小足够小,基本上能做的就是拆分,把每个问题拆成子问题,子问题拆成子子问题,直到拆到不能够再拆下去为止。然后,对这些问题用函数进行封装,就这样,我们可以把一个大的函数拆成多个小的函数,然后每个函数所要处理的东西就会很少,理所当然,每个函数的大小就会变得很小。可能读到这里,你还是会有一堆的问题,比如怎么拆?拆到什么样子才算是不能继续拆?拆完之后怎么组合?别急,我们就通过一个现实生活中的例子 - 找工作 来说说,基本上找工作的大致流程如下:
找工作流程:
投简历
面试
offer
入职
可能到这里你就觉得其实也就这几个步骤,先别急,我们继续拆下去:
找工作流程:
投简历
填写简历
选择投递简历的方式
根据投递方式填写信息
面试
定日期
HR 面试
电话技术面试
现场面试
offer
对比 offer
谈薪资
下决定
入职
定开始日期
了解公司文化
适应公司环境
如果要继续拆下去,也是可以的:
找工作流程:
投简历
填写简历
准备项目素材
详细填写
润色
选择投递简历的方式
联系HR或是寻找相关渠道
。。。
根据投递方式填写信息
准备简历以外的一些信息
。。。
面试
定日期
。。。
HR 面试
。。。
电话技术面试
。。。
现场面试
。。。
offer
对比 offer
。。。
谈薪资
。。。
下决定
。。。
入职
定开始日期
。。。
了解公司文化
。。。
适应公司环境
。。。
如果你创建一个名字叫做找工作的函数,这里面就需要包含这些步骤,不进行拆分的话,你可想而知,函数的大小会变得非常大,里面会涵盖种种细节。这就会导致一个严重的问题,在阅读找工作这个函数的时候,除了函数的名字以外,我们很难知道这个函数具体是干什么的?而且这里面的信息巨大,逻辑也很复杂,很难理清。
那么我们就需要把这个函数进行拆解,拆解的方式也展示在上面的例子中。这里我再详细说说,就是每个函数是对事物的封装,封装是分级别的,比如这里的找工作函数就是最高级别的封装。每一级的封装只能看到其下一级的封装,比如在找工作函数里,你只能看到投简历、面试、offer、入职,你看不到投简历里面的填写简历,offer 里面的谈薪资。投简历是下一级的分装,里面只能看到 填写简历、选择投递简历的方式、根据投递方式填写信息 这几个封装,看不到具体简历该如何写,如何润色。这样做的好处是什么呢?那就是阅读函数的人所需要的思维跨度不会太大,只需要往前一步,函数的逻辑让人一看就懂。而且展现在眼前的仅仅是封装好的黑盒子,往往大可不必去理解细枝末节,就能清楚知道代码结构。
最后我们需要关心的一个问题就是,拆到怎么样才算是不能再拆。这个也很好解释,那就是,如果你发现继续拆下去,分装只是对语句本身的重新描述,比如对于下面这一行代码
list.add(element);
我们将其分装就是:
void addElementToList(List list, Element element) {
list.add(element);
}
上面的分装就会显得有些多余,list.add(element) 本身就有很好的自述性,再多加一层分装只会是对这一行语句的重新叙述,而且这样的拆分也不能很有效地划分封装的层次。
函数划分好了以后,我们就要对函数的位置进行摆放,作者建议的是按叙述故事的方式进行摆放,如果是上面的例子,就会是
找工作()
投简历()
填写简历()
选择投递简历的方式()
根据投递方式填写信息()
面试()
定日期()
HR 面试()
电话技术面试()
现场面试()
offer()
对比 offer()
谈薪资()
下决定()
入职()
定开始日期()
了解公司文化()
适应公司环境()
你会发现这个顺序就是时间顺序,就是程序的执行顺序,没错,就像阅读文章一样,这样是最符合人类思维的。
函数的输入参数
函数的输入参数越少越好,最好是没有参数,其次是一个参数,紧接着是两个参数,如果你要使用三个或者三个以上参数的时候,试着封装这些参数,不然的话,该考虑重新设计了。
为什么函数参数要越少越好,其实话说回来,我们所做的一切,都是为了让函数所表述的事物尽量的清晰,简洁,易懂。多个输入参数意味着读者需要去找到这些参数的来源,意义,以及它们之间的联系,这会花掉很多的时间,而且花掉之后,下次再来读这个函数的时候,又得花这些时间,很不值得。
如果你一定要传入多个参数,那就试着封装它们吧,比如使用 JS 里面的 Object 的一些机制,将参数归类,这样至少说能够减少阅读成本。
函数的错误处理
这里需要知道的就是,往往函数抛异常要好过返回错误码。在说这个之前,我们先来讲讲 Command Query Separation,这是什么意思,可以看看原文的解释:
Functions should either do something or answer something, but not both
其实还是说一个函数只能干一件事,先看一个反例:
public boolean set(String attribute, String value)
首先根据命名,这个函数肯定是做了一个 set 的动作,这个动作按理来说是不会有返回值的,如果有返回值,那就会让人去猜测,这个返回值是什么?是动作的成功与否?还是添加进结构的元素?而且这里函数的名字并没有很好的讲述这个函数做的事情。
回到这里的主题,如果一个函数通过返回错误码来替代抛异常,那么这首先违反了 CQS,再进一步讲,因为上一级的函数需要确认函数返回的内容正确再进行逻辑,需要 if/else 进行条件判断,这样,调用函数一多,还会造成 if 语句的嵌套,严重影响函数的简洁与直观。反观直接抛异常,虽然我们需要加上 try catch,但是异常处理可以作为分装单独用一层函数包裹,因为异常处理也是一件事,这样一来,函数的内部结构就变得简单清晰了。
另外一点,返回码通常要全局保存,返回码的删改,增添往往会影响很多之前实现好的逻辑。而异常是可以通过类来表示的,也就是说我们可以使用面向对象的封装、继承来完美解决代码相互影响的问题。
函数的命名
函数的命名其实很简单,那就是 诚实。说你将会做的事,如果你发现函数做的事情已经不能用名称来表示了,很明显,这只能说明你的函数做的事情太多,不止一件,需要拆分。
还有一个问题就是,我们会不知不觉在函数中增加一些函数名称没有反应的逻辑,这也是不对的,这会让人产生误解,函数是不能有副作用的。而且,增加其他的逻辑,这也违反了我们前面提到的 “只做一件事”。
总结
命名与函数就说到这里,你可以看到这里其实并没有什么难的东西,大多都是细节,很多都是你并不觉得会出问题的东西。看了这么多,你会不会觉得 “代码处处藏学问”,你可能会问这些是怎么一下子做到的?作者也说了,没人可能一下子把程序写好,都是先写出最初的版本,然后再慢慢调整,觉得命名不好就换掉,觉得函数大了就拆分,这里没有一蹴而就,这里只有精雕细琢。想要快,得先学会慢。好了,最后就让我们用书中第一章的一个故事来作为文章的结束吧:
Rembember the old joke about the concert violinist who got lost on his way to a performance? He stopped an old man on the corner and asked him how to get to Carnegie Hall. The old man looked at the violinist and the violin tucked under his arm, and said: "Practice, son. Practice!"