【 書摘 】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 本身並沒有 $isClosed 與 $closedAt 這兩個屬性,所以造成錯誤。子類別繼承父類別的當下,子類別就承諾遵守父類別的方法,所以當我們在子類別加入新的方法時要非常小心。

待續

發佈留言

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