資料抽象化
首先我們先來看看兩種座標點的表示方式:
具體的座標點
public class Point {
public double x;
public double y;
}
抽象的座標點
public interface Point {
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}
抽象的座標點無法得知實現過程是使用直角坐標系還是極座標系,也可能兩者皆否,但這樣的介面仍可明白地表示這是一種資料結構,同時它限制了一種存取手段,你可以獨立讀取座標軸資訊,但必須同時設定一個單點的所有座標資訊。
而在具體的座標點範例內清楚地告知我們,實現過程採用直角坐標系,並且強制我們必須單獨操作各軸座標,這曝露了實現過程。
將實現的過程隱藏,並不單只是在變數之上加一層函式的介面而已,而是一種抽象化的過程。類別不只是透過 getter 及 setter 函式,讓變數供人存取而已,它只是提供一個抽象介面,讓使用者在不需要知道實現過程的狀態下,還能夠操作資料的本質。
以此範例來說,抽象化過後我只需知道如何去『操作』物件、呼叫函式,就可以得到我想要的結果。但若我只有一個資料結構,我可以知道實際的狀態是什麼,但若是需要做些轉換,就得自己來了。
[討論]1. interface 定義要先做什麼,後續實作是由各個 Class 進行實作,每個 Class 實作的方式可能不太相同。至於動態語言 ruby/js 並沒有提供類似 interface 的功能,但 interface 的精神在於類別間共享相同的行為,因此 duck typing 就是我們的解藥
2. Ruby 動態語言的 interface 實作方式:透過 duck typing 特性,實現 interface,測試可以透過 rspec shared examples 確認介面一致性。
3. JS 的 interface 實作:透過 duck typing 特性,實現 interface,測試可以透過 shared examples (jest/mocha 等) 確認介面一致性
抽象化詮釋
- 具體化的交通工具類別
public interface Vehicle {
double getFuelTankCapacityInGallons();
double getGallonsOfGasoline();
}
- 抽象化的交通工具類別
public interface Vehicle {
double getPercentFuelRemaining();
}
前者使用較具體的詞彙,來表示一個交通工具還剩下多少燃料,而後者雖也做同樣的事,但後者使用了百分比的抽象概念。前者幾乎可以確定這些函式只是變數的 getter 函式,而後者你對於內部資料的型態皆一無所知。
以上兩個例子,後者是比較好的選擇。我們並不想將資料的細節暴露在外,我們想要的是找到最能詮釋「資料抽象概念」的方式,利用抽象化的詞彙來表達資料,這並不是只透過介面、getter 及 setter 函式就能完成。最糟糕的做法則是天真地加上 getter 及 setter 函式。
抽象化有時候是為了更精煉資料、物件的反對稱性
- 資料結構:將資料曝露在外,而且也沒有提供有意義的函式。
- 物件:將資料在抽象層後方隱藏起來,然後將操縱這些資料的函式曝露在外。
資料及物件的反對稱性
- 採用簡單的資料結構
public class Square {
public Point topLeft;
public double side;
}public class Circle {
public Pointer center;
public double radius;
}public class Geometry {
public final double PI = 3.14159; public double area(Object shape) throws NoSuchShapeException
{
if (shape instanceof Square) {
Square s = (Square)shape;
return s.side * s.side;
} else if (shape instanceof Circle) {
Circle c = (Circle)shape;
return PI * c.radius * c.radius;
} throw new NoSuchShapeException;
}
}
- 採用物件導向的做法
public class Square implements Shape {
private Point topLeft;
private double side; public double area() {
return side * side;
}
}public class Circle implements Shape {
private Point center;
private double radius;
public final double PI = 3.14159; public double area() {
return PI * radius * radius;
}
}
採用簡單的資料結構可以發現如果要添加新的方法,只需要在 Geometry 添加新的方法,並且將每個資料結構對應的處理寫在方法內即可,並不需要再每個資料結構上添加新方法,然而如果 Geometry 今天已有 5 個方法,我們需要添加 2 種資料結構,則我們必須在 5 個方法內各添加 2 種資料結構的處理。
而採用物件導向的做法,我們要添加新的資料結構是相對簡單的,我們只要建立一個新的物件基於 Shape 實作即可,然而如果我們有 5 個實作 Shape 的物件,我們想在 Shape 內添加新方法,則我們需要在這 5 個物件內分別實作這個新方法。
結論
使用資料結構
- 容易添加新的函式,不需要變動已有的資料結構
- 難以添加新的資料結構,因為必須改變現有的函式
使用物件
- 容易添加新的類別,而不需要變動已有的函式
- 難以添加新的函式,因為必須改變所有類別
[討論] 雖然這邊寫了一些比較,但資料結構與物件是不衝突的,只要掌握上面的優缺點,並且透過適當的架構與設計模式(Design Pattern)來組合物件與資料結構,某種層面上就可以達到容易添加新函式又容易添加資料結構的效果。
Ruby 沒有 interface 如何實作抽象化呢?
常寫 Ruby on Rails,但發現 Ruby 是 duck typing 的語言,所以本身沒有提供 interface、implements 等關鍵字可以定義、實作抽象化介面。但 Ruby 仍然可以做到類似 Java interface 的功能,我們參考 Ruby 蠻多人用來操作 HTTP Request 套件 rest-client 的程式碼吧。
- 定義抽象化介面 (lib/restclient/abstract_response.rb)
定義一個 AbstractResponse module,裡面的 inspect 方法直接 raise 一個 NotImplementedError,其目的是希望後面 include 這個 module 時,如果未實作 inspect 方法,在呼叫此方法時會直接報錯。
- 實作抽象化介面 (lib/restclient/raw_response.rb)
透過 include 方法使用定義好的介面,並實作 inspect 方法,其原理在於 Ruby 的 class 如果方法名稱相同,會直接覆蓋掉原本存在的方法,而如果未實作 inspect 方法,則呼叫時會報錯。
由於其他實作與 raw_response.rb 差不多,想了解的話可以參考 response.rb 這邊就不將其程式碼列上。而 rest-client 會先定義一個 AbstractResponse 的module 原因在於:「rest-client 希望提供經處理後的 Response Class 及原始未經處理的 RawResponse Class 供使用者選擇想要哪種 Response,而 Response 及 RawResponse 都是基於 HTTP Response 的結果,進而做一些處理,因此這兩個 Class 會有些許共同之處,可以透過抽象化進行實作」。
德摩特爾法則 The Law of Demeter
模組不該知道「關於它所操縱物件的內部運作」,所以一個物件不應該透過 getter 曝露其內部結構,因為如果這樣做便是曝露而非隱藏本身的內部結構了。
一個類別 C 內的方法 f,應該只能呼叫以下事項的方法:
- C
- 任何由 f 所產生的物件
- 任何當作參數傳遞給 f 的物件
- C 類別裡實體變數所持有的物件
方法不該呼叫由任何函式所回傳之物件的方法,換句話說:「只和朋友說話,不跟陌生人聊天」。
以下的程式碼違反 The law of Demeter,因為在 getOptions() 的回傳物件上呼叫了 getScratchDir() 的物件,並且在 getScratchDir() 的回傳物件上呼叫 getAbsolutePath()。
final String outputDir = ctxt.getOptions().getScratchDir().getAboslutePath();final String something = objectA.getObjectB().getObjectC().getObjectD();
最好將這類的程式碼分割成下述的形式:
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();
這到底算不算違反了 The law of Demeter 取決於 ctxt、Options 和 ScratchDir 是物件還是資料結構。如果它們是物件,那麼它們的內部結構應該被隱藏起來,而非曝露在外。從另一方面來看如果它們只是一種無其他行為的資料結構,那它們在本質上必然會揭露內部的結構,所以此法則在這種狀況下並不適用。
[討論]- Wiki 較好理解: https://zh.wikipedia.org/wiki/得墨忒耳定律
- 你叫狗往前走,不會呼叫他哪隻腳要怎麼動,你會直接叫狗走不需要知道他有幾隻腳
Rails 的 delegate 方法
在 Rails 我們可以簡單的獲取到 ActiveRecord 中關聯的資料,舉個例子:我們想拿到某訂單的使用者地址資訊。
<%= order.user.address.state %>
<%= order.user.address.street %>
此種方式已經違反了 The Law of Demeter,Order 知道太多關於 User 的資訊了,如果我們改透過 order.user_street 的方式便不會違反此法則,而在 Rails 有一個簡單快速的方法 delegate,可以透過在 Model 定義後,幫我們產生如 order.user_street 的方法。
JavaScript 範例
上述範例因為 Rails 提供 delegate 方法,使我們得簡單快速地避免違反 The Law of Demeter,但如果像 JavaScript 沒提供這樣的方法呢?
我們可以透過 method chaining 直接得到了訂單的使用者街道、區域名稱:
// 街道名稱
order.getUser().getAddress().getStreet(); // 襄陽路// 區域名稱
order.getUser().getAddress().getDistrict(); // 中正區
Order object 一樣知道太多關於 Address 的資訊了,我們在操作 Order 的方法時,並不需要知道 Address 有哪些方法,因此我們可以像這樣改寫
class Order {
// ... getUserAddressStreet() {
return this.user.getAddressStreet();
}
}order.getUserAddressStreet();[討論]1. 遵循 The Law of Demeter 可避免呼叫方知道太多實作細節導致後續結構修改上的麻煩
2. 當遇到上述情況,破壞了 Object 的封裝性時,應重新思考 Object 間的關係,是否合理?需不需要重新設計?而不是一昧的透過 delegate 增加 wrapper 方法
混合體
擁有公共變數或公共 getter/setter 的半物件半資料結構,不論出於哪種意圖,他們將私有變數公用化,吸引其他外部函式使用這些私有變數,就像一個結構化程式使用資料結構一樣,會使得難以添加新的函式,同時也難以添加新的資料結構。
應避免使用這類的混合體,它們代表著一種糊塗的設計,也代表作者根本不確定它們是否需要函式或型態的保護。
隱藏結構
如果 ctxt、options 和 scratchDir 是擁有真實行為的物件,而此時我們又想拿到暫存目錄(scratch directory)的絕對路徑(absolute path),則可以透過下列兩種方式:
// 第一種
ctx.getAbsolutePathOfScratchDirectoryOption();// 第二種
ctx.getScratchDirectoryOption().getAbsolutePath();
第一種會導致 ctxt 物件擁有非常多方法,而第二個選項則假設 getScratchDirectoryOption()
會回傳資料結構,而非物件。
如果 ctxt 是一個物件,那麼應該要告訴它去做某某事情,不應該還被問到它的內部結構是什麼。所以我們要思考取得暫存目錄的絕對路徑要做什麼?
String outFile = outputDir + "/" + className.replace('.', '/') + ".class";
FileOutputStream fout = new FileOutputStream(outFile);
BufferedOutputStream bos = new BufferedOutputStream(fout);
思考上述在同樣模組內的程式碼,發現不同層次的細節構成的混合物有點麻煩。點、斜線、副檔名和 File 物件不該被如此不小心地混合在一起。同時我們發現取得暫存目錄的絕對路徑是因為「要在此目錄下產生一個給定名稱的檔案」,所以如果我們叫 ctxt 物件做如下的事情,看起來便挺合理的。
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);
資料傳輸物件(Data Transfer Objects, DTO)
DTO 是以資料結構形式,類別裡只有公用變數,沒有任何函式,這種資料結構被稱為資料傳輸物件。DTO 是非常有用的結構,但我們要和資料庫溝通或解析由 socket 傳來的訊息。它們將「資料庫的原始資料」轉換成「應用程式物件」時,往往應用於轉換過程中的第一階段。
public class Address {
private String street;
private String streeExtra;
private String city;
private String state;
private String zip;
public Address(String street, String streetExtra,
String city, String state, String zip) {
this.street = street;
this.streetExtra = streetExtra;
this.city = city;
this.state = state;
this.zip = zip;
}
public String getStreet() {
return street;
}
public String getCity (){
return city;
}
...
}
活動紀錄(Active Records)
活動紀錄是一種特殊的 DTO,它們是擁有公用變數的資料結構;但它們通常擁有 save 與 find 等用來瀏覽的方法。而通常活動紀錄是由資料庫表格或資料來源直接轉換而來。
這類的結構常被看作是物件,然後加入處理商業邏輯(business rule)的方法,這非常的不恰當,因為會產生資料結構與物件的混合物。比較好的解法應該是將活動紀錄看作是純資料結構,並另外建立包含商業邏輯,隱藏內部資料的物件。
[備註]商業邏輯與技術架構分離同時也能夠使得整體架構更為彈性,當任何一方有變動時,只需要改變變動的那一方即可。
小結
正確地使用資料結構、物件能使我們在未來的擴充上較為簡單,同時也應避免使用混合的結構。而資料傳輸物件、活動紀錄則是我們常見拿來進行資料傳輸的資料結構,我們也應避免加入處理商業邏輯的方法,避免其成為資料結構與物件的混和物。