位元詩人 Java Class 撰寫原則:初學者最常踩的坑

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

Java 帶有強烈的工程特質,直接把物件導向融入語法。但初學者沒有物件導向觀念,有時會不小時寫出語法正確但不符合設計初衷的程式碼。

本文整理了一些常見情境,讓讀者避開這些因 Java 語法帶來的坑。

這些坑大多不是語法錯誤,而是「設計責任不清」導致的問題。

其實你不需要物件導向

由於 Java 的工程特質,學習者被迫一開始就用物件導向的思維寫程式碼。這是誤用語法的來源。

C 的主函式很單純:

int main (int argc, char **argv)
{
    /* Implement your code here. */
}

但 Java 的主函式就稍微長一點:

public class MainProgram
{
    public static void main (String[] args)
    {
        /* Implement your code here. */
    }
}

一般來說,Java 講師會說「這是樣板,照著寫。」這是為你好。因為一開始就講太多細節,反而對學習有害。

不過,如果你想控制你的 Java 程式碼,減少不必要的物件導向,可以參考這個寫法:

/* A driver class. */
public final class MainProgram
{
    /* The main function of a Java program. */
    public static void main (String[] args)
    {
        /* Implement your code here. */
    }

    /* A home-made assert. */
    private static void assertCond (boolean cond)
    {
        if (!cond)
            throw new AssertionError("Wrong condition");
    }

    /* A home-made assert with a custom statement. */
    private static void assertCond (boolean cond, String stat)
    {
        if (!cond)
            throw new AssertionError(stat);
    }
}

這種寫法有幾個特點:

  • MainProgram 代表這是一個沒有語意的容器
  • 使用 final 明確表示這個類別不參與繼承設計;它只是語意上的容器。
  • 只有在必要處用 public,其他都用 private,不開放不必要的權限
  • 用 class method 模擬 C、C++ 的 function

這裡的重點不是「不要用物件導向」,而是:

在問題還很單純時,不要為了符合語法而強行套入物件設計。

很多初學者會卡住,是因為同時在學兩件事:

  • Java 語法
  • 物件導向設計

這兩者其實是不同層次的問題。

上帝物件

我們前面的樣板在展示小程式時很好用,但在程式碼變大時,會衍生另一個問題:權責不清的類別。

簡單地說,就是一個完全不劃分程式功能的超大物件,把所有程式碼都塞在一起。

上帝物件通常不是一開始就存在,而是這樣長出來的:

  1. 一開始所有程式碼都寫在 main
  2. 發現太長 → 抽成 method
  3. method 還是很多 → 全部留在同一個 class
  4. 最後變成「什麼都做的 class」

也就是說,它其實是「沒有拆責任」的結果,而不是設計錯誤的起點。

若要防止程式碼滑向上帝物件,同時要避免過度物件導向,可以參考以下寫法:

/** Just a namespace for assertions. */
final class AssertUtils
{
    /* Prevent object creation. */
    private AssertUtils() {
        throw new AssertionError();
    }

    /* A home-made assert. */
    static void assertCond(boolean cond)
    {
        if (!cond)
            throw new AssertionError("Assertion failed");
    }

    /* A home-made assert with a custom statement. */
    static void assertCond(boolean cond, String stat)
    {
        if (!cond)
            throw new AssertionError(stat);
    }
}

public final class MainProgram
{
    public static void main (String[] args)
    {
        AssertUtils.assertCond(3 == (1 + 2));
    }
}

這種寫法有幾個特點:

  • 同樣使用 final 明確表示這個類別不參與繼承設計
  • 刻意阻止建構物件,將類別保持在命名空間的角色
  • 將程式碼的權責區分開來,防止上帝物件

這種「工具型 class」有一個重要前提:

它不應該有狀態(stateless)

AssertUtils 這類 class:

  • 不需要建構物件
  • 不應該保存資料
  • 只負責純計算或檢查

一旦開始出現狀態(例如 member variable),就應該重新思考是否仍適合用這種設計。

實務上,也有將類別當成命名空間的做法,像是 java.lang.Math 類別。這個類別很單純,包了一些常見的數學函式,完全無狀態,不需要生成物件就可以使用。

將欄位公開

對於有狀態的類別來說,將欄位公開幾乎沒有任何益處。你無法預期類別使用者使用欄位。你寫的欄位檢查函式形同失效。

由於 Java 沒有 struct。偶爾會有程式設計者把 class 當成 struct 來用。這時候你要知道那是個 struct,不要試圖將它長成 class。

初學者會這樣寫,通常是因為:

  • 想模仿 C 的 struct
  • 覺得 getter / setter 很麻煩

但在 Java 中,這會讓你失去一個關鍵能力:

控制狀態變化(state transition)

例如:

user.age = -10;   // 編譯不會錯,但邏輯壞了

如果你真的只是需要一個「純資料結構」,可以考慮:

  • 明確把它當 DTO(Data Transfer Object)
  • 或使用 record(Java 16+)
public record User(String name, int age) {}

這樣比手動開欄位更清楚,也更安全。

過度抽象

函式、類別的本質是抽象化。但沒控制好的話,有可能做過頭,只是增加程式碼閱讀的困難度。

以下範例程式碼用來模擬 C 的主函式:

public final class MainProgram
{
    public static void main (String[] args)
    {
        int exitCode = run(args);
        System.exit(exitCode);
    }

    private static int run (String[] args)
    {
        /* Implement your code here. */

        return 0;
    }
}

如果你沒有想要測試 run 函式的話,這樣的寫法其實是過度設計。

過度設計還蠻常見的。例如:只有一個類別時就寫 interface,過早撰寫 dependency injection。這在教學上有時是不可避免的。那是在展示語法或模式。但實際撰寫程式碼,不該讓抽象大於需求。

可以用一個簡單標準來判斷是否過度抽象:

如果拿掉這層抽象,程式碼會變更難維護嗎?

如果答案是「不會」,那這層抽象很可能是不必要的。

抽象應該用來「隔離變化」,而不是「預測未來」。

結語

這些問題看起來分散,但本質都一樣:

沒有清楚定義「這個 class 的責任是什麼」

當你在設計 class 時,可以問自己三件事:

  1. 這個 class 負責什麼?
  2. 它的狀態應該由誰控制?
  3. 如果需求改變,這個 class 會不會變得很難改?

如果這三個問題答不出來,通常就會開始踩坑。

關於作者

位元詩人 (ByteBard) 是資訊領域碩士,喜歡用開源技術來解決各式各樣的問題。這類技術跨平台、重用性高、技術生命長。

除了開源技術以外,位元詩人喜歡日本料理和黑咖啡,會一些日文,有時會自助旅行。