如何提高代码可读性
一、前言
我希望我的代码在几个月后修改时,能快速的跟上当时的心路历程,减少理解代码的时间和复杂度,以及修改出现BUG的几率。我也希望我的代码可以被产品读懂,有需求变更的时候他来改(我就想看看他还要不要改需求)。带着这个愿景,最近梳理了代码可读性的一些想法。
二、复杂度
在聊可读性前先聊聊复杂度,对复杂度有所定义后,才能更好的探讨可读性的优化。
《博弈圣经》中提出信息熵是[信息论]中用于度量信息量的一个概念。一个系统越是有序,信息熵就越低;反之,一个系统越是混乱,信息熵就越高。所以,信息熵也可以说是系统有序化程度的一个度量。简单来说,"AAAAA"(熵为零)<"ABCDE"(有序信息熵)<"DMXTL"(无序信息熵),其中无序信息熵的熵值最大,复杂度最大。
而对于代码来说,需要实现逻辑功能就需要有信息熵。所以让代码有序在很大程度上可以降低代码的复杂度。
三、代码中的复杂度
代码中的复杂度往往来源于我们对代码不能顺畅的阅读和理解,而通过规范、公约、以及符合大脑阅读和理解的方式去写代码,把代码中的无序尽量控制在我们方便理解和接受的范围,从而帮助我们减少对代码的认知成本,降低认知的复杂度。
- (1)规范:业内已经有比较成熟的开发规范,如:阿里代码规范、谷歌代码规范等,所以这里就不展开赘述了。阿里的代码规范不仅有《Java开发手册》,同时idea也有代码检查插件,有兴趣的小伙伴可以去了解一下。
- (2)公约:对代码进行公约,主要是减少没必要的沟通,提高找代码、代码理解的效率提高开发效率。同时减少维护成本和不可替代性。代码中通过项目目录结构,文件命名定义等方式,从整体对项目代码结构性的进行公约。
项目的结构性可以参看maven项目结构,maven项目结构在业内已经形成一种公约这里不再展开。
文件命名时,对每个文件需要有一个定义词,方便对类职能的理解。如:和数据库交互的类以DAO结尾,从数据库返回的结果封装类以PO结尾,处理业务逻辑的类以Service结尾,转发类以Controller结尾以及工具类以Util结尾等。值得注意的是,在封装数据传递过程中,各种封装类的区分。主要包括:
PO Persistant Object 持久化对象,与数据库结构映射的实体,数据库中的一个表即为一个PO类。实际开发中是否在实体类的后面加PO来命名都可以,但是需要统一。
DTO Data Transfer Object 数据传输对象,用于内部服务间数据传输。主要使用在服务拆分、微服务等不是同一个项目内,内部服务调用时的结果集封装。
VO View Object 页面展示数据对象,用于封装前端需要展示的数据内容。
PO、DTO、VO,都是POJO Plain Ordinary Java Object 简单的Java对象,只拥有属性及属性的get set方法。VO根据实际情况可拥有一些valueOf、toDto等类型转换方法。方便相似页面接口复用VO。
BO Business Object 业务对象,主要作用是把业务逻辑封装成一个对象,这个对象可以包括一个或多个其它对象。如我们的商品评价对象,里面有用户、商品、评价内容等PO。可以适当的包含PO之间相互处理、关联的业务逻辑方法方便共用。 - 流程示例:
需求:商城用户查看用户资料
(1)微服务项目(跨项目调用情况):A、用户中心服务项目;B、商城应用项目。商城应用调用到用户中心服务的流程:
开发流程:用户中心服务项目dao --po--> service --dto--> controller --dto--> 商城应用项目service --vo--> controller --> 前端
用户中心服务项目dao层返回用户po,service层对po进行业务处理后(如将po里的password version deleted过滤)返回dto,controller层使用dto进行传输。
商城应用项目service层调用服务获取到dto,进行加工后(如将dto里前端不需要的数据过滤掉;性别字段由0,1转换成男,女等)返回vo,给到前端显示。
(2)单体应用
开发流程:用户服务模块dao --po--> service--vo--> controller --> 前端
从项目文件(目录路径)、文件命名方面进行公约,可以约定类的存放地方以及类的职责,方便阅读代码时快速对类的定位以及职能的理解。
四、良好的方法命名降低复杂度
方法作为一个实现逻辑的入口,实现往往决定了代码的复杂性。方法在被调用时,调用者希望能更快速简单的找到对应服务类里面想要的方法,所以方法的命名尤为重要。方法的命名得好,可以大幅度缩短找到方法的时间,并且可以让别人快速理解方法的实现的功能是什么。
示例:我们需要提供一个根据:“商品类型ID统计有效的冰冻商品数量”的接口,命名为:
public Integer freezeGoodsCountInValidByGoodsTypeId(Integer goodsTypeId)
为了表达清楚方法实现的功能命名长了一点,方法名也能比较贴切的表达了方法功能,但是实际上要找到并理解这个方法名并不是很快速的事情,特别是商品类中提供的对外方法很多的时候。问题在于我们不是英语母语的国家,很多开发人员特别是经验不够丰富的开发人员本身对英语就不太敏感,对于这么一大段英文描述需要慢慢看了之后慢慢理解意思。如果调用者刚好记得冰冻的英文这么写,通过工具(IDEA)也还算快速能定位到商品类中freeze开头的方法。如下图,我想要快速找到map中cmpute相关的方法:
image.png
但是实际上命名时五花八门,这个方法命名为:goodsCountInFreezeAndValidByGoodsTypeId也没毛病,同时还有可能使用另外的单词描述冰冻商品的情况。所以即便有工具帮助我们也不一定能很好的快速定位到我们想要找到的方法。回到需求本身,我们对方法要做的事情进行拆分后得到:
冰冻商品(业务描述)+统计(限定词)+有效的(业务情况)+根据商品类型ID(条件)
- 我们可以看到拆分之后可以看到方法名除了有业务描述之外,还包含了限定词。而对于方法来说业务描述可以五花八门,但是限定词却可以统一公约起来。同时把限定词放在最前面(有的书推荐放在后面,本人更推荐放在前面,方便结合开发工具查看),便可以通过工具比较快速的找到方法。修改方法名为:countFreezeGoodsInValidByGoodsTypeId后,我们通过商品类结合工具.count就可以快速的看到这个方法。所以当一个方法中包含了限定词时,我们通过公约限定词,并且把限定词写在方法前面,就可以方便在调用阅读方法时,对方法名的理解。
比如这些都可以作为公约的限定词,且放在方法的前面:getOne、list、page、count、total、sum、Average、Max、Min、add、remove、source、target、next、previous等等 - 刚刚还聊到一个情况,就是对于非英语母语的我们来说,部分人可能会对一大串英文理解起来比较慢,这里可以通过一些公约的切割方式来切割命名,通过数字断词来提高英文连词的阅读和理解方法名的效率(注:这里不是主流命名方式,仅供参考)。
切割结果为:count4FreezeGoodsInValid8GoodsTypeId。其中公约的切割词为:2、4、8。2是to的谐音表示英文中的to,4是for的谐音表示英文中的for,8是by的谐音表示英文中的by。
最后我们可以直观的感受一下方法名修改前后的可读性和可理解性:
public Integer freezeGoodsCountInValidByGoodsTypeId(Integer goodsTypeId)
public Integer count4FreezeGoodsInValid8GoodsTypeId(Integer goodsTypeId)
五、提高方法的可读性降低项目复杂度
(1)通过方法拆分提高代码可读性
方法作为最小的逻辑实现单位,方法的实现很大程度上决定了代码的复杂度。回到我们的实际场景中,当我们在开始阅读一个方法的时候,我们最关注的是什么?在开始阅读的时候,我们首先关注的是这个方法做了什么(what),每个步骤分别是什么,然后在定位到了我们想要找的步骤以后,才想要关注它是怎么实现的(how),实现逻辑是什么,在哪里修改,哪里出了问题等。
这是一种符合我们大脑认知的过程,而实际的开发中,我们可能看到这样的代码:
private static void copyProperties(Object source, Object target, @Nullable Class<?> editable,
@Nullable String... ignoreProperties) throws BeansException {
Assert.notNull(source, "Source must not be null");
Assert.notNull(target, "Target must not be null");
Class<?> actualEditable = target.getClass();
if (editable != null) {
if (!editable.isInstance(target)) {
throw new IllegalArgumentException("Target class [" + target.getClass().getName() +
"] not assignable to Editable class [" + editable.getName() + "]");
}
actualEditable = editable;
}
PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);
List<String> ignoreList = (ignoreProperties != null ? Arrays.asList(ignoreProperties) : null);
for (PropertyDescriptor targetPd : targetPds) {
Method writeMethod = targetPd.getWriteMethod();
if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {
PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());
if (sourcePd != null) {
Method readMethod = sourcePd.getReadMethod();
if (readMethod != null &&
ClassUtils.isAssignable(writeMethod.getParameterTypes()[0], readMethod.getReturnType())) {
try {
if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {
readMethod.setAccessible(true);
}
Object value = readMethod.invoke(source);
if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {
writeMethod.setAccessible(true);
}
writeMethod.invoke(target, value);
}
catch (Throwable ex) {
throw new FatalBeanException(
"Could not copy property '" + targetPd.getName() + "' from source to target", ex);
}
}
}
}
}
}
例子中方法代码实现中的函数过长,全是细节的平铺,非常不利于看的人理解方法做了什么,需要耗费更多的精力去慢慢阅读和理解。同时堆在一起代码在功能调整或修复BUG时也更容易产生新的BUG。我们对代码重新梳理一下,发现方法主要实现了几个功能步骤:
private static void copyProperties(Object source, Object target, @Nullable Class<?> editable,
@Nullable String... ignoreProperties) throws BeansException {
//1、检查确定可复制的参数(复制的前置检查)
Class<?> actualEditable = checkAndResolve4Editable(source, target, editable);
//2、构建参数的属性信息
PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);
//3、根据属性信息复制源对象内容到目标对象内容
copyProperty4SourceToTarget(source, target, targetPds, ignoreProperties);
}
private static Class<?> checkAndResolve4Editable(Object source, Object target, @Nullable Class<?> editable) {
Assert.notNull(source, "Source must not be null");
Assert.notNull(target, "Target must not be null");
Class<?> actualEditable = target.getClass();
if (editable != null) {
if (!editable.isInstance(target)) {
throw new IllegalArgumentException("Target class [" + target.getClass().getName() +
"] not assignable to Editable class [" + editable.getName() + "]");
}
actualEditable = editable;
}
return actualEditable;
}
private static void copyProperty4SourceToTarget(Object source, Object target, PropertyDescriptor[] targetPds,@Nullable String... ignoreProperties) {
List<String> ignoreList = (ignoreProperties != null ? Arrays.asList(ignoreProperties) : null);
for (PropertyDescriptor targetPd : targetPds) {
Method writeMethod = targetPd.getWriteMethod();
if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {
PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());
if (sourcePd != null) {
Method readMethod = sourcePd.getReadMethod();
if (readMethod != null &&
ClassUtils.isAssignable(writeMethod.getParameterTypes()[0], readMethod.getReturnType())) {
try {
if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {
readMethod.setAccessible(true);
}
Object value = readMethod.invoke(source);
if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {
writeMethod.setAccessible(true);
}
writeMethod.invoke(target, value);
}
catch (Throwable ex) {
throw new FatalBeanException(
"Could not copy property '" + targetPd.getName() + "' from source to target", ex);
}
}
}
}
}
}
初步拆分之后,我们发现copyProperty4SourceToTarget方法还是比较不容易理解,可以对其进行进一步拆分梳理方法逻辑。
private static void copyProperty4SourceToTarget(Object source, Object target, PropertyDescriptor[] targetPds,@Nullable String... ignoreProperties) {
//1、从target中获取写方法
//2、判断target中当前属性是否可以进行写操作
//3、从source中读取属性内容
//4、把source中读到的属性内容写入target的属性里
//偷懒,我就不写抽取方法的具体实现了(^_^)/~~
}
- 方法重新梳理之后可以感受的到整体的实现步骤和逻辑更凸显更好理解了。之所以更容易理解的原因是因为我们在看这个方法时,不会被一些平铺的实现细节(怎么实现)所干扰,而是能连贯的看到这个方法需要做什么,分了什么步骤去做。这就像我们去看一本书时,我们往往是先看书的目录先了解这本书整体上想要阐述什么,然后找到感兴趣的章节阅读这个章节点怎么表述的。试想一下,如果一本书没有目录章节,上来直接给你正文,看的人会是什么感受。
《金字塔原理》提出了一项层次性、结构化的思考、写作和沟通的技术。对于程序员来说,写代码类似于写作(让别人能读懂代码所要表达的意思),同时也是程序员之间沟通的一种方式。所以使用《金字塔原理》中的“自上而下,结论先行”的方式表达代码,对主方法的逻辑结构先进行分层分类后,分别在聚类中实现分类的功能,结构性更强,更符合大脑理解事物的方式。 - 方法的拆分也更有利于复用,提高代码健壮性和维护性。拆分后使得代码修改、扩展时的风险以及出现BUG的几率降低。这也是很多大厂的代码规范中对方法的行数有限定的原因。抽离时尽量保持抽离方法的单一职责,既一个抽离的方法只做一个具体的事情或者只表达一个事情的步骤。
(2)什么时候拆分方法
什么时候拆分方法需要根据实际情况而定:
- 如果一个方法的业务逻辑分层一开始就很清晰或者方法比较简单的情况下,可以一开始写的时候就进行逻辑上的拆分,然后再分别实现各个业务逻辑方法,这样更高效一些。
- 而一个方法的业务如果比较复杂则恰恰相反,可以先把大部分的业务代码放在主方法中,写的时候先抽离那些很明显的独立逻辑。等主方法基本实现完功能后,再次花点时间梳理调整一遍代码逻辑之后再拆分。之所以先写到主方法之后再拆分是因为对于一个复杂的业务来说,刚开始写的时候往往不能考虑的很全面,所以在如果先拆分后写代码容易出现之后的调整会比较大,拆分维度不够好等情况。而先写到主方法中,不仅有利于之后的代码整体结构和逻辑的梳理调整,也有利于拆分时各个拆分子方法分层更合理。当然思路足够清晰时也可以边写边抽离。
- 写作或表达的时候可以使用“金字塔原理”先自下而上的思考,再自上而下的表达出来,使得思路以及表达更清晰。同样写代码的时候也可以这样,在自下而上的思考同时写功能实现,写完之后梳理逻辑分层,最后通过拆分抽离的方式,使得主方法能以自上而下的方式表现出来。