如何寫好一個方法——一個看起來很基礎,很多人卻沒有做好的問題

大學畢業一年後,一個不是程序員的同學問:爲什麼程序員總是看着別人的代碼不順眼?當初的我,感覺這是對程序員赤裸裸的挑戰,直接否認了:不是這樣的吧?我就感覺某些人的代碼挺好的。但是這個問題卻一直記在心上。

工作了這麼多年,一次測試提了一個Bug單,讓我修改。這個模塊我不太熟悉,但憑經驗感覺不難,可是看到代碼時,我的內心是:“唉呀媽呀,你個傻X,你寫的這個是什麼?”,但是我轉念一想,對方這樣寫代表了他對這個功能的理解,只是我沒有理解。忽然我好想找到了問題的答案。

 

程序員這個崗位和其他崗位有一個很大的差異:深度的思想合作。從業務理解,到方案設計,到實施時類的命名、變量名稱、參數順序。你的每一個決定,都會被合作者接觸到,被以後的維護人員接觸到,你的每一個決定都在和其他人的想法碰撞。所以能讓人舒暢的理解意圖,是多麼的重要呀。難怪《Clean Code》這本書第一章,用了整整一章的篇幅來講代碼可讀性的重要性。

這個就是我同學開始提出問題的答案:我認爲程序員之間互相就看着不順眼的主要原因是——沒有能很好的使用程序語言表達意圖。

我重新閱讀代碼,想找到問題原因

  • 方法命名不好嗎?看起來都挺對的。

  • 變量命名不好嗎?也不差。

  • 函數太長嗎?基本都在20行內,短得令人髮指。

  • 圈複雜度高嗎?函數都不長,能複雜到哪裏去呢?

看起來代碼符合各種開發規範,過程質量很不錯,但是結果還是不易讀。

經過一段時間的思考,我得到這麼幾個點

關注業務

《Clean Code》中對函數的要求:短小;只做一件事。其實還有一個要求:每個函數一個抽象層級。我理解爲:關注業務。

 

在我們一般的項目中,都有三級結構Controller、Service、Repository。每層負責不同的職責,Controller負責處理Http請求項關鍵的傳輸內容,Service負責處理業務邏輯,Repository負責處理持久化。當Service層接收到HttpRequest對象的時候,我們能明顯的知道越級了。但是實際上我們的業務並不只有三級,每個函數都應該當做一級處理,每一級都有自己的業務。

你可能會說:作爲一個程序員,Show me your code。OK,上代碼。下面代碼負責計算一個任務,檢測任務是否有效,加載任務信息,計算Kpi數據,保存結果即可

public void calculate(CalculateTask task) {
    if (!task.isValidate()) {
        return;
    }
    List<TaskData> taskDatas = loadTaskData(task);
    List<TaskKpiData> kpiData = calculateAllKpi(taskDatas);

    // Save Result
    String version = getVersion(task);
    List<String> ids = getKpiIds(kpiData);
    esObjectClient.deleteByVersion(version, ids);
    esObjectClient.saveAll(kpiData);
    updateLatest(kpiData, task);
}

這裏的邏輯比較簡單,代碼前面寫得也挺簡單,後面的代碼塊通過註釋也能看出來是負責保存計算結果的,但是calculate方法的業務應該是對整個Task的調度過程,此過程僅僅關心保存這個動作什麼時候進行,不關心如何保存數據的細節,因此這樣才更爲合理

public void calculate(CalculateTask task) {
    if (!task.isValidate()) {
        return;
    }
    List<TaskData> taskDatas = loadTaskData(task);
    List<TaskKpiData> kpiData = calculateAllKpi(taskDatas);

    saveResult(kpiData, task);
}

這裏將代碼塊提成一個方法,並不是因爲代碼行太多了,而是因爲這個方法的功能是描述計算過程,不是細節。因此代碼寫成下面的樣子也是不合理的

public void calculate(CalculateTask task) {
    if (!task.isValidate()) {
        return;
    }
    loadAndCalculateKpiAndSaveResult(task);
}

calculate的邏輯應該包含:校驗、加載、計算、保存,四個過程。這四個過程需要在方法中直接有體現,不多也不能少


