API 設計 — 建構之道: 類別建構子設計

好的 API 設計,缺不了好的建構子 (constructor) 設計,建構子設計得好,基本上這個類別就不會髒到哪裡去。一個骯髒的物件,很可能是:

  • 物件屬性全部開放,無法確定 Caller 從外部修改了哪些東西,增加重構時的不確定性。(除非物件本身是 Data Object)

  • 暫用變數存成物件屬性,甚至跨方法存取這個暫時用變數。

  • 物件行為過度依賴屬性狀態,以及過度複雜的狀態,以至於最後有些 method 必須在某個狀態下使用,若在其他狀態下呼叫,則會導致無法預的狀況發生。

  • 無法很清楚的快速分辨這個物件所需的必要參數。

  • 外部開放的 helper method。

類別方法基本上是圍繞著物件屬性在打轉的,如果沒辦法控制得好哪些屬性該放哪些屬性不該放,最後就會導致整個類別方法走火入魔。

必要參數

基本上一個類別的建構子的參數,一定要是必要參數,讓這個物件被初始化之後,就可以直接呼叫方法使用(不是 Setter),必要參數不可以在物件初始化之後才去設定。

換句話說,只要傳了必要參數給建構子,這個類別就可以直接有操作的行為,藉此就可以簡化物件的狀態。

我們常會看到初學者這樣寫:

$car = new Car;
$car->setWheel(4);
$car->setColor('red');
$car->setDriver($john);
$car->setKey("key of the car");
$car->drive(); // drive requires wheel property

其中 Wheel 跟 Color 很明顯的是必要參數,因為 drive() 方法需要 wheel,原因是,現實生活中一臺車子是不允許臨時改輪子數或顏色的 :p。

思考一下,倘若開發者忘記呼叫 setWheel 就直接呼叫 drive() ,不就會造成錯誤嗎?

key 是後取得參數,不見得可以在物件建構時取得,又或是我們可以換 key,也因此 Wheel 跟 Color 應該變成 constructor 的一部份,而 key 應保留為 setter,讓 drive() 方法內直接檢查是否有 key 來做錯誤處理,又或是直接傳遞給 drive() 方法做檢查。

而如果只有一個方法需要那個參數,執行完就會拋棄,則不應該存取參數為類別屬性,取而代之,應該是變成該方法的參數

重新整理過,變成:

$car = new Car('red', 4);
$car->setKey("key of the car");
$car->drive($driver); // drive requires driver to drive

又或是將 key 包裝在 Driver 內,一來也比較符合真實世界的邏輯,如此一來,就簡化成:

$car = new Car('red', 4);
$car->drive($driver);

那麼 drive($driver) 的原型以及實作,便可寫成下列方式:

public function drive(Driver $driver) {
   $key = $driver->getKey();
   // validate the key
   // ... more stuff goes here
}

建構子的參數,如果是不可改的,則存到類別屬性後,不可提供 Setter 給予使用者操作。

相反的,如果是可改的,就可以安心的加上 Setter。

那究竟什麼是可改什麼是不可改?你只需要問自己,在物件操作時,突然改掉這個屬性的值,有沒有可能造成其他方法出錯?如果是,那就是不可改。

那麼該物件屬性的 Scope 該設為 protected 或 private 呢?

當你是在設計 final class 時,使用 private,並在 class 上加上 final keyword;若你允許其他 user 透過延展 (extend) 你的類別,那麼就使用 protected ;若這個屬性被 end user class 修改後會產生不確定性,那麼就使用 private。

又譬如,一個 ArrayMapper 或 ArrayFilter,很明顯的 Array 就是他的必要參數,必須要設計在建構子裡,而不是建構後才去 Set Array。譬如:

class ArrayFilter
{
    public function __construct(array $array, $opt1 = 'default')
    {
        ...
    }
}

透過必要參數來設計建構子跟傳遞初始化屬性有幾個好處,在新增類別方法時,你可以非常確定有哪些屬性是一定會有 Value,而不用害怕去存取尚未初始化的屬性而造成錯誤。 簡化屬性狀態

避免類別方法直接依賴屬性的不同狀態,如果你真的有很多屬性狀態要操作,建議直接把方法包裝在狀態物件上,透過 Chain of Responsibility pattern 來處理。譬如:

class Account
{
    protected $state;
    protected $objectRequiredByState1;
    protected $objectRequiredByState2;

    public function __construct()
    {
        $this->state = 0;
    }

    public function proceed()
    {
        switch ($this->state++) {
        case 1:
            $this->objectRequiredByState1 = new Foo;
            break;

        case 2:
            $this->objectRequiredByState2 = new Foo;
        }
    }

    public function doSomething()
    {
        $this->objectRequiredByState1->run();
    }

    public function doSomething2()
    {
        $this->objectRequiredByState2->run();
    }
}

看得出來問題點嗎?doSomething 隱含相依於狀態 1,而 doSomething2 隱行相依於狀態 2,這樣就違反了物件設計的包裝原則 — 你必須很清楚知道現在這個物件是在哪個狀態才能做操作,否則就會出錯。 跨方法存取暫用變數

你可能見過像這樣的寫法:

class Foo
{
    public function foo()
    {
        $this->tmpVar = new Service;
    }

    public function bar()
    {
        $ret = $this->tmpVar->create(....);
        // do more stuff on $ret
    }
}

看得出來問題所在嗎?bar 很明顯的依賴於 foo,但這個依賴是隱含的,也就是說,外部操作必須很明顯呼叫 foo() 後才能呼叫 bar(),否則將會出錯。當這樣的 use case 在同一個類別中越來越多時,這個類別就髒掉了。

你可能會說,那我就設計個例外好了?事實上設計成以下這樣,也不是太好,因為對這個類別使用者來說,要記住的規則還是太多:

class Foo
{
    public function foo()
    {
        $this->tmpVar = new Service;
    }
    public function bar()
    {
        if (!$this->tmpVar) {
            throw new LogicException(...);
        }

        $ret = $this->tmpVar->create(....);
        // do more stuff on $ret
    }
}

其實整個概念很簡單,就是物件的設計,要盡量避免「一定要看範例才寫得出來正確的程式」,或減少在寫程式時,思考「這樣寫會不會出問題」的疑慮,因為疑慮一多,去查找程式碼或寫測試驗證,耗費的時間就越多。

How About Dependency Injection?

讀到這邊,讀者一定會想,所以才要用 Dependency Injection 呀!其實 DI 也不是銀彈,The downsides are:

  • 很難只看 constructor prototype 就清楚 required argument,最後會變成 required argument 一定要寫在文件裡,且要建構好足夠用的 DI object 才可以使用。

  • 因為改用 DI 的關係,Type Checking 也都要自己寫檢查,相反的,若寫成 function argument 我們可以透過 programming language 本身自帶的 type checking 幫你做參數檢查。

  • 跨元件或跨框架開發時,DI 也必須要支援到同一套 prototype,否則會變成各用一套 DI library 最後弄巧成拙。

  • 難以透過靜態分析工具分析正確性。

但也不是這樣就不要用 DI 了,個人認為 DI 最適用的場景是 Framework 之類的角色,由於 Web Framework 是一整個 Application 的主控者,本身需要分享各種不同的 Service 來跨元件使用,而這些元件本身外部性不高,而外部性高的,可以寫成 ServiceProvider 多包一層,透過 DI 建構外部物件所需要的參數。 也就是說, It depends.

什麼時候需要 Factory?

我們常會看到許多遭到濫用的 Factory pattern,因為初學者學 Design Pattern 第一個學的就是 Factory pattern。所以很多時候會看到 Factory pattern 裡面的建構方法只有一行…..

事實上,只有在建構子參數複雜,需要花上很多行數準備參數並建構一個物件時,我們才需要 Factory pattern,讓外部呼叫邏輯清晰。

譬如說:

$foo = Foo::complexLogics1($request);
$bar = Bar::complexLogics2($request);
$environment = new Environment($foo, $bar);
$environment->setTimeout(…);
$environment->setOtherRequirement();

變成:

$environment = EnvironmentFactory::createFromRequest($request);

這樣在 Application code 中,就不會因為交互穿插的參數準備打亂了整個閱讀順暢度,一方面在其他地方要建構類似的物件時,也可減少 Copy Paste 的數量。

簡而言之,當一個系統中類別數量超過上百上千,你很難清楚記住每個類別中隱含的操作邏輯或行為。

也因此,在設計時,一定要盡可能減少類別最小參數需求,或減少基本參數建構的複雜度,就算你的測試沒有面面俱到,也可以保有最基本的品質。