一个较重的代码坏味:“炫技式”的单行代码

风格和习惯很重要。


很多代码坏味都是由于不良的风格和习惯导致的,并不涉及高深复杂的技术。

有一些众所周知的代码坏味。当然,也有一些个人觉得很不好的习惯和做法。我个人就不喜欢把多行代码都“挤”到一行的写法。这种代码更像是一种“炫技式”的代码,虽然体现了一点技艺水平,但放在生产环境里并不合适:这样的代码难以理解、排查和修改起来都麻烦且耗时。我认为这种风格的代码很丑陋,这种代码所体现出来的风格和习惯很糟糕。

这样的代码俯拾即是。请看:

代码坏味举例

链式写法的误用

链式写法是把关联的多个方法调用的代码全部“挤”到一行。

究其原因,是这么写很爽。不过爽是有代价的: NPE 潜伏其中,而且报 NPE 时,定位是哪一步调用产生的 NPE 是耗时且费脑的。

如下代码所示:如果 response 为 null,或者 response.getData() 为 null,或者 response.getData().getShoppingCartDTOList() 为 null, 或者 CartResponseBuilderV2::buildCartList 的结果为 null,都会导致 NPE。链式写法仅适合于 Builder 模式,可以平行调用给一个对象的多个字段赋值,但不适合这种递进式的调用。

我之所以不喜欢链式写法,是因为排查过一个链式写法导致的 NPE。需要把链式写法拆成多行,然后单步调试是哪一行报了 NPE。很麻烦,很耗时。我不喜欢自找麻烦,痛恨时间在这种看似很爽却遗患无穷的做法中徒然地消逝:芝麻点的问题排查起来也至少是两个小时。

有两种解决技巧:1. 拆解成多个单行调用并做判空(如果变量有复用更好); 2. 用 Optional 避免 NPE。

反例:


cartList = response.getData().getShoppingCartDTOList().stream().map(CartResponseBuilderV2::buildCartList).collect(Collectors.toList());

正例:

T data = response.getData();
if (data != null && CollectionUtils.isNotEmpty(data.getShoppingCartDTOList())) {
  cartList = StreamUtil.map(data.getShoppingCartDTOList(), CartResponseBuilderV2::buildCartList);
}


多个变量赋值与运算“挤”在一行

这种代码的每一个子句都会潜藏 BUG。此外,这种代码无论如何都是缺乏美感的。

实际上应该分拆成多行,每行一个赋值语句。

反例:


long discounts =
                    Optional.ofNullable(orderItemInfo).map(OrderItemInfo::getPrice).orElse(0L)
                    - Optional.ofNullable(orderItemInfo).map(OrderItemInfo::getPcOrderItemPrice)
                        .map(PcOrderItemPrice::getUnitPrice).orElse(0L);

正例:


Long originPrice = Optional.ofNullable(orderItemInfo).map(OrderItemInfo::getPrice).orElse(0L);
Long unitPrice = Optional.ofNullable(orderItemInfo).map(OrderItemInfo::getPcOrderItemPrice)
                        .map(PcOrderItemPrice::getUnitPrice).orElse(0L);
long discounts = originPrice - unitPrice


参数的构造与方法的调用糅合在一行

参数构造稍微复杂一点,要弄清楚括号的匹配都是件杀死很多脑细胞的事情。有时甚至会不小心把参数写到不正确的地方,而这时候恰巧不报错,就只能在运行时报错和排查了。我真遇到过。

如下代码所示 如果 findImageIds 方法返回 null 时, addAll 方法会报 NPE。

反例:

configTrustImages.addAll(imageClient.findImageIds(ImageQueryParam.builder().imageIds(imageIds).build()));

正例:

ImageQuery query = ImageQueryParam.builder().imageIds(imageIds).build();
Set<String> imageIds = imageClient.findImageIds(query);  // 如果这个方法返回空列表而不是 null ,那就没问题。
if (CollectionUtils.isEmpty(imageIds)) {
    configTrustImages.add(imageIds);
}

另一个示例:Long.parseLong(orderInfoList.get(0).getShopId()) 就有潜在的数组越界问题。

反例:



BatchQuerySnapshotDTO requestParam =
      buildRequestParam(Long.parseLong(orderInfoList.get(0).getShopId()),
           sameUserVersionOrderMap.keySet(), exportContext);

正例:

if (CollectionUtils.isNotEmpty(orderInfoList)) {
    Long shopId = Long.parseLong(orderInfoList.get(0).getShopId()); 
    Set<String> sameUserVersionOrderKeys = sameUserVersionOrderMap.keySet();
    BatchQuerySnapshotDTO requestParam = buildRequestParam(shopId, sameUserVersionOrderKeys, exportContext);
}


滥用三目运算符

坏味是什么?

  1. 看这段逻辑,我就看了好一会,耗时耗神;
  2. 如果要添加关于 refundStatus 的新的逻辑,写在哪里 ? 继续在里面加 ?
  3. 里面复杂的 refundStatus 逻辑一定是缺乏覆盖性单测的,代码不可靠。
  4. 滥用了三目运算符。这些快捷运算符本来是为了写成简洁的单行代码,可是一旦滥用,就会与其原意背道而驰。

解决方法: 把构建 refundStatus 的部分抽离出来,如下所示。这样,这个方法就清晰很多,也很容易进行覆盖性单测,更可靠。

人是很容易效仿的。这样的代码看多了,你也会忍不住来上几行。


拆解单行复杂的代码

自从 Java8 有了 stream 和 lambda 表达式,程序员的自由放纵就如银河之水天上泻,奔流在屏无羁绊。lambda 表达式原本只是用于表达简短的表达式,类似于 C 语言的内联函数,但实际上被当做方法的替代者滥用,且喜欢嵌套在整个逻辑流,让整行代码理解起来很费劲、修改起来很麻烦、几乎难以单独测试。这种代码很容易滋生 BUG,很丑陋。

lambda表达式含有API调用

先来看一个比较简单的反例。如下代码所示:


response.setData(result.stream().map(
                    container -> ImageContainerDto.toDto(container, hostService.findById(container.getAgentId())))
                    .collect(Collectors.toList()));

这行代码将多个动作糅合在一起,显得不够清晰:

  • hostService.findById(container.getAgentId()) 是一个依赖 IO 的调用;
  • result.stream().map(...).collect(...) 是流的转换;
  • response.setData 是一个赋值的过程。

可以写成如下,更加清晰:


List<ImageContainerDto> imageContainers = StreamUtil.map(result, this::convert);
response.setData(imageContainers);

public ImageContainerDto convert(Container c) {
    Host host = hostService.findById(container.getAgentId());
    return ImageContainerDto.toDto(container, host);
}

此外,这行代码可能潜藏性能风险。当 result 条数很多时,每次都去查一遍 DB 获取 host ,有潜在性能(如果接口调用有缓存还好),且 IO 与 流操作(CPU 操作)混合在一起,是不好的做法。应当将 IO 操作和 CPU 操作分离,IO 部分做成批量并发的。进一步地,写成:

List<String> agentIds = StreamUtils.map(result, Container::getAgentId);
List<Host> hosts = hostService.findBatchHosts(agentIds);
Map<String,Host> hostMap = buildMap(hosts);
 
List<ImageContainerDto> imageContainers = StreamUtils.map(result, container -> ImageContainerDto.toDto(container, hostMap.get(agentId)));
 
response.setData(imageContainers);


lambda表达式滥用和嵌套

看一看,以下代码你要花多长时间才能理解其意图? 第一眼,你是理解成了 orderInfoList.forEach(orderInfo -> orderInfo.map.map.map.ifPresent) 还是理解成了 orderInfoList.forEach(orderInfo -> orderinfo.map.map).map.ifPresent , 还是理解成了 orderInfoList.forEach(orderInfo -> orderinfo.map.map.map).ifPresent ?

反例:


orderInfoList.forEach(orderInfo -> Optional.ofNullable(orderInfo.getTcExtra())
        .map(extra -> extra.get(EDU_STUDENT_INFO))
        .map(studentInfoStr -> JsonUtil.readMap((String) studentInfoStr)).map(studentInfo -> {
          Long studentId = (Long) studentInfo.get(STUDENT_ID);
          Integer version = (Integer) studentInfo.get(VERSION);
          if (studentInfo.containsKey(ID_CARD_NO) && studentId != null && version != null) {
            return Pair.of(studentId, version);
          } else {
            return null;
          }
        }).ifPresent(
            idVersionPair -> sameVersionOrderMap
                .computeIfAbsent(idVersionPair, k -> Lists.newArrayList())
                .add(orderInfo.getOrderNo())));

