【 書摘 】PHP OOP Way

受惠於有無數的前人願意分享以及開源自己的程式碼,才能讓我這種只會去 Stackoverflow 找答案複製貼上的偽工程師開始逐漸學習 PHP,因為當貼過來的東西不能用或是需要修改的時候,就必須要去理解自己到底貼上了什麼碗糕。

對於長年下來習慣 trial and error 寫程式的我來說,當要開發一個功能時,我會先就這項功能的每個步驟開始 Google,譬如要做一個會員註冊系統的後端程式,我可能會先查這些問題:「如何將資料傳送進資料庫」、「如何進行資料庫連線」、「如何判斷資料庫資料寫入完成」等等,然後根據我查到的程式碼依序貼入編輯器裡面,接著見證奇蹟的時刻~

想當然一定會到處噴錯無法執行,接下來就開始一行一行檢查出錯的地方,然後把錯誤訊息再丟進 Google 裡面查解決辦法,就這樣用老牛拖車的方式把這個功能給兜起來,我常覺得自己不是在「寫」程式而是在「拼」程式,但透過這樣「拼」的過程,也慢慢培養出一些嗅覺,知道哪樣拼不行、應該該怎麼喬才會 Work。

這就像完全不會說英文的人住在美國,為了生活必須要先學會用英文滿足自己吃喝拉撒睡的基本需求,我學程式的過程大概就是這樣的感覺,為了要完成工作領到薪水吃飯,用盡各種方法都要把客戶要的東西生出來,拜 WordPress 所賜,有太多神人貢獻自己的知識、Know How、以及「外掛」XD

但街頭智慧總是會有遇到瓶頸的時候,當一個專案的規模變大、功能變得複雜,或是要回去修改自己 N 年前寫的程式碼,總是會讓我痛到吱吱叫,所以為了要讓自己少叫一點,開始乖乖把 PHP 的基礎知識再重新學習,然後了解該如何組織檔案結構,讓未來的自己或是接手的人好過些。

學習基礎的過程很辛苦,因為我超不愛看一堆理論解釋,只要東西能跑得出來我要的效果就好,管他是怎麼實現的,但逼著吞的結果,卻常常有意外的發現,像是發現這個東西竟然可以這樣用、原來還有這樣的語法可以簡化原本的寫法,因此為了這些出乎意料的小驚喜,還是乖乖的 K 了下去…

但其中我對於物件導向以及設計模式這兩個東西我是怎樣也吞不下去,市面上的 PHP 書籍多半都是參考書,讀起來都很像考試用的,至於 Google 能找到的中文資料只讓我對這兩件事心生畏懼:

在自然語言中,我們理解抽象的概念是,一個物體的一種大的描述,這種描述對某類物體來說是共有的特性。那麼在PHP中也是一樣的,我們把一個類進行抽象,可以指明類的一般行為,這個類應該是一個模板,它指示它的子方法必須要實現的一些行為。 

為什麼每個中文字我都看得懂,但組在一起後我就沒半句看得懂…

後來我發現很多專有名詞都是中國用語,而這些寫法跟台灣用語完全不一樣,有時候就算是台灣工程師寫的文章,也會因為每個人的背景不同而有不一樣的講法,像是 Class 在中國叫做「類」,台灣通常叫做「類別」,OOP 中國叫做「面向对象」,台灣叫做「物件導向」,某些技術論壇會直接把國外的文章 Google 翻譯成簡體中文,然後再將其繁體中文化,這種東西我看得懂才有鬼啊 😱

後來才知道洋人的玩意兒就直接去看洋人寫的比較快,雖然英文閱讀對我來說很吃力,總是要拿著手機在旁邊查單字,一堆單字查了之後組成句子還是看不懂,但回想起拼湊程式碼的日子,這樣的學習模式自己再熟悉也不過了,所以我最早先從 WordPress Handbooks 開始看,為了護眼還整理成電子書版本,一共有七本,

然後又看了 Building Web Apps with WordPress 還整理了幾篇工作上能直接派上用場的書摘,另外又買了這本 Discover object-oriented programming using WordPress,逐漸對於 OOP 到底該怎麼在實務上運用有了一些些模糊的概念,也對於這個領域的英文單字有了基礎的熟悉度。

而這個月在 K 的這本由 Segrey Zhuk 寫的 PHP OOP Way 好像有把我更敲醒了一點,秉持著有紀錄才是有確實內化成自己東西的精神,我會試著用實務上的經驗來整理書中的知識,希望可以帶領自己走過學習 OOP 的門檻。另外為了能正確表達專有名詞,我會註記原始的英文單字以便對照。

好長的前言,本篇主角終於現身了

使用方法來組合屬性

