代码之美-代码整洁之道

360影视 2025-01-09 14:19 3

摘要:京东零售从9月开始对技术风险系统性地跟踪汇报,以故障数、监控发现率、故障发现时间、故障恢复时间等多个指标进行统计和跟踪汇报,相比于之前线上小故障杖责二十、大故障发配宁古塔,有了向惩前毖后、治病救人方向的转变。我也有机会参与到其中部分问题的复盘,发现问题的原因要

京东零售从9月开始对技术风险系统性地跟踪汇报,以故障数、监控发现率、故障发现时间、故障恢复时间等多个指标进行统计和跟踪汇报,相比于之前线上小故障杖责二十、大故障发配宁古塔,有了向惩前毖后、治病救人方向的转变。我也有机会参与到其中部分问题的复盘,发现问题的原因要不是因为需求变更过程中理解有误,要不是因为需求变更中原程序圈复杂度高而衍生的错误。

2024年,跨进21世纪已经两个轮回,这些计算机技术基础问题似乎并没有因为软件工程和计算机相关专业的发展,而有所改善。需求在各个域中表现千差万别,问题在各处代码中也似乎毫无规律,本文说代码之美是希望大家认识代码的美,化繁为简,创造出美好的产品。

代码之美有很多方面,可以谈高级编程语言以及各语言的框架/类库,也可以聊算法和数据结构,还可以谈操作系统内核或是层出不穷的中间件,不过这里我们看最不起眼的代码整洁之道。

 1983年贝聿铭设计的香港中银大厦,共70层。
 2013年农民胡光州自建的土城堡,共7层。

从上面两个建筑的对比可以看出:系统整洁度与系统规模非强相关,一切从心开始。

说起代码整洁之道,不得不提到人称Bob大叔的Robert C. Martin,以及他写的《Clean Code》一书。

《Clean Code》告诉我们,糟糕的代码也能运行,但如果代码不整洁会使整个团队深陷泥潭,让系统危如累卵,让每一次修改都如履薄冰,每年耗费难以计数的时间、资源和士气。然而这一切并非不可避免。

可能有人觉得谈代码太基础,有点low,不够高大上,应该谈谈设计、模型、算法,亦或是业务域;或者也有人觉得随着AI技术发展,代码可以自动生成了;我也曾一度觉得代码包括整洁代码,对于以此为生的专业人士,应该人人已经掌握。现实的世界却是啪啪地打脸,我们的注册应用系统数量以万记,没听说一个系统的代码是靠AI自动生成,而每个月的故障汇总中都有代码引起的问题,要解决这些基础问题,还是要从基础说起。

《Clean Code》中引用了数位知名程序员的话,Bjarne Stroustrup,C++之父提到的核心词:“优雅”、“高效”、“只做好一件事”。《面向对象分析与设计》的作者Grady Booch比喻“整洁的代码如同优美的散文”,《修改代码的艺术》的作者Michael Feathers指出整洁代码的关键在于代码作者在意代码。

整洁代码我总结为以下4点:

◦可读性:代码是给人阅读的,不只是让机器运行的

◦减少认知负担:层次抽象适度,避免主逻辑被细节扰乱

◦简明扼要:每个函数、对象都为一个主旨服务

◦让写和维护代码变得有趣:整洁代码既是果又是因

整洁代码是为了代码可读性,让代码的读者易于理解和维护代码;设计模式是为了系统具有良好的可扩展性。当然他们之间也有联系,如果你能够恰当的运用设计模式,可以提高系统代码的可读性,降低了系统复杂度。但如果你过度设计,为功能相对简单的应用抽象出一堆接口、工厂、策略、代理,反而增加了应用复杂度。所以整洁代码和设计模式是两个不同维度的概念,有关联但侧重点不同。

整洁代码关注的是代码层面的整洁,整洁架构关注的是设计层面的整洁,设计反映在代码逻辑应该写在哪个服务或者模块更为合理,服务之间的接口如何定义可以减少服务间的依赖。所以整洁代码和整洁架构有相似之处,但关注的粒度完全不同。

理想情况下,一个新项目,从开始你就可以写下完美的整洁代码,但现实中你的编程水平在进步,项目的需求在变更,你需要不断地质疑并重构已有的代码。项目中的整洁代码往往不是一个完美的不变的代码,而是一个不断调整的精进的代码。

项目开发过程中,重构是一个非常正常的事情,如果你可以做到不断质疑和改进代码,你才可以在系统演进的过程中保持代码的整洁,保持系统的可维护性,加快明天的速度。

