流程與資料的封裝

May 27, 2022

在〈定義類別〉看過使用類別單純地將多個資料組合在一起,這是封裝的意圖之一;進一步地,有些流程操作與某些資料是息息相關的,這時會將資料與操作放在一起,以便於使用。

封裝初始流程

假設你要寫個可以管理儲值卡的應用程式,首先得定義儲值卡會記錄哪些資料,像是儲值卡號碼、餘額、紅利點數,這可使用 class 關鍵字進行定義:

package cc.openhome;

class CashCard {
    String number;
    int balance;
    int bonus;
}

假設你將這個類別是定義在 cc.openhome套件,使用 CashCard.java 儲存,並編譯為 CashCard.class,並將位元碼給朋友使用,你的朋友要建立 5 張儲值卡的資料:

var card1 = new CashCard();
card1.number = "A001";
card1.balance = 500;
card1.bonus = 0;
       
var card2 = new CashCard();
card2.number = "A002";
card2.balance = 300;
card2.bonus = 0;
       
var card3 = new CashCard();
card3.number = "A003";
card3.balance = 1000;
card3.bonus = 1;  // 單次儲值 1000 元可獲得紅利一點
...

在這邊可以看到,如果想存取物件的資料成員,可以透過 . 運算子加上資料成員名稱。

你發現到每次他在建立儲值卡物件時,都會作相同的初始動作,也就是指定卡號、餘額與紅利點數,這個流程是重複的,更多的 CashCard 物件建立會帶來更多的程式碼重複,在程式中出現重複的流程,往往意謂著有改進的空間,可以定義建構式(Constructor)來改進這個問題:

package cc.openhome;

class CashCard {
    String number;
    int balance;
    int bonus;

    CashCard(String number, int balance, int bonus) {
        this.number = number;
        this.balance = balance;
        this.bonus = bonus;
    }
}

建構式是與類別名稱同名的方法(Method),不用宣告傳回型態,在這個例子中,建構式上的 numberbalancebonus 參數,與類別的 numberbalancebonus 資料成員同名了,為了區別,在物件資料成員前加上 this 關鍵字,表示將 numberbalancebonus 參數的值,指定給這個物件的 numberbalancebonus 資料成員。

在你重新編譯 CashCard.java 為 CashCard.class 後,交給你的朋友,同樣是建立五個 CashCard 物件,現在他只要這麼寫:

var card1 = new CashCard("A001", 500, 0);
var card2 = new CashCard("A002", 300, 0);
var card3 = new CashCard("A003", 1000, 1);
...

比較看看,他應該會想寫這個程式片段,還是剛剛那個程式片段?那麼你封裝了什麼?你用了 Java 的建構式語法,實現物件初始化流程的封裝。封裝物件初始化流程有什麼好處?拿到 CashCard 類別的使用者,不用重複撰寫物件初始化流程,事實上,他也不用知道物件如何初始化,就算你修改了建構式的內容,重新編譯並給予位元碼檔案之後,CashCard 類別的使用者也無需修改程式。

實際上,如果你的類別使用者想建立 5 個 CashCard 物件,並將資料顯示出來,可以用陣列,而無需個別宣告參考名稱。例如:

package cc.openhome;

public class CardApp {
    public static void main(String[] args) {
        CashCard[] cards = {
            new CashCard("A001", 500, 0),
            new CashCard("A002", 300, 0),
            new CashCard("A003", 1000, 1),
            new CashCard("A004", 2000, 2),
            new CashCard("A005", 3000, 3)
        };
        
        for(var card : cards) {
            System.out.printf("(%s, %d, %d)%n",
                    card.number, card.balance, card.bonus);
        }
    }
}

執行結果如下所示:

(A001, 500, 0)
(A002, 300, 0)
(A003, 1000, 1)
(A004, 2000, 2)
(A005, 3000, 3)

封裝操作流程

假設現在你的朋友使用 CashCard 建立 3 個物件,並要再對所有物件進行儲值的動作:

var scanner = new Scanner(System.in);
var card1 = new CashCard("A001", 500, 0);
var money = scanner.nextInt();
if(money > 0) {
    card1.balance += money;
    if(money >= 1000) {
        card1.bonus++;
    }
}
else {
    System.out.println("儲值是負的?你是來亂的嗎?");
}
       
var card2 = new CashCard("A002", 300, 0);
money = scanner.nextInt();
if(money > 0) {
    card2.balance += money;
    if(money >= 1000) {
        card2.bonus++;
    }
}
else {
    System.out.println("儲值是負的?你是來亂的嗎?");
}
       
var card3 = new CashCard("A003", 1000, 1);
// 還是那些if..else的重複流程
...

你的朋友做了簡單的檢查,就是儲值不能是負的,而儲值大於 1000 的話,就給予紅利一點,很容易就可以發現,那些儲值的流程重複了。你想了一下,儲值這個動作應該是 CashCard 物件自己處理!你可以定義方法(Method)來解決這個問題:

package cc.openhome;

class CashCard {
    String number;
    int balance;
    int bonus;

    CashCard(String number, int balance, int bonus) {
        this.number = number;
        this.balance = balance;
        this.bonus = bonus;
    }
    
    void store(int money) {  // 儲值時呼叫的方法
        if(money > 0) {
            this.balance += money;
            if(money >= 1000) { 
                this.bonus++;
            }
        }
        else {
            System.out.println("儲值是負的?你是來亂的嗎?");
        }
    }
    
    void charge(int money) { // 扣款時呼叫的方法
        if(money > 0) {
            if(money <= this.balance) {
                this.balance -= money;
            }
            else {
                System.out.println("錢不夠啦!");
            }
        }
        else {
            System.out.println("扣負數?這不是叫我儲值嗎?");
        }
    }
    
    int exchange(int bonus) {  // 兌換紅利點數時呼叫的方法
        if(bonus > 0) {
            this.bonus -= bonus;
        }
        return this.bonus;
    }
}

CashCard 類別中,除了定義儲值用的 store 方法之外,你還考慮到扣款用的 charge 方法,以及兌換紅利點數的 exchange 方法。在類別中定義方法,如果不用傳回值,方法名稱前可以宣告 void

先前看到的儲值重複流程,現在都封裝到 store 方法中,這麼作的好處是使用 CashCard 的使用者,現在可以這麼撰寫了:

var scanner = new Scanner(System.in);
var card1 = new CashCard("A001", 500, 0);
card1.store(scanner.nextInt());
       
var card2 = new CashCard("A002", 300, 0);
card2.store(scanner.nextInt());
       
var card3 = new CashCard("A003", 1000, 1);
card3.store(scanner.nextInt());

好處是什麼顯而易見,相較於先前得撰寫重複流程,CashCard 使用者應該會比較想寫這個吧!你封裝了什麼呢?你封裝了儲值的流程。哪天你也許考慮每加值 1000 元就增加一點紅利,而不像現在就算加值 5000 元也只有一點紅利,就算改變了 store 的流程,CashCard 使用者也無需修改程式。

同樣地,chargeexchange 方法也分別封裝了扣款以及兌換紅利點數的流程。為了知道兌換紅利點數後,剩餘的點數還有多少,exchange 必須傳回剩餘的點數值,方法若會傳回值,必須於方法前宣告傳回值的型態。

在 Java 命名慣例中,方法名稱首字是小寫。

其實如果是直接建立三個 CashCard 物件,而後進行儲值並顯示明細,可以如下使用陣列,讓程式更簡潔:

package cc.openhome;

import java.util.Scanner;

public class CardApp {
    public static void main(String[] args) {
        CashCard[] cards = {
            new CashCard("A001", 500, 0),
            new CashCard("A002", 300, 0),
            new CashCard("A003", 1000, 1)
        };

        var scanner = new Scanner(System.in);
        for(var card : cards) {
            System.out.printf("為 (%s, %d, %d) 儲值:", 
                    card.number, card.balance, card.bonus);
            card.store(scanner.nextInt());
            System.out.printf("明細 (%s, %d, %d)%n",
                    card.number, card.balance, card.bonus);
        }
    }
}

執行結果如下所示:

為 (A001, 500, 0) 儲值:1000
明細 (A001, 1500, 1)
為 (A002, 300, 0) 儲值:2000
明細 (A002, 2300, 1)
為 (A003, 1000, 1) 儲值:3000
明細 (A003, 4000, 2)

封裝物件資料

你希望使用者如下撰寫程式,這樣才可以執行 stroe 等方法中的相關條件檢查流程:

var card1 = new CashCard("A001", 500, 0);
card1.store(scanner.nextInt());

老實說,你的希望完全就是一廂情願,因為 CashCard 使用者還是可以如下撰寫程式,跳過你的相關條件檢查:

var card1 = new CashCard("A001", 500, 0);
card1.balance += scanner.nextInt();
card1.bonus += 100;

當然,這可以成為團隊約定,寫在文件裡,要大家不得這麼做,不過,Java 可以實現更進一步地封裝,可以使用 private 關鍵字限定哪些資料是私有的,使用者不用知道也不應該存取:

package cc.openhome;

class CashCard {
    private String number;
    private int balance;
    private int bonus;
    ...
    void store(int money) {
        if(money > 0) {
            this.balance += money;
            if(money >= 1000) {
                this.bonus++;
            }
        }
        else {
            System.out.println("儲值是負的?你是來亂的嗎?");
        }
    }

    int getBalance() {
        return balance;
    }

    int getBonus() {
        return bonus;
    }

    String getNumber() {
        return number;
    }
}

在這個例子,你不想讓使用者直接存取 numberbalancebonus,因此使用 private 宣告,如此一來,編譯器會讓使用者在直接存取 numberbalancebonus 時編譯失敗,如果沒有提供方法存取 private 成員,那使用者就不能存取,在 CashCard 的例子中,如果想修改 balancebouns,就一定得透過 storechargeexchange 等方法,也就一定得經過你定義的流程。

如果沒辦法直接取得 numberbalancebonus,那這段程式碼怎麼辦?

for(var card : cards) {
    System.out.printf("(%s, %d, %d)%n",
        card.number, card.balance, card.bonus); // 編譯錯誤
}

除非你願意提供取值方法(Getter),讓使用者可以取得 numberbalancebonus 的值,否則使用者一定無法取得,基於你的意願,CashCard 類別上定義了 getNumbergetBalancegetBonus 等取值方法,你可以如下修改程式:

for(var card : cards) {
    System.out.printf("(%s, %d, %d)%n",
        card.getNumber(), card.getBalance(), card.getBonus());
}

在 Java 命名規範中,取值方法的名稱形式是固定的,也就是以 get 開頭。

你封裝了什麼?封裝了類別私有資料,讓使用者無法直接存取,而必須透過你提供的操作方法,經過你定義的流程才有可能存取私有資料,事實上,使用者也無從得知你的類別中有哪些私有資料,使用者不會知道物件的內部細節。

這邊的封裝意圖,主要就是隱藏物件細節,將物件當作黑箱進行操作。就如先前的範例,使用者會呼叫建構式,但不知道建構式的細節,使用者會呼叫方法,但不知道方法的流程,使用者也不會知道有哪些私有資料,要操作物件,一律得透過你提供的方法呼叫。

private 也可以用在方法或建構式宣告上,私有方法或建構式通常是類別內部某個共用的演算流程,外界不用知道私有方法的存在。private 也可以用在內部類別宣告,內部類別會在之後說明。

私有建構式的使用,可以參考 Singleton 模式。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter