开篇 我们总是很容易就能写出满足某个特定功能的代码,却很难写出优雅代码。又最欣赏那些优雅的代码,因为优雅代码更能体现一个开发者的积累。
就像写一篇散文,有的就像初学者不得其门而入,遣词造句都非常困难,然后纠纠结结,最终不了了之。或者啰哩吧嗦,看起来说了一堆,其实就像是村妇闲聊,毫无重点,不过是口水文而已。
好代码应该是这样的,如涓涓细流、如同一首诗,一篇优美的故事,将作者编写代码时的情感慢慢铺垫开来,或是高潮迭起,此起彼伏,或是平铺直述,却蕴含道理。我始终相信优秀的代码是有灵魂的,代码的灵魂就是作者的逻辑思维。
编写整洁代码 or 非整洁代码,就像平时生活中是否注意爱护环境的一点点小习惯,一旦坏味道代码没有及时处理,就会成为破窗效应,然后逐渐的代码越写越烂,最终这些代码要么以重构收场,要么就被抛弃。
我们见过太多没有毫无质量可言的代码,许多时候开发者们由于能力原因、或者时间有限,写了许多能够满足当前工作的代码,然后就弃置高阁,不再理会。于是,代码写之前的只有自己和上帝能理解代码的意思,而写完了之后,只有上帝能懂了;还有一些开发者说:我只会写代码,不会优化代码,他们仿佛特别勤奋,每天都会比其他人都热衷于熬工时,但是写出的代码,实际上是一个个难以维护的技术债。而且许多代码的作者总喜欢找各种借口来抵赖,例如喜欢说代码出了问题都是底层框架太垃圾了、或者别人的代码封装得太差。他们总是抱怨这抱怨那,但是即便有优秀的框架、技术,就一定能写出优秀的代码么?
在这里笔者列举了平时看到过一些自认为不太整洁的代码,以及与《代码整洁之道》(Clean Code · A Handbook of Agile Software Craftsmanship)一书中相对应的范例,欢迎大家一起来拍砖。
(经验有限,时间仓促,请轻喷。)
一些栗子 1、命名规则 在我们刚刚开始学习写代码的古老时代,或许会有下面这种习惯。
class ZhangsanTest { private void TestGetData ( ) { int a, b, c; } private int ZhangsanGet (int s1, int s2 ) { int s3 = s1 + s2; return s3; } private List<string > GetData ( ) { return null ; } }
这是一个喜欢用自己的姓名来命名类和方法的作者,在他的代码中,经常可以看到这样奇怪的对象定义,而且他还喜欢用a,b,c,d,e,f或者s1,s2这样的命名,仿佛他的代码自带混淆特效。这样的代码嗅起来会不会觉得充斥着奇怪的味道? 另外,有没有发现有许多开发者喜欢用 GetData() 来定义获取数据的方法?然后这个方法就成为一个万金油的方法,不管是爬虫采集、或者数据绑定,无论是 C# 写的后端或者 Java 写的后端代码,或者用 vue 写的前端代码,仿佛在任何场景、任何数据应用都可以看到这样的方法。 如果一个项目中,有十几个地方都出现了这个** GetData() **方法,那种感觉一定非常难受。
随着技能的增长,或许我们会学到一些新的代码概念,例如,Model、DTO 是经常容易弄混淆的一种概念,但是在某些代码中,出现了下面的命名方式就有点令人窒息了。
public class XXXModelDto { public int Id { get ; set ; } public string Name { get ; set ; } public string Alias { get ; set ; } }
这是大概是一位对概念严重消化不良的资深开发者,居然同时把 Model 和 DTO 复用在一个对象上, (当然,一个开发者定义变量的背后一定有他的动机)。 他到底是想要的是用来在 MVC 模式解决数据传输和对象绑定的模型对象?还是用于传输数据的 DTO 呢? --其实他定义这个对象,是为了定义存储数据对象的实体( Entity )。
近年来开发者素质越来越高,所以许多优秀开发者会倾向于使用翻译软件来翻译变量名,然后用英语来命名,但是即便如此,许多政务项目总是能嗅出一些奇怪的味道。 例如前不久看到一条这样的短信:(原图已经消失)
xxx公积金中心提醒您:您于{TQSJ}日进行了{TQCZ}操作,账上剩余金额为{SYJE}元。
这是个bug将xxx公积金中心的某些秘密透露在大家面前。作为一个严谨的项目,居然使用中文首字母大写命名法,这让习惯于大驼峰、小驼峰的我看了之后尴尬癌犯了,很不舒服。但是这也是许多政务信息化项目的中字段命名的规范,而且在这种情况下,往往会输出一份非常规范的数据库字段对照表,确保中文和首字母的语义不让人产生歧义。 所以特定语境下,变量和方法本身没有严格的规定,但是一定要使用恰当的语境概念,对于这样的特定场景,尽量维护一份实时更新的术语表吧。
2、状态码返回值 似乎在对外提供接口时,使用下列接口状态码是一种比较常见的惯例。提供统一格式的 code 状态码以及返回的消息和成功返回结果时的填充数据,能够让开发者高效的完成接口对接,无需关心http状态码背后的含义。
{"code" :"100101" ,"message" :"success" ,"data" :{},"count" :"" }
上面这是一种经典的流派,还有一种流派则会使用http状态码来返回指定的数据,事实上 http 协议本身已经提供了许多状态码,例如下面的这些大家都非常熟悉的状态码。 但是这些状态码为啥不够?主要是为了减少前后端、服务上下游之间接口对接的难度,也是一种提高效率的方式。 但是 http 状态码是一种通用的格式,应尽量使用这种方式,而不应该通过解析正常响应后的 json 来判断是否正确操作。
200 :正常响应 标准成功代码和默认选项。 201 :创建对象。 适用于存储行为。 204 :没有内容。 当一个动作成功执行,但没有任何内容可以返回。 206 :部分内容。 当您必须返回分页的资源列表时很有用。 400 :请求不正确 无法通过验证的请求的标准选项。 401 :未经授权 用户需要进行身份验证。 403 :禁止 用户已通过身份验证,但没有执行操作的权限。 404 :找不到资源自动返回。 500 :内部服务器错误。 理想情况下,您不会明确地返回此消息,但是如果发生意外中断,这是您的用户将会收到的。 503 :服务不可用 相当自我解释,还有一个不会被应用程序显式返回的代码。
3、switch 语句与判断语句 我曾经跟小组中一位大佬交流他的一段代码,他的这段代码大概是这样的。
public void FlowProcess (int auditType ) { switch (auditType) { case 1 : break ; case 2 : break ; case 3 : break ; case 4 : break ; } }
(读者卒。) 且不说这位大佬的代码是写得好或者不好,仅仅就这200多行代码的4个大switch读起来大概会让人便秘难受吧。于是在我读完这段代码之后,我冒死向他请教这么写代码的原因,他说我这个流程处理就是一个简单的用例场景,哪里还有什么可以优化的余地? 我跟他介绍了20分钟代码封装的必要性,于是,他把代码写成了这样。
public void FlowProcess (int auditType ) { switch (auditType) { case 1 : AuditOK(); break ; case 2 : AuditNotOK(); break ; case 3 : ReAuditOK(); break ; case 4 : ReAuditNotOK(); break ; } }public void AuditOK ( ) { }public void AuditNotOK ( ) { }public void ReAuditOK ( ) { }public void ReAuditNotOK ( ) { }
这酸爽令人简直难以置信。(事实上这个新鲜出炉的遗留应用,正是这样一点点堆积了许多总代码行超过千行的类文件) 《代码整洁之道》书上有一个类似的例子,大概与上文类似,Robert 大叔给出了这样的建议:
对于switch 语句,我的规矩是如果只出现一次,用于创建多态对象,而且隐藏在某个集成关系中,在系统中其他部分看不到,就还能容忍。当然也要就事论事,有时我也会部分或全部违反这条规矩。
上文我给出的示例,有点像面向过程的代码风格,而 Robert 大叔在他的书中写下的示例是这样的(抽象工厂模式的示例)。
图片 这清爽的感觉,让人很舒服啊。
当然,原示例是一个流程处理的例子,似乎大家的流程处理代码都习惯于使用这种面向过程风格的写法,反正要加判定条件,就加一个 case 就可以了。 而在某些特定情况下,甚至用 if / else 来写逻辑判断更简单,于是我们经常在某些销量很好的快速开发平台中,看到这样的例子。
图片 这些典型的面向过程风格的代码,确实读起来似乎更加简单、而且也易于实现。Robert 大叔是这样说的:过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新函数,面向对象代码便于在不改动既有函数的前提下添加新类。 反过来讲也说得通:过程式代码难以添加新数据结构,因为必须修改所有函数,面向对象代码难以添加新函数,因为必须修改所有类。
所以究竟是使用面向过程式代码,还是面向对象式代码?没有万试万灵的灵丹妙药。
4、奥卡姆剃刀定律、得墨忒耳律 一旦开始初步掌握面向对象开发的基本原则,于是我们就会新建许多各种不同的模型对象。尤其是在webapi接口开发过程中,更是如此。
切勿浪费较多东西去做,用较少的东西,同样可以做好的事情。
假设有一段代码是这样的。
public class GrandParent { public Father Son { get ; set ; } public string Name { get ; set ; } public Father GetSon ( ) { return Son; } }public class Father { public Me Son { get ; set ; } public string Name { get ; set ; } public Father GetSon ( ) { return Son; } }public class Me { public Son Son { get ; set ; } public string Name { get ; set ; } public Son GetSon ( ) { return Son; } }public class Son { public GrandSon GrandSon { get ; set ; } public string Name { get ; set ; } public GrandSon GetSon ( ) { return GrandSon; } }public class GrandSon { public string Name { get ; set ; } public string GetSon ( ) { return Name; } }
会不会为了获得某些数据,而写出这样的代码呢?
return new GrandParent ().GetSon ().GetSon ().GetSon ().Name ;
这样就是典型的对得墨忒耳律的违背。这个原则指出:
模块不应了解它所操作对象的内部情形。 更准确的说,得墨忒耳律认为,类C的方法f只应该调用以下对象的方法: C(本身) 由方法f创建的对象。 作为参数传递给f的对象; 由C的实体变量持有的对象。 对象不应调用由任何函数返回的对象的方法。换言之,只跟朋友说话,不与陌生人说话。
在上文中我举的例子,祖父只跟自己的亲儿子(Father)说话,而不跟孙子说话。
5、圈复杂度 在软件测试的概念里,圈复杂度用来衡量一个模块判定结构的复杂程度,数量上表现为线性无关的路径条数,即合理的预防错误所需测试的最少路径条数。圈复杂度大说明程序代码可能质量低且难于测试和维护,根据经验,程序的可能错误和高的圈复杂度有着很大关系。 据说在Oracle数据库中有一些屎山代码,是通过一堆标识量来判断某些特定逻辑的,大概是这样的。 (示例仅供参考,由于资源限制,未能考证,还请大佬指正一二。)
public string HighCCMethod ( ) { int flag = 1 ; int flag1 = 2 ; int flag2 = 3 ; int flag3 = 4 ; int flag4 = 5 ; if (flag == 1 ) { if (flag1 == 2 ) { if (flag2 == 3 ) { if (flag3 == 4 && flag4 == 5 ) { return "编译器 die" ; } } } } return "..." ; }
这是一个圈复杂度非常复杂的方法,我想任何一个读到这样代码的开发者都会对自己的人生充满了积极而乐观的判断,那就是“活着比一切都好”。 对于这样的代码,我们应该尽可能的降低代码的圈复杂度,让程序满足基本可读的需求。
6、注释 public void UploadImg () { int flag = 3 ; if (flag == 3 ) { } }
我曾经参加过一个使用objectc编写的应用的,其中有一段代码是这样的,这个flag大概是魔法值,作者未经考证直接就在代码中使用了。然后一直流传下来,成为一段佳(gui)话(hua)。 还有这样的注释。傻傻分不清楚。
public bool IsTrue { get ; set ; }public bool IsVisible { get ; set ; }
还有这样的。
Thread.Sleep(3000 );
Robert大叔如是说:
什么也比不上放置良好的注释来得有用。什么也比不会乱七八糟的注释更有本事搞乱一个模块。什么也不会比陈旧、提供错误信息的注释更有破坏性。
当然很多中国程序员自称其变量命名是自注释的,例如大概是这样的。万能的 Is 命名法,只要是判断状态皆可用。 (每个程序员能够成功的生存下来都不容易,他一定有异于常人的本事。)
public bool IsShow { get ; set ; }public bool IsGet { get ; set ; }public bool IsUsed { get ; set ; }
7、霰弹式修改 CRUD开发者或许经常会看到这样的代码,例如,如果我要对某一个对象的状态( Status)进行更改,可能会这么做:
public class ShotGun1 { public void Method1 () { DataStatus dataStatus = new DataStatus(); dataStatus.Flag = 1 * 3 ; dataStatus.Status = "1234" ; } }public class ShotGun2 { public void Method2 (int i, int status) { DataStatus dataStatus = new DataStatus(); dataStatus.Flag = 1 * 3 ; dataStatus.Status = "1234" ; } }
这种霰弹式代码中,一处代码规则的变化,可能会需要对许多处代码进行同步修改,使得我们的代码异常的难以维护。
8、异常 有时候可能会遇到这样的代码,在方法中定义一些文本的状态码,然后调用方法时,再去判断这个状态码的内容,当返回错误码时,要求调用者立即处理错误。
public class XXXApi { public CurrentUser CurrentUser { get ; } public string DoSomething ( ) { if (GetCurrentUid() == "用户为空" ) { } else { } return "" ; } public string GetCurrentUid ( ) { if (CurrentUser == null ) { return "用户为空" ; } return "" ; } }public class CurrentUser { public string Uid { get ; set ; } }
不如直接抛出异常,让异常处理机制进行处理吧。
public string GetCurrentUid ( ) { if (CurrentUser == null ) throw new NoLoginExecption("" ); return "" ; }
9、边界 9.1 模块间的边界 即便是简单的CRUD应用系统,优秀的开发者也能更好的处理应用程序模块间的边界。某种意义上讲,应用程序内部的边界看起来或许没有明确的界限之分,但是稍不留心就可能导致应用程序间关系过于紊乱,让其他开发者捉摸不透。 例如,假设有一段代码是这样的,在用户操作类中,加入了一个获取应用数据的方法,确实会让人很费解吧。(而应用领域驱动设计的思维,或许是一种不错的模式。)
public class UserService { public string GetAppData (string config) { } }
9.2应用间的边界 相对而言,或许应用间的边界似乎能相对清晰的分析出来?并非如此。 在当今时代,我们很少开发完全与其他应用系统没有任何关联的独立软件,这意味着我们或许无时无刻都得与其他第三方应用进行接口行为或数据的交换。这让我们必须确保采取措施让外来代码干净利落地整合进自己的代码中。 假设有一段代码是这样的:
public class UserService { public string GetAppData (string config) { } public string UploadOssData (string file) { OssConfig oss = new OssConfig(); OssSdk ossSdk = OssSdk.CreateInstance(); ossSdk.UploadFile(file); } }
在《代码整洁之道》书中,Robert大叔推荐应该第三方接口进行隔离,通过Map那样包装或者使用适配器模式将我们的接口转换成第三方提供的接口。让代码更好地与我们沟通,在边界两边推动内部一致的用法,当第三方代码有改动时修改点也会更少。
总结 写代码是开发者的基础技能,无论你是.NET 开发者,或者 Java 开发者,你都在努力用代码实现自己的梦想。如同韩磊老师在译作《代码整理之道》封面上总结全书,写下的那句诗
“细节之中自有天地,整洁成就卓越代码”。
卓越代码从来不仅仅只是功能完善、代码齐全,做好细节,每个细节就是一方小天地。优雅的代码,不仅仅只是开发者的个人能力的体现,更是开发者的立足之本。努力改善坏习惯,提高代码质量,时刻消除异味,时刻提高自己,更有助于个人技能的全面发展。