Clean Code,意思就是整洁、清晰、漂亮的代码。相信这个也是大多数工程师梦寐以求的代码。当然,每个工程师都有自己的理解,因此本文主要针对面向对象编程的clean code来阐述。下面来看蓝鸥上海程序员培训的详细分析。
原则当先
代码大部分时候是用来维护的,而不是用来实现功能的;代码编译一般两个方面,一是让机器执行,完成功能需求;二是写给自己或者队友看。由于大部分项目的生命周期都相对较长,所以需要长期维护。因此,写出清晰好看的代码,会减少后续所付出的代价和成本。
优秀的代码大部分是可以自描述的,好于文档和注释;许多开源代码,注释非常少,但是看起来却舒服。读完源码后,很多功能设计就都清晰明了了。通过仔细斟酌的方法命名、清晰的流程控制,代码本身就可以拿出来当作文档使用,而且它永远不会过期。
相反,注释不能让写的烂的代码变的更好。如果别人只能依靠注释读懂你的代码的时候,你一定要反思代码出现了什么问题(当然,这里不是说大家不要写注释了)。
我们知道,公司同事之间的技术水平有高有低,假如你用高大上的设计,而多数人读起来困难,甚至无法理解你的代码时,那么,这就是一个设计过度造成的结果;而当你的系统内大部分抽象只有一个实现的时候,很可能就是设计过渡。这样的设计是失败的,因为代码清晰是首要原则。
实践环节
首先是,促成clean code的常见手段。
code review
通常大公司会用git的pull request机制来做code review。
①首先,凡是能通过机器检查出来的事情,无需通过人。比如换行、注释、方法长度、代码重复等。
②除了基本功能需求的逻辑合理没有bug外,更应该关注代码的设计与风格。比如,一段功能是不是应该属于一个类、是不是有很多相似的功能可以抽取出来复用、代码太过冗长难懂等等。
③集体code review,即在整个小组内形成风格统一的氛围,培养大家对clean code的热情。因为很多时候,组里相对高级的工程师能够一眼发现代码存在较大设计缺陷,提出改进意见或者重构方式。
勤于重构
为了避免重构带来的负面影响(delay需求或者带来bug),我们需要做好以下的功课:
① 掌握一些常见的“无痛”重构技巧,这在下文会有具体讲解。
② 小步快跑,不要企图一口吃成个胖子。改一点,测试一点,一方面减少代码merge的痛苦,另一方面减少上线的风险。
③ 建立自动化测试机制,要做到即使代码改坏了,也能保证系统最小核心功能的可用,并且保证自己修改的部分被测试覆盖到。
④ 熟练掌握IDE的自动重构功能。这些会很大程度上减少我们的体力劳动,避免犯错。
静态检查
现在市面上有很多代码静态检查的工具,也是发现bug和风格不好的比较容易的方式。可以与发布系统做集成,强制把主要问题修复掉才可以上线。目前美团点评技术团队内部的研发流程中已经普遍接入了Sonar质量管理平台。
编写整洁代码时的常见技巧和误区
单一职责
无论是一个module、一个package,还是一个class、一个method,甚至一个属性,都应该承载一个明确的职责。要定义的东西,如果不能用一句话描述清楚职责,就把它拆掉。
方法问题:
主张方法拆细,这也是复用的基础。如果方法干了两件事情,很有可能其中一个功能的其他业务有差别就不好重用了。另外语义也是不明确的。经常看到一个get()方法里面竟然修改了数据,这让使用你方法的人情何以堪?如果不点进去看看实现,可能就让程序陷入bug,让测试陷入麻烦。
类的问题:
我们经常会看到“又臭又长”的service/biz层的代码,里面有几十个方法,干什么的都有:既有增删改查,又有业务逻辑的聚合。每次找到一个方法都费劲。不属于一个领域或者一个层次的功能,就不要放到一起。
优先定义整体框架
先定义整体的框架,就是写很多空实现,来把整体的业务流程穿起来。良好的方法签名,用入参和出参来控制流程。这样能够避免陷入业务细节无法自拔。在脑海中先定义清楚流程的几个阶段,并为每个阶段找到合适的方法/类归属。
这样做的好处是,阅读你代码的人,无论读到什么深度,都可以清晰地了解每一层的职能,如果不care下一层的实现,完全可以跳过不看,并且方法的粒度也会恰到好处。
简而言之,我比较推崇写代码的时候“广度优先”而不是“深度优先”,这和我读代码的方式是一致的。当然,这件事情跟个人的思维习惯有一定的关系,可能对抽象思维能力要求会更高一些。如果开始写代码的时候这些不够清晰,起码要通过不断地重构,使代码达到这样的成色。
清晰的命名
老生常谈的话题,这里不展开讲了,但是必须要mark一下。有的时候,我思考一个方法命名的时间,比写一段代码的时间还长。原因还是那个逻辑:每当你写出一个类似于”temp”、”a”、”b”这样变量的时候,后面每一个维护代码的人,都需要用几倍的精力才能理顺。
并且这也是代码自描述最重要的基础。
避免过长参数
如果一个方法的参数长度超过4个,就需要警惕了。一方面,没有人能够记得清楚这些函数的语义;另一方面,代码的可读性会很差;最后,如果参数非常多,意味着一定有很多参数,在很多场景下,是没有用的,我们只能构造默认值的方式来传递。
解决这个问题的方法很简单,一般情况下我们会构造paramObject。用一个struct或者一个class来承载数据,一般这种对象是value object,不可变对象。这样,能极大程度提高代码的可复用性和可读性。在必要的时候,提供合适的build方法,来简化上层代码的开发成本。
避免过长方法和类
一个类或者方法过长的时候,读者总是很崩溃的。简单地把方法、类和职责拆细,往往会有立竿见影的成效。以类为例,拆分的维度有很多,常见的是横向/纵向。例如,如果一个service,处理的是跟一个库表对象相关的所有逻辑,横向拆分就是根据业务,把建立/更新/修改/通知等逻辑拆到不同的类里去;而纵向拆分,指的是
把数据库操作/MQ操作/Cache操作/对象校验等,拆到不同的对象里去,让主流程尽量简单可控,让同一个类,表达尽量同一个维度的东西。
面向对象设计技巧
贫血与领域驱动
不得不承认,Spring已经成为企业级Java开发的事实标准。而大部分公司采用的三层/四层贫血模型,已经让我们的编码习惯,变成了面向DAO而不是面向对象。
缺少了必要的模型抽象和设计环节,使得代码冗长,复用程度比较差。每次撸代码的时候,从mapper撸起,好像已经成为不成文的规范。
好处是上手简单,学习成本低。但是每次都不能重用,然后面对两三千行的类看着眼花的时候,我的心是很痛的。关于领域驱动的设计模式,本文不会展开去讲。回归面向对象,还是跟大家share一些比较好的code技巧,能够在一个通用的框架下,尽量好的写出漂亮可重用的code。
个人认为,一个好的系统,一定离不开一套好的模型定义。梳理清楚系统中的核心模型,清楚的定义每个方法的类归属,无论对于代码的可读性、可交流性,还是和产品的沟通,都是有莫大好处的。
为每个方法找到合适的类归属,数据和行为尽量要在一起
如果一个类的所有方法,都是在操作另一个类的对象。这时候就要仔细想一想类的设计是否合理了。理论上讲,面向对象的设计,主张数据和行为在一起。这样,对象之间的结构才是清晰的,也能减少很多不必要的参数传递。
不过这里面有一个要讨论的方法:service对象。如果操作一个对象数据的所有方法都建立在对象内部,可能使对象承载了很多并不属于它本身职能的方法。
例如,我定义一个类,叫做person,。这个类有很多行为,比如:吃饭、睡觉、上厕所、生孩子;也有很多字段,比如:姓名、年龄、性格。
很明显,字段从更大程度上来讲,是定义和描述我这个人的,但很多行为和我的字段并不相关。上厕所的时候是不会关心我是几岁的。如果把所有关于人的行为全部在person内部承载,这个类一定会膨胀的不行。
这时候就体现了service方法的价值,如果一个行为,无法明确属于哪个领域对象,牵强地融入领域对象里,会显得很不自然。这时候,无状态的service可以发挥出它的作用。但一定要把握好这个度,回归本质,我们要把属于每个模型的行为合理的去划定归属。
警惕 static
static方法,本质上来讲是面向过程的,无法清晰地反馈对象之间的关系。虽然有一些代码实例(自己实现单例或者Spring托管等)的无状态方法可以用static来表示,但这种抽象是浅层次的。说白了,如果我们所有调用static的地方,都写上import static,那么所有的功能就由类自己在承载了。
让我画一个类图?尴尬了……画不出来。
而单例的膨胀,很大程度上也是贫血模型带来的副作用。如果对象本身有血有肉,就不需要这么多无状态方法。
static真正适用的场景:工具方法,而不是业务方法。
巧用 method object
method object是大型重构的常用技巧。当一段逻辑特别复杂的代码,充斥着各种参数传递和是非因果判断的时候,我首先想到的重构手段是提取method object。所谓method object,是一个有数据有行为的对象。依赖的数据会成为这个对象的变量,所有的行为会成为这个对象的内部方法。利用成员变量代替参数传递,会让代码简洁清爽很多。并且,把一段过程式的代码转换成对象代码,为很多面向对象编程才可以使用的继承/封装/多态等提供了基础。
面向接口编程
面向接口编程是很多年来大家形成的共识和最佳实践。最早的理论是便于实现的替换,但现在更显而易见的好处是避免public方法的膨胀。一个对外publish的接口,一定有明确的职责。要判断每一个public方法是否应该属于同一个interface,是很容易的。
整个代码基于接口去组织,会很自然地变得非常清晰易读。关注实现的人才去看实现,不是嘛?
正确使用继承和组合
这也是个在业界被讨论过很久的问题,也有很多论调。最新的观点是组合的使用一般情况下比继承更为灵活,尤其是单继承的体系里,所以倾向于使用组合,否则会让子类承载很多不属于自己的职能。
个人对此观点持保留意见,在我经历过的代码中,有一个小规律,我分析一下。
protected abstract 这种是最值得使用继承的,父类保留扩展点,子类扩展,没什么好说的。
protected final 这种方法,子类是只能使用不能修改实现的。一般有两种情况:
① 抽象出主流程不能被修改的,然而一般情况下,public final更适合这个职能。如果只是流程的一部分,需要思考这个流程的类归属,大部分变成public组合到其他类里是更合适的。
② 父类是抽象类无法直接对外提供服务,又不希望子类修改它的行为,这种大多数情况下属于工具方法,比较适合用另一个领域对象来承载并用组合的方式来使用。
protected 这种是有争议的,是父类有默认实现但子类可以扩展的。凡是有扩展可能的,使用继承更理想一些。否则,定义成final并考虑成组合。
综上所述,个人认为继承更多的是为扩展提供便利,为复用而存在的方法最好使用组合的方式。当然,更为大的原则是明确每个方法的领域划分。
代码复用技巧
模板方法
这是我用得最多的设计模式了。每当有两个行为类似但又不完全相同的代码段时,我总是会想到模板方法。提取公共流程和可复用的方法到父类,保留不同的地方作为abstract方法,由不同的子类去实现。
并在合适的时机,pull method up(复用)或者 pull method down(特殊逻辑)。
最后,把不属于流程的、但可复用的方法,判断是不是属于基类的领域职责,再使用继承或者组合的方法,为这些方法找到合适的安家之处。
extract method
很多复用的级别没有这么大,也许只是几行相同的逻辑被copy了好几次,何不尝试提取方法(private)。又能明确方法行为,又能做到代码复用,何乐不为?
责任链
经常看到这样的代码,一连串类似的行为,只是数据或者行为不一样。如一堆校验器,如果成功怎么样、失败怎么样;或者一堆对象构建器,各去构造一部分数据。碰到这种场景,我总是喜欢定义一个通用接口,入参是完整的要校验/构造的参数,
出参是成功/失败的标示或者是void。然后有很多实现器分别实现这个接口,再用一个集合把这堆行为串起来。最后,遍历这个集合,串行或者并行的执行每一部分的逻辑。
这样做的好处是:
① 很多通用的代码可以在责任链原子对象的基类里实现;
② 代码清晰,开闭原则,每当有新的行为产生的时候,只需要定义行的实现类并添加到集合里即可;
③ 为并行提供了基础。
为集合显式定义它的行为
集合是个有意思的东西,本质上它是个容器,但由于泛型的存在,它变成了可以承载所有对象的容器。很多非集合的类,我们可以定义清楚他们的边界和行为划分,但是装进集合里,它们却都变成了一个样子。不停地有代码,各种循环集合,做一些相似的操作。
其实很多时候,可以把对集合的操作显示地封装起来,让它变得更有血有肉。
例如一个Map,它可能表示一个配制、一个缓存等等。如果所有的操作都是直接操作Map,那么它的行为就没有任何语义。第一,读起来就必须要深入细节;第二,如果想从获取配置读取缓存的地方加个通用的逻辑,例如打个log什么的,你可以想象是多么的崩溃。
个人提倡的做法是,对于有明确语义的集合的一些操作,尤其是全局的集合或者被经常使用的集合,做一些封装和抽象,如把Map封装成一个Cache类或者一个config类,再提供GetFromCache这样的方法。