物件導向程式開發最為人所熟知的就是「封裝」的概念,關於它的定義不太好記,但常用的話大概會知道它是關於把事物的原理隱藏在一個黑箱裡面的過程,就像我在煮飯時按下電鍋的按鈕,時間到飯就會煮好了,而這中間的是如何實現的我不需要去理解,更不用去自己實作這個過程。

但為何這個概念如此重要?難道不能直接把所有屬性都讓人可以公開存取嗎?首先,我們要了解類別在物件導向程式開發中所扮演的角色:它是所有實體物件的設計圖,而它是被設計用在特定用途上的,所以我們必須要確保它能正確的被使用,而如何使用就是透過操作介面,就像是電鍋的煮飯按鈕一樣。

當我們決定哪些功能要做成可以被使用者操作的介面時,這樣的設計過程就是封裝,而封裝的主要目的就是要限縮介面,以避免使用者誤用,想像一下,如果電鍋設計師決定讓使用者自行控制溫度,那麼在煮飯時就要隨時調整溫度,萬一跑去炒個菜或是收衣服沒留意溫度,飯可能不是冷的不然就是燒焦了 > <

離開廚房回到程式開發現場,當我們有一個類別叫做 Client,然後根據這個 Client 類別設計出實體物件,而這個實體物件就能依照 Client 類別所設計的方式來讀取屬性或功能。想像這個 Client 是一位站在你眼前活生生的真人客戶,你可以看到他的臉、衣服、眼睛顏色,你可以跟他說話、問他問題或是得到答案,但你不會看到他的想法、體溫、以及血壓。

這些特徵對於客戶本身非常重要,但卻沒有必要讓別人知道,尤其你跟他只是在閒聊的時候…

舉例來說,我們有一個非常簡單的類別:User 來處使用者資訊,它有三個屬性:姓名、電子郵件與年齡,我們將在沒有封裝概念且完全公開屬性的條件下開始,在建立實體物件的時候我們希望這三個屬性是必要的,所以在建構式函式宣告時需要帶入這三個屬性的參數

class User {
  public $name;
  public $email;
  public $age;
  
  public function __constructor( string $name, string $email, $int $age ) {
    $this->name = $name;
    $this->email = $email;
    $this->age = $age;
  }
}

$user = new User( 'John', 'john@example.com', 18 );

把類別設計完成後,我們現在有一個商業邏輯:檢查該名使用者是否已滿 18 歲,滿了才能瀏覽 18 禁的內容:

if( $user->age < 18 ) {
  throw new TooYoungException ( '等你滿 18 歲再來吧~' );
}

上面的寫法看起來很合理,直接存取 $user 的公開屬性 age,就能判斷是否有滿足特定年紀,而這樣的判斷式會遍佈整個程式裡面,只要有需要判斷 $user 年紀的地方就需要下這一個判斷式,假設到了下個月,需要改成 21 歲時該怎麼做?對,這時候就只能用編輯器進行全站搜尋取代 18 這個數字。

另一個問題在於任何人都可以隨意修改 $user 的年紀,因為這個屬性是可以公開存取的,所以在建立實體之後,還是能夠被進行複寫,這是不合理的:

$user = new User( 'john', 'john@example.com', 18 );
$user->age = 21; // 可以直接被覆寫

還有一個更嚴重的問題:雖然年齡這個屬性被強制要求於建構式函式中必填,但還是缺少了參數驗證的機制,雖然我們已經有用 int 來確認 $age 參數是整數,但卻無法驗證年齡是否為負數:

$user = new User( 'John', 'john@example.com',  -20);

以上這些問題的主要原因在於 User 類別沒有把屬性綁定在方法裡面,這個類別看起來比較像是某種類型的資料結構而非真正的類別。所以接下來,我們開始來設計介面,第一個步驟就是先把隱藏所有的屬性,以及新增方法來取得 $user 的年齡:

class User {
  private $name;
  private $email;
  private $age;
  
  public function __constructor( string $name, string $email, int $age ){
    $this->name = $name;
    $this->email = $email;
    $this->age = $age;
  }
  
