在物件導向程式設計領域,SOLID 原則是一組旨在提升軟體可維護性、可擴展性和可重用性的五項核心設計原則。遵循這些原則,可以幫助開發者寫出更清晰、更易於理解與修改的程式碼,進而減少錯誤並加速開發流程。
本文將深入探討 SOLID 原則的每一個面向,並透過實際的程式碼範例,幫助您理解如何在日常開發中應用這些原則。
1. 單一職責原則 (Single Responsibility Principle - SRP)
定義
單一職責原則(SRP)指出:「一個類別只應該有一個改變的理由。」 換句話說,一個類別或模組應該只負責一項特定的功能或職責。如果一個類別承擔了多個職責,那麼當其中任何一個職責的需求發生變化時,這個類別就可能需要修改,這會增加程式碼的耦合度,並使得維護變得更加困難。
為什麼重要?
- 降低耦合度: 當每個類別只專注於一項職責時,類別之間的依賴關係會減少,使得系統的各個部分更加獨立。
- 提高可維護性: 當需要修改某個功能時,您只需要修改負責該功能的類別,而不會影響到其他功能。
- 提高可讀性: 每個類別的職責清晰明確,程式碼更容易理解和閱讀。
- 提高可重用性: 獨立的職責單元更容易在不同的專案或模組中重用。
反面範例 (Bad Practice)
假設我們有一個 Order 類別,它不僅負責訂單的資料處理,還負責將訂單儲存到資料庫中,並向客戶發送訂單確認郵件。
<?php
class Order
{
public string $orderId;
public string $customerEmail;
public array $items;
public function __construct(string $orderId, string $customerEmail, array $items)
{
$this->orderId = $orderId;
$this->customerEmail = $customerEmail;
$this->items = $items;
}
public function calculateTotal(): void
{
// 計算訂單總價的邏輯
}
public function saveToDatabase(): void
{
// 將訂單儲存到資料庫的邏輯
echo "Saving order {$this->orderId} to database.\n";
}
public function sendConfirmationEmail(): void
{
// 發送訂單確認郵件的邏輯
echo "Sending confirmation email to {$this->customerEmail} for order {$this->orderId}.\n";
}
}
?>
在這個範例中,Order 類別有三個職責:
- 訂單管理: 處理訂單資料,計算總價。
- 資料持久化: 將訂單儲存到資料庫。
- 通知: 發送訂單確認郵件。
如果資料庫的儲存方式改變了,或者郵件發送服務變更了,Order 類別都需要被修改。這違反了 SRP。
正面範例 (Good Practice)
為了遵循 SRP,我們可以將不同的職責分離到不同的類別中:
<?php
class Order
{
public string $orderId;
public string $customerEmail;
public array $items;
public function __construct(string $orderId, string $customerEmail, array $items)
{
$this->orderId = $orderId;
$this->customerEmail = $customerEmail;
$this->items = $items;
}
public function calculateTotal(): void
{
// 計算訂單總價的邏輯
}
}
class OrderRepository
{
public function save(Order $order): void
{
// 將訂單儲存到資料庫的邏輯
echo "Saving order {$order->orderId} to database.\n";
}
}
class EmailService
{
public function sendConfirmationEmail(Order $order): void
{
// 發送訂單確認郵件的邏輯
echo "Sending confirmation email to {$order->customerEmail} for order {$order->orderId}.\n";
}
}
?>
現在,Order 類別只負責管理訂單資料,OrderRepository 負責資料持久化,而 EmailService 負責郵件通知。這樣一來,每個類別都有單一的職責,並且它們之間的耦合度更低,更容易進行單獨的修改和測試。
2. 開放封閉原則 (Open/Closed Principle - OCP)
定義
開放封閉原則(OCP)指出:「軟體實體(類別、模組、函數等)應該對擴展開放,對修改封閉。」 這意味著,當您需要增加新的功能或行為時,應該通過添加新的程式碼來實現,而不是修改現有的、已經通過測試的程式碼。
為什麼重要?
- 提高穩定性: 修改現有程式碼容易引入新的錯誤,而擴展程式碼則可以避免這種風險。
- 提高可維護性: 新功能的增加不會影響到現有功能,降低了維護成本。
- 提高靈活性: 系統更容易適應未來需求變化,而不需要進行大規模的重構。
- 促進重用: 穩定且可擴展的模組更容易被重用。
反面範例 (Bad Practice)
假設我們有一個 Rectangle 類別和一個 Circle 類別,用於計算不同形狀的面積。
<?php
class Rectangle
{
public float $width;
public float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
}
class Circle
{
public float $radius;
public function __construct(float $radius)
{
$this->radius = $radius;
}
}
class AreaCalculator
{
public function calculateArea(object $shape): float
{
if ($shape instanceof Rectangle) {
return $shape->width * $shape->height;
} elseif ($shape instanceof Circle) {
return M_PI * $shape->radius * $shape->radius;
}
// 如果需要添加新的形狀(例如:Triangle),我們必須修改這裡
// 這違反了 OCP
else {
throw new Exception("Unknown shape type");
}
}
}
?>
在這個範例中,當我們需要添加一個新的形狀(例如 Triangle)時,AreaCalculator 類別的 calculateArea 方法就必須被修改,以添加對新形狀的判斷邏輯。這顯然違反了 OCP,因為它對修改開放。
正面範例 (Good Practice)
為了遵循 OCP,我們可以利用多型(Polymorphism)的概念,將計算面積的職責下放到每個形狀類別中。
<?php
interface Shape
{
public function area(): float;
}
class Rectangle implements Shape
{
private float $width;
private float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
public function area(): float
{
return $this->width * $this->height;
}
}
class Circle implements Shape
{
private float $radius;
public function __construct(float $radius)
{
$this->radius = $radius;
}
public function area(): float
{
return M_PI * $this->radius * $this->radius;
}
}
class Triangle implements Shape
{
private float $base;
private float $height;
public function __construct(float $base, float $height)
{
$this->base = $base;
$this->height = $height;
}
public function area(): float
{
return 0.5 * $this->base * $this->height;
}
}
class AreaCalculator
{
public function calculateArea(array $shapes): float
{
$totalArea = 0.0;
foreach ($shapes as $shape) {
$totalArea += $shape->area();
}
return $totalArea;
}
}
?>
現在,我們定義了一個抽象基類 Shape,它包含一個抽象方法 area()。每個具體的形狀類別(Rectangle、Circle、Triangle)都繼承自 Shape 並實現了自己的 area() 方法。AreaCalculator 類別現在只需要迭代形狀列表,並調用每個形狀的 area() 方法即可。
當我們需要添加新的形狀時,我們只需要創建一個新的形狀類別,繼承自 Shape 並實現 area() 方法,而不需要修改 AreaCalculator 類別的任何現有程式碼。這就實現了「對擴展開放,對修改封閉」的原則。
3. 里氏替換原則 (Liskov Substitution Principle - LSP)
定義
里氏替換原則(LSP)指出:「如果 S 是 T 的子類型,那麼在程式中,所有使用到類型 T 的地方,都可以替換成類型 S,而不影響程式的正確性。」 簡單來說,子類別必須能夠替換其父類別,並且程式碼在使用父類別的地方,不應該因為替換成子類別而產生任何錯誤或未預期的行為。
為什麼重要?
- 確保繼承的正確性: LSP 強調了繼承關係的語義,確保子類別在行為上與父類別保持一致,避免了「打破」父類別原有契約的情況。
- 提高程式的健壯性: 遵循 LSP 可以讓程式碼更加穩定和可預測,因為您不需要擔心子類別會改變程式碼的既有行為。
- 促進多型應用: LSP 是實現多型的重要基礎,它使得我們可以透過父類別的介面來操作不同的子類別物件,而無需知道其具體類型。
反面範例 (Bad Practice)
考慮一個經典的矩形與正方形的例子。從數學角度看,正方形是一種特殊的矩形。但從程式設計角度,如果 Square 繼承自 Rectangle,可能會違反 LSP。
<?php
class Rectangle
{
public float $width;
public float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
}
class Square extends Rectangle
{
public function __construct(float $side)
{
parent::__construct($side, $side);
}
public function set_width(float $width): void
{
$this->width = $width;
$this->height = $width; // 正方形的寬高必須保持一致
}
public function set_height(float $height): void
{
$this->height = $height;
$this->width = $height; // 正方形的寬高必須保持一致
}
}
function print_area(Rectangle $rectangle): void
{
$rectangle->set_width(5);
$rectangle->set_height(4);
echo "Expected area: 20, Actual area: " . $rectangle->area() . "\n";
}
// 使用 Rectangle
$rect = new Rectangle(2, 3);
print_area($rect); // 輸出: Expected area: 20, Actual area: 20
// 使用 Square
$square = new Square(2);
print_area($square); // 輸出: Expected area: 20, Actual area: 16 (因為set_height(4)後,width也變成了4)
在這個範例中,Square 類別重寫了 set_width 和 set_height 方法,以確保寬高始終相等。當 print_area 函數期望一個 Rectangle 並設置了不同的寬和高時,如果傳入的是 Square 物件,其行為就與預期不同了。Square 在此處無法完全替換 Rectangle,這違反了 LSP。
正面範例 (Good Practice)
為了遵循 LSP,我們應該讓 Rectangle 和 Square 都繼承自一個更通用的抽象基類,或者將它們設計成不具備繼承關係的獨立類別,或者讓 Square 繼承自 Shape 而非 Rectangle。
<?php
interface Shape
{
public function area(): float;
}
class Rectangle implements Shape
{
private float $width;
private float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
public function area(): float
{
return $this->width * $this->height;
}
}
class Square implements Shape
{
private float $side;
public function __construct(float $side)
{
$this->side = $side;
}
public function area(): float
{
return $this->side * $this->side;
}
}
function calculate_and_print_area(Shape $shape): void
{
echo "Area: " . $shape->area() . "\n";
}
$rect = new Rectangle(5, 4);
calculate_and_print_area($rect); // 輸出: Area: 20
$sq = new Square(4);
calculate_and_print_area($sq); // 輸出: Area: 16
// 現在,無論傳入的是 Rectangle 還是 Square,calculate_and_print_area 函數都能正確運作,
// 因為它們都實現了 Shape 介面的 area 方法,並且沒有改變預期的行為。
在這個改進的範例中,Rectangle 和 Square 都繼承自 Shape 抽象基類,並各自實現了自己的 area() 方法。Square 不再是 Rectangle 的子類別,避免了因繼承而帶來的行為不一致問題。這樣,無論我們傳入 Rectangle 或 Square 物件給操作 Shape 的函數,都能保證其行為的正確性,從而遵循了 LSP。
4. 介面隔離原則 (Interface Segregation Principle - ISP)
定義
介面隔離原則(ISP)指出:「客戶端不應該被強制依賴它們不使用的介面。」 換句話說,一個類別不應該實現它不需要的方法。大型的、包羅萬象的介面應該被拆分成更小、更具體的介面,每個客戶端只需要知道和依賴它實際使用的介面。
為什麼重要?
- 降低耦合度: 避免了客戶端與其不需要的方法產生不必要的依賴,從而降低了系統的耦合度。
- 提高靈活性: 系統更容易適應變化,因為介面的修改只會影響到少數相關的客戶端,而不是所有實現該介面的類別。
- 提高可維護性: 更小的介面更容易理解和管理,減少了程式碼的複雜性。
- 促進重用: 更精確的介面可以提高程式碼的重用性,因為類別只需實現它們真正需要的行為。
反面範例 (Bad Practice)
假設我們有一個 Worker 介面,它包含了工作、吃飯和睡覺的方法,而機器人只需要工作,不需要吃飯和睡覺。
<?php
interface Worker
{
public function work(): void;
public function eat(): void;
public function sleep(): void;
}
class HumanWorker implements Worker
{
public function work(): void
{
echo "Human working...\n";
}
public function eat(): void
{
echo "Human eating...\n";
}
public function sleep(): void
{
echo "Human sleeping...\n";
}
}
class RobotWorker implements Worker
{
public function work(): void
{
echo "Robot working...\n";
}
public function eat(): void
{
// 機器人不需要吃東西,但被迫實現這個方法,可能做空操作或拋出異常
echo "Robot eating...\n";
}
public function sleep(): void
{
// 機器人不需要睡覺,但被迫實現這個方法
echo "Robot sleeping...\n";
}
}
在這個範例中,RobotWorker 被迫實現了 eat() 和 sleep() 方法,儘管它並不需要這些功能。這違反了 ISP,因為 RobotWorker 被強制依賴了它不使用的介面。
正面範例 (Good Practice)
為了遵循 ISP,我們可以將 Worker 介面拆分成更小的、更具體的介面。
<?php
interface Workable
{
public function work(): void;
}
interface Feedable
{
public function eat(): void;
}
interface Sleepable
{
public function sleep(): void;
}
class HumanWorker implements Workable, Feedable, Sleepable
{
public function work(): void
{
echo "Human working...\n";
}
public function eat(): void
{
echo "Human eating...\n";
}
public function sleep(): void
{
echo "Human sleeping...\n";
}
}
class RobotWorker implements Workable
{
public function work(): void
{
echo "Robot working...\n";
}
}
現在,我們將 Worker 介面拆分成了 Workable、Feedable 和 Sleepable 三個更小的介面。HumanWorker 實現了所有三個介面,而 RobotWorker 只實現了 Workable 介面。這樣,每個類別只依賴它實際需要的介面,避免了不必要的實現,從而遵循了 ISP。
5. 依賴反轉原則 (Dependency Inversion Principle - DIP)
定義
依賴反轉原則(DIP)指出:「高層模組不應該依賴低層模組,兩者都應該依賴抽象。抽象不應該依賴細節,細節應該依賴抽象。」 這意味著,我們應該設計程式碼,使其依賴於抽象(例如介面或抽象類別),而不是具體的實作。這樣可以降低模組之間的耦合度,並提高程式碼的彈性和可測試性。
為什麼重要?
- 降低耦合度: 高層模組和低層模組都依賴於抽象,而不是具體實作,從而降低了它們之間的直接依賴。這使得系統更加靈活,更容易替換或修改底層實作。
- 提高可測試性: 由於模組依賴於抽象,我們可以更容易地使用模擬物件(Mock Object)來測試高層模組,而無需依賴具體的低層實作。
- 提高可維護性: 系統的各個部分可以獨立開發和修改,減少了因修改一個模組而影響其他模組的風險。
- 促進擴展性: 透過抽象層,可以輕鬆引入新的實作,而無需修改現有程式碼。
反面範例 (Bad Practice)
假設我們有一個 LightBulb 類別和一個 Switch 類別,Switch 直接控制 LightBulb。
<?php
class LightBulb
{
public function turn_on(): void
{
echo "LightBulb: turned on\n";
}
public function turn_off(): void
{
echo "LightBulb: turned off\n";
}
}
class Switch
{
private LightBulb $bulb;
public function __construct(LightBulb $bulb)
{
$this->bulb = $bulb; // 開關直接依賴於具體的燈泡實作
}
public function operate(): void
{
// 根據某種條件操作燈泡
if (true) { // 簡化邏輯,假設總是打開
$this->bulb->turn_on();
} else {
$this->bulb->turn_off();
}
}
}
在這個範例中,Switch 類別直接依賴於 LightBulb 的具體實作。如果我們想要替換成不同的裝置(例如 Fan),我們就需要修改 Switch 類別的程式碼。這違反了 DIP,因為高層模組(Switch)直接依賴於低層模組(LightBulb)的具體實作。
正面範例 (Good Practice)
為了遵循 DIP,我們可以引入一個抽象介面,讓高層模組和低層模組都依賴這個抽象介面。
<?php
interface Switchable
{
public function turn_on(): void;
public function turn_off(): void;
}
class LightBulb implements Switchable
{
public function turn_on(): void
{
echo "LightBulb: turned on\n";
}
public function turn_off(): void
{
echo "LightBulb: turned off\n";
}
}
class Fan implements Switchable
{
public function turn_on(): void
{
echo "Fan: turned on\n";
}
public function turn_off(): void
{
echo "Fan: turned off\n";
}
}
class Switch
{
private Switchable $device;
public function __construct(Switchable $device) // 開關依賴於 Switchable 抽象介面
{
$this->device = $device;
}
public function operate(): void
{
// 根據某種條件操作裝置
if (true) { // 簡化邏輯,假設總是打開
$this->device->turn_on();
} else {
$this->device->turn_off();
}
}
}
// 使用 LightBulb
$bulb = new LightBulb();
$light_switch = new Switch($bulb);
$light_switch->operate(); // 輸出: LightBulb: turned on
// 使用 Fan
$fan = new Fan();
$fan_switch = new Switch($fan);
$fan_switch->operate(); // 輸出: Fan: turned on
在這個改進的範例中,我們引入了一個 Switchable 介面,它定義了 turn_on() 和 turn_off() 方法。LightBulb 和 Fan 都實現了這個 Switchable 介面。Switch 類別現在依賴於 Switchable 抽象介面,而不是具體的 LightBulb 或 Fan 實作。透過依賴注入(Dependency Injection),我們可以在創建 Switch 物件時傳入任何實現 Switchable 介面的裝置。
這樣,當我們需要引入新的可開關裝置時,只需要創建一個新的類別並實現 Switchable 介面,而無需修改 Switch 類別的程式碼。這實現了「高層模組不應該依賴低層模組,兩者都應該依賴抽象」的原則。
總結
SOLID 原則為物件導向設計提供了堅實的基礎。遵循這些原則不僅能夠幫助我們撰寫出更具彈性、可維護和可擴展的程式碼,還能提升團隊協作效率,降低專案的長期維護成本。儘管在實際應用中,完美地遵循所有原則可能具有挑戰性,但將它們作為設計指南,將有助於您持續改進程式碼品質,打造出更加健壯和可靠的軟體系統。