[Java] 用了 Stream 後,代碼反而越寫越醜?

Java8 的 stream 流,加上 lambda 表達式,可以讓代碼變短變美,已經得到了廣泛的應用。我們在寫一些複雜代碼的時候,也有了更多的選擇。

代碼首先是給人看的,其次纔是給機器執行的。代碼寫的是否簡潔明瞭,是否寫的漂亮,對後續的 bug 修復和功能擴展,意義重大。很多時候,是否能寫出優秀的代碼,是和工具沒有關係的。代碼是工程師能力和修養的體現,有的人,即使用了 stream,用了 lambda,代碼也依然寫的像屎一樣。

不信,我們來參觀一下一段美妙的代碼。好傢伙,filter 裏面竟然帶着瀟灑的邏輯。

public List<FeedItemVo> getFeeds(Query query,Page page){
    List<String> orgiList = new ArrayList<>();
    
    List<FeedItemVo> collect = page.getRecords().stream()
    .filter(this::addDetail)
    .map(FeedItemVo::convertVo)
    .filter(vo -> this.addOrgNames(query.getIsSlow(),orgiList,vo))
    .collect(Collectors.toList());
    //...其他邏輯
    return collect;
}

private boolean addDetail(FeedItem feed){
    vo.setItemCardConf(service.getById(feed.getId()));
    return true;
}

private boolean addOrgNames(boolean isSlow,List<String> orgiList,FeedItemVo vo){
    if(isShow && vo.getOrgIds() != null){
        orgiList.add(vo.getOrgiName());
    }
    return true;
}

如果覺得不過癮的話,我們再貼上一小段。

if (!CollectionUtils.isEmpty(roleNameStrList) && roleNameStrList.contains(REGULATORY_ROLE)) {
    vos = vos.stream().filter(
           vo -> !CollectionUtils.isEmpty(vo.getSpecialTaskItemVoList())
                    && vo.getTaskName() != null)
           .collect(Collectors.toList());
} else {
    vos = vos.stream().filter(vo -> vo.getIsSelect()
           && vo.getTaskName() != null)
           .collect(Collectors.toList());
    vos = vos.stream().filter(
            vo -> !CollectionUtils.isEmpty(vo.getSpecialTaskItemVoList())
                    && vo.getTaskName() != null)
           .collect(Collectors.toList());
}
result.addAll(vos.stream().collect(Collectors.toList()));

代碼能跑,但多畫蛇添足。該縮進的不縮進,該換行的不換行,說什麼也算不上好代碼。

如何改善?除了技術問題,還是一個意識問題。時刻記得,優秀的代碼,首先是可讀的,然後纔是功能完善。

  1. 合理的換行

在 Java 中,同樣的功能,代碼行數寫的少了,並不見得你的代碼就好。由於 Java 使用;作爲代碼行的分割,如果你喜歡的話,甚至可以將整個 Java 文件搞成一行,就像是混淆後的 JavaScript 一樣。

當然,我們知道這麼做是不對的。在 lambda 的書寫上,有一些套路可以讓代碼更加規整。

Stream.of("i""am""xjjdog").map(toUpperCase()).map(toBase64()).collect(joining(" "));

上面這種代碼的寫法,就非常的不推薦。除了在閱讀上容易造成障礙,在代碼發生問題的時候,比如拋出異常,在異常堆棧中找問題也會變的困難。所以,我們要將它優雅的換行。

Stream.of("i""am""xjjdog")
    .map(toUpperCase())
    .map(toBase64())
    .collect(joining(" "));

不要認爲這種改造很沒有意義,或者認爲這樣的換行是理所當然的。在我平常的代碼 review 中,這種糅雜在一塊的代碼,真的是數不勝數,你完全搞不懂寫代碼的人的意圖。

合理的換行是代碼青春永駐的配方。

  1. 捨得拆分函數

爲什麼函數能夠越寫越長?是因爲技術水平高,能夠駕馭這種變化麼?答案是因爲懶!由於開發工期或者意識的問題,遇到有新的需求,直接往老的代碼上添加 ifelse,即使遇到相似的功能,也直接選擇將原來的代碼拷貝過去。久而久之,碼將不碼。

首先聊一點性能方面的。在 JVM 中,JIT 編譯器會對調用量大,邏輯簡單的代碼進行方法內聯,以減少棧幀的開銷,並能進行更多的優化。所以,短小精悍的函數,其實是對 JVM 友好的。

在可讀性方面,將一大坨代碼,拆分成有意義的函數,是非常有必要的,也是重構的精髓所在。在 lambda 表達式中,這種拆分更是有必要。

