伯乐在线/程序员的故事已获得授权转载
代码,顾名思义就是整洁的代码,或者说是清晰漂亮的代码,相信大多数工程师都希望能够写出这样的代码。
也许这是一个对不同的人来说角度不同的话题,每个工程师都有自己的理解。比如我从被批评写出糟糕的代码,到逐渐学习成长,现在我能写出“像人一样”的代码。这期间我积累了一些经验,想分享给大家,激发讨论。
本文主要描述面向对象的编程代码。过程式代码的思路有所不同,不属于本文的讨论范围。
整洁代码的前提
很多时候,代码是用来维护的,而不是用来实现功能的。
这个原则适用于大多数项目,一方面我们的代码是写给机器执行的,完成功能需求;另一方面,代码是写给队友和自己看的,需要长期维护,大多数项目都不是短命的。
大多数情况下,如果你不能写出清晰、好看的代码,也许你会高兴一时,但后续维护的代价和成本会比你想象的要高得多。
追求清晰优美的代码比一切技巧都重要。
大多数好的代码都是自我描述的,比文档和注释更好。
当你翻阅大量开源代码时,会发现注释甚至比我们自己写的项目还要少,但读起来还是很舒服的。当你读完源码,很多功能设计都清晰明了。通过精心思考的方法命名和清晰的流程控制,代码本身可以作为文档,而且永远不会过期。
相反,注释并不能使糟糕的代码变得更好。如果别人只能依靠注释来理解你的代码,你就必须反思一下代码到底哪里出了问题(当然,这并不意味着你不应该写注释)。
我们来说一下比较适合写评论的两种场景:
,清楚地向其他人发布你的函数的语义、输入和输出,而无需担心实现。
当功能有歧义或者涉及很深的专业知识的时候,比如你正在写一个客户端,各种参数的含义等等。
设计模式只是手段,清晰的代码才是目的
我见过一些所谓的“专家”写出相对抽象的代码,有各种工厂和继承。找到实现总是一条漫长而曲折的道路。项目中的大多数类都是抽象类或接口。如果找不到一两行实现代码,阅读代码是非常困难的。当我和他交谈时,他的主要立场是:保留适当的扩展点并克服所有硬编码。
其实在我看来,或许他的代码是“过度设计”的。首先我们必须承认,在同一家公司工作的同事水平参差不齐,无论你采用的设计有多高端,如果大多数人看不懂你的代码,或者觉得难以阅读,其实这是一个失败的设计。
当你的系统中大多数抽象只有一种实现时,你应该仔细考虑一下设计是否有点过度设计。清晰度永远是第一原则。
清洁代码的常用方法
记住了原理之后,我们就开始进入实战阶段,我们先来看一下常见的促成代码的手段。
代码
很多大公司都是用git的pull机制来写代码的,我们该关注什么呢?是代码格式,业务逻辑还是代码风格?我想说的是,凡是机器能检查的东西,就不需要人来检查了。比如换行,注释,方法长度,代码重复等等。除了基本功能需求的逻辑合理性,没有bug之外,我们更应该关注代码的设计和风格。比如,某个函数是否应该属于某个类,是否有很多类似的函数可以提取出来复用,代码是否过于冗长难以理解等等。
我个人非常提倡集体代码,因为往往小组中比较资深的工程师可以一眼就发现代码中的重大设计缺陷,并提出改进或重构的方法,可以在整个小组内部形成良好的文化底蕴和风格统一,很大程度上培养了大家对代码的热情。
勤奋重构
好的代码通常不是一蹴而就的,即使一开始很优秀,但随着业务的快速迭代,也有可能被改得面目全非。
为了避免重构带来的负面影响(需求或者bug),我们需要做到以下几点:
①掌握一些常见的“无痛”重构技术,下面会详细讲解。
②小步快跑。不要妄图一口吃掉一个胖子。一点点改,一点点测试,一方面减少编码的痛苦,另一方面减少上线的风险。
③建立自动化测试机制,保证即使代码被错误修改,系统最小核心功能仍然可用,并且修改的部分被测试覆盖。
④ 熟练掌握IDE的自动重构功能。这将大大减少我们的体力劳动,避免犯错。
静态检查
市面上有很多代码静态检查工具,这也是一种比较容易发现 Bug 和不良风格的手段。它们可以和发布系统集成,强制修复主要问题后再上线。目前质量管理平台已经在美团点评技术团队内部广泛接入研发流程。
阅读更多开源代码和身边优秀同学的代码
感谢开源社区给我们提供了这么好的学习机会,不管是JDK的源码,还是经典的、、、或者一些小工具等等,都是代码示例,多学习,多反思,多总结,一定会受益匪浅。
清洁代码的常用技巧
前面的内容只是热身,让大家有个大概的了解,现在终于进入实战部分,我会从几个角度讲解编写干净代码的常见技巧和误区。
一般提示
单一职责
这是干净代码最重要和最基本的原则。简单来说,从单个事物到单个属性,所有事物都应该有明确的职责。如果你不能用一句话描述某件事的职责,那就把它删掉。
当我们编写代码时,最常犯的错误是一个方法执行多项操作或一个类承载多项功能。
先说方法吧。我极力主张把方法拆开,这是复用的基础。如果一个方法做了两件事,很可能其中一个功能和另外一个业务不一样,不容易复用。另外语义不清晰,我经常看到一个 get() 方法居然修改了数据。这对使用你的方法的人来说有多尴尬?如果你不点进去看看实现,可能会让程序陷入 bug,让测试陷入困境。
再说一下类的问题,我们经常看到“又长又臭”的/biz层代码,里面有几十个方法,增删改查什么的都有,还有业务逻辑的聚合,每次都很难找到一个方法,不属于同一领域或者层级的函数不要放在一起。
对我们团队的代码最常见的批评是某个方法应该属于哪个类。
优先定义总体框架
我写代码的时候,喜欢先定义整体框架,也就是写很多空的实现来串起整体业务流程。好的方法签名使用输入和输出参数来控制流程。这样可以避免陷入业务细节。先在脑子里定义流程的各个阶段,然后为每个阶段找到合适的方法/类。
这样做的好处是读你代码的人无论读得多深都能清楚的了解每一层的作用,如果不关心下一层实现的话可以直接跳过,方法的粒度就刚刚好。
总之,我写代码时更喜欢“广度优先”而不是“深度优先”,这和我读代码的方式一致。当然这跟个人的思维习惯有关系,可能对抽象思维能力要求比较高。如果在开始写代码时这些都不清楚,至少要不断重构,让代码达到这个水平。
清晰命名
这个老话题我就不细说了,但不得不记下来。有时候,我思考一个方法的命名时间,比写一段代码的时间还要长。原因还是那句话:每次写一个像“temp”、“a”、“b”这样的变量,每个维护代码的人,都需要花几倍的精力去梳理。
而这也是代码自描述的最重要的基础。
避免使用长参数
如果一个方法参数的长度超过了4个,就需要提高警惕了。一方面,没人能记住这些函数的语义;另一方面,代码的可读性会很差;最后,如果参数太多,就意味着肯定有很多参数在很多场景下是没用的,我们只能构造默认值来传递它们。
这个问题的解决方法很简单,一般我们会构造一个或者一个来携带数据,一般这个对象都是不可变的对象,这样可以大大提高代码的复用性和可读性,必要时提供适当的方法来简化上层代码的开发成本。
避免使用过长的方法和类
当一个类或方法太长时,读者总会感到沮丧。简单地将方法、类和职责拆分开来,往往会有立竿见影的效果。以类为例,拆分的维度有很多,最常见的就是水平/垂直。例如,如果一个类处理了与库表对象相关的所有逻辑,水平拆分就是将创建/更新/修改/通知逻辑按照业务拆分到不同的类中;而垂直拆分就是
将数据库操作/MQ操作/操作/对象验证等分离到不同的对象中,让主流程尽可能简单可控,尽量让同一个类表达同一维度的事物。
让相同长度的代码段表示相同粒度的逻辑
我这里想要表达的是,提取尽可能多的方法,使得代码具有自描述性。
g(地图,地图){
执行1执行1 = ();
Do2 do2 = ();
do2.设置A(.get("a"));
do2.设置B(.get("b"));
do2.设置C(.get("c"));
(执行1,执行2);
(地图 );
(do1,do2){…};
这样的代码在业务代码中随处可见,获取do1是一个方法,但是获取do2的代码却写在主流程中。这种代码,流程越长,读起来越累。很多人读代码都是“广度优先”,先看主流程,再看细节。这样的代码,如果能把构造do2的代码抽取出来,放到一个方法中,那就舒服多了。
面向对象设计技术
贫血和领域驱动
不得不承认它已经成为企业级Java开发事实上的标准,大部分公司采用的三层/四层贫血模型使得我们的编码习惯变成了面向DAO的,而不是面向对象的。
缺乏必要的模型抽象和设计环节,导致代码冗长,复用度比较差,每次写代码,从头开始似乎已经成为一种不成文的规范。
优点是上手容易,学习成本低。但是每次都无法复用,然后看着那两三千行的类就头晕了,内心很痛苦。本文就不展开领域驱动设计模式了,回归面向对象,跟大家分享一些更好的编码技巧,让大家在通用的框架下,尽可能写出漂亮且复用性高的代码。
我个人认为一个好的系统必须建立在好的模型定义之上,梳理系统中的核心模型,明确定义各个方法的类归属,对代码的可读性、互操作性,以及与产品的沟通都会有很大的益处。
为每种方法找到正确的类,并尝试将数据和行为保持在一起
如果一个类的所有方法都对另一个类的对象进行操作,那么你就要好好思考一下这个类的设计是否合理了。面向对象设计在理论上主张数据和行为应该在一起。这样对象之间的结构清晰,可以减少很多不必要的参数传递。
但是这里要讨论的还有一种方法:对象。如果所有操作对象数据的方法都构建在对象内部,那么对象可能会携带许多不属于其自身功能的方法。
比如我定义一个类叫,。这个类有很多行为,比如:吃饭,睡觉,上厕所,生孩子;还有很多字段,比如:姓名,年龄,性格。
显然,领域在更大程度上定义和描述了我这个人,但很多行为跟我的领域无关,上厕所什么的,我不管我多大年纪,如果把跟人有关的行为都往内部扛,这个类肯定会臃肿。
这就是方法的价值体现的时候了,如果一个行为不能明确标识出它属于哪个领域对象,那么强行把它归到领域对象中就显得不自然了,这时候无状态就能发挥它的作用了,但是要把握好度,回归本质,要合理定义属于各个模型的行为。
警报
方法本质上是面向过程的,无法清晰体现对象之间的关系。虽然可以用一些代码实例的无状态方法来表示(比如实现单例或者自己托管),但这种抽象是浅薄的。说白了,如果我们把所有的调用位置都写出来,那么所有的功能就都由类自己来承载了。
让我画一个类图?抱歉,我不会画。
单例的膨胀很大程度上也是贫血模型的一个副作用,如果对象本身是有血有肉的,就没必要有那么多无状态的方法了。
真正适用的场景:工具方法,而不是商业方法。
巧妙利用
是大规模重构的常用手法。当一段逻辑特别复杂的代码,充斥着各种参数传递和非因果判断时,我首先想到的重构方法就是提取。所谓提取,就是一个有数据和行为的对象,依赖的数据会成为这个对象的变量,所有的行为都会成为这个对象的内部方法。用成员变量代替参数传递,会让代码简洁清晰很多。另外,将过程式代码转换成对象式代码,为面向对象编程中才能用到的继承/封装/多态提供了基础。
例如,上面引用的代码如果表达为
{
地图 ;
地图 ;
做1做1;
做2做2;
(地图,地图){
这个。=;
这个。=;
(){
do1 = ();
do2 = ();
(执行1,执行2);
();
();
(){
(do1+do2);
面向接口编程
面向接口编程是多年来的共识和最佳实践,最早的理论是为了方便替换,但现在更明显的好处是避免方法扩展。对外的接口必须职责明确,很容易判断各个方法是否应该属于同一个接口。
整个代码都是按照接口来组织的,自然就会变得非常清晰易读,关心实现的人就会看实现,对吧?
正确使用继承和组合
这也是业界讨论已久的问题,争论颇多。最新的观点是,组合的使用一般比继承更灵活,特别是在单继承体系中,所以优先使用组合,否则会要求子类承载很多不属于自己的功能。
我个人对这个观点是持保留态度的,在我经历过的代码中,有一个小的模式,我会分析一下。
这种继承方式最值得用,父类保留扩展点,子类进行扩展,没什么好说的。
此方法中子类只能使用而不能修改实现,一般有两种情况:
①抽象出不可修改的主要流程。但一般情况下,这种功能比较适合,如果只是流程的一部分,就需要考虑这个流程的类了。大部分情况下,把它合并到其他类中比较合适。
②父类是抽象类,不能直接对外提供服务,也不希望子类修改其行为。多数情况下这是一种工具方法,更适合由另一个领域对象承载,以组合的方式使用。
这是有争议的,因为父类有默认实现,但子类可以扩展它。只要可以扩展,最好使用继承。否则,定义它并将其视为组合。
总结一下我觉得继承更多的是为了扩展的方便,为了复用而存在的方法最好结合起来,当然更大的原则是明确每个方法的领域划分。
代码重用技巧
模板方法
这是我最常用的设计模式。每当有两个行为类似但不完全相同的代码片段时,我总会想到模板方法。将共同的流程和可重用的方法提取到父类,将差异保留为方法,并在不同的子类中实现它们。
并在适当的时候,上拉(重用)或者下拉(特殊逻辑)。
最后,对于不属于流程的可重用方法,判断其是否属于基类的领域职责,然后使用继承或者组合的方法为这些方法找到合适的归宿。
很多复用层次没那么大,可能只是几行相同的逻辑被复制了好几遍,为什么不尝试把方法()抽出来,这样可以明确方法行为,实现代码复用,何乐而不为呢?
责任链
我经常看到这样的代码,一系列类似的行为,但数据或行为不同。比如一堆验证器,成功了怎么办,失败了怎么办;或者一堆对象构建器,各自构建一部分数据。遇到这样的场景,我总是喜欢定义一个通用的接口,输入的参数就是要验证/构建的完整参数,
输出参数是成功/失败指示符或者void。然后有很多实现者实现这个接口,然后用一个集合把这些行为串起来。最后遍历这个集合,串行或者并行地执行每一部分逻辑。
这样做的好处是:
①很多公共的代码都可以在责任链原子对象的基类中实现;
②代码清晰,遵循开放封闭原则,每当产生新的行为时,只需要定义该行的实现类并加入到集合中即可;
③ 提供了并行性的基础。
明确定义集合的行为
集合是个有意思的东西,本质上是一个容器,但由于泛型的存在,它变成了可以容纳所有对象的容器。对于很多非集合类,我们可以明确定义它们的边界和行为分工,但把它们放进集合里,就都变得千篇一律了。有无数的代码在循环集合,做类似的操作。
其实很多时候,对集合的操作是可以显式地封装起来的,以让它更加有血有肉。
比如一个 Map 可能代表一个配置,一个缓存等等,如果所有的操作都直接在 Map 上做,那么它的行为就没有语义了。首先,你得去详细地去读它;其次,如果要从获取配置和读取缓存的地方加上通用的逻辑,比如日志记录,可想而知这是多么的破烂。
我个人比较提倡的是针对集合上的一些具有明确语义的操作,特别是全局集合或者经常使用的集合,做一些封装和抽象,比如把Map封装成类或者类,然后提供这样的方法。
总结
本文首先从代码的几个主要前提讲起,然后提出了一些练习代码的手段,重点介绍了一些常见的促进代码编写和重构的技术。
当然,这些只是我的一些个人感悟,好的代码,最重要的是需要不断追求卓越的精神,欢迎大家在这个领域进行探索和交流,为代码提供更多好的思路和方法。
关于作者
王晔现为美团点评旅游后端研发团队工程师,曾就职于百度、去哪儿、优酷,专注于Java后端开发,对网络编程、并发编程有浓厚兴趣,做过一些基础组件,也看过一些源码,典型的技术极客。期待与更多朋友一起在路上。联系邮箱: