---
# System prepended metadata

title: 第六章 物件及資料結構
tags: [clean code]

---

# 第六章 物件及資料結構
###### tags: `clean code`
### - 資料結構與物件的差別

* 資料結構：將資料直接暴露在外，可以直接對資料讀取與寫入。例如：list、dict、set 等。
* 物件：將資料隱藏起來，提供可以操作這些資料的函式在外面。

第一段程式碼中，暴露了程式的實踐過程，很清楚的知道是一個直角座標系。
```
public class Point {
    public double x;
    public double y;
}
```

第二段程式碼中，我們將實現的過程隱藏，不只是加上一層函式的介面而已，確切來說這是一種抽象化的過程，讓使用者在不需要知道實現的過程狀態下，還能夠操作資料的本質。(也就是說再不清楚資料本質的情境下，我們限制了只能透過一種存取手段(setter、getter)，可以獨立存取座標資訊，但同時也必須設定單點的所有座標資訊)
```
public class Point {
    double getX();
    double getY();
    void setCartestion(double x, double y);
    double getR();
    double getTheta();
    void setPolar(double r, double theta);
}
```

再來個舉例 - 具體化&抽象化

```
\\具體化的交通工具類別
FuleTankCapacityInGallons() {
  double getGallonsOfGasoline();
}

\\抽象化的交通類別
public interface Vehicle {
  double getPercentFuelRemaining();
}
```

很明顯的，後者是比較好的選擇，理由在於，我們不想將資料的細節暴露在外，因此利用抽象化的詞彙來表現資料，讓其他物件可以繼承並使用。

### - 結構化程式碼(程序式圖形)

```
public class Square { 
    public Point topLeft; 
    public double side; 
} 

public class Rectangle { 
    public Point topLeft; 
    public double height; 
    public double width; 
}

public class Circle { 
    public Point center; 
    public double radius; 
}
```

```
public class Geometry { 
    public final double PI = 3.141592653589793 ; 
    public double area (Object shape) throws NoSuchShapeException 
    { 
        if (shape instanceof Square) { 
            Square s = (Square) shape; 
            return s.side * s.side; 
        } else if (shape instanceof Rectangle) { 
            Rectangle r = (Rectangle) shape; 
            return r. height * r. width; 
        } else if (shape instanceof Circle) { 
            Circle c = (Circle) shape; 
            return PI * c. radius * c. radius; 
        } 
    }
    throw new NoSuchShapeException(); 
}
```
當你要新增一個新的perimeter(周長)函式到Geometry類別時，這些圖形類別完全不會受到影響(因為圖形類別是單純的資料結構)!任何其他相依於圖形類別的類別也不會受到影響，另一方面，如果我新增了一個新的圖形類別，則我必須改變在Geometry裡所有的函式來處理它。

### - 物件導向化程式碼(多型的圖形)

```
public class Square implements Shape { 
    private Point topLeft; 
    private double side; 

    public double area() { 
    	return side*side; 
    } 
}

public class Rectangle implements Shape { 
    private Point topLeft; 
    private double height; 
    private double width; 

    public double area() { 
        return height * width; 
    }
}

public class Circle implements Shape { 
    private Point center; 
    private double radius; 
    public final double PI = 3.141592653589793; 

    public double area() { 
        return PI * radius * radius; 
    }
}
```

上面是物件導向的程式風格，如果新增一個圖形類別，不用修改所有的類別，但如果我要新增一個新的函式，例如添加一個周長函數，則所有的圖形類別都必須要修改(因為每個圖形都需要周長)。

這就是物件和資料結構的二分性，結構化的程式碼(使用資料結構的程式碼)容易添加新的函式，而不需要變更既有的資料結構，而物件導向的程式碼，容易添加新的類別，而不用變動既有的函式。


### - 德摩特爾法則 (The Law of Demeter) 最少知識原則
符合 LoD 的函式要求在物件 O 中的函式 m 只能調用以下幾種類型的函式：
1. O 本身
2. m 的參數
3. 在 m 中建立的物件
4. 宣告在 O 中的物件
5. 被 O 存取的全域變數，並且在 m 的 scope 中

```
private A a;
 
private void f() {...}
 
public void m(B b) { // 方法中只能呼叫
  f();               // 類別定義的方法
  b.action();        // 引數的方法
  new D().run();     // 自建物件之方法
  a.execute();       // 實例擁有物件之方法
}
```

如果是物件，則破壞了物件封裝；而如果是資料結構，資料結構本來就暴露了資料在外面，所以透過巢狀存取資料是沒有問題的。

```
// 破壞封裝，違反 LoD
// 通常又被稱作火車事故般的糟糕程式碼，因為一連串呼叫容易混淆目前正在做的事，也不太容易看懂個別的呼叫的意義。
class A {
    fun(b) {
        b.getOption().getSelection()
    }
}

// 存取資料結構，沒有違反 LoD 
data = {
    a: {
        b: 2
    }
}
console.log(data.a.b)
```

> 你不應該讓一個函式知道太多事情，否則會破壞物件封裝原本的意義。


***補充 : 合理的設計***

![](https://hackernoon.com/hn-images/1*TX9ZrbWgboigN0yyYQMqZg.png)

![](https://hackernoon.com/hn-images/1*8oTF1QLs0Y0Z7k8wQr0oiQ.png)

### - DTO

1. 最爲精簡的數據結構，只有公共變量、沒有函數的類。多用在與資料庫通信，解析套接字信息之類的場景中。
2. Active Record，特殊 DTO 形式。擁有公共變量的數據結構，通常也會擁有類似 save 和 find 這樣可瀏覽方法。一般是對資料庫表或者其他數據源的直接翻譯。不要在這類數據結構裡面塞進業務規則，應該創建包含業務規則隱藏內部數據的獨立對象。

### 總結:
1. 物件的私有變數不應該隨意將它們暴露在外，物件即是要隱藏其內容，只讓外部的人看見它們想公開的行為。
2. 德摩特爾法則是一種撰寫程式的規範，我們不應該知道太多物件內的事情，否則就會破壞物件的封裝，讓被使用的物件難以被維護。


# 第七章 錯誤處理

### - 使用例外事件而非回傳錯誤碼

```
public class DeviceController{
    ...
    public void sendShuntDown(){
        DeviceHandel handle = getHandel(DEV1);
        //Check the ststus of the device
        if(handle != DeviceHandel.INVAILD){
            //Save the device status to the record field
            retrieveDeviceRecord(handle);
            //if not suspended ,shut down
            if(record.getStatus()!= DEVICE_SUSPENDED){
                pauseDevice(handle);
                clearDeviceWorkQueue(handle);
                closeDevice(handle);
            } else{
                logger.log("Device suspended .Unable to shut down");
            }
        }else{
            logger.log("Invalid handel for :"+DEV.toString());
        }
    }
    ...
}
```

上面的程式碼問題在於，在呼叫這段程式碼之後還必須立刻檢查錯誤，如果返回了錯誤的log，呼叫者還要做出不同的邏輯處理，但因為程式本身順利執行，所以這個步驟也是常常被忽略。

再來看另一段改寫過後的程式碼，我們將自行定義了一個例外實體，並在函式發生例外時拋出，這樣事情便會變得簡單許多，原本糾纏在一起的兩個事件邏輯(裝置關閉演算法和錯誤處理)，現在被巧妙分開了。

```
public class DeviceController{
    ...

    public void sendShutDown(){
        try{
            tryToShutDown();
        }catch(DeviceShutDownError e){
            logger.log(e);
        }
    }
    private void tryToShutDown() throws DeviceShutDownError{
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }

    private DeviceHandle getHandle(DeviceID id){
        ...
        throw new DeviceShutDownError("Invalid handle for:" + id.toString());
        ...
    }
}
```

> 使用例外事件的方式，讓程式偵測到錯誤時，可以主動拋出例外。


### - 從呼叫者的角度定義例外類別

當我們在應用程式裡定義例外類別時，應該關心的是，他們如何被捕獲的。

下面的程式碼中，依照每個可能會拋出的例外都進行了捕獲，雖然沒有錯，但卻包含著許多重複的程式碼，也無法進行複用。

```
CMEPort port = new ACMEPort(12);
try{
    paro.open();
} catch(DeviceResponseException e){
    reportPortError(e);
    logger.log("Device response exception" ,e);
}catch(ATM1212UnlockedException e){
    reportPortError(e);
    logger.log("Unlock exception" ,e);
}catch(GMXError e){
    reportPortError(e);
    logger.log("Device response exception");
}finally{
    ...
}
```

但如果改成以下的寫法，我們將可能會拋出例外的程式碼邏輯獨立包裹成一個LocalPort類別，當進行呼叫時，同時就已經做好了錯誤處理。

```
LocalPort port = new LocalPort(12);
try{
    port.open();
}catch(PortDeviceFailure e){
    reportError(e);
    logger.log(e.getMessage(),e);
}finally{
    ...
}


public void LocalPort{
    private ACMEPort innerPort;
    public LocalPort(int portNumber){
        innerPort = new ACMEport(portNumber);
    }

    public void open(){
        try{
            innerPort.open();
        }catch(DeviceResponseException e){
            reportPortError(e);
            logger.log("Device response exception" ,e);
        }catch(ATM1212UnlockedException e){
            reportPortError(e);
            logger.log("Unlock exception" ,e);
        }catch(GMXError e){
            reportPortError(e);
            logger.log("Device response exception");
        }finally{
        ...
        }
    }
}
```

### - Special Case Pattern (特殊情況模式)

我們可以看到程式碼中如果 HanResponse.search() 經過搜尋後，無法找到問題的解答，get_response() 拋出了一個例外事件 AnswerNotFoundError，轉而使用另一個函式 get_response_from_experts() 讓其他專家回答問題。

```
try:
    han_response = HanResponse.search(question)
    response = han_response.get_response()
except AnswerNotFoundError:
    response = get_response_from_experts()
```
    
![](https://miro.medium.com/max/649/1*mfo5VCKJVpoKnYW9UtiKFQ.png)

當我們用到 Special Case Pattern 時，經常會使用工廠模式 (Factory Pattern) ，讓工廠中的物件繼承同一個抽象類別，並且可以額外傳遞 Special Case 的物件。

```
response_factory = ResponseFactory.search(question)
response = response_factory.get_response()
```

![](https://miro.medium.com/max/875/1*Lydcd8ofLXS7duKQil-AeA.jpeg)

```
from abc import ABC, abstractmethod


class AbstractResponse(ABC):
    @abstractmethod
    def get_response(self):
        pass


class HanResponse(AbstractResponse):
    def __init__(self, question):
        self.response = question

    def get_response(self):
        return "Han Response."


class ExpertResponse(AbstractResponse):
    def __init__(self, question):
        self.response = self.search(question)

    def get_response(self):
        return self.response
    
    def search(self):
        ...
```

### - Null Object Pattern

![](https://miro.medium.com/max/875/1*W-JhOhHrDpq-0tgf5BMIHA.jpeg)

👉 STEP 1：建立抽象類別 AbstractCustomer，並且讓物件共同繼承這個類別，限定所有類別都必須實作在抽象類別中的抽象方法。因此，如同前一章節，所有類別都有同樣的方法。

```
from abc import ABC, abstractmethod


class AbstractCustomer(ABC):
    @abstractmethod
    def get_name(self):
        pass

    @abstractmethod
    def is_none(self):
        pass


class RealCustomer(AbstractCustomer):
    def __init__(self, name):
        self.name = name

    def get_name(self):
        return self.name

    def is_none(self):
        return False


class NullCustomer(AbstractCustomer):
    def get_name(self):
        return "Not Available in Customer Database"

    def is_none(self):
        return True
```

👉 STEP 2：我們建立一座工廠，讓工廠找不到名字時回傳 Null Object。

```
from Customer import RealCustomer, NullCustomer


class CustomerFactory:
    def __init__(self):
        self.names = ["Rob", "Joe", "Julie"]
    
    def get_customer(self, customer_name):
        for name in self.names:
            if name == customer_name:
                return RealCustomer(name)
        return NullCustomer()
```

👉 STEP 3：使用 Special Case Pattern 的好處就是，儘管我們查詢的名字不存在，但是回傳的物件同樣繼承 AbstractCustomer，所以最後能夠呼叫同樣名稱的函式。

```
from CustomerFactory import CustomerFactory


customer_factory = CustomerFactory()
customer_1 = customer_factory.get_customer("Rob")
customer_2 = customer_factory.get_customer("Henry")
customer_3 = customer_factory.get_customer("Julie")
customer_4 = customer_factory.get_customer("Peter")

print("Customer 1: ", customer_1.get_name())
print("Customer 2: ", customer_2.get_name())
print("Customer 3: ", customer_3.get_name())
print("Customer 4: ", customer_4.get_name())
```

### - 不要回傳Null值

如果打算在方法中返回null值，不如拋出異常，或是返回特例對象。因為當今天程式可能出現null值，我們就必須加上一層一層的null判斷if else，防止空值出現。

Worst code

```
public void registerItem(Item item) { 
    if (item != null) {
        ItemRegistry registry = peristentStore.getItemRegistry(); 
        if (registry != null) {
            Item existing = registry.getItem(item.getID());
            if (existing.getBillingPeriod().hasRetailOwner()) {
                existing.register(item); 
            }
        }
    }
}
```

Better code

相對回傳null，我們可以回傳一個空陣列。

```
public List getList(){
    ...
    if(lis != null){
        return lis;
    }else {
        return Collections.emptyList();
    }
}
```

# 第八章 邊界

第一種是與第三方軟體的邊界，第二種是已知與未知的邊界。更白話一點，邊界就是「程式與程式的邊界」。

但使用第三方套件的缺點就是，如果沒有透過適當的方式呼叫，將會使軟體邊界模糊不清，讓我們來看一下以下這個寫法。

```
Map sensors = new HashMap();
Sensor s = (Sensor)sensors.get(sensorId);
```

上面這段程式碼其實沒有甚麼錯誤，也可以正確執行，但如果整個專案都充斥這種寫法，可讀性將會變得很差。

如果換成多型的寫法，將會使可讀性變得更好:

```
Map<Sensor> sensors = new HashMap<Sensor>();
Sensor s = sensors.get(sensorId);
```

現在這種寫法雖然比較好，但還是會有一個缺點，就是當Sensor介面發生改變時，譬如回傳物件內容、型態改變，就必須連帶修正系統內相關的地方(因為我們在系統內傳遞Map<Sensor>實體)。

如果要更簡潔一點的話，我們可以再修正為以下寫法:

```
public class Sensors {
  private Map sensors = new HashMap();
  public Sensor getById(String id){
     return (Sensor) sensors.get(id);
  }
}
```
    
我們把Map隱藏並封裝進Sensors類別中，轉型及多型都在Sensor類別中處理，若真的需要修改，就只要修改Sensor就好。

### - 學習式測試(第三方軟體的邊界)

未來為了滿足更多的需求進行升級了第三方軟體的版本，或是資料庫被修改了一些欄位，舊有的程式碼不一定會相容於現階段的系統。因此，假設未來升級時，無法通過我們先前撰寫的單元測試，我們就能夠立即發現問題。

> 而這些測試，作者稱為邊界測試，可以減輕升級整合所造成的負擔。

### - 使用尚未存在的程式(已知與未知的邊界)

![](https://grantliblog.files.wordpress.com/2021/02/image.png)
    
> 採用這種設計，Adapter 封裝了與 API 的互動，當 API 升級時，Adapter 是唯一需要被修改的地方。

Transmitter是一個未知的API，而作者們將 CommunicationsController 類別從 Transmitter API (未被定義，且不在作者們掌控之下)分離出來。一旦 Transmitter API 被定義出來，作者們就撰寫 TransmitterAdapter 來轉接。

因此，往後若TransmitterAPI升級或更改時，Adapter是唯一需要修改的地方。同時，我們也可以透過撰寫邊界測試來測試Adapter是否正確使用了Transmitter API。

透過以上的兩種方法(封裝特定介面或引用、使用Adapter轉接API)，我們將可以有效且簡潔使用第三方軟體，當第三方軟體發生變動時，只需要更改最少的地方，也就是說，維護會更加方便。