我將拿一個經常在代碼中出現的實體轉換示例來說明一下。下面的轉換,創建了一個匿名的函數order->{},它在語義表達上,是非常弱的。

public Stream<OrderDto> getOrderByUser(String userId){
    return orderRepo.findOrderByUser().stream()
        .map(order-> {
            OrderDto dto = new OrderDto();
            dto.setOrderId(order.getOrderId());
            dto.setTitle(order.getTitle().split("#")[0]);
            dto.setCreateDate(order.getCreateDate().getTime());
            return dto;
    });
}

在實際的業務代碼中,這樣的賦值拷貝還有轉換邏輯通常非常的長,我們可以嘗試把 dto 的創建過程給獨立開來。因爲轉換動作不是主要的業務邏輯,我們通常不會關心其中到底發生了啥。

public Stream<OrderDto> getOrderByUser(String userId){
    return orderRepo.findOrderByUser().stream()
        .map(this::toOrderDto);
}
public OrderDto toOrderDto(Order order){
    OrderDto dto = new OrderDto();
            dto.setOrderId(order.getOrderId());
            dto.setTitle(order.getTitle().split("#")[0]);
            dto.setCreateDate(order.getCreateDate().getTime());
    return dto;
}

這樣的轉換代碼還是有點醜。但如果 OrderDto 的構造函數,參數就是 Order 的話public OrderDto(Order order),那我們就可以把真個轉換邏輯從主邏輯中移除出去,整個代碼就可以非常的清爽。

public Stream<OrderDto> getOrderByUser(String userId){
    return orderRepo.findOrderByUser().stream()
        .map(OrderDto::new);
}

除了 map 和 flatMap 的函數可以做語義化,更多的 filter 可以使用 Predicate 去代替。比如:

Predicate<Registar> registarIsCorrect = reg -> 
    reg.getRegulationId() != null 
    && reg.getRegulationId() != 0 
    && reg.getType() == 0;

registarIsCorrect,就可以當作filter的參數。

  1. 合理的使用 Optional

在 Java 代碼裏,由於 NullPointerException 不屬於強制捕捉的異常,它會隱藏在代碼裏,造成很多不可預料的 bug。所以,我們會在拿到一個參數的時候,都會驗證它的合法性,看一下它到底是不是 null,代碼中到處充滿了這樣的代碼。

if(null == obj)
if(null == user.getName() || "".equals(user.getName()))
    
if (order != null) {
    Logistics logistics = order.getLogistics();
    if(logistics != null){
        Address address = logistics.getAddress();
        if (address != null) {
            Country country = address.getCountry();
            if (country != null) {
                Isocode isocode = country.getIsocode();
                if (isocode != null) {
                    return isocode.getNumber();
                }
            }
        }
    }
}

Java8 引入了 Optional 類,用於解決臭名昭著的空指針問題。實際上,它是一個包裹類,提供了幾個方法可以去判斷自身的空值問題。

上面比較複雜的代碼示例,就可以替換成下面的代碼。

 String result = Optional.ofNullable(order)
      .flatMap(order->order.getLogistics())
      .flatMap(logistics -> logistics.getAddress())
      .flatMap(address -> address.getCountry())
      .map(country -> country.getIsocode())
      .orElse(Isocode.CHINA.getNumber());

當你不確定你提供的東西,是不是爲空的時候,一個好的習慣是不要返回 null,否則調用者的代碼將充滿了 null 的判斷。我們要把 null 消滅在萌芽中。

public Optional<String> getUserName() {
    return Optional.ofNullable(userName);
}

另外,我們要儘量的少使用 Optional 的 get 方法,它同樣會讓代碼變醜。比如:

Optional<String> userName = "xjjdog";
String defaultEmail = userName.get() == null ? "":userName.get() + "@xjjdog.cn";

而應該修改成這樣的方式:

Optional<String> userName = "xjjdog";
String defaultEmail = userName
    .map(e -> e + "@xjjdog.cn")
    .orElse("");

那爲什麼我們的代碼中,依然充滿了各式各樣的空值判斷?即使在非常專業和流行的代碼中?一個非常重要的原因,就是 Optional 的使用需要保持一致。當其中的一環出現了斷層,大多數編碼者都會以模仿的方式去寫一些代碼,以便保持與原代碼風格的一致。

如果想要普及 Optional 在項目中的使用,腳手架設計者或者 review 人,需要多下一點功夫。

  1. 返回 Stream 還是返回 List?

很多人在設計接口的時候,會陷入兩難的境地。我返回的數據,是直接返回 Stream,還是返回 List?

如果你返回的是一個 List,比如 ArrayList,那麼你去修改這個 List,會直接影響裏面的值,除非你使用不可變的方式對其進行包裹。同樣的,數組也有這樣的問題。

