菜雞與物件導向 (3): 封裝
封裝包含了兩個重要的觀念:
- 控制物件和外部進行互動的出入口
- 隱藏物件內部的細節資訊
強者我同事整理的文章裡的例子就舉得不錯:當你按下鍵盤的A鍵,螢幕隨即出現了A,你不必知道中間發生了什麼事,你只需要知道怎麼操作和最後得到什麼就可以了。
其中鍵盤提供的按鍵,就是我們對電腦進行互動的出入口;而電腦實際上做了什麼事情,也被隱藏了起來,讓我們只需要關注結果就好。
此外我也看到過販賣機的例子,當你去販賣機買飲料,你也不需要知道裡面的構造,只要知道你選了飲料投了錢,飲料就會跑出來就行。
從上面的兩個例子,相信大家已經掌握到封裝的概念了:將物件視作一個整體,把內部的實作內容隱藏起來,讓使用者只需要知道怎麼使用這個物件即可。(相似的思路,我們後續的介面會再提到)
如果封裝做得夠好,除了可以將程式碼整理得井井有條以外,也能讓物件內部的修改不會直接影響到使用物件的地方,達成了降耦合的目標
並且也能讓物件的使用者直覺地知道如何使用物件提供的方法,如此使用者就可以專注在更高層次的抽象,而不用被物件內部的細節所干擾。
最後,從上面的敘述中我們可以察覺到要實現封裝,最重要的就是:對外的開放程度(存取範圍)的控制。或是套一句前輩的說法:給程式碼隱私的空間。
補充:如果想問「什麼是耦合?」的朋友,建議可以看看這篇:實務上的高內聚與低耦合
或是參照本系列後續的 內聚與耦合
存取範圍與存取子
先讓我們從存取範圍開始說起吧,因為我個人慣用的是 C#,因此就介紹一下 C# 是怎麼控制存取範圍的。
在 C# 之中,類別裡控制可見度是使用修飾子來定義存取範圍,也就是當我們替類別宣告欄位時常看到的 Public
和 Private
。
Public
: 這是公開的,所有人都看得到Private
: 這是私有的,只有自己看得到
除了最常用的這兩個以外,還有其他的修飾子可以先知道一下:
Protected
: 這是受到保護的,只有自己和繼承的孩子們看得到internal
: 這是內部的,只有身為同一個組件的朋友們看得到Protected internal
:組合上面兩個,也就是可以給同個組件的朋友們,或是其他組件繼承的孩子們看見
接下來的部分會以最常見的 Public
和 Private
來繼續說明,對存取範圍的這些修飾子有興趣的朋友,可以參照 存取範圍層級 的說明。
現在我們已經知道了有哪些修飾子可以用來控制存取範圍,但為什麼我們會需要宣告存取範圍的大小呢?其根本是為了將控制權掌握在物件本身。
就像大話設計模式比喻的:物件就像間房子,我們不希望被看光光,可以看見的 Public
就像門和窗,而不該看見的 Private
則是用牆壁隱藏起來,而對於這間房子而言,門窗是可以控制的。
對於這部分的範例,我覺得 微軟文件的範例 裡設定月份的區塊已經能很清楚表達了。但為了這篇文章的一致性,還是硬擠著一個範例出來:
某一天,我們突然決定讓使用者可以傳入卡牌敘述了,但是卡牌上能顯示的字數有限,只能顯示 30 個字,因此首先我們先把卡牌敘述改成私有的:
public class Card
{
public string Name;
public int Level;
public int Attack;
public int Health;
private string _description; // 更改為私有的
/* 一些其他方法 */
}
呃可能第一步就會讓人有些疑惑:「啊你要給人家傳東西進來還改私有?」但等等,且聽我娓娓道來:C# 中的屬性,是用 Set
和 Get
兩個方法去存取的,又稱做存取子。這兩個看門仔也就擔當了房屋的門窗、出入境時的海關、古代大戰中的關隘這類「控制進出通道」的角色。
現在讓我們試著規劃出我們的門和窗,在上面的例子中,我們想要當卡牌的敘述進來時,保持在 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 個參考
,那可能當場整個腦子就直接下班了。
但如果我們是使用 get
和 set
的方式去處理的話,那麼我們只需要修改 get
存取子的規則,讓它讀取的時候幫忙四捨五入到小數點第二位就好了:
public class Card
{
private double _power;
public double Power
{
set { this._power = value; }
get { return Math.Round(this._power, 2);}
}
}
這樣應該就能看出使用 get
和 set
去把攔位封裝起來的好處了,也就是:把「資料進出時加以處理的主導權」留在物件本身。
而在 C# 裡,如果你並沒有(或是說「還沒有」)要特別針對存取另做額外處理,可以直接使用自動實作:
public string Description { set; get; }
這樣實際上就會自動幫你建立一個私有屬性,並且只能經由這個公開屬性的 set
和 get
進行存取。
藉由自動實作來簡化寫法之後,例如唯讀就可以這樣寫:
public string Description { get; }
或是
public string Description { get; private set; }
使用自動實作時,若要加上預設值的話請這樣寫
public string Description { get; } = "這是一張卡牌。";
可以說是方便很多。將來如果要針對設值給值的地方進行修改,也會比較方便一點。
稍微了解了上面提到的存取範圍、存取子、自動實作這些工具之後,現在,我們就可以決定外部的使用者能看到物件的哪些部份了。
隱藏複雜度
當然,封裝的概念並不僅僅只是對屬性定義存取範圍如此而已,提高類別內的內聚性,降低對外的耦合性,隱藏複雜資訊才是最重要的方針。
也就是說,我們需要妥善地運用「把大的類別和方法切割成小的類別和方法」、「活用存取範圍,對外隱藏複雜資訊、對內切割成各個工作的私有方法」等等技巧,才能夠更接近完善的封裝一點。然而這只能在設計時,或是維護到頭痛才能親自體會了。
接續著上面的技巧來說:當你面對在一個公開方法中需要處理一長串的商業邏輯,以至於需要將他們切割成數個小函式時,將它們宣告成 Private
就是相當好的選擇。
例如說,我們有個連線到資料庫取得客戶資料的方法(可能是 UserRepository.Get(int UserId)
這種感覺),可能我們除了 Public
的 Get
方法以外,還有一些 Private
的 ConnectDB
等輔助方法。
這意味著這些工具僅讓你的物件內部使用,外面的人不應該直接調用其中的任何功能,同時又能幫助你的主要流程變得更簡潔,提升維護和修改時的速度。
同時以資料庫的例子來說:呼叫這個函式的使用端不需要知道這個函式是怎麼連線到資料庫的,又是怎麼搜尋出資料的,只需要知道呼叫了之後能拿到客戶資料就好。
兩個物件之間「知道」得越多,其耦合就越高。替換和修改時互相牽連的機會和規模也越大,因此封裝可以說是物件導向的基石也不為過。封裝的好或不好(亦即物件是否足夠內聚,其職責是否單一,暴露內部資訊的多寡等等),直接關係到整個架構的優劣,不可謂不慎。
補充:封裝的核心在於「隱藏複雜資訊」。而我們前段所提到需要注意的幾個部份:
「物件是否內聚」可以參照 菜雞與物件導向 (8): 內聚、耦合
「職責是否單一」可以參照 菜雞與物件導向 (10): 單一職責原則
這些概念之間彼此相扣,此處就先按下不表。
小結
封裝的部分就講到這裡,並不是很難理解,但是要封裝得好,或是說知道怎樣才算封裝得好,還是需要經驗,不是我這種菜雞一時半刻能理得白說得清的,之後有心得再和大家分享。
封裝、繼承、多型並稱物件導向三大特性,我們也會按照這個順序快速地介紹。接著我們就繼續來看 繼承 吧!
本系列下一篇:菜雞與物件導向 (4): 繼承
延伸閱讀:菜雞與物件導向 (15): 最少知識原則
參考資料
- 實務上的高內聚與低耦合 - 可不可以不要寫糙 code
- 什麼是OO?物件導向與封裝 - Chun Yeung - Medium
- Object Oriented物件導向-3:封裝(Encapsulation)、繼承(Inheritance)與多型(polymorphism) - Sian
- [心得整理] c# 物件導向程式 - 2.封裝、繼承、多型的三大特性 - 聊聊程式
- 思考物件導向(1)物件導向與封裝 - 蔡學鏞
- 物件導向(Object Oriented Programming)概念 - Po-Ching Liu - Medium
- 《大話設計模式》附錄:物件導向基礎
- Public? Private? 比較各種修飾詞存取範圍 – 理工宅 Nelson’s Diary (aihuadesign.com)
- 存取範圍層級 - C# 參考 | Microsoft Docs
- 限制存取子的存取範圍 - C# 程式設計手冊 | Microsoft Docs
推薦系列文
同系列文章
- 菜雞與物件導向 (0): 前言
- 菜雞與物件導向 (1): 類別、物件
- 菜雞與物件導向 (2): 建構式、多載
- 菜雞與物件導向 (3): 封裝
- 菜雞與物件導向 (4): 繼承
- 菜雞與物件導向 (5): 多型
- 菜雞與物件導向 (6): 抽象、覆寫
- 菜雞與物件導向 (7): 介面
- 菜雞與物件導向 (8): 內聚、耦合
- 菜雞與物件導向 (9): SOLID
- 菜雞與物件導向 (10): 單一職責原則
- 菜雞與物件導向 (11): 開放封閉原則
- 菜雞與物件導向 (12): 里氏替換原則
- 菜雞與物件導向 (13): 介面隔離原則
- 菜雞與物件導向 (14): 依賴反轉原則
- 菜雞與物件導向 (15): 最少知識原則
- 菜雞與物件導向 (Ex1): 小結
其他文章
哈囉,如果你也有 LikeCoin,也覺得我的文章有幫上忙的話,還請不吝給我拍拍手呦,謝謝~ ;)