相对而已,如果你从项目开始,只是快速的开发功能,输出大量的代码和功能特性。但随着时间的推移,代码库成为一种负担,不论是bugfix还是新增特性开发都变得异常困难,最终不得不在某个时候新建一个下一代系统来替代它。这不是臆测,下图是业界的研究分析,我也在几家企业中见证了数个大型系统的轮回,其中几家欢喜几家愁,对于组织系统的不断推倒重建无疑是个巨大的损失。

要实现整洁代码,手段有多种,可以是前面提到的设计模式的运用、代码的重构,也可以通过集体code review来发现代码中的bad smell,团队的代码规范、检查工具等。这里将整洁代码中的关键点列举出来,你可以在写代码或重构时加以关注。

代码真正 “读” 的成分比 “写” 多得多,读与写花费时间的比例超过 10:1。写新代码时,我们一直在读旧代码,所以我们要让读的过程变得轻松,即便这会对写代码提出更高的要求。

代码格式在本文不再赘述,Java代码可以参考《Java代码之美,从遵循样式规范开始》,c++代码可以使用clang-format,go代码可以使用gofmt,团队只需要选择统一的规范配置即可。

软件应用中命名随处可见,我们给变量、函数、类命名,我们给代码文件、目录、模块、应用服务命名,这里范围且限定在代码里面的变量、函数、类命名,对于其它命名规则和道理是相通的。

命名是如此常见,常常被技术人所轻视,认为命名没有什么技术含量。我的态度恰恰相反,命名是如此的重要,可以说是整洁代码的基石。code review中能看到有意义的命名,不论是类、函数还是变量,都会让我对这段代码信任度提高,对代码开发者也有很好的印象。通常能把命名这种基本功做好的人,逻辑的全面性一般不会差,质量就有了保障。

看左侧的这段代码,它可能是任何对象,做了一个不知道什么操作的处理动作。再看右侧的代码段,即使没有任何注释,也能明白用户保存操作。

