为什么我们谈论代码?
也许有人会认为,谈论代码已经有点落后了——代码不再是问题,我们应当关注模型、需求、功能设计上。运用 Google 的自动编程框架 AutoML 和UIzard 的 pix2code就可以自动生成代码,看起来我们正在临近代码的终结点。
但是,注意但是!我们永远无法抛弃代码, 就算语言继续抽象,领域特定语言数量继续增加,也终结不了代码。因为代码呈现了需求的细节,这些细节无法被忽略或抽象,必须明确之。将需求明确到机器可以执行的细节程度,就是编程要做的事。
我们可以创造各种与需求接近的语言,我们可以创造帮助把需求解析和汇整为框架结构的各种工具。然而,我们永远无法抛弃需求中的精确性和细节 —— 所以代码永存。
为什么我们总是在写烂代码?
有的人是因为大量的业务工作导致失去思考,有的人则是把提高代码质量寄希望于重构,但是往往在写烂代码的人不知道自己写的就是烂代码,他们没有掌握好代码的技巧,或者根本没有见过好代码,从而不知道什么是好的实践。
针对这种情况,下面会介绍好代码的特性和重构的技巧。从一个小的命名规范开始、逐步讲到函数、再到类、再到模块单元、乃至整个设计。
培养你的代码感
就好像好的读者不一定是好的作者,能分辨整洁代码和肮脏代码,也不意味着会写整洁代码。
写整洁代码,需要遵循大量的小技巧,贯彻刻苦习得的「代码感」。这种「代码感」就是关键所在。有些人生而有之,有些人费点劲才能得到。缺乏「代码感」的程序员,看混乱是混乱,无处着手。有「代码感」的程序员才能从混乱中看出其他的可能与变化,选出最好的方案。
想要得到「代码感」,最根本的途径是反复练习,接下来我将介绍大量的重构技巧和 demo ,强烈建议你在阅读时带上思考,对照自己的代码。
好代码需要遵循什么?重构有哪些技巧?
技巧一:起一个清晰、合理、有意义的命名
有意义的命名是体现表达力的一种方式。
方法名应当是动词或动词短语,表达你的意图。如 deletePage 或 savePage。
类名和对象名应该是名词或名词短语。如 Customer、WikiPage、Account和AddressParser。
不要以数字来命名,除非是
changeJson2Map()
这种情况。单字母名称仅用于短方法中的本地变量。比如循环体内的 i,但是你不应该在类变量里使用 i ,名称长短应与其作用域大小相对应。
别给名称添加不必要的语境。 对于 Address 类的实体来说,AccountAddress 和 CustomerAddress 都是不错的名称,不过用在类名上就不太好了。
遵循专业的术语。如果名词无法用英文表达,一定要用中文拼音,则不能用拼音缩写。
命名我往往会修改好几次才会定下名字来,借助 IDE 重命名的代价极低,所以当你遇到不合理的命名时,不要畏惧麻烦,直接修改吧!
技巧二:保持函数短小、少的入参、同一抽象层级
我们都知道函数要尽量短小,职责要单一。但是你有没有忽视过其他的问题?
来看下这段糟糕的示例代码:
private String url; private void startDownload(boolean isFormat, String userChannel, String userLevel, String regSource) { if (isFormat) { url = String.format(URL, userChannel, userLevel, regSource); if (url.length() != 0) { service.getData(url) .onSuccess(new Action() { Toast.showToast(context, "success").show(); }).onError(new Action() { Toast.showToast(context, "error").show(); }); } } else { // ... } }
这段代码至少犯了 4 个错误:
用了标识参数
isFormat
,这样方法签名立刻变得复杂起来,也表示了函数不止做一件事情,这应该把函数一分为二。使用了多元参数。如果函数需要两个、三个或三个以上的参数,就说明其中一些参数应该封装成类了。
使用了输出参数
url
,输出参数比复杂的输入参数还要难以理解。读函数时,我们惯于认为信息通过参数输入函数,通过返回值从函数中输出。我们不期望信息通过参数输出,输出参数往往包含着陷阱。如果函数要对输入参数进行转换操作,转换结果就该体现为返回值上。例:
appendFooter(s);
这个函数是把s
添加到什么东西后面吗?或者它把什么东西添加到了s
后面?s
是输入参数还是输出参数?如果是要给s
添加个Footer
,最好是这样设计:s.appendFooter();
。没有保持同一抽象层级。函数中混杂不同抽象层级,去拼接代码的同时又发起了网络请求,还处理了请求结果,这往往让人迷惑。
思考下,你会怎么重构这段代码?
我们展示重构后的情况:
private void startDownloadWhenNotFormat() { // ... } private void startDownloadWhenFormat() { UserProperty property = createUserProperty(); String url = jointUrl(property); startDownload(url); } private UserProperty createUserProperty(){ //... } private String jointUrl(UserProperty property) { return String.format(URL , property.getUserChannel() , property.getUserLevel() , property.getRegSource()); } private void startDownload(String url) { if (url.isEmpty()) { return; } service.getData(url) .onSuccess(new Action() { onGetDataSuccess(); }).onError(new Action() { onGetDataError(); }); } public class UserProperty { private String userChannel; private String userLevel; private String regSource; //...}
阅读这样的代码你会觉得很舒服,代码拥有自顶向下的阅读顺序,主程序就像是一系列 TO 起头的段落,每一段都描述当前抽象层级,并引用位于下一抽象层级的后续 TO 起头段落,呈现出总-分的结构。
技巧三:短小、单一权责、内聚的类,暴露操作,隐藏数据细节
类的名称其实就表现了权责,如果无法为某个类命以精确的名称,说明这个类太长了,就应该拆分为几个高内聚的小类。
那么怎么评估类的内聚性?类中的方法和变量互相依赖、互相结合成一个逻辑整体,如果类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。
保持内聚性就会得到许多短小的类,仅仅是将较大的函数切分为小函数,就将导致更多的类出现。想想看一个有许多变量的大函数。你想把该函数中某一小部分拆解成单独的函数。不过,你想要拆出来的代码使用了该函数中声明的 4 个变量。是否必须将这 4 个变量都作为参数传递到新函数中去呢?
完全没必要!只要将 4 个变量提升为类的实体变量,完全无需传递任何变量就能拆解代码了。将函数拆分为小块后,你会发现类也丧失了内聚性,因为堆积了越来越多被少量函数共享的实体变量。
等一下!如果有些函数想要共享某些变量,为什么不让它们拥有自己的类呢?当类的变量越来越多,且变量的无关性越来越大,就拆分它! 所以,将大函数拆为许多小函数,往往也是将类拆分为多个小类的时机。
你以为这就结束了?停止你乱加取值器和赋值器的行为!我们不能暴露变量的数据细节和数据形态,应该以抽象形态表述数据。
著名的得墨忒耳律认为:模块不应了解它所操作对象的内部情形,即每个单元(对象或方法)应当对其他单元只拥有有限的了解,不应该有链式调用。
哈?我们觉得方便的链式调用风格,实际上暴露了其他单元的内部细节??
我认为是要区别情况来对待,链式调用风格比较整洁和有表现力,但是不能随意滥用,举个简单例子:
a.getB().getC().doSomething()
这种链式调用就违反了得墨忒耳定律,如果把a.getB().getC().doSomething()
改成 a.doSomething()
,仍然违反了得墨忒耳定律。因为a
里面会有b.getC().doSomething()
,所以 b 类中还应该有一个doSomething()
方法去调用 c 的 doSomething()
,a.doSomething()
再来调用b.doSomethine()
,a
对b
的具体实现不可知。
链式风格用在 a.method1().method2().method3();
这种情况会比较合理。所以能不能用链式,需要看链的是一个类的内部还是不同类的连接。
技巧四:分离不同的模块
系统应将初始化过程和初始化之后的运行时逻辑分离开,但我们经常看到初始化的代码被混杂到运行时代码逻辑中。下面就是个典型的例子:
public Service getService() { if (service == null) { service = new MyServiceImpl(...); } return service; }
你会自以为很优雅,因为延迟了初始化,在真正用到对象之前,无需操心这种对象的构造,而且也保证永远不会返回 null 值。
然而,就算我们不调用到getService()
方法,MyServiceImpl 的依赖也需要导入,以保证顺利编译。 如果MyServiceImpl 是个重型对象,单元测试也会是个问题。我们必须给这些延迟初始化的对象指派恰当的测试替身(TEST DOUBLE) 或仿制对象(MOCK OBJECT)。
我们应当将这个初始化过程从正常的运行时逻辑中分离出来,方法有很多:
1. 交给 init 模块
将全部构造过程移到 init 模块中,设计其他模块时,无需关心对象是否已经构造,默认所有对象都已正确构造。
2. 抽象工厂方法
系统其他模块与如何构建对象的细节是分离开的,它只拥有抽象工厂方法的接口,具体细节是由 init 这边的接口实现类实现的。但其他模块能完全控制实体何时创建,甚至能给构造器传递参数。
3. 依赖注入中的控制反转
对象不负责实例化对自身的依赖,而是把工作移交给容器,实现控制的反转。比如 Android Dagger2 和 JavaEE Spring 都是这方面的实践。
4. Builder 模式
可以简单地把构造和构造的细节分离。
我们拆分了初始化和正常运行时逻辑,还有什么可以继续拆分的呢?
正常运行时逻辑除了业务逻辑,往往还混合了持久化、事务、打印日志、埋点等模块,如果说 OOP 是把问题划分到单个模块的话,那么 AOP 就是把涉及到众多模块的某一类问题进行统一管理。比如按 OOP 思想,设计一个打印日志 LogUtils 类,但是这个类是横跨并嵌入众多模块里的,在各个模块里分散得很厉害,到处都能见到。而利用 AOP 思想,我们无需再去到处调用 LogUtils 了,声明哪些方法需要打印日志,AOP 会在编译时把打印语句插进方法切面。AOP 思想有很多实践:
1. 代理
代理适用于简单的情况,例如在单独的对象或类中包装方法调用。然而,JDK提供的动态代理仅能与接口协同工作。对于代理类,你得使用字节码操作库,比如CGLIB、ASM或Javassist 。
2. AOP 框架
把持久化工作用 AOP 交给容器,使用描述性配置文件或 API 或注解来声明你的意图,驱动依赖注入(DI)容器,DI容器再实体化主要对象,并按需将对象连接起来。
Android中的 AOP 思想、框架选型和具体应用场景可详见: 一文读懂 AOP | 你想要的最全面 AOP 方法探讨
概言之, 最佳的系统架构由模块化的关注面领域组成,每个关注面均用纯 Java 对象实现。不同的领域之间用最不具有侵害性的「方面」或「类方面」工具整合起来。
技巧五:用异常代替错误码,但不传递异常,不传递 null
if (deletePage(page) == SUCCESS)
,咋看之下好像没什么问题,但是返回错误码,就是在要求调用者立刻处理错误。你马上就会看到这样的场景:
if (deletePage(page) == SUCCESS) { mView.onSuccess(); } else { mView.onError(); }
熟悉不?更恶心的是这种情况:
int code = deletePage(page);if (code == CODE_404) { mView.onError1(); } else if(code == CODE_403){ mView.onError2(); } else if(code == CODE_505){ mView.onError3(); }
当你开始编写错误码时,请注意!这意味着你可能在代码中到处存在 if(code == CODE)
,其他许多类都得导入和使用这个错误类。当错误类修改时,所有这些其他的类都需要重新编译。 而且,错误码和状态码一样,会引入大量的 if-else 和 switch,随着状态扩展,if 就像面条一样拉长。回忆一下,你是不是用了不同的 code 来区分不同的错误?不同的用户状态?不同的表现场景?
所以忠告有 2 点:
使用异常替代返回错误码,将错误处理代码从主路径中分离
不仅仅分离错误处理代码,还要把 try-catch 代码块的主体部分抽离出来,另外形成函数,函数应该只做一件事。错误处理就是一件事。因此,处理错误的函数不该做其他事。
重构后:
public void delete(Page page) { try { deletePageAndAllReferences(page); } catch (Exception e) { logError(e); } }private void deletePageAndAllReferences(Page page) throws Exception { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); }private void logError(Exception e) { logger.log(e.getMessage()); }
在上例中,异常使我们把正常代码和错误代码隔离开来,但是我不建议你滥用异常,思考一下,如果你在低层的某个方法中抛出异常,而把 catch 放在高级层级,你就得在 catch 语句和抛出异常处之间的每个方法签名中声明该异常。每个调用这个函数的函数都要修改,捕获新异常,或在其签名中添加合适的throw子句。以此类推,最终得到的就是一个从最底端贯穿到最高端的修改链。封装完全被打破了,在抛出路径中的每个函数都要去了解下一层级的异常细节。
所以不要传递异常,在合适的地方,及时解决它!
还有另一种情况你经常看到:
UserData userData = service.getUserData(url); if (userData != null) { if (userData.getUserName != null && userData.getUserName.length > 0) { userNameTextView.setText(userData.getUserName); } else { userNameTextView.setText("--"); } if (userData.getRegChannel != null && userData.getRegChannel.length > 0) { regChannelTextView.setText(userData.getRegChannel); } else { regChannelTextView.setText("WHAN"); } }
真是可怕!到处都是判空和特殊操作!如果你打算在方法中返回 null 值,不如抛出异常,或是返回空对象或特例对象。你可以学习Collections.emptyList( )
的实现,创建一个类,把异常行为封装到特例对象中。
对付返回 null 的第三方 API 也是如此,我们可以用新方法包装这个 API,从而干掉判空。
技巧六:保持边界整洁,掌控第三方代码
我们经常会使用第三方开源库,怎么将外来代码干净利落地整合进自己的代码中。是每个工程师需要掌握的技巧,我们希望每次替换库变得简单容易,所以首先要缩小库的引用范围!怎么缩小?
封装:不直接调用第三方api,而是包装多一层,从而控制第三方代码的边界,业务代码只知道包装层,不关心工具类的具体实现细节。在你测试自己的代码时,打包也有助于模拟第三方调用。 打包的好处还在于你不必绑死在某个特定厂商的API 设计上。你可以定义自己感觉舒服的API。
使用 ADAPTER 模式
代码整洁之道还提出个有意思的做法,为第三方代码编写学习性测试。
我们可以编写测试来遍览和理解第三方代码。在编写学习性测试中,我们通过核对试验来检测自己对 API 的理解程度。测试帮助我们聚焦于我们想从 API 得到的东西。
当第三方开源库发布了新版本,我们可以运行学习性测试,马上看到:程序包的行为有没有改变?是否与我们的需要兼容?是否影响了旧功能?
技巧七:保持良好的垂直格式和水平格式
垂直格式上
最顶部展示高层次的概念和算法,细节往下渐次展开,越是细节和底层,就应该放在源文件的越底部。
紧密相关或相似的代码应该互相靠近,调用者应该尽可能放在被调用者的上面,实体变量要靠近调用处,相关性弱的代码用空行隔开。
水平格式上
代码不宜太宽,避免左右拖动滚动条的差劲体验。
用空格字符把相关性较弱的事物分隔开。
遵守缩进规则。
技巧八:为代码添加必要的注释,维护注释
请注意,我说的是必要的注释,只有当代码无法自解释时,才需要注释。
好的代码可以实现自文档,自注释,只有差的代码才需要到处都注释。
如果你开始写注释了,就要思考下:是否代码有模糊不清的地方?命名是否有表达力?是否准确合理?函数是否职责过重,做了太多事情,所以你必须为这个函数写长长的注释?如果是这样,你应该重构代码,而不是写自认为对维护有帮助的注释。很多情况下只需要改下命名、拆分函数,就可以免去注释。
不要以为写完注释就完了,注释和代码一样,需要维护。
如何规避重构的风险?
在写代码之前,强烈建议你先完成单元测试,然后一边实现功能一边调整单测覆盖场景。
实现功能时,代码一开始都冗长而复杂,完成功能后,通过单元测试和验收测试,我们可以放心地重构代码,每改动一小块,就及时运行测试,看功能是否被破坏,不断分解函数、选用更好的名称、消除重复、切分关注面,模块化系统性关注面,缩小函数和类的尺寸,同时保持测试通过。
如何保持代码的优雅?
只要遵循以下规则,代码就能变得优雅:
编写更多的测试,用测试驱动设计和架构。
测试编写得越多,就越能持续走向编写较易测试的代码,持续走向简单的设计,系统就会越贴近 OOP 低耦合高内聚的目标。没有了测试,你就会失去保证生产代码可扩展的一切要素。正是单元测试让你的代码可扩展、可维护、可复用。原因很简单:有了测试,你就不担心对代码的修改!没有测试,每次修改都可能带来缺陷。无论架构多有扩展性,无论设计划分得有多好,没有了测试,你就很难做改动,因为你担忧改动会引入不可预知的缺陷。保持重构,当加入新功能的时候,要思考是否合理,是否需要重构这个打开修改的模块。
不要重复,重复代码代表遗漏了抽象,重复代码可能成为函数或干脆抽成另一个类。
保持意图清晰,选用好的命名,短的函数和类,良好的单元测试提高代码的表达力。
尽可能减少类和方法的数量,避免一味死板地遵循以上 4 条原则,从而导致类和方法的膨胀。
开始重构,逐步改进
衡量成长比较简便的方法,就是看三个月前,一年前,自己写的代码是不是傻逼,越觉得傻逼就成长越快;或者反过来,看三个月前,一年前的自己,是不是能胜任当下的工作,如果完全没问题那就是没有成长。
既然聊到代码规范和重构技巧,Talk is cheap. Show me the code. 就以自己两年前的代码为例,但当我拿起两年前的项目时……
简单粗暴放上 gif,重构过程更直观。
更换命名、干掉switch、拆分成子函数
选用更准确的命名
选用有表达力的命名
按最少知道原则修改方法
最后
技巧是可以学习掌握的,重点是有意识培养自己的代码感,培养解耦的思想。不要生搬硬套技巧,不要过度设计,选择当下最适合最简单的方案。
同时我们需要不断回顾自己写过的代码,如果觉得无需改动,要么是设计足够优秀,要么就是没有输入,没有成长。
如果你对自己有更高的要求,希望以下的资料可以帮助你。
帮助你管理代码质量的工具
SonarLint
阿里编码规范插件
作者:FeelsChaotic
链接:https://www.jianshu.com/p/0e31122c38f7
共同学习,写下你的评论
评论加载中...
作者其他优质文章