关于代码清晰度的话题,我之前已经写过很多了。
那么今天,再从另外一个维度来讨论代码清晰度的问题。也即是:代码的排列顺序(相关性)对可读性的影响。
不说算法逻辑,也不说什么架构设计,也跟任何开发语言没有关系,就是最简单的,代码行与行之间的排列顺序。同样的逻辑,代码行的排列方式不一样,读起来的感受是完全不一样的。
正式开始之前呢,我们先说一个现象:方法里的每行代码单独看都没问题的,逻辑也是对的,但整体读起来就是有点费劲。需要反复上下翻看,需要自己在脑子里把分散的变量/逻辑/方法等重新关联起来。其中的一个原因就出在代码的排列顺序上。
一个读起来费劲的方法
就用我生产环境里进销存模块相关的代码来举例。
有一个业务方法,根据原料ID列表查出使用了这些原料的商品。它的数据流有三条链路:
- 原料ID → 配方明细 → 配方 → 商品;
- 原料ID → 原料信息;
- 最后汇聚组装结果。
看这段代码:
Java
var recipeItems = recipeItemRepository.findByMaterialIds(materialIds);
var bomIds = recipeItems.stream().map(RecipeItem::getRecipeId).toList();
var materials = materialRepository.findByIds(materialIds);
var recipes = recipeRepository.findByIds(bomIds);
var materialMap = materials.stream().collect(Collectors.toMap(Material::getId, Function.identity()));
var productIds = recipes.stream().map(Recipe::getProductId).toList();
上面的代码省略了后续的Map构建部分,但相互交错的问题已经很明显了:前两行走的是配方项的数据链路,而第三行则跳到原料查询,第四行又回到配方,第五行构建原料的Map,第六行又再次跳回配方链路取商品ID。两条数据链的查询和Map构建穿插进行,阅读代码的人的注意力在两条链之间反复切换。
这段代码没有任何逻辑错误。功能完全正确,变量命名也没问题。但读起来有些费劲,因为「相邻的代码行不在同一条数据链上」,大脑需要额外的精力去关联分散的变量。
那可以怎么解决呢? 来,继续往下看。
按数据流重新排列
同样的代码,只是重新排列一下顺序:
Java
var recipeItems = recipeItemRepository.findByMaterialIds(materialIds);
var bomIds = recipeItems.stream().map(RecipeItem::getRecipeId).toList();
var recipes = recipeRepository.findByIds(bomIds);
var productIds = recipes.stream().map(Recipe::getProductId).toList();
var materials = materialRepository.findByIds(materialIds);
var materialMap = materials.stream().collect(Collectors.toMap(Material::getId, Function.identity()));
var recipeItemMap = recipeItems.stream().collect(Collectors.groupingBy(RecipeItem::getRecipeId));
代码行数没变,逻辑没变,做的事情一模一样。但读起来的流畅度完全不一样。
前四行的数据链从原料一路走到商品,中间没有被打断。「空行」(哈哈,空行的作用之一)把另一条独立的数据链路隔出来,视觉上一眼就能看出这是另一个分组。最后两行集中构建Map。每个变量声明的位置紧挨着它被使用的上下文,不需要往回翻看。
按数据流排列后,相邻的代码行之间有明确的因果关系:上一行查出的结果,是下一行的输入。读代码时大脑不需要在不同数据链之间跳转,顺着一条链路走完,再看下一条。空行在这里起到视觉分隔的作用,让不同的数据块在结构上更清晰,读者不用逐行判断这段代码属于哪个阶段。
读到这里,你可能会质疑我说,抽取子方法,效果会更好。
是,大多数情况下这个想法没问题,但不是绝对的。为啥? 因为传参的问题。
请继续往下看。
提取方法不是万能的
碰到这类可读性问题,大部分程序员的第一反应是提取方法:把配方查询链路提取成一个独立方法,把Map构建也提取成一个方法。
大部分时候这个思路有效。但在这个场景里,materialMap在配方链的日志记录和最终的结果组装里都有使用。提取配方链路的时候,要么把materialMap作为参数传进去,要么作为返回值带出来,提取出来的方法参数和返回值都会变得臃肿。
数据被多处引用的时候,提取方法的收益会被参数传递的开销抵消。强行拆分出来的方法,读起来可能比原来还费劲,因为需要同时理解调用方和被调用方的上下文。
这种情况下,调整代码排列是更直接的方式。不需要引入新方法,只需要把现有代码重新排列,就能达到类似提取方法的可读性改善。
数据流有交叉时怎么办
实际项目里,数据流之间经常有交叉,没办法做到完美分组。有个原则能改善大部分情况的可读性:变量声明尽量靠近首次使用的地方。
Java
// materialMap在日志记录和结果组装两处使用
var materialMap = materials.stream().collect(Collectors.toMap(Material::getId, Function.identity()));
auditLogger.log(materialMap);
// 主数据链保持连贯,materialMap就近声明在它首次使用的位置附近
var recipeItems = recipeItemRepository.findByMaterialIds(materialIds);
声明和使用之间的距离越远,读代码时就要花越多脑力去追踪这个变量的来源。围绕主要的数据链路来排列代码,优先保证最核心的链条连贯,次要的变量只要不造成太大的阅读干扰就行。
小结
代码的排列顺序不属于编码规范的范畴,任何代码审查工具也不会检查变量声明的位置。但它实实在在地影响读代码的人的体验。写代码的时候替读代码的人想一想,让对方的眼睛和脑子不用费那么多劲,这往往是好代码和能跑的代码之间的差距。
希望这篇内容可以帮助到你,另外呢,现在AI写代码很强,但是它的产出质量还是取决于你提供的约束。
你可以把代码可读性相关的知道点,写入到代码工程规则里去,让AI执行这些规则,这样AI写出来的代码,可读性会更加的好。