但對於一個 Stream 來說,是不可變的,它不會影響原始的集合。對於這種場景,我們推薦直接返回 Stream 流,而不是返回集合。這種方式還有一個好處,能夠強烈的暗示 API 使用者,多多使用 Stream 相關的函數,以便能夠統一代碼風格。

public Stream<User> getAuthUsers(){
    ...
    return Stream.of(users);
}

不可變集合是一個強需求,它能防止外部的函數對這些集合進行不可預料的修改。在 guava 中,就有大量的Immutable類支持這種包裹。再舉一個例子,Java 的枚舉,它的values()方法,爲了防止外面的 api 對枚舉進行修改,就只能拷貝一份數據。

但是,如果你的 api,面向的是最終的用戶,不需要再做修改,那麼直接返回 List 就是比較好的,比如函數在 Controller 中。

  1. 少用或者不用並行流

Java 的並行流有很多問題,這些問題對併發編程不熟悉的人高頻率踩坑。不是說並行流不好,但如果你發現你的團隊,老在這上面栽跟頭,那你也會毫不猶豫的降低推薦的頻率。

並行流一個老生常談的問題,就是線程安全問題。在迭代的過程中,如果使用了線程不安全的類,那麼就容易出現問題。比如下面這段代碼,大多數情況下運行都是錯誤的。

List transform(List source){
 List dst = new ArrayList<>();
 if(CollectionUtils.isEmpty()){
  return dst;
 }
 source.stream.
  .parallel()
  .map(..)
  .filter(..)
  .foreach(dst::add);
 return dst;
}

你可能會說,我把 foreach 改成 collect 就行了。但是注意,很多開發人員是沒有這樣的意識的。既然 api 提供了這樣的函數,它在邏輯上又講得通,那你是阻擋不住別人這麼用的。

並行流還有一個濫用問題,就是在迭代中執行了耗時非常長的 IO 任務。在用並行流之前,你有沒有一個疑問?既然是並行,那它的線程池是怎麼配置的?

很不幸,所有的並行流,共用了一個 ForkJoinPool。它的大小,默認是CPU個數-1,大多數情況下,是不夠用的。

如果有人在並行流上跑了耗時的 IO 業務,那麼你即使執行一個簡單的數學運算,也需要排隊。關鍵是,你是沒辦法阻止項目內的其他同學使用並行流的,也無法知曉他幹了什麼事情。

那怎麼辦?我的做法是一刀切,直接禁止。雖然殘忍了一些,但它避免了問題。

總結

Java8 加入的 Stream 功能非常棒,我們不需要再羨慕其他語言,寫起代碼來也更加行雲流水。雖然看着很厲害的樣子,但它也只不過是一個語法糖而已,不要寄希望於用了它就獲得了超能力。

隨着 Stream 的流行,我們的代碼裏這樣的代碼也越來越多。但現在很多代碼,使用了 Stream 和 Lambda 以後,代碼反而越寫越糟,又臭又長以至於不能閱讀。沒其他原因,濫用了!

總體來說,使用 Stream 和 Lambda,要保證主流程的簡單清晰,風格要統一,合理的換行,捨得加函數,正確的使用 Optional 等特性,而且不要在 filter 這樣的函數里加代碼邏輯。在寫代碼的時候,要有意識的遵循這些小 tips,簡潔優雅就是生產力。

如果覺得 Java 提供的特性還是不夠,那我們還有一個開源的類庫vavr,提供了更多的可能性,能夠和 Stream 以及 Lambda 結合起來,來增強函數編程的體驗。

<dependency>
    <groupId>io.vavr</groupId>
    <artifactId>vavr</artifactId>
    <version>0.10.3</version>
</dependency>

但無論提供瞭如何強大的 api 和編程方式,都扛不住小夥伴的濫用。這些代碼,在邏輯上完全是說的通的,但就是看起來彆扭,維護起來費勁。

寫一堆垃圾 lambda 代碼,是虐待同事最好的方式,也是埋坑的不二選擇。

寫代碼嘛,就如同說話、聊天一樣。大家幹着同樣的工作,有的人說話好聽顏值又高,大家都喜歡和他聊天;有的人不好好說話,哪裏痛戳哪裏,雖然他存在着但大家都討厭。

代碼,除了工作的意義,不過是我們在世界上表達自己想法的另一種方式罷了。如何寫好代碼,不僅僅是個技術問題,更是一個意識問題。

算法愛好者 算法是程序員的內功!「算法愛好者」專注分享算法相關文章、工具資源和算法題,幫程序員修煉內功。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/yQBU-UrpbW5LDrohxeJ-rQ