- 行业发展迅速
- 技术发展迅速
- 代码编写本身的难度
二、为什么要写好代码
从公司角度讲,现在互联网已经进入到一个相对成熟理性的阶段,很多一二线互联网公司成立的时间都超过了十年。现在各家公司的发展方式都逐渐从单纯注重速度转变为速度与质量兼顾。软件的质量,尤其是关键系统、核心系统的软件质量,对于各家公司的重要性不言而喻。最近的一个忽视软件质量的例子 —— 波音公司,其后果我想大家都是知道的。
互联网公司的软件质量如果出问题,通常不至性命关天,但大笔真金白银和用户口碑的损失也是担受不起。
从程序员角度讲,代码编写能力就有如武林中人的内功般重要。内功会决定你的发展高度。即便日后做了技术管理岗的工作,如有扎实的代码编写能力,也会助你做好管理工作。因为技术人员总有这么一个特点,外行是不容易管好内行的。如果你打算长期在技术线上发展,那代码编写能力就更加重要。
从管理角度讲,如果你是一个技术团队的直接负责人,那你需要重视你团队成员的编码质量,因为这直接关乎你所负责项目的质量、团队的研发效率、业务的发展。如果你是更高等级的 Leader,你的直接下属是其它 Leader。虽然你通常无需关心的技术细节,但编码质量还是会对前面几个问题产生影响。更重要的是,对于一个技术部门,或者是一个以技术为驱动力的公司而言,管理层需要从整个公司技术人员和技术团队长期良性发展的角度考虑问题,代码质量等技术细节如果长期不被重视,会对技术团队的稳定性等方面产生负面影响。
三、何为好代码
如同想成为好作者就要先学会识好书一样,想成为优秀的程序员就要知道什么样的代码是好代码。个人认为,判断一段代码的好坏,要从可读性和可扩展性两点入手。
3.1 可读性
可读性是大家谈到好代码时往往会第一个想到的。什么是可读性?顾名思义,就是指代码易于阅读和理解的程度。一些大牛所说过的关于何为好代码的名言,往往都是从可读性的角度表述的:
比如 ThoughtWorks 的首席科学家,《重构》一书的作者 Martin Fowler 说过:
任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。
IBM 的首席软件工程科学家 Grady Booch 说过:
整洁的代码如同优美的散文。
换言之,代码是给人看的,而不是给计算机看的。可读性好的代码就如同一篇优秀的说明书,第一步做什么、第二步再做什么,都应有清晰的描述;可读性好的代码要有恰如其分、名副其实的命名;可读性好的代码要有合适的格式和组织,哪些代码在前,哪些代码在后,哪里需要缩进,哪里需要空行。这些都是有严格规范的。
总之,对于代码可读性,其实
3.2 可扩展性
但可读性好的代码也不一定会是优秀的代码。优秀的代码还应具有良好的可扩展性。
可扩展性指的是代码易于扩展功能的程度。软件行业是个变化迅速的行业,互联网更是如此。面对迅速的变化,扩展性的重要便体现了出来。可读性好的代码,程序员易于修改,从而易于扩展功能。但这往往还不够。可扩展性往往追求的是在不修改原有代码的情况下去扩展功能。即软件设计原则中的开闭原则。
不过很多时候,代码的可读性和可扩展性是有一定程度的相互矛盾。如果大家阅读过一些开源软件的源码,对这一点就会有体会。这些开源软件的代码质量通常都不错,但读懂却不是那么容易。背后的原因除了你需要具备对应领域的知识以外,更多的就是因为可扩展性所引入的复杂设计一定程度上降低了可读性。
但在这种情况下,可读性的稍微降低并不代表这个软件的代码不优秀。优秀但却复杂的代码,往往会有详尽的文档和注释,代码设计和编写上往往能让阅读者有章可循。并且从表及里呈现出层层递进形式,使阅读者即可了解大意和结构,也可逐渐深入,了解细节。这一点同优秀的书籍类似。
3.3 何为烂代码
判断何为好代码,也可以从另一个角度进行,那就是判断何为烂代码。烂代码的特性通常被称为代码的坏味道。坏味道在《重构》一书中有详细讨论,这里我只简单说几个:
- 代码重复
- 方法过长、类过长、参数过长
- 过多的、嵌套过深的 if...else 或 switch
- 分散式变化、霰弹式修改、依赖情结
代码重复
编程界的另一位大神 Martin 叔叔说过:
重复可能是软件中一切邪恶的根源。—— Robert C.Martin
所以说代码重复可以说是头号坏味道,原因是重复代码会大幅增加代码维护成本,也是各种 Bug 潜在的温床。现在各种集成开发环境和代码检查工具都有重复代码检查功能,可以大大降低重复代码发现成本,可以帮助开发者及时消除重复代码。
除了工具可发现的重复代码,在项目中可能还会有很多需要程序员仔细观察才能发现的重复代码。这些重复代码往往是由原来简单的重复代码演变而来,并且具有更大的隐蔽性和危害性。这也说明了重复代码需要及时修复。
不过现在流行的微服务架构,会在一定程度上增加代码重复程度(有些同学可能对此没有体会,详细,微服务做的多了就能理解了),而且因为这些重复的代码是跨系统、跨项目的,传统的工具无法发现。
方法过长、类过长、参数过长
通常而言,过长的方法、类和参数都意味着这段代码是一段糟糕的代码。那么多长算长呢?以 Java 为例,一个方法长度不应超过50行,一个类不应超过1000行,最好不超过500行,方法参数不超过4个。
这些只是建议,不应该一刀切地判断。因为对于一个复杂算法或技术的实现,过于控制方法、类和参数的长度是不适宜的,因为对于这些算法技术本身的理解其实远超过理解代码实现的难度。但是,这不能作为普通程序员对自己代码的长度不加控制的理由,毕竟多数人写的代码所要表达的逻辑都是很容易理解的。
方法和类过长通常都说明这段代码违反了单一职责原则。参数过长同样如此,通常都是一个方法关心了太多不该它关心的事情所致,也有些是由于所有参数平铺所致。
过多的、嵌套过深的 if...else 或 switch
过于复杂的条件语句是另一种很明显的代码坏味道。对于这一点,我想我不必做过多解释,写过代码的应该都懂。
对于如何解决复杂条件语句这个问题,我写过专门的文章 —— 《如何“干掉”if...else》https://www.jianshu.com/p/1db0bba283f0 。因此这里我就不再赘述。
分散式变化、霰弹式修改、依赖情结
这三点坏味道不如之前的容易理解。这里先一句话介绍这三个坏味道的含义(在《重构》一书中有详细解释):
- 分散式变化:一个类常因为不同原因而进行修改
- 霰弹式修改:多个类常因为相同原因而进行修改
- 依赖情结:一个方法对其它类的兴趣高过自己所属类的兴趣
看过一句话介绍之后相信还会有很多同学不理解,再详细介绍一下。分散式变化常反映出一个类(或方法)不满足单一职责原则。它做的事太多,才会导致各种原因的变化都会带来对它的修改。霰弹式修改则与分散式变化相对,它反映的是软件设计的另一个问题:低内聚。一个功能,分散的到处都是,这样通常就会导致一个需求变化需要到处修改。
从上面的介绍也能看出,软件设计的复杂性。很多原则其实相互矛盾,就像单一职责和内聚性。软件工程师需要在设计时平衡这些相互矛盾的原则,才能设计出优秀的软件。
依赖情结,虽然字面上不容易理解,但是在日常工作中体现的其实更多。经常能看到这样的方法:它从一个或几个类中取出数据,然后经过处理,然后设置到另一个类中。这个方法从始至终都没有使用过自己类的属性。如果是静态方法,通常也无可厚非(毕竟静态方法不能访问自己类的属性)。可是我们更常见到的都实例方法。这其实反映出一个事实:定义这个方法的位置错了。
小结一下:
- 分散式变化反映软件设计违反了单一职责
- 霰弹式修改反映出软件设计的不够内聚
- 依赖情结反映出方法放错了位置
四、写好代码的方法
写好代码应该是各级别程序员共同的目标。换言之,写好代码就是程序员的自我修养。
但不同级别的程序员,写好代码这件事其实有不同的要求。
对于普通的程序员,更多的精力应该放在如何提高代码可读性为主要的目标。即努力把代码写的清楚、写的明白。这里涉及到的技术通常是代码编写的一些基本规范、技巧、简单的代码重构手段,可能还包括面向对象方面的知识。
重点说明的是,我并没有提及各种软件设计方面的原则,比如单一职责、开闭原则。原因在于,所谓原则,就是一些你看似明白,实则不懂的东西。掌握原则,需要多加练习和思考。
而对于高级和资深的工程师,应具备编写兼具可读性和可扩展性的代码。这里还需再次强调,可读性和可扩展性有时是矛盾的。因此,这一阶段的程序员需要能平衡好可读性和可扩展性。同时也需要能从工程和业务的角度考虑,代码要避免过度设计,但也不能不考虑扩展。
所以,编写可扩展性高的代码,除了需要具备熟练掌握各种设计模式、设计原则和思想、重构手段等等。还需要开发者对所在业务领域有深入理解,从而在何时的地方做出具有合适扩展能力的设计。
接下来说几个简单的提高代码质量的方法。
4.1 命名
第一个想强调的代码的命名。命名是一个不被人重视的编码细节,但能够为代码、软件起一个简单明了、恰如其分的名字其实是非常有价值的,而且也不是一个简单的事情。
试想一下,如果你有了孩子,是不是需要仔细考虑孩子的姓名?如果随便起个张三李四,那是一定不是一个称职的父母。同样,对于代码,你随便起个名字,那同样也是不负责任的表现。
命名并不是简单想几个单词并拼接在一起而已。命名其实反映了开发者对业务理解的程度和软件设计的能力。一个好名字实际是对一个业务功能简短而又精准的表述,其背后体现了开发者对代码规范、面向对象设计、设计原则、设计模式,甚至架构设计等能力的掌握和运用的好坏。
方法命名
方法命名的一个原则是解释目的,而不是手段。即方法命名只需说明这个方法是干什么的即可,不用通过方法命名体现这个方法是如何做的。
方法命名的一般格式是:动词+名词短语+(额外修饰)。
例如,在 Spring 的 BeanFactory 接口中有如下方法定义:
Object getBean(String name)
这个方法命名就是动词+名词的形式,因为方法功能比较简单,所以没有加额外修饰。
有时我们能看到方法名称体现了内部实现方式。假如,我们需要实现一个分布式的 Spring,Bean 的定义存在 Redis 里(实际显然没有这个必要,这里只是举个多数人容易理解的例子)。那估计 getBean 这个方法就会有人定义成如下形式:
Object getBeanInRedis(String name)
这时,InRedis
体现就是方法内部实现方法。这么做是多余的,即违反了简单的原则,也违反了方法命名体现目的,而非方法的原则(另外也非常的不面向对象。如果你真的想实现一个基于 Redis 的 Spring,可以创建一个 BeanFactory 的实现类 —— RedisBeanFactory)
其实上述方法命名有时还不够简单。例如在 Spring Data 的 Repository 定义中,我们能看到如下方法:
save
saveAll
findById
findAll
这些方法的命名简单到连名词部分也省略了。原因在于 Repository 接口的实现(如 OrderRepository)中已经包含了这些方法所操作的对象,所以也就不用重复了。在面向对象语言中,方法调用通常都是 object(class).method(args)
的形式。这时,object 或 class 的命名应该反映出一些业务含义,这些含义不必在方法命名中重复表现。
在非面向对象语言中,道理同样存在。如在 Golang 中有这样的方法 time.Parse(layout, value string)
。这里的 time 是包名,但在命名上起到作用同面向对象语言的对象和类是一样的。
刚看到了一些简单的方法命名的例子,接下来看一些复杂的命名:
startEventDispatchThreadIfNecessary
上面这个例子是 JDK 中的一个方法。这个方法的命名很长,翻译过来就是“启动时间分发线程,如果必要”。前半句好理解,那为什么后面要加上一句“如果必要”呢?原因在于如果不加,其他开发者会误以为调用这个方法一定会启动一个事件分发线程,但实际情况是有些情况下不会这么做。那什么情况下不会这么做呢?这算是一个细节,一般情况下不用在方法命名上体现,否则方法名就太长了。如果这个细节确属必要,那可以通过注释来描述。
方法命名中带有 IfXxx
例子还有很多,在各种开源软件的源码中都能找到。这里想要说的是,为了达到让使用者正确理解一个方法所要达到的目的,有时需要在动词+名词的命名形式之上再增加额外的描述。
变量命名
对于变量的命名,它的作用主要有两点:一是描述对象(或数据结构)所具有的属性;二是对方法执行过程进行辅助性描述。
接下来我将介绍一些代码命名的基本规则,以及几个例子。
对于变量命名的第一点作用,很容易理解。因为对于面向对象语言来说,一个类就是数据和行为的封装,而数据其实就是对象的属性。对于非面向对象语言,如 C、Go,它们的结构体也包含有数据(虽然不能定义方法,没有行为)。
对于变量命名的第二点作用,多解释一下。这一点作用通常是对局部变量而言的。在一个方法体中,另一个方法的返回值需要被使用多次使用,这时最好使用临时变量保存这个方法的返回值。这很容易理解。如果只是用一次呢?其实有时也需一个临时变量。这个临时变量的作用通常为了更好的解释这个值的目的和含义。
举例来说:
List<Order> paidOrders = findAllByStatus(OrderStatus.PAID);
这时,paidOrders
显然比 findAllByStatus(OrderStatus.PAID)
更容易理解,也更简短。
理解了变量的作用之后,如何命名也就清楚了。毕竟命名的目的在于用更简单的方式描述作用。
所有,对于下面的例子,哪种命名更好呢?
class Order {
private String name;
private String orderName;
}
显然,name
更好。虽然 orderName
也能体现“订单名称”这个作用,但是前者更简单。
类
在 Java 语言中,类是第一类公民,也是编程者遇到的第一个需要命名的东西。类的基本命名规则通常为形容词+名词的形式,最后一部分的名词词组表示的是这个类所表示的是哪一类事物。如果一个接口只有一种实现类,通常可将这个类命名为接口名+Impl,这也是被广泛接受的命名形式。
类的背后其实体现的是面向对象的设计(看到这里我相信会有很多人对面向对象嗤之以鼻。确实,以 Java 为代表的面向对象编程语言不如函数式的语言简单灵活。但请相信,在更好的方法出现之前,面向对象的设计方法是应对复杂业务逻辑最好的方法。同时,Java 使用过程中出现的很多问题,实则是开发者没有理解好面向对象设计所导致的)
面向对象设计,看似简单,但其实需要对业务领域的深刻理解。在这方面,领域驱动设计是一个非常好的指南,它能够指导如何设计一个业务系统,自然也能够指导如何命名。
但仅仅将类命名成某实体类、某 Factory、某 Repository、某 Service 是远远不够的。除此之外,能够指导我们命名类(也包括接口)的是各种设计模式。比如某某 Builder、某某 Strategy、某某 Command。当然,也没必要死抱着设计模式,因为设计模式体现的是实现方法,而这一点通常不是命名首要考虑的问题(命名首要考虑的是目的)。
在类的命名上,常见的一个具体问题是 Service 和 Manager 这两种命名随意使用。表面上这两者都是用来实现务逻辑的组件,但还是有些区别。一般来说,Service 通常是无状态的业务组件,而 Manager 通常为有状态的。
小结一下:
- 类命名是面向对象设计的体现
- 业务系统的类命名可参考领域驱动设计
- 其它领域的类命名可参考设计模式
- 不用死抱上述建议,只要命名体现类设计的目的即可
接口
不同于类所表示的具体的概念,接口表示的是泛化的概念。接口通常表示一类事物,或一类事物所共有的特性。同类和变量的基本命名规则一样,接口的命名通常也是名词形式。例如,Spring Framework 中的 ApplicaitonContext
、BeanFactory
、InitializingBean
等接口。但我们有时能看到一些代码在接口前加 I 这个前缀,以表示这个是一个接口,而非一个类。这种风格是非常不推荐的。开发者应当把接口看作是更通用的一个概念,而非特殊概念。因此,不应在命名前加增 I 这个前缀,因为增加前缀是一种特殊化的做法。比如,ApplicationContext
是一个好的接口命名的实例,但 IApplicationContext
就不是。少数情况下,增加前缀还会导致歧义,比方说,没人会把 IPhone
理解为是电话这个概念的接口。
除了名词形式的命名,接口还有另一类命名方式 —— 形容词命名。比如,在 JDK 中,我们常见的有 Serializable
、Cloneable
、Comparable
、Runnable
,其它开源项目中这样的命名方式也有很多,就不一一列举了。这种风格的命名所表示的都是一种特性 —— 能做什么。
其它接口命名实例还有 XxxAware,这在 Spring Framework 中比较常见。
小结一下,接口命名的方法主要体现了接口的两类作用:
- 表示一类事物:名词形式接口命名
- 表示某种特性:形容词形式接口命名
4.2 一些提高编码能力的“旁门左道”
重复造轮子
重复造轮子通常都是编程界的贬义词,但在今天这个话题里,我认为“重复造轮子”是褒义词。这里我们重复造轮子的目的是通过模仿现有开源技术提高自己的编程能力。提高编程能力没有捷径,最终看的就是代码编写量,还要是高质量的代码编写量。在日常工作中,允许你对代码精打细磨的机会并不多,这时你就需要寻找额外“训练”机会。研究开源技术源码,尝试重写,或者更进一步,为开源技术贡献代码,能让你的编码能力提高很多。
结对编程
结对编程是敏捷开发中所提到的一个工程实践。不过似乎在国内公司中实践的较少(我在外企和互联网行业工作时实践过一些结对编程)。
结对编程有很多好处,在提高编码质量方面,因为结对编程通常一人写一人看,或一人写实现一人写单测。因此,你的代码不仅需要自己理解,至少还需要你的同伴理解。而且因为这一阅读理解的过程是实时进行的,这就使得对代码的 Review 非常细粒度,这也促使你的代码质量的提高。
单元测试
要写好代码就少不了修改代码,那如何对已正确实现业务功能的代码进行修改还保证不出错呢?这就需要单元测试。测试可以是软件工程中最重要的环节之一,重要性不亚于开发。而单元测试,应当是测试粒度最细,也是与开发人员距离最近的测试形式。如果一个项目没有任何单元测试,基本可以断定它不会是一个好项目。
单元测试可说是写好代码的前提,因此要想把代码写好的同学已经要掌握编写单元测试的技术。可能有同学觉得写单元测试就是 JUnit(对于其它语言也有类似框架或有内置单元测试支持)。如果你这么想,那你就是太 naive 了。我面试时问过十几个程序员关于单元测试的问题,没听说过值校验和行为校验的一个没有。可能这个问题有些偏门,答不出来可以理解。那单测中的 Mock 技术都有作用?Mock 和 Stub 的区别?能答出来也没有。这些问题表面上是概念性的问题,但其实能反映一个技术人员对单元测试技术的实际经验的多少。
单元测试的不易的另一个体现在于单元测试的两个矛盾。第一个矛盾是单元测试本身也是代码,开发人员编码质量的好坏也会影响单元测试代码。单元测试写不好,最终会导致别人无法理解测试用例的含义,也会对整个项目的维护性造成很大的负面影响。另外就是单元测试如果覆盖完整的话,实际的代码量会比被测代码本身还多。如何把单元测试写的精简、易于理解、覆盖完整也是一个颇有技术含量的工作。
单元测试的第二个矛盾是对于某些代码质量不高的项目来说,补充单元测试是一个很有挑战的工作。但是不补充单元测试项目的代码重构又很难保证质量。不重构又难以提高代码质量。。。看到没,这就是个死循环:代码质量差 -> 难以单测 -> 代码质量差。笔者之前所在那个外企项目就死在这一点上了。
所以,早写单测。
少动鼠标
用好各种开发工具,如 VIM、IDEA 的快捷键,以及各种命令行工具,尽量少用鼠标。这么做不一定成为编程高手,但编程高手都能这么玩。如同竞技游戏中,哪个魔兽、星际、DOTA 高手用鼠标放技能呢?编程同理。
五、参考
- 《重构 - 改善既有代码的设计》by Martin Fowler
- 《代码整洁之道》by Robert C. Martin
- 《重构与模式》by Joshua Kerievsky
- 《实现模式》by Kent Beck