让我们来谈谈代码审查(Code
Review)。如果花几秒钟去搜索有关内容,你会发现许多论述代码审查好处的文章(例如,Jeff Atwood的这篇文章)。你还会发现许多介绍如何使用代码审查工具的文档,比如我们常用的Upsource。但能够在你审查他人代码时指导查什么的内容却很少见。
或许没有明确审查条目的原因是:有太多不同的因素需要考虑。就像对任何(功能性或非功能性)需求,个体组织对各个方面的优先级都有不同的考虑。
由于文章主题覆盖面广,本文旨在概述了代码审查者在代码审查时可以关注的一些方面。各方面优先级的分配和持续检查是一个非常复杂课题,具有研究价值。
审查他人代码时,查什么?
无论是使用 Upsource 类似工具审查代码,还是在同事查他们代码期间,不管哪种情况,相比较而言评审有些内容要更容易。例如:
格式:在什么地方放置空格和断行符?使用制表符还是空格?大括号如何放置?
风格:变量、参数是否声明为final?函数变量的定义是否与调用代码或函数开始代码太相似?
命名:域、常量、变量、参数、类的名称是否符合标准?命名是否太短?
覆盖测试:这段代码是否进行了测试?
这些检查都是有意义的——你希望尽可能减少不同代码间的上下文切换并减少认知负荷,那么代码看上去越一致越好。(译者注:认知负荷(Cognitive
Load)是指人在完成任务过程中进行信息加工所需要的认知资源的总量。)
但是,人工审查这些内容可能没有充分利用团队的时间和资源,因为其中大部分检查可以自动化。许多工具都能确保代码格式一致,包括命名和
final 关键字的使用规范,而且能发现简单编程错误造成的常见bug。例如,你在命令行中运行IntelliJ
IDEA的检查,那么就不需要所有团队成员在他们的集成开发环境中进行相同的检查。
你应该审查什么?
哪些事情适合代码审查者做?在代码审查时,哪些事情是工具不能代劳的?
事实上,代码审查者可以做的事情很多。当然,下面给出的不是一份详尽的列表,这里也不会深入探讨其中的任何一项。相反,这应该是团队交流的起点,现在你们在代码审查中关注什么,也许正是你们需要审查的内容。
设计
新代码是否与整体架构匹配?
代码是否遵循SOLID原则、领域驱动设计以及团队喜爱的其它设计模式。
新代码采用哪些设计模式?这些模式合适吗?
如果代码库采用混合标准或设计风格,新代码是否符合当前风格?代码迁移是按正确的方向进行,还是效仿即将被淘汰的陈旧代码?
代码是否处于正确的位置?例如,如果代码执行与顺序有关,它是否能按顺序执行?
新代码能否复用部分已存在代码?新代码能否给已存在代码提供复用部分?
新代码是否包含冗余代码?如果包含,是应该重构成更加可复用部分,还是在这个阶段能接受这种冗余?
代码是否超出设计标准?这种复用构造现在是不是不需要?团队如何根据YAGNI权衡复用?
可读性与可维护性
命名(字段、变量、参数、函数以及类)能否反映它们代表的事物?
通过阅读代码,我能否理解代码的目的?
我能否理解测试的目的?
测试是否覆盖了绝大部分情况?是否覆盖常见情况和异常情况?是否存在没考虑到的情况?
异常错误消息是否易于理解?
难以理解的代码段是否进行了备注、评论或者使用易懂的测试案例进行覆盖(符合团队的偏好)?
功能
代码的实际工作是否符合预期?如果有自动化测试来确保代码的正确,测试能否测出代码满足约定要求?
代码看上去是否含有细微错误,比如使用错误变量进行检查,或者把 or 误用为
and?
你是否考虑过……?
代码中是否存在潜在的安全问题?
是否需要满足规范要求?
对于没有覆盖自动化性能测试的代码段,新代码是否引入了不可避免的性能问题,比如不必要的数据调用或远程服务?
作者是否需要创建公共文档,或者修改现有的帮助文档。
展示给用户的消息是否检查无误?
是否存在导致产品崩溃的明显错误?代码是否会意外指向测试数据库,或者是否有应该替换成真正服务的硬编码存根代码?
请关注本博客中详细探究这些主题的文章。
如何编写“良好”代码是每个开发者都能各抒己见的主题。如果你有一些可以加入我们列表的内容,希望能在评论中收到你的回复。
在上一篇我们谈了很多可以在代码审查时留意的事情。现在我们会聚焦这个关注点:在测试代码中找什么。
本文假定:
你的团队已经为代码写过自动化测试。
测试在持续集成(CI)环境下定期运行。
审查的代码已经经过自动编译和测试流程,结果也通过了。
这篇中我们会将介绍审查者在查看测试时需要考虑的事情。
新的或修改的代码有测试吗?
无论是修改 bug 还是新功能开发,几乎没有新代码不需要一个新的或更新过的测试去覆盖它。甚至类似性能这样的非功能性修改,也被证明往往需要经过测试验证。如果在代码审查中没有测试,作为审查者你应该问的第一个问题是“为什么没有测试?”。
测试有覆盖到代码中令人困惑或者复杂的部分吗?
比提出“有测试吗?”这个问题更进一步是要回答这个问题,“重要的代码是否被至少一个测试覆盖?”。
检查测试覆盖率无疑是应该自动化的。但我们不仅仅可以检查覆盖率的明确百分比,还可以使用覆盖率检测工具来确保被覆盖到的代码位于正确的位置。
考虑下面这个例子:
你可以检查新代码(应该容易识别,特别是当你使用类似 Upsource工具)的测试覆盖率报告以确保充分覆盖。
上面这个例子,审查者可以让作者增加一个测试去覆盖 303 行 if 判断为真的情况,因为测试工具标记304-306
行为红色,说明他们没有被测试到。
对于任何团队而言,100 % 的测试覆盖都是不现实的目标,深入了解哪些代码需要被覆盖到,比工具得出的数字更有用。
你特别想检查所有的逻辑分支和复杂的代码都有被覆盖到。
我能理解这些测试么?
进行测试,提供满意的测试覆盖率是一件事,但如果我这个人无法理解这些测试,那它们的使用就被限制了。当它们出了问题该怎么办?很难知道要如何修复它们。
考虑下面这个测试:
这是一个很简单的测试,但是我并不完全确定它测试什么。它是测试 save 方法?还是 getMyLongID
?而且为什么同样的事需要做两遍?
下面测试的意图可能更清楚:
阐述测试目的所采取的特定步骤,取决于你使用的语言、库、所在团队和个人喜好。这个例子证明通过选择清楚的名字、内联常量和增加注释,作者可以让一个测试对于开发者而言更容易阅读,而不仅是他(她)自己。
这些测试需要满足什么要求?
这是一个需要专门知识的领域。无论这些审核的代码需要满足的需求是在正式文档里,或许在一张用户故事卡中,还是在用户提交的
bug里,被审查的代码都需要满足一些基本要求。(译者注:在scrum开发流程中,用户故事 User-Story
是从用户的角度来描述用户渴望得到的功能,通常写在一张卡片上)
审查者应该找到最初的需求来确认:
无论是单元测试、端对端测试、还是其它的测试,这些测试是否满足这些要求。比如,如果要求是“允许特殊字符‘#’,‘!’
和 ‘&’ 出现在密码字段”,就应该有一个在密码字段使用这些值的测试。如果测试使用其它的特殊字符,则不能证明代码符合要求。
测试需要覆盖所有的要求。在我们特殊字符的例子中,要求可能继续“……如果输入了其它特殊字符,给用户提示一个错误信息”。审查者在这里应该检查是否有一个测试来确认当使用一个错误字符会发生什么。
我可以考虑没有被现有测试覆盖到的用例吗?
通常我们的要求不是清晰明确。在这种情形下,审查者应该考虑最初的 bug、问题、用例中没有考虑到的边界条件。
例如,我们有一个新功能,即“让用户可以登录系统”,审查者应该想:“如果用户输入的用户名为空会发生什么?”。或者“如果系统中不存在该用户,会发生什么错误?”。如果被审查代码有这些测试,审查者就比较有信心代码本身会处理这种情况。反之,如果这些异常用例不存在,审查者就不得不仔细检查代码,看它们是否有正确处理。
如果代码里有但没有相应的测试,要由团队决定你们的策略 —— 是否需要让作者加上这些测试?或者你觉得通过代码审查保证边界条件被覆盖到就好了?
这些测试是否有说明代码的限制条件?
审查者经常会看到审查代码中的限制条件。这些限制条件有时是故意设计的 —— 例如,一个批处理最多只能处理
1000 个条目。
一种说明这些刻意限制条件的方法就是明确地测试他们。在上个例子,我们可以用一个测试来证明当批处理大小超过
1000,会有某种异常发生。
自动化测试中,不必表明这些限制条件,但如果作者写了一个测试表明他们实现中的这个限制,这就意味着这些限制是有意的(有文档说明的)而不是疏忽造成的。
审查代码中的测试类型、测试级别正确吗?
例如,如果单元测试就足够了,作者还需要做昂贵的集成测试么?他们写的性能微基准能不能在 CI 环境下以一致方式有效地运行呢?
理想情况下自动化测试会尽可能快地运行,这样就不需要用昂贵的端对端测试去检查所有类型的功能。用一个数学运算或者布尔逻辑检查的函数,可以很好地替代方法级单元测试。
有没有针对安全性的测试?
代码安全性可以从代码审查中受益。我们后续将有一篇安全方面的文章,但就测试而言,我们可以编写测试覆盖一些常见问题。例如,我们上面提到的登录代码,我们可以写一个测试,看如果没有授权我们是否无法进入网站中被保护的区域(或称为保护
API 方法)。
性能测试
在上篇中我谈到性能是审查者需要检查的部分。自动化性能测试是另外一类测试,我本可以在本篇探讨,但后面会写一篇在代码审查中具体看性能方面的文章,我们在那里再讨论这种类型的测试。
审查者也可以写测试
不同组织有不同的代码审查方法 —— 有时非常明确地要求作者负责所有的代码改动,有时会和审查者协作由审查者自己提交代码的评审建议。
无论采取哪种方法,作为审查者你会发现,写一些额外的测试去玩一下审查的代码,对于理解代码是很有帮助的。类似地,你也可以启动UI界面尝试一下新功能,同样也是很有意义的。有些方法和工具可以很容易被用来试验代码。从团队的利益出发,在代码审查中要让代码和玩代码变得尽可能容易。
提交额外的测试作为审查的一部分当然很好,但也不是必须的,例如这个实验已经给我这个审查者满意的答案。
总结
无论你在组织中如何执行,做代码审查有很大好处。代码审查可以在代码集成到主线之前,就发现代码中可能出现的问题。这个阶段,开发者还记得来龙去脉,修正问题的成本也不高。
作为一个代码审查者,你应该要检查最初的开发者放在他(她)代码中的想法,哪些情况下会变坏,处理边界条件,用自动化测试“记录”预期的行为(正常条件和异常情况)。
如果审查者查找已有的测试,检查测试的正确性,那么你们团队对代码会很有信心。而且,如果这些测试在 CI
环境下被定期执行,你可以看到这些代码一直可以工作 —— 他们提供了自动化回归检查。如果代码审查者很看重他们审查的代码,要求高质量的测试,那么这个代码审查的价值在审查者按下“接受”按钮后,还会延续下去。
在代码审查系列的第三篇,我们将涉及就性能而言审查代码时需要留意什么。
和所有的架构或设计领域一样,一个系统性能的非功能性要求应该在前期就确定下来。无论你是工作在一个响应要求在纳秒级别的低延迟交易系统上,还是在创建一个需要响应客户的在线商店,或者正在编写一个电话应用去管理
“待办事项”列表,对“太慢”都要有相应的认识(译者注:环境不同,含义不同)。
让我们来讨论一些影响性能的事情,审查者可以在代码审查时留意。
首先
这部分功能有硬性的性能需求吗?
这部分审查的代码是否属于一个以前发布的服务层级协议(Service Level Agreement SLA)?或者在需求中有规定必需的性能指标吗?
如果最初的需求来自于一个bug —— “登录界面加载太慢”,最初的开发者应该搞清楚一个合理的加载时间是多少
—— 否则审查者或者作者如何相信这个优化后的速度满足需求?
如果有,有测试去检查吗?
任何对性能敏感的系统都应该有自动化测试,来保证可以满足公布的 SLA(在 10 ms 内为所有订单需求提供服务)。如果没有这些,你就只有等到客户投诉时才知道你没有满足
SLA。 这不仅让用户感觉很糟,而且还会导致了不必要的罚款和费用。在这系列的上一篇里,已经详细讨论了代码审查的测试部分。
功能修改或新功能对已有性能测试结果有不良影响吗?
如果你会定期执行性能测试(或者你有一套可以按需要执行的测试集),应该检查对性能有严格要求部分的新代码,确保它不会造成系统性能下降。
这可以是一个自动化的流程,但性能测试通常不像单元测试那么普遍地被集成到 CI 环境中,所以值得强调这一审查步骤。
代码审查中没有硬性的性能需求要怎么做?
如果经过数小时的痛苦,只节省了一点点 CPU 周期,这样的优化实在划不来。但有些事情如果审查者检查了,就可以保证代码不会存在常见的性能缺陷
(完全可以避免)。检查列表的其他部分,看是不是有简单的方法可以防止未来可能出现的性能问题。
服务或应用外的调用开销很大
在自己的应用之外使用需要跨网络跃点的系统和使用一个优化很差的 equals() 方法相比,前者开销更大。请考虑这些:(译者注:网络跃点
network hop 即路由。一个路由为一个跃点。传输过程中需要经过多个网络,每个被经过的网络设备点(有能力路由的)叫做一个跃点,地址就是它的
IP)
数据库调用
最大的麻烦可能躲在抽象后面,比如 ORM(Object Relational Mapping,对象关系映射)。但在代码审查时,你应该能够找到性能问题的常见原因,就像一个循环里的多次数据库调用
—— 例如 ,加载一个 ID 列表,然后基于每个 ID 查询数据库里的对应项目。
(↑↑↑ 点击可查看大图,下同)
例如 ,19 行看起来没有什么问题(无辜的),但它在一个 for 循环里 —— 你不知道这行代码会导致多少次数据库调用。
不必要的网络调用
如同数据库,有时远程服务也被过度使用。就像有些地方使用了多次远程调用,其实一次就足够了,或者可以用批处理或缓存避免开销巨大的网络调用。另一个和数据库类似的是,一个抽象有时候会隐藏一个实际在呼叫远程
API的方法调用。
移动程序、可穿戴程序过于频繁地调用后端
这基本上和“不必要的网络调用”类似,但在移动设备上还增加了一个问题,不必要的调用不仅降低性能,而且还很耗电。
高效且有效果地使用资源
除了注意如何使用网络资源外,审查者还可以查看其他的资源使用,来确定可能的性能问题。
代码通过锁来访问共享代码吗?这样会导致性能下降和死锁吗?
锁是性能的杀手,特别是在多线程环境下。考虑这样的方式:仅有一个线程写入或改变数值,而其他线程可以任意读取;或者使用无锁算法。
代码会造成内存泄漏吗?
一些 Java 中的常见原因是:可变静态字段,使用 ThreadLocal 和 ClassLoader。
应用程序的内存会无限增加吗?
这和内存泄漏不同 —— 内存泄漏是由于垃圾收集器不能回收不再使用的对象。但任何语言,包含那些没有垃圾收集机制的语言,都可以创建无限增长的数据结构。作为审查者,如果你看到新的值不断被加入一个列表或者映射表(Map)
,问一下这个列表或者映射表是否或者何时被释放或者减少。
上面的代码中我们可以看到,所有来自 Twitter 的消息都被加入一个映射表。如果我们仔细检查这个类,我们发现
allTwitterUsers 映射表从来没有减少过,TwitterUser中的 tweets 列表也没有。这个映射表可能很快就变得很大,这取决于我们监控用户的数量和我们添加
tweets 的频率。
代码有关闭连接或流吗?
一不小心就会忘记关闭连接、文件或网络流。当你审查其他人(无论什么语言)的代码时,确保每个使用的文件、网络或数据库连接都有被正确地关闭。
上面代码很幸运地编译通过,最初的作者很容易忽视这个问题。作为审查者你应该注意 Connection、Statement
和 ResultSet 这些对象在方法退出前都要关闭。在 Java 7 中通过 try-with-resources
可以很容易做到这一点。下面的截屏显示了作者利用 try-with-resources 修改代码后代码审查的结果。
资源池配置正确吗?
一个环境的最优配置受很多因素的影响,作为审查者你不可能马上知道,就好像一个数据库连接池的大小是否合适。但是有些事情你可以很容易判断,比如这个池子太小(大小为1)或者太大(数以百万计的线程)。优化这些值需要经过测试,测试环境越接近真实环境越好,但如果池子(例如线程池或者连接池)真的太大,在代码审查时可以抓出这个常见问题。逻辑表明越大越好,但每个对象都会消耗资源,对象太多通常会造成管理的开销比带来的好处大很多。如果有疑问,最好先使用默认设定。没有使用默认设定的代码需要通过某些测试或计算,来验证数值是合理的
。
审查者容易发现警告标志
某些类型的代码可以立即显示一个可能的性能问题。这取决于使用的语言和函数库(请让我们知道,在你们环境下对“代码异味”的评价)。
(译者注:代码异味 code smells,代码中的任何可能导致深层次问题的症状都可以叫做代码异味。代码异味包括重复代码、巨型类、方法过长等。)
反射
在Java中使用反射比不使用要慢很多。如果你检查包含反射的代码,问一下反射是不是一定需要。
上面的截屏显示了一个审查者在 Upsource 里点击一个方法确认它来自哪里,可以看到这个方法来自 java.lang.reflect
包,这就应该是一个警告标志。
超时
当你审查代码时,你可能不知道一个操作的正常超时应该是多少,但应该这样考虑“如果超时,系统中的其他部分会受到什么影响?”。作为审查者你应该考虑最坏的情况
—— 如果 5 分钟超时到了,应用程序会卡住吗?如果超时被设置为1秒,最坏会发生什么?如果代码的作者无法判断超时的大小,而你作为审查者也不知道如何选择一个值,这时最好让懂行的人参与进来。不要等到你的用户向你抱怨性能问题。
并行性
代码中有使用多个线程来执行一个简单的操作吗?除了增加时间和复杂性外,却没有带来性能的提高?现代Java中,并行性比直接创建线程更加微妙:代码有使用Java
8 炫酷的并行流机制,但是却没有从并行性中受益吗?例如在并行流中处理非常简单的操作,可能会比在一个顺序流上执行慢很多。
上面代码中增加的并行性没有给我们带来任何好处 —— 这个流作用在 Tweet 上,上面一个字符串不会超过140个字符。就把那么几句话的操作并行化,也许不会改善性能,反而拆分成并行线程的开销会更大。
正确性
这些事情不一定会影响系统的性能,但它们就会影响到正确性,因为它们很大程度上和运行在多线程环境有关。
多线程环境下的代码使用了正确的数据结构吗?
上面的代码中,作者在第 12 行用一个 ArrayList 存储所有的会话(session)。但这代码特别是
onOpen 方法会被多个线程调用,sessions 字段就必须是一个线程安全的数据结构。这种情况下有多种选择:可以使用Vector,也可以用
Collections.synchronizedList()创建一个线程安全列表(List),但最好的选择是用CopyOnWriteArrayList,因为对这个列表的修改通常远低于对它的读取。
代码容易出现竞态条件吗?
多线程环境下,编写代码很容易造成微妙的竞争问题。例如:
虽然递增代码只有一行(第16 行),但从代码读取值到设定新值的这段时间里,另一个线程很可能已经增加了
orders 的值。作为审查者,要注意 get 和 set 组合都不是原子操作。
代码中对锁的使用正确吗?
对于竞态条件,作为审查者你应该要检查代码,不允许多个线程用可能会引发冲突的方式修改数值。应该要使用同步、锁或者原子变量来控制修改的代码块。
代码的性能测试有价值吗?
例如,一不小心就会写出糟糕的微基准测试。或者如果测试使用的数据和量产数据不一样,也会得到一个错误的结果。
缓存
虽然缓存可以避免过多的外部请求,它也会面临自己的挑战。如果审查的代码使用了缓存,你应该注意一些常见的问题,例如不正确的缓存项失效。
代码级优化
如果你在审查代码,同时你也是一名开发者,下面的内容中可能会有你想建议的优化方法。作为团队,你们应该很清楚性能对你们有多重要,这些优化方法对你们的代码是否有帮助。
大部分公司不会开发低延迟的应用程序,这些优化很可能都属于过早的优化。
代码中是否使用了不必要的同步、锁?如果代码总是执行在单线程下,加锁只会带来额外开销。
代码中是否使用了不必要的线程安全数据结构?例如,可以用ArrayList
替换 Vector 吗?
代码中是否使用了在常见操作上性能很差的数据结构?例如,使用了链表结构,却经常搜索其中的一项。
代码是否使用了锁或者同步机制,而实际上可以用原子变量替代?
代码可以得益于延迟加载吗?
if 语句或其他逻辑语句能通过短路机制进行优化吗?比如把最快的计算放在条件的开始?
有很多的字符串格式吗?可以更有效率吗?
log 语句有使用字符串格式化吗?有用检查log级别的 if 语句包起来或者使用的日志提供者可以进行延迟操作?
上面代码只会在 logger 被设定为 FINSET 时才会记录消息。但无论这条消息是否真的被记录下来,每次都会运行这个开销很大的String.format
操作。
可以像上面代码那样优化性能,即确保只在 log 级别等于某一数值时,才把消息被写入在 log。
Java 8中,可以使用 lambda 表达式来提高性能,而不是使用 if。由于使用到 lambda,除非消息真的被记录下来,String.format
不会执行。这应该是Java 8推荐的方法。
涉及到性能有很多要考虑的事情……
正如我在第一篇中列出代码审查需要留意的事情,本文强调了其中的一些关注点,可能是你们团队在审查时会持续检查的。这取决于你们项目的性能需求。
虽然本文是面向所有的开发人员,但大部分例子都是使用Java或 JVM。我最后介绍一些简单的事情,是在审查
Java代码时需要留意的,这样可以让JVM 而不是你自己去优化代码:
编写小的方法和类。
保持逻辑简单 —— 不要使用深入嵌套的 if 或 循环。
代码越容易让人阅读,JIT 编译器越有可能理解你的代码从而去优化它 。
如果代码看上去容易理解和整洁,它的性能也可能很好 —— 这应该很容易在代码审查时发现。
总结
谈到性能,你要理解通过代码审查,某些方面可以快速得到改善(比如,没有必要的数据库调用),有些方面也很诱人(就像代码级优化)但很可能不会给你们的系统带来很大的改善。
这是代码审查“查”什么系列第5篇。 查看该系列以前的文章。
在今天的文章中,我们将更多地关注代码本身的设计,专门检查代码是否遵循了良好的面向对象设计实践。与所有我们讨论的其他方面一样,并不是所有的团队将它作为最高准则来优先检查,但是如果你尝试遵循SOLID原则,或尝试将你的代码往这个方向上努力,这里的一些建议可能会对你有所帮助。
什么是SOLID?
SOLID原则 是面向对象程序设计和编程的五个核心原则。这篇文章的目的并不是教会你这些原则是什么或者深入探讨为什么要遵循它们,而是指出那些代码评审中发现的代码异味,它们可能是没有遵循这些原则的结果。
SOLID代表:
S – 单一职责原则
O – 开放封闭原则
L – 里氏替换原则
I – 接口分离原则
D – 依赖倒置原则
单一职责原则(SRP)
不应该有多种情况需要修改某个类的对象。
仅仅通过代码评审有时会很难发现上述情况。根据定义,作者是(或应该是)由一个独立的要求去改变代码库——修复一个bug,添加一个新功能,或一次专门的重构。
你应该看看在一个类中哪些方法在同一时间会改变,以及哪些方法几乎是不可能受其他方法变化影响而被改变。例如:
上面Upsource工具排展示的diff中,一个新功能被添加到TweetMonitor,能够基于user接口的某种排序画出Tweeter的十大排行榜。虽然这似乎是合理的,
因为它使用了onMessage方法收集的数据,但有迹象显示这违反了SRP。onMessage和 getTweetMessageFromFullTweet方法都是和接收或解析一个Twitter消息相关,而draw用来组织数据并在UI上显示。
代码审查者应该标记这两个职责,然后和作者研究出一个分离这些特性的更好方法:也许通过移动Twitter字符串解析方法到不同的类,或者创建一个不同的类负责来渲染排行榜。
开放封闭原则(OCP)
软件实体应该对扩展开放,对修改封闭。
作为一个代码审查者,如果你看到一系列的if语句检查对象的特殊类型,可能这就是违反该原则的迹象:
如果你审查上面的代码应该很清楚,当一个新的Event类型添加到系统,新创建的Event类型可能要添加另一个else到这个方法中来处理它。
最好使用多态来移除这个if:
像往常一样,这个问题的解决方案不止一个,但关键是把复杂的if/else和instanceof判断移除。
里氏替换原则(LSP)
函数使用基类的引用,必须能够在不知不觉的情况下使用派生类的对象。
发现违反这一原则的一个简单的方法是寻找显示的类型转换。如果你不得不把一个对象转换为某种类型,你就没有做到不知不觉地使用继承了基类的子类对象。
检查以下两条时可以找到更细微的冲突:
先决条件不能在子类型加强。
后置条件不能在子类型减弱。
例如,想象我们有一个抽象类Order和一些子类——BookOrder、ElectronicsOrder等等。placeOrder方法可能需要一个Warehouse,可以用这个方法来改变仓库中的实物产品的库存水平:
现在想象一下,我们引入电子礼品卡,只需简单地为钱包添加余额,但不需要物理盘点。如果实现为GiftCardOrder,placeOrder方法就不必使用warehouse参数:
这可能在逻辑上看起来像继承,但事实上你可以说使用GiftCardOrder应该期望这个类和其他类有类似的行为,例如,你应该期望这个参数传递给所有子类:
但这不会传递,作为GiftCardOrders有不同类型的order行为。如果你审查这样的代码,应该质疑这里继承的使用,也许order行为可以通过使用组合替代继承的方式插入。
接口分离原则(ISP)
多个客户端特定的接口比使用一个通用的接口要好。
可以很容易识别某些代码违反这一原则,因为接口上具有很多方法。这一原则与SRP呼应,正如你可能看到一个接口具有许多方法,实际上它是负责了多个功能。
但有时甚至一个接口只有两个方法也可以拆分成两个接口:
在这个例子中,有些时候可能不需要decode方法,并且一个编解码器根据使用要求可能被视为一个编码器或解码器,把SimpleCodec接口分割成一个编码器和解码器可能会更好。一些类可以选择两个都实现,但不必去重载不需要的方法,或者某些类只需要编码器,同时他们的编码器实例也实现解码。
依赖倒置原则(DIP)
依赖抽象。不要依赖于具体实现。
虽然想要在试图寻找违反该原则的简单例子时,类似到处使用new关键字(而不是使用依赖注入或工厂模式)和滥用集合类型(例如声明ArrayList变量或参数而不是List),作为一个代码审查者你在代码审查时,应该确保代码作者已经使用或创建了正确的抽象。
例如,服务级的代码直接连接到一个数据库读写数据:
这段代码依赖于大量的具体的实现细节:连接到一个(关系)数据库的JDBC,基于具体数据库的SQL,需要知道数据库结构,等等。这段代码来自你的系统的某处,但是不应该出现在这里。这里还有其他方法不需要知道具体的数据库。更好的方式是,提取出一个DAO或使用Repository模式,将DAO或repository注入到这个服务中。
总结
一些代码异味可能意味着可能已经违反了一个或多个SOLID原则:
很长的if/else语句
强制转换成一个子类型
很多公共方法
实现的方法抛出UnsupportedOperationException异常
与所有的设计问题一样,在遵循这些原则和故意回避这些原则间需要找到一个平衡,这个平衡取决于你的团队的喜好。但是如果你在代码审查中看到复杂的代码,会发现应用这些原则将提供一个更简单、更容易理解的解决方案。 |