一段有逻辑没有意义的代码 const us = new MainEntity; us.process; if (login) { // ... }一段自描述有意义的代码 const user = new User; user.save; if (isLoggedIn) { // ... }分类简短定义命名规则示例变量数据存储的容器使用名词或有限定词的短语const userData = {...} bool isValid = ...函数/方法完成某项特定功能使用动词或动宾结构的短语sendData inputIsValid类一类事物的抽象描述,用来创建对象使用名词或名词短语class User {...} class RequestMessage {...}

变量命名

存储的数据描述糟糕的命名可接受的命名好的命名一个用户对象u datauserData personuser customer没有意义,"u"或"data"可以存储任何数据"userData"有点啰嗦,"person"指向不明确"user"是自描述的 "customer"指向明确
检查用户输入是否有效(boolean)v valcorrect validatedInputisCorrect isValid"v"可能表达任何数据,"val"可能是value的缩写这两个不能表示boolean值(true/false)自描述的,且数据类型清晰

函数命名

功能描述糟糕的命名可接受的命名好的命名保存用户数据到数据库process(...) handle(...)save(...) storeData(...)saveUser(...) user.store(...)两个都没有含义,没有回答对什么东西做了什么处理执行动作是存储,但什么东西被存储没有明确从命名看到明确的意图
检查用户的输入process(...) save(...)validateSave(...) check(...)validate(...) isValid(...)"process"动作不明确 "save"有误导性两个都不太明确两个都有明确意义的,可根据情况选择

类命名

事物描述糟糕的命名可接受的命名好的命名一个用户class UEntity class ObjAclass UserObj class AppUserclass User class Admin两个都没有含义两个都有冗余信息"User"中规中矩 "Admin"用户类型更为明确
一个数据库class Data class DataStorageclass Db class Dataclass Database class SQLDatabase两个都指代不明确不够具体,缩写易有歧义数据库指代明确,"SQLDatabase"限定类型更好
忌讳示例建议命名说明中文拼音缩写long hlUserId;long goldProcessUserId;黄流缩写为hl,在没有注释的情况让阅读者抓狂俚语item.gotoDieitem.delete国内用英语俚语的少不明确的英文缩写ymdt = '20241218CST'dateWithTZ='20241218CST'可以接受的缩写,如:ts(timestamp)误导性名字userList = {u1: ..., u2: ...} uniqueNames = {a, b, b, ... }userMap = {u1: ..., u2: ...} nameList = {a, b, b, ... }误导性名字会被其他开发者误用

如果你的代码本身就是自描述的,让阅读者可以清晰地识别代码意图,注释是不必要的。注释是为了弥补代码表达意图的失败,如果有能力尽量把你的代码收拾的整洁点,其次才是使用注释来解释。

糟糕的注释比代码还让人困惑,因为注释存在的时间越久,会越偏离代码的本意,因为程序员在修改代码时很少维护注释。

•重复信息

注释和代码表示的是一个意思,且代码已经足够自描述,这时的注释是多余的

•分隔标记

使用大段的块注释作为分隔标记,全局变量、类这些关键字就足够标记,这些注释也是多余的

•误导信息

注释和代码的意思不一致,明明是插入一条数据,注释偏偏写成更新

•注释代码块

将代码块注释掉,担心有朝一日这块还会再次被使用。其实不论哪种代码仓库(git、svn...)都可以保证代码的修改历史,注释掉的代码块无形中给代码阅读者带来认知负担。

•法律信息

•解释或意图

•警示信息

•TODO注释

•API中的Javadoc

上面是对正则表达式的解释,并给出正则匹配的示例,这对不熟悉正则的人非常友好。下面的例子是一个警示信息,提醒方法适用的工作环境,补充代码难以表达的信息。

在《Clean Code》中,Bob说了十多项原则,我这里只提两点:函数长度和函数参数。

基本原则:函数应该短小。Bob无法证实小函数更好的研究成果,我也不能给出一个科学的解释。回到整洁代码的问题上,短小的函数可读性更强,一个函数只负责一件事,在阅读时不需要背负过多的上下文信息,读代码时心情更轻松愉快,维护修改代码时也不容易犯错。

•单一职责

一个函数应该做一件事,做好这件事,只做这一件事。

•一个抽象层次

"这个函数只做一件事,但这件事逻辑比较复杂,有很多步骤,要把这件事做完整就会写很长。"解决这个问题,就需要对函数内部操作分层抽象,函数内只写这一层的操作。

就如经典笑话"要把大象装冰箱,总共分几步?三步,第一步,把冰箱门打开;第二步,把大象装进去;第三部,把冰箱门盖上。"这里三个步骤就是很好的抽象,它也适用于把长颈鹿装冰箱,把骆驼装冰箱。而测量大象大小、检查冰箱大小是否足够,就不是这个问题下的三个步骤同层抽象。

下面再看一个javascript的函数示例,这个函数只做了一件事,但看上去还是有点杂乱。因为它把内容检查、渲染内容构建和最后的渲染动作一一平铺上这个函数内。

function renderContent(renderInformation) { const element = renderInformation.element; if (element === 'script' || element === 'SCRIPT') { throw new Error('Invalid element.'); } let partialOpeningTag = ''; const closingTag = ''; const content = renderInformation.content; const template = openingTag + content + closingTag; const rootElement = renderInformation.root; rootElement.innerHTML = template;}

对比下面代码,函数里面的几个操作抽成几个独立的子函数,在renderContent函数内操作是一个抽象层次,没有细节层面的干扰。同时也解决了大家长抱怨的函数难以单元测试的问题,函数、子函数输入输出明确,单元测试用例水到渠成。

function renderContent(renderInformation) { const element = renderInformation.element; const rootElement = renderInformation.root; validateElementType(element); const content = createRenderableContent(renderInformation); renderOnRoot(rootElement, content);}

基本原则:函数参数越少越好,最好是零参数,其次是单参数,再次是双参数,尽量避免三参数或更多参数。

看下面的函数重构的示例,让一个保存用户的双参数函数,重构为一个类的无参数方法,使用的时候构造用户对象并保存,丝滑地顺应了人的正常思维逻辑。

◦双参数函数saveUser

function saveUser(email, password) { const user = { id: Math.random.toString, email: email, password: password }; db.insert('users', user);}saveUser('test@test.com', 'test-pass');

◦类的无参数函数save

class User { constructor(email, password) { this.email = email; this.password = password; this.id = Math.random.toString; } save { db.insert('users', this); }}const user = new User('test@test.com', 'test-pass');user.save;

要清除复杂的控制逻辑代码,我们要面对if else控制流、各种嵌套逻辑,以及错误处理。这里介绍两个处理方法:使用Guard尽早返回,使用异常替代返回码。

使用Guard

看一个五层嵌套的代码示例,如何重构避免多层嵌套呢?

void processTransactions(List transactions) { if (transactions && transactions.length > 0) { for (Transaction transaction: transactions) { if ("PAYMENT".equals(transaction.type)) { if ("OPEN".equals(transaction.status)) { if ("CREDIT_CARD".equals(transaction.method)) { processCreditCardPayment(transaction); } else if ("PAYPAL".equals(transaction.method)) { processPayPalPayment(transaction); } else if ("ALIPAY".equals(transaction.method)) { processAliPayPayment(transaction); } } else { console.log("Invalid transaction status!"); } } else if ("REFUND".equals(transaction.type)) { if ("OPEN".equals(transaction.status)) { if ("CREDIT_CARD".equals(transaction.method)) { processCreditCardRefund(transaction); } else if ("PAYPAL".equals(transaction.method)) { processPayPalRefund(transaction); } else if ("ALIPAY".equals(transaction.method)) { processAliPayRefund(transaction); } } else { console.log("Invalid transaction status!"); } } } }}

使用Guard,尽早发现尽早返回,对上述代码逻辑按照一个抽象层次进行重构

void processTransactions(List transactions) { if (isEmpty(transactions)) { showErrorMessage("No transactions provided!"); return; } for (Transaction transaction: transactions) { processTransaction(transaction); }}void processTransaction(Transaction transaction) { if (!isOpen(transaction)) { showErrorMessage("Invalid transaction status!"); return; } if (isPayment(transaction)) { processPayment(transaction); } else if (isRefund(transaction)) { processRefund(transaction); } else { showErrorMessage("Invalid transaction method!"); }}boolean isEmpty(List transactions) { return transactions == null || transactions.length == 0;}boolean isOpen(Transaction transaction) { return "OPEN".equals(transaction.status);}boolean isPayment(Transaction transaction) { return "PAYMENT".equals(transaction.type);}boolean isRefund(Transaction transaction) { return "REFUND".equals(transaction.type);}使用异常

上述案例中对于错误的处理是检查判断再返回,还可以使用抛异常代替错误返回。异常的捕获和处理集中在两处:1、调用processTransactions的位置;2、下沉到processTransaction。在检查中抛出异常,而不是返回错误,try catch捕获异常并统一处理,这样让processTransactions和processTransaction的主干逻辑保持简洁。

main { List transactions = [...]; try { processTransactions(transactions); } catch (Exception e) { showErrorMessage(e.message); }}void processTransactions(List transactions) { validateTransactions(transactions); for (Transaction transaction: transactions) { processTransaction(transaction); }}void validateTransactions(List transactions) { if (isEmpty(transactions)) { throw new TransactionException("No transactions provided!"); }}void processTransaction(Transaction transaction) { try { validateTransaction(transaction); if (isPayment(transaction)) { processPayment(transaction); } else if (isRefund(transaction)) { processRefund(transaction); } } catch(Exception e) { showErrorMessage(e.message); }}void validateTransaction(Transaction transaction) { if (!isOpen(transaction)) { throw new TransactionException("Invalid transaction status!"); } if (!isPayment(transaction) && !isRefund(transaction)) { throw new TransactionException("Invalid transaction method!"); }}

谈到类和数据结构,不得不提到面向对象编程。类和对象的概念是随着面向对象编程的提出而出现的,在面向过程编程时代只有数据结构,没有类的抽象和封装。

理解差异

引入面向对象编程就是为了将一个事物抽象,事物内部的数据和操作逻辑封装为一个黑盒,使用方不需要关心对象内部的细节。

类/对象私有的数据,公开的操作接口类的业务逻辑包在接口内部黑盒 一个事物的抽象数据结构公开的数据,没有操作接口只为保存和传输数据白盒 具体的数据

理解了对象和数据结构的区别,可以在程序中根据应用场景的需求,选择合适的数据类型。如果选择了类,就要按照面向对象设计原则:迪米特法则(Law of Demeter),一个类对于其他类知道的越少越好。那这个原则怎么影响到代码的可读性呢?

符合迪米特法则的代码片段,db作为一个独立完整的对象进行操作,代码具有良好的可读性。

class Database { private String uri; private Provider provider; private Connection connection; void connect { try { connection = provider.establishConnection(uri); } catch (Exception e) { log.error("Failed to connect!"); } } void disconnect { connect.close; } void storeData(Data data) {...}}const Database db = new Database("mysql-db:8100", sqlEngine);db.connect;db.storeData(data);db.disconnect;

如果违反迪米特法则,操作db对象内部的成员变量,Database类修改disconnect操作逻辑需要在至少两处修改,同时代码的可读性因为引入db.connection破坏db的独立性。

class Database { private String uri; private Provider provider; public Connection connection; // 设置为public}const Database db = new Database("mysql-db:8100", sqlEngine);db.connect;db.connection.close; // 违反原则

即使类为内部的成员变量提供的get/set方法,迪米特法则也不提倡通过get方法去获取类的成员变量,这样暴露了类的内部结构,破坏了类的封装性。

类大小

基本原则:类应该短小。这个原则和函数基本原则一致,我们应该将大的类尽可能拆分为多个小的类结构。不同于函数,一个类因为封装了多个数据和操作,可能不只是完成一件事;但与函数相同的是,类应该职责单一,只是一个事物的抽象。

如何检验一个类的设计是单一职责呢?可以计算下类的内聚度:类的成员函数如何使用这个类的成员变量。如果每个成员函数都使用了所有的成员变量,那么这个类的内聚度就是100%;如果每个成员函数都没有使用成员变量,那么这个类的内聚度就是0%,这个类就应该改拆分为数据结构(Data Structure)和外部函数。内聚度高的类就是单一职责,不用拆分;否则就可以考虑将关联度高的成员函数和成员变量拆分为一个独立的类。

三、实践案例

在技术风险复盘案例,选择一个代码案例,不考虑业务逻辑和线上问题,从代码整洁角度来分析代码片段。

public ServiceResponse>> batchQueryBelongedIdsFromGivenCrowdIds(QueryCrowdIdsBatchReqDTO batchReqDTO) { ... List> result = Lists.newArrayList; CallerInfo callerInfo = UmpUtil.methodReg("bmallCrowdPinQueryProvider.batchQueryBelongedIdsFromGivenCrowdIds", batchReqDTO.getForcebot); try { PipelineClient pipeline = bmallCrowdJimdb.pipelineClient; Map>> jimFutureMap = Maps.newHashMap; List jimdbKeyList = Lists.newArrayList; Map> jimdbKeyCrowdIdListMap = Maps.newHashMap; String bmallUseOriginalPin = labelDuccManager.getAvailConfigStr("bmallUseOriginalPin"); for (int index = 0; index > jimFuture = pipeline.zRangeByScore(jimdbKey, minCrowdId, maxCrowdId); jimFutureMap.put((index + "_" + jimdbKey), jimFuture); } // 执行管道中的命令 pipeline.flush; for (String jimdbKey : jimdbKeyList) { ... } return ServiceResponse.ok(result); } catch (Exception e) { ... } finally { ... }}

可以闻到几处bad smell代码:

1、控制逻辑:存在try catch、两层for循环,再加if判断逻辑,即4层逻辑;可以使用函数拆分将循环逻辑独立;

2、函数参数:Ln14-17的String format参数有8个,其中还有复杂的表达式参数,开发者极易在参数表达式调整、参数位置调整上犯错,且难以发现;

3、命名:Ln24的jimdbKeyList保存的不是jimdbKey,而是新构成的一个key;这样的命名有误导性,且不止一处;

4、重复代码:index + "_" + jimdbKey连续出现3次;

5、注释:Ln30的注释"执行管道中的命令"和Ln31的代码含义重复,可以更换为使用pipeline执行命令的意图;

代码不够整洁带来的就是系统维护风险的飙升。代码维护者往往遵循最小改动原则,在本已经复杂的逻辑上再加上几个if else判断来处理异常问题,让圈复杂度再上一个台阶。"勿以恶小而为之,勿以善小而不为",要改变系统的代码质量,需要每个人都行动起来,重构:重命名一个变量,抽取一个函数,也是一份无量功德。

四、小结寄语

本文从代码之美引出代码整洁之道,代码整洁的重要性不言而喻,它不仅影响着代码的可读性,还关系到项目的整体质量和每个开发者的工作环境。

追求代码之美,追求代码整洁,应该是每位程序员的目标。本文整理了代码整洁最核心的关键点供大家参考,也结合了组织的案例进行分析,这些只是个起点。更为重要的是大家在日常开发中的实践,需要不断地反思和改进系统中的代码,已达到更高的代码整洁度。

"种一棵树最好的时间是十年前,其次就是现在。"

2024年11月我的《整洁之道》分享视频:https://joyminutes.jd.com/video/80f52f7d8be76fef

UDemy的"Clean Code"课程:https://www.udemy.com/course/writing-clean-code/?couponCode=ST21MT121624

《代码整洁之道》京东自营:https://item.jd.com/12812626.html

来源:京东云开发者

相关推荐