什麼是Information hiding ?
Information hiding相信很多人都聽過,但要把此概念應用在現實專案中的話,還是有許多地方需要注意。
這篇文章用來記錄我對Information hiding的理解 (個人觀點不保證正確),希望能幫助大家達到以下目標:
- 了解什麼是Information hiding。
- 了解為什麼Information hiding。
- 了解為什麼getter、setter違反Information hiding。
什麼是Information hiding?
在介紹Information hiding之前,我們先從模組化說起:良好的模組化是讓軟體好修改、維護的關鍵之一,那麼什麼是模組化?
模組化的定義如下:
將大問題切割成數個子問題,這些子問題會成為數個模組,讓開發者在修改、理解系統時,只需要了解大問題中的小部分問題即可,不會被迫了解系統的全貌。
舉個例子:
- 當修改商業邏輯時,不需要被迫理解資料庫實作。
- 當修改商業邏輯時,不需要被迫理解使用者畫面實作。
- 當修改折扣 (Discount)價錢計算時,不需要考慮訂單 (Order)本身。
為什麼需要模組化?
因為大腦的能力有限,一次處理一件事情能讓大腦更有效率的運作。反之,同時處理太多事情會讓大腦運作效率大幅度降低。
試著想想,當要修改一個功能時,是閱讀50行程式碼比較容易,還是閱讀散落在幾百行之間的50行程式碼比較容易呢?
後者光用想的就覺得累了,這也是我們大腦的運作方式,他不喜歡太複雜的工作,最好一次讓他做一件事情,模組化正好能讓我們一次只關注一件事情 (關注點分離)。
如何切割模組?
在了解模組化的重要性後,那我們要如何切割模組?這跟Information hiding有什麼關係呢?
在了解如何切割模組前,需要了解什麼是Information hiding,其定義如下:
把可能變動的設計決策封裝到元件內部,並確保元件的介面不會暴露可能變動的設計決策。
這句話聽起來有點抽象,但如果能理解這句話,就能讓你專案的維護性獲得大幅度的提升。
接下來繼續將要介紹為什麼需要Information hiding…
為什麼需要Information hiding?
答案很簡單:確保修改模組時,只需要修改唯一一個地方,且不會影響到其他模組 (Isolate Change into One Place)。
修改模組如果能限縮在一個地方,能大幅度降低專案開發的成本,不僅讓工程師開心、也讓老闆開心,可以說是雙贏的局面。
物件導向與程序式導向的差別,有一部分是兩者對於Information hiding處理有著不同的差異。下面透過程式碼進行說明:
private static class ProcedurePoint {
int x;
int y;
ProcedurePoint(int x, int y) {
this.x = x;
this.y = y;
}
}
private static class FakeObjectPoint {
private int x;
private int y;
FakeObjectPoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
}
private static class ObjectPoint {
private final int x;
private final int y; ObjectPoint(int x, int y) {
this.x = x;
this.y = y;
}
ObjectPoint right() {
return new ObjectPoint(x + 1, y);
}
}
public static void main(String[] args) {
// procedure code
ProcedurePoint p1 = new ProcedurePoint(10, 10);
p1.x += 1;
// procedure code with setter and getter
FakeObjectPoint p2 = new FakeObjectPoint(10, 10);
int x = p2.getX();
p2.setX(x + 1);
// object oriented code
ObjectPoint p3 = new ObjectPoint(10, 10);
ObjectPoint p4 = p3.right();
}
上述程式碼我們看到三種不同point的實作:
- ProcedurePoint:對於資料的存取沒有任何限制,跟c語言中的struct一樣。
- FakeObjectPoint:對於資料的存取有限制 (private),但客戶端可以透過getter、setter來操作其內部結構。
- ObjectPoint:對於資料的存取有限制 (private),客戶端只能透過message來操作物件,沒辦法知道其內部結構。
哪個範例做得較好呢?在回答此問題之前,我們先進行簡單的分析:
- ProcedurePoint
應該不用過多的討論,就是典型的c語言寫法,ProcedurePoint本身就是沒有行為的資料結構。所有使用它的客戶端都知道ProcedurePoint的內部結構 (x, y)。
- FakeObjectPoint
這個案例就比較有趣了,看起來像是物件,但跟第一種範例比較又好像差不多,那麼他是物件還是資料結構呢?
答案:一樣是資料結構!!!
為什麼?他不是有把資料透過private keyword來限制存取?並且提供客戶端getter、setter嗎?不是許多物件導向教學都這樣做嗎?
答案:物件之所以為物件,是因為他能提供一系列的行為 (behavior)從而解決客戶端的問題。物件本身是活的 (active)、資料結構是死的 (inactive),從上述範例來看,他沒有什麼行為,也沒辦法為客戶端解決什麼問題,因此為資料結構。
上述程式碼可以看出,客戶端的用法跟ProcedurePoint沒有太大差別,一樣暴露其內部結構,反而多了更多冗余程式碼 (getter、setter)。
- ObjectPoint
相信看到現在,大家慢慢有感覺了吧?相較於其他兩個範例,ObjectPoint提供客戶端有用的行為 (right),並且客戶端不知道ObjectPoint的內部結構,因此ObjectPoint Information hiding的效果最好!!!
從上述比較我們得知,物件導向的設計方式相較於程序式導向,能隱藏更多的內部結構、實作細節 (Information hiding)。
假設有300個地方reference到point物件,當point物件需要將x與y的型態修改時,哪種實作方式能有效限制影響範圍?試著想想不同範例所影響的範圍,答案應該非常明顯了。
物件導向封裝內部結構、提供API的特性,能避免資訊散落在多個模組,降低修改時所影響的範圍。反之,程序式導向沒有封裝的特性,會使資訊散落在多個模組,增加修改時所影響的範圍。
簡單來說:封裝的資訊越多,影響的客戶端就越少,維護性就更高。
如何切割模組?
回到正題,如何切割模組?經過上述分析物件導向與程序式導向的差異,我們能得出兩種切割方式:
- 根據執行的步驟 (procedure)的順序進行切割。
- 根據可能變動的資訊進行切割。
哪種比較好?答案很簡單:根據可能變動的資訊進行切割。
為什麼?
根據執行的步驟去切割模組,會讓模組的介面暴露內部的設計決策 (資料結構、實作細節、使用套件)。
根據可能變動的資訊進行切割 (資料結構、實作細節、使用套件),能讓模組的介面只顯示行為,不會暴露內部的設計決策。
這邊透過簡單的範例來說明兩者之間的差異,讓大家對於兩者的差別有更深的了解。
假設我們要撰寫一個讀取檔案、更新檔案資料、寫入檔案的程式。
以下為透過步驟來切割模組:
private static class File {
private String filePath;
private String content;
private File(String filePath, String content) {
this.filePath = filePath;
this.content = content;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
private static class FileReader {
File readFile(String filePath) {
// parse file format
String content = ""; // parsed content ...
return new File(filePath, content);
}
}
private static class FileWriter {
void save(File file) {
// convert file content into file format
// write file into its filePath
}
}
public static void main(String[] args) {
FileReader fileReader = new FileReader();
File file = fileReader.readFile("/file/path");
String content = file.getContent();
String newContent = content += "some update content";
file.setContent(newContent);
FileWriter fileWriter = new FileWriter();
fileWriter.save(file);
}
這種方式有很明顯的缺點:
- 類別數量過多 (FileReader、FileWriter、File)。
- 實作細節落在多個類別 (FileReader與FileWriter皆需要parse file format)。
- 修改內部實作細節,影響範圍很大 (FileReader、FileWriter、File)。
- 增加客戶端耦合 ,客戶端需要耦合到FileReader、FileWriter、File,才能完成功能。
- 難以針對客戶端進行單元測試,需要Mock非常多物件 (FileReader、FileWriter、File),降低測試維護性。
附上圖片讓大家更理解這種設計的缺點:
以下為透過封裝可能變動的資訊來切割模組:
private static class ObjectFile {
private String filePath;
private String content;
public ObjectFile(String filePath) {
this.filePath = filePath;
this.content = ""; // parse from file
}
public void append(String content) {
this.content += content;
}
public void save() {
// convert content into file format
// write back to file
}
}
public static void main(String[] args) {
ObjectFile objectFile = new ObjectFile("/file/path");
objectFile.append("update content");
objectFile.save();
}
上述切割方式解決了步驟切割所造成的問題:
- 大幅度降低類別數量。
- 實作細節限縮在一個類別 (ObjectFile)。
- 修改內部實作只會影響ObjectFile類別 (Isolate Change into One Place)。
- 降低客戶端的耦合。
- 針對客戶端進行測試很簡單,只需要先使用extract interface在使用Fake Object即可。
測試範例 (只用來示範單元測試,沒有考慮Domain Isolation的問題,介面設計有Leaky Abstraction Code Smell):
private interface Text {
void append(String content);
void save(); // leaky abstraction
}
private static class ObjectFile implements Text {
private String filePath;
private String content;
public ObjectFile(String filePath) {
this.filePath = filePath;
this.content = ""; // parse from file
}
@Override
public void append(String content) {
this.content += content;
}
@Override
public void save() {
// convert content into file format
// write back to file
}
}
private static class Client {
private final Text text;
public Client(Text text) {
this.text = text;
}
public void start() {
this.text.append("update content");
this.text.save();
}
}
private static class FakeText implements Text {
private String content = "";
@Override
public void append(String content) {
this.content += content;
}
@Override
public void save() {
}
}
@Test
void update_content() {
FakeText fakeText = new FakeText();
Client client = new Client(fakeText);
client.start();
assertEquals(fakeText.content, "update content");
}
相信看完篇文章,大家應該能回答以下問題:
- 什麼是Information hiding?
- 為什麼要Information hiding?
- 違反Information hiding會造成什麼後果?
- 什麼是模組化?
- 為什麼要模組化?
- 模組如何切割?
- 物件導向為什麼維護性較好?
- 模組化與Information hiding的關係為何?
結論:
- 透過封裝可能變動的設計決策,能降低修改影響的範圍,提升整體維護性。
- 透過步驟進行模組化,會導致實作細節散落在多個類別,增加修改影響的範圍,降低整體維護性。