  public function getAge(): int {
    return $this->age;
  }

我們先透過宣告私有屬性,修正了公開屬性可以直接存取因而被覆寫的問題,下一步是把年齡的判斷邏輯隱藏在類別裡面,對於實體物件來說,它不用知道具體的年齡,只要知道他是否滿足特定年紀即可:

class User {
  ...
  pbulic function isYoung(): bool {
 	 return $this->age < 18;
  }
}

新增 isYoung() 這個方法就能解決之後年齡要變動的問題。現在 User 這個類別看起來比較像真正的「類別」了,當我們要檢查 user 的年紀不用直接存取屬性來進行判斷,而改用 isYoung() 來檢查即可。

if($user->isYoung(){
  throw new TooYoungException;
}

透過以上的改寫就能把商業邏輯與實現細節隱藏在類別之中,而這就叫做「封裝」。我們把屬性的存取放在方法裡面,這些屬性對於實體物件來說是無從得知的,實體物件只能透過方法來讀取它需要的資料。但在實務上該如何判斷哪些屬性是公開私有或是可繼承?我們應該全部都用私有屬性然後透過方法來設定其值嗎?

設計類別的時候我們的主要目標是要保護屬性避免被實體物件直接存取。所以我們可以從只有私有屬性開始,藉此來確保安全性。如果要提供公開屬性最好有明確且必要的理由。另一方面,千萬別認為只要用了私有屬性跟公開設定方法就可以不用做資料驗證動作:

class User {
  private $name;
  private $email;
  private $age;
  
  public function __constructor( string $name, string $email, int $age ){
    $this->name = $name;
    $this->email = $email;
    $this->setAge($age);  // 改使用 setter 來設定年紀,才能加入驗證機制
  }
  
  ...
  
  public function setAge( int $age ): self {
    $this->age = abs($age);
    return $this;
  }
}

透過以上的修改,就能把不需對外公開的屬性隱藏,並且使用公開介面來設定這些屬性。

定義新的資料類型

關於物件導向的抽象化概念很多人都聽過,但卻很難解釋的很清楚,常聽到的解釋是:「抽象化是一種可以讓程式碼重複使用的概念」,而這也是我們從網路上學到關於物件導向程式設計的知識。如果你真的需要重用程式碼,建立抽象類別並且繼承它,這樣就寫完收工了,但,真的是如此嗎?這邊有個陷阱:有 N 種方法可以達成重複使用程式碼的目的,而使用抽象類別是最糟糕的一種XD

關於抽象化的概念光看名字就覺得很抽象不好理解,這邊借用科技島讀第 454 期的文章「掌握複雜系統的利器 — 抽象化思考」來說明:中文將 abstraction 翻譯為「抽象化」不太貼切。這裡的 abstract 比較接近「摘要」,因此翻譯為「摘要化」或「本質化」會更精準。摘要的目標是提綱挈領,找出系統的核心,是具體的;而不是像「抽象」聽起來是模糊、不具體的。

我喜歡科技島讀 Michael 對於 Abstract 的詮釋,很精準的點出抽象類別的真正含義,它是一種資料類型的呈現方式,就跟我們常用的字串、整數、布林值一樣,是一種很純粹且具體的事物,我們不會說字串、整數很抽象,因為我們每天都用到那頭去,因此要把 Abstract Class 看成是一種資料類型,後文會提及更多這樣的觀念。

讓我們從另外一個方向來思考:PHP 作為一種程式語言,它有許多不同的資料類型,像是整數浮點數字串陣列等等,當需要做數學運算時,我們會用整數、浮點數,當需要邏輯判斷時,我們會用布林值,這是大家都知道的基本觀念,因為我們每天都在用,連想都不用想就知道該用誰。

有了這些基礎類型後,我們就能處理更複雜的資料。舉例來說,像是需要儲存使用者的資料,當然,我們可以用陣列的形式來儲存:

$user = [
  'name' => 'John',
  'email' => 'john@mail.com',
  'age' => 30
];

這樣的資料類型雖然簡潔但卻不實用,因為它只能存放屬性而沒有定義方法,這時候就是類別派上用場的時候。我們可以把它視為一種新的資料類型,在 PHP 內建的資料類型裡面,並沒有一種類型叫做使用者,所以我們使用類別來自行建立,我們可以設計屬性及方法,未來就可以像用字串或是陣列一樣使用它。

class User {
  ...
}

$user = new User();
$user->do_method();

一個新的類別定義了介面來讓其他人可以使用這個資料類型,當我們設計了公開方法後,就像在對使用這個資料類型的工程師說:「嗨!歡迎使用這個新的資料類型,只要遵循特定規則你就知道怎麼使用了~」,當我們把類別視為資料類型時這是非常重要的觀念,類別的公開介面就是一連串的規則,它定義了該類型如何使用。

以 PHP 內建的資料類型來說,我們知道如何讀取陣列值的用法,如果不照著它的規則來用,就絕對會噴錯:

$var = 3;
echo $var[0]; // 應該不會有人這樣用吧?

$var_arr = ['a','b','c'];
echo $var_arr[0]; // 陣列就是這樣用的啊

每一個資料類型都有自己的一套的行為,也就是公開介面所提供的操作方式,這些行為可不能被隨意改變,不然就會是場災難,保持一致的使用介面是身為資料類型最基本的觀念之一。

所以當我們把抽象類別也看做是一種資料類型時,上述的觀念也同樣適用在它身上,在我們設計類別的時候,我們就有責任必須要寫出一致的使用介面供他人使用,而 PHP 提供了一些規則協助我們設計類別,帶有關鍵字 abstract 的類別無法建立實體,而方法應該被定義在子類別之中。

當我們使用繼承時,我們的職責是要遵守父類別的介面,下一個章節會討論更多關於繼承的部分。總括來說,封裝抽象化是互相關聯的,抽象化是移除不需要的細節以聚焦在事物的本質( 本質化 / 摘要化 ),而封裝是在抽象化的過程中會使用到的一門技術,它將細節隱藏起來,只提供公開介面來讓使用者進行操作。

抽象化是一個較大的範疇,如果沒有封裝是無法實現抽象化的。

關聯資料類型家族

當聽到繼承的時候你腦海中第一個浮現的印象是什麼?無疑是程式碼的重複使用,對吧?讓我們忘記這事從頭開始吧~

在上一個章節中提到,我們把抽象化看做是定義一個新資料類型的過程,然後使用 extend 關鍵字來建立子類別,而這也就是所謂的繼承,透過繼承可以判斷類別之間是否有親戚關係,子類別會擁有父類別所有非私有的屬性與方法,所以子類別是其父類別更明確具體規格的版本( 青出於藍勝於藍XD )。

晚上睡不著的時候想到:如果 Abstract 是本質化、摘要化的意思,那麼繼承抽象類別的子類別,比較像是把抽象類別的細節完整描述出來,就像做報告的時候一樣,第一頁通常會是大綱或目錄,然後再從目錄去延伸內容,而這延伸的內容就是子類別要做的事,如果以這樣的關係來發想,父類別可能比較適合稱作為「根類別」或「主類別」,所有繼承它的類別則叫做「延伸類別」。

雖然子類別擁有自己具體的規格,但使用者基本上還是認定它是父類別的延伸,因此父類別能用的方法也能完全無痛的用在子類別身上,以程式碼來說明:

class Task {
  private $isClosed;
  private $closedAt;
  
  public function close(){
    $this->isClosed = true;
    $this->$closedAt = date("Y-d-m H:i:s");
    
    return $this;
  }
}

class Project extends Task { }

範例中有一個父類別叫做 Task,子類別 Project 繼承了所有非私密屬性與方法,使用者會認定這兩個類別都是一種新的資料類型叫做 Task,而能使用的方法為 close()。接下來考慮另外一個類別 User 要使用 Task:

class User {
  public function completeTask( Task $task ){
    $task->close();
  }
}
$user = new User()
$user->completeTask( new Task() );
$user->completeTask( new Project() );

當子類別 Project 沒有定義任何新的方法時,上述的程式碼運作正常,但設計子類別就是為了更多更具體的功能所以才需要它,但把新的方法加入子類別之後,很容易就會開始造成問題:

class Task {
  private $isClosed;
  private $closedAt;
  
  public function close(){
    $this->isClosed = true;
    $this->$closedAt = date("Y-d-m H:i:s");
    
    return $this;
  }
}

class Project extends Task { 
   public function close(){
     parent::close();
   }
}

// Client Code
class User {
  public function completeTask( Task $task ){
    $task->close();
  }
}
$user = new User()
$user->completeTask( new Task() );  // 正常運作
$user->completeTask( new Project() ); // 直接炸裂

發現到最後一行為何會炸裂嗎?Project 新增了與 Task 相同的方法名叫做 close,然後執行父類別的 close,但因為 Project 的 close() 沒有回傳值,而父類別是有的,在子類別沒有遵循父類別的情況下因此造成錯誤。子類別繼承父類別的當下,子類別就應該要遵守父類別的規則,所以當我們在子類別加入新的方法時要非常小心。

這種回傳資料的錯誤可以加上類型提示來讓除錯容易得些,程式碼編輯器可以透過這些宣告來提醒你錯誤的地方:

class Task {
  private $isClosed;
  private $closedAt;
  
  public function close(): self { // 加入 self 來提示回傳的是類別本身
    $this->isClosed = true;
    $this->$closedAt = date("Y-d-m H:i:s");
    
    return $this;
  }
}

class Project extends Task { 
   public function close(): void { // 加入 void 來提示沒有回傳任何值
     parent::close();
   }
}

另外一個關於繼承的重要議題是繼承的深度,也就是這個家族有幾代同堂,除了子類別外,是否還有孫類別、曾孫類別、甚至是曾曾孫類別,實務上的建議是能夠越小越好,小家庭會讓事情比較單純,不然要繼承遺產的的話就很麻煩XD

減少繼承的深度可以比較容易掌握每一代之間的關係,如果上面 Project 的範例有繼承到曾曾孫類別,這樣我們必須要橫跨好幾代才能找到原來問題是出在子類別,當程式龐大時可就不這麼容易找到了。

或是我們也有可能會在繼承的類別中覆寫父類別的方法,以及忘記寫父類別中必要的方法,當這些錯誤發生而繼承的深度又太深時,都會讓除錯非常痛苦。所以理想的深度大概是兩到三代,再多就失控了。

繼承有很多好處,像是將類別群組化以及覆寫被繼承的方法,接下來我們要討論繼承最為人所熟知的用途:程式碼重用。對於程式碼的重複利用之所以會用繼承的方式來實作,是因為很多 MVC 的框架都是這樣做的,想要新增一個 controller?繼承 contorller 父類別即可~

namespace App;

use Illuminate\Database\Eloquen\Model;

class User extends Modle {
  ...
}

使用框架的好處是當需要某些功能時,使用 extends 關鍵字來繼承框架已經設計好的父類別,在大部分情況下這樣做沒問題,但繼承的子類別未必會變成 model 或 view,所以當下次為了要使用某些實用的方法而繼承父類別建立子類別時,要記住這些類別都是一種資料型態,不然很容易會遇到上面提及的問題。

同時要記得要盡量維持淺薄的繼承關係,只有父與子兩代是最好的深度。當然,不是所有情境都能符合這個原則,但如果要發展更多階層,最好有明確的理由以及知道自己在做什麼。

盡可能使用 Final 關鍵字

當新增類別的時候,全部使用 final 關鍵字來進行宣告,來確保該類別是不能被繼承的,除非這個類別是被設計來給其他類別來繼承的:

final class User {
  ...
}

使用 final 關鍵字最大的好處是不允許子類別的存在,因此沒有任何人可以改變它的行為。當沿用一個類別的時候就會把封裝的概念給破壞掉,因為子類別可以看到父類別的內部行為並且複製取代,就像前面提到的 Projcet 與 Task 這兩個類別一樣,當子類別複寫父類別的方法時就很容易造成錯誤。

同時,final 關鍵字也避免讓我們使用繼承作為程式碼重用的手段,當我們不能透過繼承來重用程式碼,我們就會開始思考該如何設計更有邏輯與組織的類別。當然,只要我們有需要,隨時都可以移除 final 關鍵字,但如果先把 final 類別作為預設值,我們就會考慮到要移除它的理由,final 就像是一個提醒功能,提醒我們深思熟慮繼承類別的必要性。

不同的類型擁有相同的行為

在進入主題之前,我們先來複習一下關於介面的定義方式:

interface InterfaceName {
  public function method( $parameter );
}

介面可以包含方法與常數,但卻不能有任何的屬性。所有在介面裡面定義的方法都必須是公開而且沒有實作細節,在 PHP 中,可以使用 extends 關鍵字來繼承另外一個介面:

interface ParentInterface {
  public function method( $parameter );
}

interface ChildInterface extends  ParentInterface {
  public function another_method( $parameter );
}

與類別不同之處在於介面可以從其他多個介面進行繼承:

interface LoggerInterface extends WritableInterface, ReadableInterface {
  ...
}

當我們建立介面的時候,代表有使用這個介面的類別都必須要把該介面裡面的方法實作出來,舉例來說,當我們需要確保一個物件必須執行某些特定的方法時,我們就可以使用介面,介面是不同程式開發者之間的協議規範,就跟公司與公司之間的合約一樣,不履行就會發生問題。( 上法院或是程式爆炸 )

interface Flyable {
  public function fly( int $distance );
}

final class Bird implements Flyable {
  public function fly( int $distance ){
    // implementation
  }
}

final class Plane implements Flyable {
  public function fly( int $distance ){
    // implementation
  }
}

當類別決定實作某個介面時,這個類別就必須要有這個介面的公開方法,如同上面的範例一樣,鳥跟飛機都實作了 Flyable 這個介面,所以這兩個類別都必須要有 fly 這個公開方法,不然就會發生錯誤。要注意的是如果一個類別同時實作了兩個介面,那這兩個介面不可以有相同的方法名稱,不然會造成混淆。

而抽象類別也有提供介面,但關鍵的不同之處在於抽象類別直接提供了實作邏輯,以下使用抽象類別來改寫上面的例子:

abstract class FlyableEntity {
  protected $wings;
  abstract public function fly( int $distance );
}

當使用了抽象類別即代表我們設計了一個全新的資料類型,這個資料類型提供了一個抽象方法來作為介面,所以在子類別中可以進行介面的實作:

final class Bird extends FlyableEntity {
  public function fly( int $distance ){
    // child class implementation
  }
}

以真實世界的邏輯來看上面的例子可能有點奇怪,因為 FlyableEntity 不是一種本質化或是摘要化,這邊只是為了要示範抽象類別作為介面使用的範例。

如果使用抽象類別就可以達到介面的效果,那為何還需要宣告 Interface 呢?下面會繼續探討兩者之間的關係與差異。

介面與抽象類別

抽象類別是以一種新的資料類型呈現,而類別定義了物件的藍圖,我們知道如何透過類別來建立物件,就像我們知道如何使用字串或陣列一樣,當我們繼承一個類別時,就等於是創造了一個新的次要類型,我們不需要學新的用法就能使用它,因為只要能知道父類別怎麼用即可,下面以一個 Cahce 類別作為範例:

namespace Cache;

abstract class Cache {
  abstract public function get($key);
  abstract public function set($key,$value);
}
namespace Cache;
final class FIleSystemCache {}
namespace Cache;
final class RedisCache {}
namespace Cache;
final class NullCache {}

命名空間 namespace 的用途是避免相同的類別名稱而造成衝突,第一段程式中的 namespace Cache 宣告這個檔案的命名空間為 Cache,而下面三段的開頭都使用了 namespace Cache,即代表接在後面的類別都屬於 Cache 這個命名空間之中,因此類別 FileSystemCache、RedisCache、NullCache 這三個類別都擁有父類別 Cache 的 get 與 set 方法。

更多命名空間介紹可以參考這篇文章

上面所有的類別都應該遵守它們的父類別 Cache 的行為,每一個子類別都與父類別相關聯,雖然它們可以用自己的方法來實作快取機制,但它們都用共同的公開介面:使用 get 來設定快取的值、使用 set 來儲存需要被快取的內容,這兩個方法都需要帶入 $key 來傳送給這兩個方法。

想像一下有個類別叫作 Product,我們需要把它加入快取之中,我們可以使用前綴 prodcut_ 來組合它的 ID 來作為快取的 key:

$product = new Product();

//...

$cache->set( 'product_'.$product->id, $product )

簡單明暸,但假設今天我們也想要把商品分類加進快取之中,我們就會比照辦理:

$cache->set( 'category_'.$category->id, $category )

然後再次假設如果我們因為業務需求改變,因此決定要變更 Proudct 的快取 key,這件任務就會非常的阿雜,我們必須要翻遍所有程式碼然後搜尋取代 product_.$product->id 這個地方,有很高的機率會漏掉它並且造成臭蟲,而且這樣的蟲非常不好抓。

如果我們讓類別 Cahce 不用設定 key、或是將其封裝於 Product 裡面呢?舉例來說,每一個需要被快取的 Product 或是 Category 類別都有一個方法叫做 getCacheKey,這方法會回傳要用在 Cache 裡面的 key,未來當需要修改 key 值時,只要變更 getCacheKey 這一個方法即可。

class Product {
  public function getCacheKey(): string {
    return 'product_'.$this->id;
  }
}

class Category {
  public function getCacheKey(): string {
    return 'category_'.$this->id;
  }
}

看起來比較好一些,但還有另外一個問題:我們必須確保每一個要使用快取類別的物件都有 getCacheKey 這個方法,回到 Cache 類別來加入一些判斷:

abstract class Cache {
  public function get( $key ) {
    $key = $this->resolveKey( $key );
    return $this->getFromCache( $key );
  }
  
  abstract protected function getFromCache( $key );
  
  protected function resolveKey( $key ) {
    if( is_object( $key ) && method_exists( $key, 'getCacheKey') ) {
      return $key->getCacheKey();
    }
    throw new CacheException( 'Object should implement method getCacheKey()' );
  }
  
  public funciton set( $key='', $entity ) {
    $key = $this->resolveKey( $key );
    return $this->storeInCache( $key );
  }
  
  abstract protected function storeInCache( $key, $data );
}

為了要檢查傳進去 Cache 的物件是否帶有 getCacheKey 方法,必須增加了一些條件來判斷,這讓程式變得又臭又長。根據 Cache 的業務邏輯,它必須要有 Key 才能作後續的動作,這時後類型提示就派上了用場,舉例來說,當我們要求參數必須是陣列的時候,就可以使用 array 來提示參數的資料類型。

在我們的範例中,我們擁有 Product 與 Category 這兩個類別,他們是兩種不同的資料類型,我們沒有辦法建立像是 Cache 這種抽象類別的階層關係,當然,雖然可以另外再新增一個抽象類別叫做 CachedEntity 並擁有 getCacheKey 方法,然後讓它的子類別實作 getCacheKey。

但這不是一個好主意,因為這些類別並不是真的彼此關聯,它可能看起來很彈性但這類的解決方法最後都會變成包山包海的超大物件,所以,讓我們來考慮使用介面:Interface。

介面不會產生新的資料類型,取而代之的是描述一個資料類型的單ㄧ面向,它只提供方法的骨架,這骨架裡面沒有任何的內容,我們可以建立一系列方法的骨架,最後在物件裡面來實作細節,根據上面的範例,我們定義一個介面叫做 Cacheable,然後讓 Product 與 Category 來實作 Cacheable 裡面的方法:

interface Cacheable {
  public function getCacheKey(): string;
}

class Category implements Cacheable {
  public function getCacheKey: string {
    return 'category_'.$this->id;
  }
}

class Product implements Cachebable {
  public function getCachekey: string {
    retrun 'product_'.$this->id;
  }
}

現在我們可以用類型提示來檢查要傳進去 Cache 的參數是否為 Cacheable:

abstract class Cache {
  abstract public funciton get(Cacheable $entity);
  abstract public funciton set(Cacheable $entity, $data);
}

另外 Cacheable 介面還可以定義另外一個方法來取得快取資料:getCacheData(),這個方法會回傳已快取資料的陣列。

interface Cacheable {
  public function getCacheKey(): string;
  public function getCacheData();
}

class Category implements Cacheable {
  public function getCacheKey: string {
    return 'category_'.$this->id;
  }
  public function getCacheData(): array {
    return [
      'name' => $this->name,
      'slug' => $this->slug,
      'productsNum' => $this->products->count()
    ];
  }
}

class Product implements Cachebable {
  public function getCachekey: string {
    retrun 'product_'.$this->id;
  }
   public function getCacheData(): array {
    return [
      'name' => $this->name,
      'slug' => $this->slug,
      'images' => $this->images,
      'productsNum' => $this->products->count()
    ];
  }
}

透過這樣的改寫來把所有的邏輯都封裝在 Cacheable 裡面,實作 Cacheable 這個介面的類別都擁有自己的 getCacheKey 與 getCacheData 的方法,抽象類別 Cache 完全不知道快取儲存與讀取的細節,只要用原本的 set 與 get 方法就能操作快取,而原本的 set 方法也不需要傳第二個 $data 參數了:

abstract class Cache {
  abstract public funciton get(Cacheable $entity);
  abstract public funciton set(Cacheable $entity);
}

Cache 類別完全信任 Cacheable 介面,因為實現 Cacheable 的類別一定會把介面定義的一堆方法完全實作出來,也因為這樣,Cache 類別可以完全信賴 Cacheable。

可以注意到介面的命名規則通常是用 able 或是 ing 的形容詞結尾,更進一步的說,它可以適用在所有不同的資料類型上,就像形容詞可以用在形容各種名詞,上面的範例我們有兩個類別叫做 Category 跟 Proudct,而我們用「可快取的 – Cacheable」 來形容它們,因此在實務上會慣用形容詞來幫介面命名。

當使用抽象類別的時候,我們的常數可能會有許多不同的值:

abstract class AbstractTable {
  const TABLE = null;
}

class UsersTable extends AbstractTable {
  const TABLE = 'users';
}

class OrdersTable extends AbstractTable {
  const TABLE = 'oreds';
}

我們可以在子類別裡面複寫或是重新宣告父類別中的常數,有時候很方便,就像上面的例子一樣,但這樣就不是常數了,假設我們真的需要一個不能被變動的常數,就可以把它定義在介面之中:

interface MathInterface {
  const PI = 3.14159;
}

class MathOperations implements MathInterface {
  const PI = 3.14 // Fatal Error!
}

在 PHP 中關於介面的常數還有另外一個特性:使用該介面的類別雖然無法重新宣告其常數,但如果又有一個子類別繼承了使用該介面的類別,那麼在這個子類別中又可以重新宣告這個常數:

interface MathInterface {
  const PI = 3.14159;
}

class MathOperations implements MathInterface {}

class MultiplicationOperations extends MathOperations {
  const PI = 3.14; // This works fine
}

總結:使用抽象類別就是我們要定義新的資料型態的時候,在繼承抽象類別的子類別中,我們可以複寫或是實作父類別中方法的細節,最重要的是我們要確保這些子類別與父類別是真的有血緣關係(同資料型態),而非為了要取得父類別的某些好處(重用程式碼)而去繼承它。

至於介面只提供公開的方法,使用介面可以確保實作該介面的類別具有介面指定的方法,而方法的實作細節都在類別裡面,因此可以根據業務邏輯作調整,同時又能達到資料型別提示的作用,省去寫一堆驗證物件的判斷。

複製與貼上程式碼

在 PHP 裡面沒有提供複數繼承的功能,一個子類別只能繼承一個父類別,有些人會覺得這樣不好,因為就不能同時繼承多個帶有實用的方法的類別,看到這邊我們已經理解為何透過繼承來達到程式碼重用的作法是最糟的,但假設我們在某些情境下真的需要重用之前寫過的程式碼,難道真的要複製貼上嗎?

舉例來說,我們有一個購物商城,它有商品型錄,型錄裡面有商品分類與商品品項,我們想要讓網址不要用 id 顯示,而是用人類看得懂的名稱,所以我們需要讓分類與品項都擁有自己的網址代稱

class Product {
  private $name;
  
  public function slug(): string {
    $cleared = preg_replace('/[^A-Za-z0-9]+/','-',$this->name);
    return strtolower($cleared);
  }
}

class Category {
  private $name;
  
  public function slug(): string {
    $cleared = preg_replace('/[^A-Za-z0-9]+/','-',$this->name);
    return strtolower($cleared);
  }
}

很明顯的 slug 方法完全一模一樣,沒有人喜歡重複,因為很容易產生臭蟲,所以上面的寫法不推薦。如果另外寫一個類別叫做 BaseModelWithSlug 來處理的話,也不是一個好主意,因為這樣就只是為了功能而去做類別的繼承,還記得上面提到的基本觀念嗎?繼承一定要有血緣關係(同資料型態),BaseModelWithSlug 類別連資料型態都稱不上。

在這種情境下最適合的作法就是使用關鍵字 Trait,根據 php.net 的定義:Trait 是一套程式碼重用的技術,並減少單一繼承的限制,讓開發者可以自由地在不同的類別之中重複使用方法。

換句話說,Trait 可以讓我們用一個可重用的物件來整合相關的方法,然後這些重用物件會把裡面定義好的方法貼入到類別裡面。這跟繼承有什麼不同?差別在於 Trait 跟繼承一點關係都沒有,它比較像是 Mixin 混搭的概念。

Trait 也提供了很多方式來避免在繼承情況下所造成的衝突問題,如果一個方法同時被宣告在多個 Trait 裡面,那麼就會產生錯誤提示。只要記住使用 Trait 就是要取代複製貼上程式碼的行為,當一個類別使用 Trait 的時候,就好像是把 Trait 裡面的方法複製下來然後貼到類別裡面。

下面我們來建立一個產生網址代稱的 Trait:

trait HasSlug {
  public function slug(): string {
    $cleared = preg_replace('/[^A-Za-z0-9]+/','-',$this->name);
    return strtolower($cleared);
  }
}

class Product {
  use HasSlug;
  private $name;
}

class Category {
  use HasSlug;
  private $name;
}

首先使用關鍵字 trait 宣告後,在類別裡面使用 use 來貼上 Trait 裡面的方法,這樣就能夠重複使用 slug 方法了,記得 use 要在類別的最前面,因為 PHP 需要找出類別的結構。但假設我們想要讓網址代稱是用類別的其他私有屬性呢?上面的範例是直接寫死 $this->name,如果要保持彈性可以新增一個方法來取得 name:

trait HasSlug {
  public function slug(): string {
    $string = $this->getStringForSlug();
    $cleared = preg_replace('/[^A-Za-z0-9]+/','-', $string);
    return strtolower($cleared);
  }
  abstract protected function getStringForSlug(): string;
}

class Product {
  use HasSlug;
  private $name;
  protected function getStringForSlug(): string {
    return $this->name;
  }
}

class Category {
  use HasSlug;
  private $name;
  private $shortName;
  protected function getStringForSlug(): string {
    return $this->shortName;
  }
}

上面的範例中我們在 trait 裡面多加入了一個方法叫做 getStringForSlug(),它會返回字串,而真正產生網址代稱是透過這個方法,因此在不同的類別中,我們可以定義不同的網址代稱取得方式,這樣就能同時達成動態產生代稱且又不重複程式碼的目標了。

現在我們重新思考一下關於資料型別與介面的問題,我們把 Cateogry 變得更複雜一些:

class Category {
  use HadSlug;
  private $name;

  protected function getStringForSlug(): string {
    return $this->shortName;
  }
  
  public function parent(): self {
    // returns a parent category
  }
  
  public function products(): array {
    // returns products
  }
 
}
  

萬一使用 trait 的類別有自己的公開介面會發生什麼事?在使用 trait 前有兩個方法:parent() 與 products(),而透過 trait 新增加了 slug() 方法,而沒有人可以保證實作 Category 的類別有 slug 方法,所以就必須要去爬類別的原始碼,然後找到 trait 裡面帶有的方法。

因此我們需要建立一個新的介面來確保該類別是有帶有 slug 方法的:

interface Sluggable {
  public function slug(): string;
}

trait HasSlug {
  // ...
}

class Category Implements Sluggable {
  use HasSlug;
  
  // ...
}

待續

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *