Java雜談之代碼重構的方法多長才算長

每當看到長函數,我們都得:

  • 被迫理解一個長函數
  • 在一個長函數中,小心翼翼地找出需要的邏輯,按需求微調

幾乎所有程序員都會有類似經歷。
沒人喜歡長函數,但你卻要一直和各種長函數打交道。

幾百上千行的函數肯定是不足以稱霸的。

多長算“長”?

100 行?對於函數長度容忍度太高瞭!這是導致長函數產生的關鍵點。

看具體代碼時,一定要能夠看到細微之處。關鍵點就是將任務拆解得越小越好,這個觀點對代碼同樣適用。隨著對代碼長度容忍度的降低,對代碼細節的感知力就會逐漸提升,你才能看到那些原本所謂細枝末節的地方隱藏的各種問題。

“越小越好”是一個追求的目標,不過,沒有一個具體的數字,就沒辦法約束所有人的行為。所以,通常情況下,我們還是要定義出一個代碼行數的上限,以保證所有人都可以按照這個標準執行。

像 Java 這樣表達能力稍弱的靜態類型語言,爭取 20 行代碼解決問題。

這不是一個說說就算的標準,我們應該把它變成一個可執行的標準。比如,在 Java 中,我們就可以把代碼行的約束加到 CheckStyle 的配置文件:

<module name="MethodLength">
    <property name="tokens" value="METHOD_DEF"/>
    <property name="max" value="20"/>
    <property name="countEmpty" value="false"/>
</module>

這樣,在我們提交代碼之前,執行本地的構建腳本,就可以把長函數檢測出來。

即便以 20 行上限,這也已經超過很多人的認知,具體的函數行數可以結合團隊的實際情況來制定。
非常不建議把這個數字放得很大,就像我前面說的那樣,如果你放到 100 行,這個數字基本上是沒有太多意義的,對團隊也起不到什麼約束作用。

  • 如果函數裡面的行寫得很長呢?還應不應該插入換行?如果插入換行的話就會增加行數,如果不差入換行,在看代碼時就要經常移動水平滾動條,按代碼行而非物理行計數。

長函數的產生

限制函數長度,是一種簡單粗暴的解決方案。最重要的是你要知道,長函數本身是一個結果,如果不理解長函數產生的原因,還是很難寫出整潔的代碼。

以性能為由

像 C 語言這種在今天已經是高性能的程序設計語言,在問世之初,也曾被人質疑性能不彰,尤其是函數調用。

在一些寫匯編語言的人看來,調用函數涉及到入棧出棧的過程,顯然不如直接執行來得性能高。這種想法經過各種演變流傳到今天,任何一門新語言出現,還是會以同樣的理由被質疑。

所以,在很多人看來,把函數寫長是為瞭所謂性能。不過,這個觀點在今天是站不住的。性能優化不該是寫代碼的第一考量:

  • 有活力的程序設計語言本身是不斷優化的,無論是編譯器,還是運行時,性能都會越來越好
  • 可維護性比性能優化要優先考慮,當性能不足以滿足需要時,我們再來做相應的測量,找到焦點,進行特定的優化。這比在寫代碼時就考慮所謂性能要更能鎖定焦點,優化才有意義。

平鋪直敘

寫代碼平鋪直敘,把自己想到的一點點羅列出來。比如下面這段代碼(如果你不想仔細閱讀,可以直接跳到後面):

public void executeTask() {
    ObjectMapper mapper = new ObjectMapper();
    CloseableHttpClient client = HttpClients.createDefault();
    List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
    for (Chapter chapter : chapters) {
        // Send Chapter
        SendChapterRequest sendChapterRequest = new SendChapterRequest();
        sendChapterRequest.setTitle(chapter.getTitle());
        sendChapterRequest.setContent(chapter.getContent());


        HttpPost sendChapterPost = new HttpPost(sendChapterUrl);
        CloseableHttpResponse sendChapterHttpResponse = null;
        String chapterId = null;
        try {
            String sendChapterRequestText = mapper.writeValueAsString(sendChapterRequest);
            sendChapterPost.setEntity(new StringEntity(sendChapterRequestText));
            sendChapterHttpResponse = client.execute(sendChapterPost);
            HttpEntity sendChapterEntity = sendChapterPost.getEntity();
            SendChapterResponse sendChapterResponse = mapper.readValue(sendChapterEntity.getContent(), SendChapterResponse.class);
            chapterId = sendChapterResponse.getChapterId();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                if (sendChapterHttpResponse != null) {
                    sendChapterHttpResponse.close();
                }
            } catch (IOException e) {
                // ignore
            }
        }


        // Translate Chapter
        HttpPost translateChapterPost = new HttpPost(translateChapterUrl);
        CloseableHttpResponse translateChapterHttpResponse = null;
        try {
            TranslateChapterRequest translateChapterRequest = new TranslateChapterRequest();
            translateChapterRequest.setChapterId(chapterId);
            String translateChapterRequestText = mapper.writeValueAsString(translateChapterRequest);
            translateChapterPost.setEntity(new StringEntity(translateChapterRequestText));
            translateChapterHttpResponse = client.execute(translateChapterPost);
            HttpEntity translateChapterEntity = translateChapterHttpResponse.getEntity();
            TranslateChapterResponse translateChapterResponse = mapper.readValue(translateChapterEntity.getContent(), TranslateChapterResponse.class);
            if (!translateChapterResponse.isSuccess()) {
                logger.warn("Fail to start translate: {}", chapterId);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if (translateChapterHttpResponse != null) {
                try {
                    translateChapterHttpResponse.close();
                } catch (IOException e) {
                    // ignore
                }
            }
        }
    }

把沒有翻譯過的章節發到翻譯引擎,然後,啟動翻譯過程。

翻譯引擎是另外一個服務,需通過 HTTP 的形式向它發送請求。相對而言,這段代碼還算直白,當你知道瞭我上面所說的邏輯,你是很容易看懂這段代碼。

這段代碼之所以很長,主要原因就是把前面所說的邏輯全部平鋪直敘地擺在那裡瞭,這裡既有業務處理的邏輯,比如,把章節發送給翻譯引擎,然後,啟動翻譯過程;又有處理的細節,比如,把對象轉成 JSON,然後,通過 HTTP 客戶端發送出去。

從這段代碼中,可看到平鋪直敘的代碼存在的兩個典型問題:

  • 把多個業務處理流程放在一個函數裡實現
  • 把不同層面的細節放到一個函數裡實現

這裡發送章節和啟動翻譯是兩個過程,顯然,這是可以放到兩個不同的函數中去實現的,所以,我們隻要做一下提取函數,就可以把這個看似龐大的函數拆開,而拆出來的幾個函數規模都會小很多,像下面這樣:

public void executeTask() {
    ObjectMapper mapper = new ObjectMapper();
    CloseableHttpClient client = HttpClients.createDefault();
    List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
    for (Chapter chapter : chapters) {
        String chapterId = sendChapter(mapper, client, chapter);
        translateChapter(mapper, client, chapterId);
    }
}

拆出來的部分,實際上就是把對象打包發送的過程,我們以發送章節為例,先來看拆出來的發送章節部分:

private String sendChapter(final ObjectMapper mapper,
                           final CloseableHttpClient client,
                           final Chapter chapter) {
    SendChapterRequest request = asSendChapterRequest(chapter);


    CloseableHttpResponse response = null;
    String chapterId = null;
    try {
        HttpPost post = sendChapterRequest(mapper, request);
        response = client.execute(post);
        chapterId = asChapterId(mapper, post);
    } catch (IOException e) {
        throw new RuntimeException(e);
    } finally {
        try {
            if (response != null) {
                response.close();
            }
        } catch (IOException e) {
            // ignore
        }
    }
    return chapterId;
}


private HttpPost sendChapterRequest(final ObjectMapper mapper, final SendChapterRequest sendChapterRequest) throws JsonProcessingException, UnsupportedEncodingException {
    HttpPost post = new HttpPost(sendChapterUrl);
    String requestText = mapper.writeValueAsString(sendChapterRequest);
    post.setEntity(new StringEntity(requestText));
    return post;
}


private String asChapterId(final ObjectMapper mapper, final HttpPost sendChapterPost) throws IOException {
    String chapterId;
    HttpEntity entity = sendChapterPost.getEntity();
    SendChapterResponse response = mapper.readValue(entity.getContent(), SendChapterResponse.class);
    chapterId = response.getChapterId();
    return chapterId;
}


private SendChapterRequest asSendChapterRequest(final Chapter chapter) {
    SendChapterRequest request = new SendChapterRequest();
    request.setTitle(chapter.getTitle());
    request.setContent(chapter.getContent());
    return request

這個代碼還算不上已經處理得很整潔瞭,但至少同之前相比,已經簡潔瞭一些。我們隻用瞭最簡單的提取函數這個重構手法,就把一個大函數拆分成瞭若幹的小函數。

長函數往往還隱含著一個命名問題。如果你看修改後的sendChapter,其中的變量命名明顯比之前要短,理解的成本也相應地會降低。因為變量都是在這個短小的上下文裡,也就不會產生那麼多的命名沖突,變量名當然就可以寫短一些。

平鋪直敘的代碼,一個關鍵點就是沒有把不同的東西分解出來。如果我們用設計的眼光衡量這段代碼,這就是“分離關註點”沒有做好,把不同層面的東西混在瞭一起,既有不同業務混在一起,也有不同層次的處理混在瞭一起。我在《軟件設計之美》專欄中,也曾說過,關註點越多越好,粒度越小越好。

一次加一點

有時,一段代碼一開始的時候並不長,就像下面這段代碼,它根據返回的錯誤進行相應地錯誤處理:

if (code == 400 || code == 401) {
  // 做一些錯誤處理
}

然後,新的需求來瞭,增加瞭新的錯誤碼,它就變成瞭這個樣子:

if (code == 400 || code == 401 || code == 402) {
  // 做一些錯誤處理
}

這段代碼有很多次被修改的機會,日積月累:

if (code == 400 || code == 401 || code == 402 || ...
  || code == 500 || ...
  || ...
  || code == 10000 || ...) {
}

後人看到就想罵人。任何代碼都經不起這種無意識的累積,每個人都沒做錯,但最終的結果很糟糕。對抗這種逐漸糟糕腐壞的代碼,需要知道“童子軍軍規”:
讓營地比你來時更幹凈。

Robert Martin 把它借鑒到瞭編程領域,我們應該看看自己對於代碼的改動是不是讓原有的代碼變得更糟糕瞭,如果是,那就改進它。
但這一切的前提是,你要能看出自己的代碼是不是讓原有的代碼變得糟糕瞭,所以,學習代碼的壞味道還是很有必要的。

至此,我們看到瞭代碼變長的幾種常見原因:

  • 以性能為由
  • 平鋪直敘
  • 一次加一點

代碼變長根本是一個無意識的問題,寫代碼的人沒有覺得自己把代碼破壞瞭。但隻要你認識到長函數是一個壞味道,後面的許多問題就自然而然地會被發掘出來,至於解決方案,你已經看到瞭,大部分情況下,就是拆分成各種小函數。

總結

沒有人願意去閱讀長函數,但許多人又會不經意間寫出長函數。

對於團隊,一個關鍵點是要定義出長函數的標準。
過於寬泛的標準沒有意義,想要有效地控制函數規模,幾十行已經是標準上限,這個標準越低越好。

長函數產生的原因:

  • 性能為借口
  • 代碼平鋪直敘

函數寫長最常見的原因。之所以會把代碼平攤在那裡:
– 把多個業務寫到瞭一起
– 把不同層次的代碼寫到瞭一起。究其根因,那是“分離關註點”沒有做好

  • 每人每次加一點點

應對主要辦法就是要堅守“童子軍軍規”,但其背後更深層次的支撐就是要對壞味道有著深刻的認識

把函數寫短,越短越好。

到此這篇關於Java雜談之代碼重構的方法多長才算長的文章就介紹到這瞭,更多相關Java 代碼重構內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: