封裝包含了兩個重要的觀念:

  • 控制物件和外部進行互動的出入口
  • 隱藏物件內部的細節資訊

強者我同事整理的文章裡的例子就舉得不錯:當你按下鍵盤的A鍵,螢幕隨即出現了A,你不必知道中間發生了什麼事,你只需要知道怎麼操作和最後得到什麼就可以了。

其中鍵盤提供的按鍵,就是我們對電腦進行互動的出入口;而電腦實際上做了什麼事情,也被隱藏了起來,讓我們只需要關注結果就好。

此外我也看到過販賣機的例子,當你去販賣機買飲料,你也不需要知道裡面的構造,只要知道你選了飲料投了錢,飲料就會跑出來就行

從上面的兩個例子,相信大家已經掌握到封裝的概念了:將物件視作一個整體,把內部的實作內容隱藏起來,讓使用者只需要知道怎麼使用這個物件即可。(相似的思路,我們後續的介面會再提到)

如果封裝做得夠好,除了可以將程式碼整理得井井有條以外,也能讓物件內部的修改不會直接影響到使用物件的地方,達成了降耦合的目標

並且也能讓物件的使用者直覺地知道如何使用物件提供的方法,如此使用者就可以專注在更高層次的抽象,而不用被物件內部的細節所干擾。

最後,從上面的敘述中我們可以察覺到要實現封裝,最重要的就是:對外的開放程度(存取範圍)的控制。或是套一句前輩的說法:給程式碼隱私的空間

補充:如果想問「什麼是耦合?」的朋友,建議可以看看這篇:實務上的高內聚與低耦合

或是參照本系列後續的 內聚與耦合

存取範圍與存取子

先讓我們從存取範圍開始說起吧,因為我個人慣用的是 C#,因此就介紹一下 C# 是怎麼控制存取範圍的。

在 C# 之中,類別裡控制可見度是使用修飾子來定義存取範圍,也就是當我們替類別宣告欄位時常看到的 PublicPrivate

  • Public: 這是公開的,所有人都看得到
  • Private: 這是私有的,只有自己看得到

除了最常用的這兩個以外,還有其他的修飾子可以先知道一下:

  • Protected: 這是受到保護的,只有自己和繼承的孩子們看得到
  • internal: 這是內部的,只有身為同一個組件的朋友們看得到
  • Protected internal:組合上面兩個,也就是可以給同個組件的朋友們,或是其他組件繼承的孩子們看見

接下來的部分會以最常見的 PublicPrivate 來繼續說明,對存取範圍的這些修飾子有興趣的朋友,可以參照 存取範圍層級 的說明。

現在我們已經知道了有哪些修飾子可以用來控制存取範圍,但為什麼我們會需要宣告存取範圍的大小呢?其根本是為了將控制權掌握在物件本身

就像大話設計模式比喻的:物件就像間房子,我們不希望被看光光,可以看見的 Public 就像門和窗,而不該看見的 Private 則是用牆壁隱藏起來,而對於這間房子而言,門窗是可以控制的。

對於這部分的範例,我覺得 微軟文件的範例 裡設定月份的區塊已經能很清楚表達了。但為了這篇文章的一致性,還是硬擠著一個範例出來:

某一天,我們突然決定讓使用者可以傳入卡牌敘述了,但是卡牌上能顯示的字數有限,只能顯示 30 個字,因此首先我們先把卡牌敘述改成私有的:

public class Card
{
    public string Name;
    public int Level;
    public int Attack;
    public int Health;
    private string _description; // 更改為私有的

    /* 一些其他方法 */
}

呃可能第一步就會讓人有些疑惑:「啊你要給人家傳東西進來還改私有?」但等等,且聽我娓娓道來:C# 中的屬性,是用 SetGet 兩個方法去存取的,又稱做存取子。這兩個看門仔也就擔當了房屋的門窗、出入境時的海關、古代大戰中的關隘這類「控制進出通道」的角色。

現在讓我們試著規劃出我們的門和窗,在上面的例子中,我們想要當卡牌的敘述進來時,保持在 30 個字:

public class Card
{
    // 略
    private string _description;

    public string Description
    {
        set
        {
            if (value.Length > 0 && value.Length < 30)
            {
                this._description = value;
            }
            else
            {
                throw new System.Exception("就跟你說限 30 個字看不懂喔!");
            }
        }
        get
        {
            return this._description;
        }
    }
    /* 一些其他方法 */
}

如此一來我們就能對存取屬性時的行為進行管控囉。

那可能有些朋友會有疑惑:那為什麼我不能直接對外開放卡牌敘述,然後修改的時候檢查完再傳進來 set 就好了呢?這個就牽涉到一些「改太多地方了我要死啦」的悲情故事,這邊再舉個例子給大家體會一下。

