我的最佳實務:通用

1. 註解:是福也是禍

好的註解是一門藝術。寫得用心是恩惠,卻也常是徒勞。

自我說明的程式碼(Self-documenting Code)並不代表「沒有註解」。程式碼本身應該盡可能表達怎麼做;註解則用來補充為什麼、不明顯的邊界條件,以及 API 的契約。

以下是一些自我說明程式碼的例子,以及好註解和壞註解的例子。

常見的一種說法是:註解很容易過時。但事實上,變數名稱和方法名稱也有一模一樣的問題!

哎唷,千萬別這樣寫:

int age = 32; // set the value of the age integer to 32

自我說明:變數名稱取得好,就成功了一半

不好的寫法:

var dateTime = DateTimeUtil.getNow();

好的寫法:

var now = DateTimeUtil.getNow();

再舉一個例子:

不好的寫法:

User user = this.getBruce();

不好的寫法:

// testing user without permission
User user = this.getBruce();

好的寫法:

User userWithoutPermission = this.getBruce();

自我說明:替方法取有意義的名稱

不好的寫法:

public String sanitizeInput(String input) {
    // replace underscore with spaces
    return input.replaceAll("_", " ");
}

好的寫法:

public String replaceUnderscoreWithSpaces(String input) {
    return input.replaceAll("_", " ");
}

自我說明:私有方法

不好的寫法:這個方法逼讀者同時解讀驗證、計算和業務規則。

