程式碼規範是程式設計師的基本功,也是團隊協作的基石。本章涵蓋命名、組織、註釋、聲明等核心主題。

GoToFail 漏洞的教訓#

2014 年,蘋果公司的 SSL/TLS 實作中出現了一個嚴重的安全漏洞,根本原因竟是一個簡單的編碼風格問題:

// 有漏洞的程式碼
if ((error = doSomething()) != 0)
    goto fail;
    goto fail;  // 這行總是執行!
if ((error = doMore()) != 0)
    goto fail;

第二個 goto fail 不在 if 條件內,導致後續驗證被跳過,任何憑證都會被接受。

如果遵循「總是使用大括號」的規範,問題一目了然:

if ((error = doSomething()) != 0) {
    goto fail;
    goto fail;  // 顯然多餘
}

教訓:編碼規範不是繁文縟節,而是防止低級錯誤的防線。

命名原則#

命名的重要性#

「計算機科學只有兩件難事:快取失效和命名。」—— Phil Karlton

好的命名能讓程式碼自我解釋,減少註釋需求。

命名方法#

方法格式適用場景
大駝峰InputStreamJava 類別、介面
小駝峰firstNameJava 方法、變數
蛇形max_valueC 語言、常數
串式background-colorCSS 屬性

Java 命名規範#

// 類別:大駝峰
public class UserService { }

// 方法:小駝峰,動詞開頭
public void calculateTotal() { }

// 變數:小駝峰,名詞
String firstName;
int itemCount;

// 常數:全大寫蛇形
static final int MAX_RETRY_COUNT = 3;

命名三原則#

  1. 準確 - 名副其實,見名知意

    // 差
    int d; // elapsed time in days
    // 好
    int elapsedTimeInDays;
  2. 規範 - 遵循語言慣例

    // 差:混用命名風格
    String user_name;  // 蛇形
    String firstName;  // 駝峰
    
    // 好:統一使用駝峰
    String userName;
    String firstName;
  3. 可讀 - 寧長勿短

    // 差
    List<String> lst;
    
    // 好
    List<String> userNames;

取名要做到「信、達、雅」—— 準確、直觀、優美。

程式碼組織#

分塊思維#

大腦按「塊」處理資訊。好的程式碼組織讓每個邏輯塊清晰可辨:

// 差:擠在一起
if(firstName!=null&&lastName!=null)

// 好:清晰分塊
if ((firstName != null) && (lastName != null))

空白的運用#

元素用途範例
空格同行內區隔a + b
縮排層級關係4 空格或 2 空格
空行區塊分隔方法之間

一行一行為#

// 差:兩個行為在一行
if (variable != null) variable.doSomething();

// 好:分開兩行
if (variable != null) {
    variable.doSomething();
}

換行原則#

  1. 每行不超過 80-120 字元
  2. 在逗號後換行
  3. 在運算符前換行
  4. 新行與上一行同級表達式對齊
String result = calculateSomething(longParameterOne,
        longParameterTwo, longParameterThree);

String value = longStringOne + longStringTwo
        + longStringThree;

註釋的藝術#

註釋的三個麻煩#

  1. 難以維護 - 註釋不會編譯,容易過時
  2. 成為藉口 - 過度依賴註釋,忽略程式碼本身的可讀性
  3. 容易濫用 - 被用來注釋掉程式碼或寫俏皮話

最好的程式碼不需要註釋。如果需要註釋,說明程式碼還不夠清晰。

三種註釋風格#

1. 版權聲明

/*
 * Copyright (c) 2024, Company Name. All rights reserved.
 */

2. API 檔案(Javadoc)

/**
 * 計算兩數之和。
 *
 * @param a 第一個加數
 * @param b 第二個加數
 * @return 兩數之和
 * @throws IllegalArgumentException 如果參數為負數
 */
public int add(int a, int b) { }

3. 實作說明

// 使用二分搜尋提高效能
int index = Collections.binarySearch(list, target);

註釋三原則#

原則說明
準確錯誤的註釋比沒有更糟
必要多餘的註釋浪費閱讀時間
清晰混亂的註釋讓程式碼更亂

不要在程式碼裡保留 TODO、除錯語句或注釋掉的程式碼。

聲明規範#

八項紀律#

  1. 取好名字 - 遵循命名規範

  2. 一行一個聲明 - 便於維護

    // 差
    int width, height;
    
    // 好
    int width;
    int height;
  3. 需要時再聲明 - 區域變數靠近使用處

  4. 類屬性集中聲明 - 便於查找

  5. 聲明時初始化 - 防止遺漏

  6. 左括號不單獨成行 - Java 慣例

    public void method() {  // 好
        // ...
    }
  7. 小括號緊靠標識符 - method() 而非 method ()

  8. 搜尋最佳化的換行 - 常見搜尋模式放同一行

類的內部結構#

推薦順序:

  1. 類屬性
  2. 建構子
  3. 工廠方法
  4. 公開方法
  5. 私有方法

控制流與條件式#

深層嵌套是可讀性殺手#

嵌套每深一層,讀者就要多扛一個「目前在哪個條件下」的心智狀態。超過 3 層,理解成本急遽上升。

嵌套深度心智負荷建議
1-2 層一般可接受
3 層開始警惕,考慮攤平
> 3 層幾乎都該重構

攤平手段:guard clause(衛述句)與早返回。 把例外/邊界情況在開頭擋掉,主流程就能保持在最外層。

// 壞味道:深層嵌套,主邏輯被埋在最內層
public double payAmount(Employee employee) {
    double result;
    if (employee.isSeparated()) {
        result = separatedAmount();
    } else {
        if (employee.isRetired()) {
            result = retiredAmount();
        } else {
            result = normalPayAmount();
        }
    }
    return result;
}

// 改善:guard clause 早返回,主流程攤平
public double payAmount(Employee employee) {
    if (employee.isSeparated()) {
        return separatedAmount();
    }
    if (employee.isRetired()) {
        return retiredAmount();
    }
    return normalPayAmount();
}

以多型或表驅動取代冗長條件#

長串 if-elseswitch,尤其是同一組判斷散落多處時,是引入多型或查表的信號。

// 壞味道:switch 隨型別擴張而膨脹
String icon;
switch (animal.getType()) {
    case DOG: icon = "dog.png"; break;
    case CAT: icon = "cat.png"; break;
    case BIRD: icon = "bird.png"; break;
    default: icon = "unknown.png";
}

// 改善 A:表驅動(型別與資料對應時最簡潔)
private static final Map<AnimalType, String> ICONS = Map.of(
    AnimalType.DOG, "dog.png",
    AnimalType.CAT, "cat.png",
    AnimalType.BIRD, "bird.png");

String icon = ICONS.getOrDefault(animal.getType(), "unknown.png");

// 改善 B:多型(型別還帶有不同「行為」時更適合)
abstract class Animal {
    abstract String icon();
}

純粹「資料對應」用表驅動(Map)最輕;若每個分支還帶不同行為,才升級為多型。先選最輕的方案。

魔術數字改用命名常數#

字面量散在邏輯裡,讀者無從得知它的意義,改值時還可能漏改。

// 壞味道:3 是什麼?86400 又是什麼?
if (retryCount > 3) { ... }
long ttl = id * 86400;

// 改善:命名常數自我解釋
private static final int MAX_RETRY = 3;
private static final int SECONDS_PER_DAY = 86_400;

if (retryCount > MAX_RETRY) { ... }
long ttl = id * SECONDS_PER_DAY;

函式簽名與參數設計#

參數過多的信號與參數物件#

參數一多(經驗上 >3-4 個就要警惕),呼叫端容易傳錯順序、簽名也難讀。當一組參數總是「一起出現」,它們往往是一個尚未命名的概念——抽成 Parameter Object。

// 壞味道:參數過多,且 start/end 總是成對出現
List<Reading> readingsInRange(Meter meter, LocalDate start,
        LocalDate end, boolean includeEstimated);

// 改善:把成對概念抽成參數物件
record DateRange(LocalDate start, LocalDate end) {
    DateRange {
        if (end.isBefore(start)) {
            throw new IllegalArgumentException("end before start");
        }
    }
}

List<Reading> readingsInRange(Meter meter, DateRange range,
        boolean includeEstimated);

參數物件不只縮短簽名,還給了驗證與相關行為一個歸宿(如上例 DateRange 自帶不變量檢查)。

旗標參數是拆分的信號#

布林旗標讓一個函式內部其實做了「兩件事」,呼叫端 doIt(true) 也無從得知 true 代表什麼。

// 壞味道:旗標參數
public void book(Concert concert, boolean isPremium) {
    if (isPremium) { ... } else { ... }
}
booking.book(concert, true); // true 是什麼意思?

// 改善:拆成兩個意圖明確的函式
public void bookRegular(Concert concert) { ... }
public void bookPremium(Concert concert) { ... }

例外:當旗標只是單純轉發給底層 API、且拆分會造成大量重複時,可保留。判準是「旗標是否改變了函式的核心行為」——若是,就該拆。

文件化前置/後置條件與不變量#

清楚標示「呼叫者要保證什麼(precondition)」「函式回傳時保證什麼(postcondition)」「物件始終維持什麼(invariant)」,能釐清責任邊界、減少防禦層數。

/**
 * 從帳戶提款。
 *
 * @param amount 提款金額,必須為正數(precondition)
 * @return 提款後的餘額,保證 >= 0(postcondition)
 * @throws InsufficientBalanceException 餘額不足時
 */
public BigDecimal withdraw(BigDecimal amount) {
    // 前置條件:對外部呼叫快速失敗
    if (amount.signum() <= 0) {
        throw new IllegalArgumentException("amount must be positive");
    }
    if (amount.compareTo(balance) > 0) {
        throw new InsufficientBalanceException();
    }
    balance = balance.subtract(amount);
    assert balance.signum() >= 0 : "invariant: balance never negative";
    return balance;
}

契約思維與防禦分層的完整討論,見 程式碼安全 的「合約式設計與進攻式編程」。

Java 註解的使用#

@Override#

覆寫的方法必須加上 @Override 註解。

好處:

  • 明確標示這是覆寫方法
  • 父類修改時編譯器會報錯
class Student extends Person {
    @Override
    public String getFirstName() {
        return super.getFirstName();
    }
}

@Deprecated#

及時廢棄不合理的 API:

/**
 * @deprecated 使用 {@link #newMethod()} 代替
 */
@Deprecated(since = "2.0", forRemoval = true)
public void oldMethod() { }

@SuppressWarnings#

盡量不要使用 @SuppressWarnings。警告是有價值的資訊。

例外處理#

例外分類#

類型範例處理方式
ErrorOutOfMemoryError不需處理
RuntimeExceptionNullPointerException需記錄在文件
Checked ExceptionIOException必須宣告或捕獲

例外處理原則#

  1. 不要用例外處理正常流程

    // 差
    try {
        checkUserName(name);
    } catch (InvalidNameException e) {
        // 處理無效名稱
    }
    
    // 好
    if (isValidUserName(name)) {
        // 處理有效名稱
    } else {
        // 處理無效名稱
    }
  2. 選擇精確的例外類型 - 不要用 Exception

  3. 提供清晰的例外描述

    throw new IllegalArgumentException(
        "User ID must be positive, but was: " + userId);

檔案組織#

專案結構#

project/
├── README           # 軟體說明
├── COPYRIGHT        # 版權資訊
├── LICENSE          # 授權條款
├── src/             # 原始碼
│   └── com/example/
├── test/            # 測試程式碼
│   └── com/example/
└── docs/            # 文件

原始碼檔案結構#

  1. 版權聲明
  2. package 宣告
  3. import 語句
  4. 類別 Javadoc
  5. 類別宣告
  6. 類別實作
程式碼規範檢查清單

命名

  • 類別使用大駝峰
  • 方法和變數使用小駝峰
  • 常數使用全大寫蛇形
  • 名稱準確表達意圖

格式

  • 統一縮排風格
  • 每行不超過 120 字元
  • if/for/while 總是使用大括號
  • 適當使用空行分隔邏輯塊

註釋

  • 公開 API 有 Javadoc
  • 沒有過時的註釋
  • 沒有注釋掉的程式碼

聲明

  • 一行一個聲明
  • 變數靠近使用處聲明
  • 覆寫方法有 @Override

例外

  • 檔案記錄所有可能的例外
  • 不用例外處理正常流程
  • 例外訊息清晰明確