例如說,我們的卡片現在加入了戰力指數系統,這個戰力是預先從卡片的各項資訊計算好,並存放在資料庫的。而且因為計算的關係可能有小數點後十位之類的,那我們拿出來的時候可能會長這樣:Power: 99.256256

而在建立類別的時候,也很自然地選用了 double 來處理,於是現在類別就長這樣:

public class Card
{
    public double Power;
}

這個系統上線運行了一段時間之後,突然上頭來了需求:請把所有顯示到卡片敘述的地方都改成小數點後兩位就好。

假設我們不能直接修改從資料表取出來時的數值(或是已經改了然後被前輩電)因為記 Log 或是什麼戰力對決(?)功能還會需要用到原本的戰力數值之類的理由,因此物件存放的戰力數值必須和資料表中的一致等等,總之不允許改資料

如果先前直接開放存取,那麼這下子要改的地方就變成「所有使用到這個屬性的地方」,再要是當場看到 Visual Studio 上面寫:99 個參考,那可能當場整個腦子就直接下班了。

但如果我們是使用 getset 的方式去處理的話,那麼我們只需要修改 get 存取子的規則,讓它讀取的時候幫忙四捨五入到小數點第二位就好了:

public class Card
{   
    private double _power;
    
    public double Power 
    { 
        set { this._power = value; } 
        get { return Math.Round(this._power, 2);}
    }
}

這樣應該就能看出使用 getset 去把攔位封裝起來的好處了,也就是:把「資料進出時加以處理的主導權」留在物件本身

而在 C# 裡,如果你並沒有(或是說「還沒有」)要特別針對存取另做額外處理,可以直接使用自動實作

public string Description { set; get; }

這樣實際上就會自動幫你建立一個私有屬性,並且只能經由這個公開屬性的 setget 進行存取。

藉由自動實作來簡化寫法之後,例如唯讀就可以這樣寫:

public string Description { get; }

或是

public string Description { get; private set; }

使用自動實作時,若要加上預設值的話請這樣寫

public string Description { get; } = "這是一張卡牌。";

可以說是方便很多。將來如果要針對設值給值的地方進行修改,也會比較方便一點。

稍微了解了上面提到的存取範圍、存取子、自動實作這些工具之後,現在,我們就可以決定外部的使用者能看到物件的哪些部份了。

隱藏複雜度

當然,封裝的概念並不僅僅只是對屬性定義存取範圍如此而已,提高類別內的內聚性,降低對外的耦合性,隱藏複雜資訊才是最重要的方針

也就是說,我們需要妥善地運用「把大的類別和方法切割成小的類別和方法」、「活用存取範圍,對外隱藏複雜資訊、對內切割成各個工作的私有方法」等等技巧,才能夠更接近完善的封裝一點。然而這只能在設計時,或是維護到頭痛才能親自體會了。

接續著上面的技巧來說:當你面對在一個公開方法中需要處理一長串的商業邏輯,以至於需要將他們切割成數個小函式時,將它們宣告成 Private 就是相當好的選擇

例如說,我們有個連線到資料庫取得客戶資料的方法(可能是 UserRepository.Get(int UserId) 這種感覺),可能我們除了 PublicGet 方法以外,還有一些 PrivateConnectDB 等輔助方法。

這意味著這些工具僅讓你的物件內部使用,外面的人不應該直接調用其中的任何功能,同時又能幫助你的主要流程變得更簡潔,提升維護和修改時的速度。

同時以資料庫的例子來說:呼叫這個函式的使用端不需要知道這個函式是怎麼連線到資料庫的,又是怎麼搜尋出資料的,只需要知道呼叫了之後能拿到客戶資料就好

兩個物件之間「知道」得越多,其耦合就越高。替換和修改時互相牽連的機會和規模也越大,因此封裝可以說是物件導向的基石也不為過。封裝的好或不好(亦即物件是否足夠內聚,其職責是否單一,暴露內部資訊的多寡等等),直接關係到整個架構的優劣,不可謂不慎。

補充:封裝的核心在於「隱藏複雜資訊」。而我們前段所提到需要注意的幾個部份:

「物件是否內聚」可以參照 菜雞與物件導向 (8): 內聚、耦合

「職責是否單一」可以參照 菜雞與物件導向 (10): 單一職責原則

這些概念之間彼此相扣,此處就先按下不表。

小結

封裝的部分就講到這裡,並不是很難理解,但是要封裝得好,或是說知道怎樣才算封裝得好,還是需要經驗,不是我這種菜雞一時半刻能理得白說得清的,之後有心得再和大家分享。

封裝、繼承、多型並稱物件導向三大特性,我們也會按照這個順序快速地介紹。接著我們就繼續來看 繼承 吧!

本系列下一篇:菜雞與物件導向 (4): 繼承

延伸閱讀:菜雞與物件導向 (15): 最少知識原則

參考資料

推薦系列文

同系列文章