stream 数据流处理里的 map, ifPresent 等应用的函数如果比较复杂,应当抽离成新的函数,作为方法引用。分隔成多行之后,代码显得更自然更清晰了。

如何拆解以上的复杂语句呢?

  • 先把容易识别的拆成子函数,减少复杂代码体的大小。比如 extra -> extra.get(EDU_STUDENT_INFO)).map(studentInfoStr -> JsonUtil.readMap((String) studentInfoStr) 还是可以识别出来的: optional.map.map.get 结构。 用一个子函数来替代 getStudentInfo(OrderInfo orderInfo)

  • 接着,studentInfo -> { // ... return Pair.of(studentId, version); else return null; } 是一个比较明显的 lambda 表达式滥用,应该抽离出一个子函数 buildStuPair

以上两步抽离出来,这个复杂代码体就比较小了。接下来,可能会有点迷惑。 ifPresent 到底是最外层的 foreach 调用的接续调用还是里层的 lambda 表达式的接续调用,add 是 ifPresent 的接续调用还是哪里的接续调用? 这时候,要数括号的匹配了。不了解 ifPresent 方法的人,肯定很难继续往下拆分和理解。

话说回来,能够写出这段代码的人,对 stream, optional 之类的运用还是可以的。

正例:


public class NestedLambda {

    private static final String EDU_STUDENT_INFO = "student_info";
    private static final String STUDENT_ID = "student_id";
    private static final String VERSION = "version";
    private static final String ID_CARD_NO = "id_card";

    public void nestedLambda() {
        List<OrderInfo> orderInfoList = Lists.newArrayList();
        Map<Pair, List<String>> sameVersionOrderMap = new HashMap();
        orderInfoList.forEach(orderInfo -> buildStudentPair(orderInfo).ifPresent(
                idVersionPair -> setOrderMap(idVersionPair, orderInfo, sameVersionOrderMap)));

    }

    private void setOrderMap(Pair idVersionPair, OrderInfo orderInfo, Map<Pair, List<String>> sameVersionOrderMap) {
        sameVersionOrderMap
                .computeIfAbsent(idVersionPair, k -> Lists.newArrayList())
                .add(orderInfo.getOrderNo());
    }

    private Optional<Pair> buildStudentPair(OrderInfo orderInfo) {
        return getStudentInfo(orderInfo).map(this::buildStuPair);
    }

    private Optional<Map> getStudentInfo(OrderInfo orderInfo) {
        return Optional.ofNullable(orderInfo.getTcExtra())
                .map(extra -> extra.get(EDU_STUDENT_INFO))
                .map(studentInfoStr -> JsonUtil.readMap((String) studentInfoStr));
    }

    private Pair buildStuPair(Map studentInfo) {
        Long studentId = (Long) studentInfo.get(STUDENT_ID);
        Integer version = (Integer) studentInfo.get(VERSION);
        if (studentInfo.containsKey(ID_CARD_NO) && studentId != null && version != null) {
            return Pair.of(studentId, version);
        } else {
            return null;
        }
    }
}


为什么会写出这样的代码

为什么会写出这种“炫技式”的代码呢? 究其根因,我认为有两点:

  • 在乎表达逻辑的“爽”感,而不在乎阅读者的“爽”感;

  • 缺乏表达语义的意识和方法技巧,语文不过关。

很多程序员仍然觉得编程只是编写大段大段的逻辑,写完逻辑就觉得完成任务了,而没有意识到:编程本质是一种表达。这种表达不仅要保证逻辑的准确性以供机器执行,更要保证语义的可读性和可理解性,供他们的同行阅读和改进。因此,代码在某种意义上类似于“文章”,是考验语言组织能力的。能不能把语义表达清晰明白,也是编程技艺的一大看点。

优化类似代码的方法也很简单:识别其中的语义,赋值给变量,抽离出函数,这样,缠结不清的复杂代码体就会化解成清晰可读的代码,回到它的本色。


小结

很多编程语言赋予了程序员很大的灵活性。然而,如果程序员不克制冲动的欲望,自由放纵地写代码,就很容易写出看上去很爽但实际上很丑陋的代码。

代码都挤在一行,也会感觉不舒服的。想想你挤地铁的感受,考虑下代码的感受。


posted @ 2022-08-07 21:16  琴水玉  阅读(990)  评论(0编辑  收藏  举报