public Invoice createInvoice(Order order, Customer customer) {
    if (order.items().isEmpty()) {
        throw new IllegalArgumentException("Order needs at least one item");
    }

    BigDecimal subtotal = order.items().stream()
            .map(item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    BigDecimal discount = customer.isPremium()
            ? subtotal.multiply(new BigDecimal("0.10"))
            : BigDecimal.ZERO;

    return new Invoice(order.id(), subtotal, discount, subtotal.subtract(discount));
}

好的寫法:公開方法敘述整體流程。私有的無狀態(stateless)方法替每個獨立的意圖命名,並透過參數和回傳值把相依關係呈現出來。

public Invoice createInvoice(Order order, Customer customer) {
    requireItems(order);
    BigDecimal subtotal = calculateSubtotal(order.items());
    BigDecimal discount = loyaltyDiscountFor(customer, subtotal);

    return invoiceFor(order, subtotal, discount);
}

private static void requireItems(Order order) {
    if (order.items().isEmpty()) {
        throw new IllegalArgumentException("Order needs at least one item");
    }
}

private static BigDecimal calculateSubtotal(List<OrderItem> items) {
    return items.stream()
            .map(item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
}

private static BigDecimal loyaltyDiscountFor(Customer customer, BigDecimal subtotal) {
    return customer.isPremium()
            ? subtotal.multiply(new BigDecimal("0.10"))
            : BigDecimal.ZERO;
}

private static Invoice invoiceFor(
        Order order,
        BigDecimal subtotal,
        BigDecimal discount
) {
    return new Invoice(order.id(), subtotal, discount, subtotal.subtract(discount));
}

並不是每一行程式碼都需要一個獨立的方法。值得抽出的是一個能被精確命名的完整想法。而像 processData() 或 handleStuff() 這樣的名稱,抽出來也毫無幫助。

經驗法則:註解為什麼這樣做,而不是做了什麼

這裡做了什麼一目了然:

public Duration retryDelay(int retryCount) {
    long seconds = Math.min(1L << retryCount, 60);
    return Duration.ofSeconds(seconds);
}

但為什麼要這樣做?

更好的寫法:

public Duration retryDelay(int retryCount) {
    // Cap the delay so an outage never blocks queued jobs for more than one minute.
    long seconds = Math.min(1L << retryCount, 60);
    return Duration.ofSeconds(seconds);
}

再來一個實際專案中的例子。下面這些方法用來顯示和隱藏 Ajax 載入動畫(spinner)。可是總共有四個方法,乍看之下不太清楚為什麼:

function showAjaxLoader() {
    $('.layout-ajax-loader').children().first().show();
}

function hideAjaxLoader() {
    $('.layout-ajax-loader').children().first().hide();
}

function suppressAjaxLoader() {
    getOnlyElementByClassOrNull('layout-ajax-loader').style.display = 'none';
}

function releaseAjaxLoader() {
    getOnlyElementByClassOrNull('layout-ajax-loader').style.display = '';
}

這些方法看起來都很相似,似乎都是透過 CSS 以某種方式控制可見性。加上一句簡短的註解,各自的使用情境就一目了然了:

showAjaxLoader: function() {
    $('.layout-ajax-loader').children().first().show();
},

hideAjaxLoader: function() {
    $('.layout-ajax-loader').children().first().hide();
},

// take the control from primefaces and don't allow the spinner to show, no matter what
suppressAjaxLoader: function() {
    getOnlyElementByClassOrNull('layout-ajax-loader').style.display = 'none';
},

// reset the state after hiding, to give the control back to primefaces
releaseAjaxLoader: function() {
    getOnlyElementByClassOrNull('layout-ajax-loader').style.display = '';
},

再舉一個例子:

不好的寫法:

/**
 * Sets the tool tip text.
 *
 * @param text  the text of the tool tip
 */
public void setToolTipText(String text) {}

好的寫法:

/**
 * Registers the text to display in a tool tip. The text
 * displays when the cursor lingers over the component.
 *
 * @param text  the string to display. If the text is null,
 *              the tool tip is turned off for this component.
 */
public void setToolTipText(String text) {}

2021-06-21 工作坊上的討論:所有開發者一致認為,原則上應該避免使用 Javadoc(參數說明是一場惡夢、老是過時,維護起來也很不順手),只有當某個參數確實需要進一步解釋,或是不寫就得讀完整個冗長的方法時,才應該使用。

經驗法則的例外:註解做了什麼,這時候很有必要

如果不加註解程式碼就不夠清楚,而註解能大幅提升可讀性:

let isAlwaysAllowedKey = (
    e.which === 8  || /* BACKSPACE */
    e.which === 35 || /* END */
    e.which === 36 || /* HOME */
    e.which === 37 || /* LEFT */
    e.which === 38 || /* UP */
    e.which === 39 || /* RIGHT*/
    e.which === 40 || /* DOWN */
    e.which === 46 || /* DEL*/
    e.ctrlKey === true && e.which === 65 || /* CTRL + A */
    e.ctrlKey === true && e.which === 88 || /* CTRL + X */
    e.ctrlKey === true && e.which === 67 || /* CTRL + C */
    e.ctrlKey === true && e.which === 90   /* CTRL + Z */
)

註解:Linux 核心程式碼裡的髒話

最後再分享一則 Linux 核心的趣聞:原始碼中所有髒話的數量統計:

https://www.vidarholen.net/contents/wordcount/#fuck*,shit*,damn*,idiot*,retard*

祝你寫註解寫得開心!

2. 大型程式常式中的私有方法很危險

注意!在現今物件導向的世界裡,私有方法總是很多。這通常不是問題。只有當這些方法存取欄位(也就是狀態),而且位於大型常式之中時,問題才會出現。這類子方法應該是私有且無狀態的。否則它們就有一個缺點:單獨被呼叫時常常會惹出麻煩。總會有開發者想到要單獨呼叫這個子方法。而非法狀態正是 bug 的主要來源之一。這裡有一篇有趣的文章,介紹了 John Carmack(id Software,Doom)的一些觀點:在程式碼必須極度穩定的應用中(例如太空飛行),副程式常常是被禁止的,這讓程式碼更具確定性:http://number-none.com/blow/john_carmack_on_inlined_code.html

這個故事只能在一定程度上套用到現代無狀態的 Java/Spring 世界,但仍不失為一個很好的思考起點。