無瑕的程式碼 (6):物件與資料結構

Whyayen
嗨,世界
Published in
15 min readJul 29, 2020
Photo by Dakota Roos on Unsplash

👉 非會員閱讀連結

前言

在實作的過程中,我們時常用到物件或資料結構,然而常常只是新增而忽略了後續的擴充或維護需求。本章我們來看看如何依據不同場景,正確地使用物件或資料結構,使我們在未來的擴充及維護更為輕鬆。

資料抽象化

首先我們先來看看兩種座標點的表示方式:

具體的座標點

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)的方法,這非常的不恰當,因為會產生資料結構與物件的混合物。比較好的解法應該是將活動紀錄看作是純資料結構,並另外建立包含商業邏輯,隱藏內部資料的物件

[備註]商業邏輯與技術架構分離同時也能夠使得整體架構更為彈性,當任何一方有變動時,只需要改變變動的那一方即可。

小結

正確地使用資料結構、物件能使我們在未來的擴充上較為簡單,同時也應避免使用混合的結構。而資料傳輸物件、活動紀錄則是我們常見拿來進行資料傳輸的資料結構,我們也應避免加入處理商業邏輯的方法,避免其成為資料結構與物件的混和物。

參考資料

--

--