我們再來一個例子🌰,這裏的功能是從文件加載發票數據,按照發票號分組保存到數據庫,並將發票對應範圍也保存入庫。明確一下需求點

  • 從文件加載發票數據

  • 按照發票號分組

  • 分組後發票信息保存到數據庫

  • 發票對應範圍保存入庫

    我們再來看看實現

void saveFileToDb() {
    Stream<InvoiceInterfaceEntity> invoiceEntities = loadInvoiceFromFile(); 
    saveDataToDb(getSortedData(invoiceEntities));
}

public void saveDataToDb(List<InvoiceInterfaceEntity> entityList) {
    entityList.forEach(entity -> {
        entityManager.persist(entity);
    });

    saveInvoiceRecorder(entityList);
}

private void saveInvoiceRecorder(List<InvoiceInterfaceEntity> interfaceEntities) {
    Map<String, List<InvoiceInterfaceEntity>> invoiceToEntityMap = interfaceEntities.stream()
            .collect(Collectors.groupingBy(InvoiceInterfaceEntity::getInvoiceId));

    getInvoiceRecorderEntityStream(invoiceToEntityMap).forEach(entity -> {
        entityManager.persist(entity);
    });
}

private Stream<InvoiceRecorderEntity> getInvoiceRecorderEntityStream(Map<String, List<InvoiceInterfaceEntity>> invoiceToEntityMap) {
  // 將invoiceToEntityMap中的每組發票信息轉換爲InvoiceRecorderEntity,InvoiceRecorderEntity是用於記錄發票開始Id和結束Id的對象
  return ...
}

private List<InvoiceInterfaceEntity> getSortedData(Stream<InvoiceInterfaceEntity> invoiceEntities) {
  // 對發票數據按照發票號排序
}

可以看得出來,這裏的命名可讀、有意義,方法短小,但你看懂它在幹什麼了嗎?問題出在哪裏呢?

需求中四個需求點,分散在各地,這導致了可讀性極差,這是維護人員的噩夢。下面是改進後結果

void saveFileToDb() {
    loadInvoiceFromFile()
            .collect(Collectors.groupingBy(InvoiceInterfaceEntity::getInvoiceId))
            .values().stream()
            .forEach(invoiceGroup -> {
                InvoiceRecorderEntity record = saveToDb(invoiceGroup);
                entityManager.persist(record);
            });
}

private InvoiceRecorderEntity saveToDb(List<InvoiceInterfaceEntity> invoiceGroup) {
    String invoiceId = invoiceGroup.get(0).getInvoiceId();
    long minId = Long.MAX_VALUE;
    long maxId = Long.MIN_VALUE;

    for (InvoiceInterfaceEntity entity : invoiceGroup) {
        entityManager.persist(entity);
        minId = Math.min(minId, entity.getId());
        maxId = Math.max(maxId, entity.getId());
    }

    return new InvoiceRecorderEntity(invoiceId, minId, maxId);
}

這裏方法saveFileToDb中幾乎一行對應一個需求,在一個方法內將整個功能的核心邏輯表述清楚了。

傳遞方法強相關參數

《Clean Code》裏面說:最理想的參數數量是零。可能有人看了這個規則,就將代碼就寫下了下面的代碼。

下面代碼用於從Excel文件的smpfp頁中讀取價格信息,然後轉換爲ServicePackage信息

public Stream<XXXDto> getServicePackages() {
    Map<String, Optional<XXXDto>> prices = parsePrices();
    // ...
    return null;
}

private Map<String, Optional<XXXDto>> parsePrices() {
    return getSheet(() -> "smpfp")
            .getObjects(XXXDto.class)
            .stream()
            .collect(groupingBy(this::generatePackageIdentity,
                            reducing((l, r) -> l))
            );
}

private SheetAgency getSheet(Supplier<String> sheetNameSpplier) {
    return this.excelReader.getSheet(sheetNameSpplier.get());
}

大家看這段代碼,是否你也好奇:

  • parsePrices從哪裏解析的價錢?

  • getSheet怎麼需要這麼奇怪的一個參數?

我想《Clean Code》中說的“最理想的參數數量是零”的意思是參數數量儘量少,但絕不是說爲了減少參數數量不擇手段。方法名稱是parsePrices,意思是要從某個東西里面解析Price信息,缺少了這個“某個東西”語義就不完善了,所以這個“某個東西”一定要有體現,這裏的Price也必須返回。而且需要什麼就傳遞什麼參數,不需要的內容也不要傳遞。這裏的方法getSheet需要指定Sheet頁的名字,所以只要名字就夠了,怎麼生成的名字不是重點,相當於傳遞了過多的信息給getSheet。

下面是修改後的結果

public Stream<XXXDto> getServicePackages() {
    Map<String, Optional<XXXDto>> prices = parsePrices(excelReader);
    // ...
    return null;
}

private Map<String, Optional<XXXDto>> parsePrices(ExcelReader excelReader) {
    return getSheet(“smpfp”, excelReader)
            .getObjects(XXXDto.class)
            .stream()
            .collect(groupingBy(this::generatePackageIdentity,
                            reducing((l, r) -> l))
            );
}

private SheetAgency getSheet(String sheetName, ExcelReader excelReader) {
    return this.excelReader.getSheet(sheetName);
}

函數需要傳遞邏輯相關的重要參數,不多也不少,函數名稱和參數能表達完整含義。

參數分貴賤

這個世界不是平等的,同爲參數,但是參數分貴賤。

下面方法是一個檢測數值是否在指定範圍的方法,如果不在範圍內則記錄錯誤信息

private void checkValue(MessageRecorder messageRecorder,
                        Set<String> range,
                        String value,
                        ExcelRowWrapper<XXXData> row,
                        String title)

調整順序後會更爲友好

private void checkValue(
        String value,       // 需要檢測的內容是核心對象
        String title,       // Title不是核心信息,但是它是核心信息的關鍵附屬信息
        Set<String> range,  // 數據所屬範圍是次級重要信息
        MessageRecorder messageRecorder,  // 如果出錯負責保存信息的對象,參與核心操作
        ExcelRowWrapper<XXXData> row) {   // 完善性信息放在最後

這個沒有什麼好說的,自己感受吧。

正向命名

在《重構》中關於代碼的壞味道第一條是神祕名稱,強調名稱一定要準確。 但是命名準確就沒有麻煩嗎?請看下面的例子

案例一:

public boolean notValidated() {
    return date == null || !date.isValidated();
}

這是檢測一個Task是否無效的方法,命名很準確,能看出來無效時返回true。 使用的代碼如下:

public void someFun() {
    ToDoItem item = new ToDoItem();
    if (item.notValidated()) {
        showRedColor();
    }
    //...
    
    if (!item.notValidated()) {	// 這是什麼鬼邏輯?
        queryMoreDetail();
    }
}

案例二:我要做一個查詢數據庫的工具,我希望提供一個靈活的表示條件的結構

public class StringEqualCriteria {
    private String field;   // 需要查詢的數據庫字段
    private String value;   // 字段需要匹配的值
}

// 查詢所有完成的任務
queryTask(Arrays.asList(new StringEqualCriteria("status", "完成")));

有了這個StringEqualCriteria,想要查詢任意條件的組合就方便多了。 不過需求總是在變的,有一天需要增加不等於的條件,於是StringEqualCriteria變成了這個樣子

public static class StringEqualCriteria {
    private String field;   // 需要查詢的數據庫字段
    private String value;   // 字段需要匹配的值
    private boolean isNot;  // 表示條件是否取反
}

爲了查詢所有沒有完成的任務就有了這樣的代碼

queryTask(Arrays.asList(new StringEqualCriteria("status", "完成", true)));

是不是感覺哪裏怪怪的?你是否也感覺是下面這個樣子的?

new StringEqualCriteria("status", "完成", true);   // 感覺表示狀態爲完成的
new StringEqualCriteria("status", "完成", false);  // 感覺表示狀態沒有完成的

這個代碼在我所在項目裏面已經出現了,因爲是一個公共的工具,很多地方都在用,不能隨便修改參數含義。雖然已經導致了幾次Bug,但是我們還是不能奈何他。

 

可能有人會問:StringUtils似乎就提供了isNotEmpty方法呀?

我想說:無論工具是否提供isNotEmpty,但它一定提供了isEmpty。

慘痛的經驗告訴我們:方法屬性命名時可以出現反向命名,但是正向命名的方法或者屬性不能丟掉。規則簡單點就是——正向命名

合理註釋

好多書中都有對註釋不好的評價

  • 《重構》中寫道

      如果你需要註釋來解釋一塊代碼做了什麼,試試提煉函數 
      如果函數已經提煉出來,但還是需要註釋來解釋其行爲,試試用改變函數聲明 

     

  • 《Clean Code》中寫道

     註釋的恰當用法是彌補我們在用代碼表達意圖時遭遇的失敗。注意,我用了“失敗”一詞。我是說真的。註釋總是一種失敗。

     

所以我們現在對註釋深惡痛絕,禁止了所有註釋。

但大家注意到沒有,《重構》也爲註釋平反了,書上說

別擔心,我們並不是說你不該寫註釋。從嗅覺上說,註釋不但不是一種壞味道, 事實上它們還是一種香味呢。

《Clean Code》更是大方的給了註釋整整一章,細細的講解如何使用註釋。 第四章第一句話:

什麼也比不上放置良好的註釋來得有用。

後面又特別強調:

註釋並不像辛德勒的名單。 

《Clean Code》4.3好註釋中部分推薦的註釋場景

  • 法律信息

    在公司規範要求是增加

  • 對意圖的解釋
private static synchronized String getUniqueTail() {
    // Reset the counter if it is greater than 99999 無用註釋,這個註釋只是對代碼的翻譯
    // 和ECC約定編號從0-99999循環變化                  這個是意圖解釋的註釋
    if (counter > 99999) {
        counter = 0;
    }
    counter = counter + 1;
    return Long.toString(System.currentTimeMillis()) + "_" + counter;
}
  • 警告
// Simple DateFormat is not thread safe
// So we need to use this cache in single thread
// We must not add 'static' qualifler
private Map<String, DateFormat> formatMap = new HashMap<>();

public static <T> Long newVersion(ESDocumentWrapper<T> wrapper) {
    // When we don't find doc in es, It does not mean the doc never appear,
    //  when the doc appear in es sometime, the doc version will can't start from 1
    //  So we return null when not find doc.It may be overwrite doc sometime.
    return (wrapper == null) ? null : wrapper.getVersion() + 1;
}
  • TODO註釋

這個大家一般都會用

SearchRequestBuilder requestBuilder = client.prepareSearch(indexName)
        .setSize(10000) // TODO: 應該變更爲Scroll的方式讀取。當前數據量在10000以內,可以使用這個臨時方案
        .setQuery(queryBuilder);
  • 公共API的Java Doc

如果代碼是公共庫,千萬記得添加註釋

/**
 * 按照Key分組
 * 前一個Iterator必須是已經根據key排序好的
 * @param keyGenerator key的獲取方法
 * @param <K> key的類型
 * @return 按照key分組後的Entity迭代器
 */
public <K> EasyIterator<Map.Entry<K, List<T>>> groupBy(Function<T, K> keyGenerator) {
    return new GroupSortedListIterator<>(this, keyGenerator, v -> v);
}

/**
 * 實現某個數據類型和字符串之間的互相轉換邏輯
 * @param <T> converter負責處理的數據類型
 * @param <S> 子類類型,用於返回自身的時候不用cast
 */
public interface IPrimitiveConverter<T, S extends IPrimitiveConverter<T, S>> {
    /**
     * 返回所有支持的數據類型。註冊的時候會使用這個類型註冊,如果類型重複後一個類型對應的Converter會被註冊進去
     * @return 所有支持的數據類型
     */
    Class[] dataTypes();

註釋不是萬惡之源,必要的時候該出手時就出手。

無論什麼註釋,要有意義。沒意義的註釋一定要刪除。

註釋不能美化糟糕的代碼。如果有辦法通過代碼表達信息,儘量使用代碼。

代碼沒有表達清楚的時候,一定要添加註釋,有註釋的代碼比表達不清楚的代碼好。

公共代碼要有良好的註釋。

總結

  • 一個函數應該將好把自己的功能描述清楚,不多也不少。

  • 函數需要傳遞邏輯相關的重要參數,不多也不少,函數名稱和參數能表達完整含義。

  • 參數分貴賤。按照語義和相關度排列。

  • 函數或者變量需要正向命名。

  • 工具類需要有註釋,最好還有demo。

參考

阿里巴巴規範:https://alibaba.github.io/Alibaba-Java-Coding-Guidelines/

Google規範:https://google.github.io/styleguide/javaguide.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章