寫程式不難,難的是半年後回頭看,還敢不敢改自己的程式碼。當一個類別同時算薪水、寫資料庫、寄信,甚至順便幫你煮咖啡,需求一變就會像耳機線一樣打結。這時候,SOLID 原則可以當一張設計地圖。

這篇會用 PHP 範例帶你理解 SOLID 的五個原則:它們各自想解決什麼問題、怎麼拆出比較健康的設計,以及什麼時候不必硬套。讀完後,你應該能看出程式碼「哪裡開始變胖」,也知道該從哪裡下刀。

SOLID 是什麼?

SOLID 是五個物件導向設計原則英文名稱的首字母:

  • S — Single Responsibility Principle(單一職責原則)
  • O — Open/Closed Principle(開放封閉原則)
  • L — Liskov Substitution Principle(里氏替換原則)
  • I — Interface Segregation Principle(介面隔離原則)
  • D — Dependency Inversion Principle(依賴反轉原則)

它們不是 PHP 的語法,也不是一份「照著做就永遠正確」的檢查表,而是幫助你降低耦合、控制變動範圍的思考工具。換句話說,SOLID 的重點不是把類別拆到像樂高散落一地,而是讓每個變動有比較清楚的落點。

S:單一職責原則(SRP)

單一職責原則常被簡化成「一個類別只做一件事」。這句話方向沒錯,但還不夠精準。Robert C. Martin 對 SRP 的經典說法是:一個模組應該只有一個「改變的理由」。

這裡的「職責」不是方法數量,而是誰會要求它改變。薪資規則由人資提出修改、資料庫格式由後端提出修改、畫面格式由前端提出修改;如果三種需求都集中在 Employee,它就背了太多不同的改變理由。

容易失控的寫法

class EmployeeService
{
	public function calculateSalary(Employee $employee): int
	{
		return $employee->baseSalary() + $employee->bonus();
	}

	public function save(Employee $employee): void
	{
		// 寫入資料庫
	}

	public function renderProfile(Employee $employee): string
	{
		// 組合 HTML
		return '<p>' . $employee->name() . '</p>';
	}
}

這個類別現在知道薪資規則、資料儲存和 HTML。任何一項需求變更,都可能碰到同一個檔案。類別不是不能有三個方法,而是這三個方法的變動原因彼此無關。

讓職責各就各位

class SalaryCalculator
{
	public function calculate(Employee $employee): int
	{
		return $employee->baseSalary() + $employee->bonus();
	}
}

class EmployeeRepository
{
	public function save(Employee $employee): void
	{
		// 將員工資料寫入資料庫
	}
}

class EmployeeProfileRenderer
{
	public function render(Employee $employee): string
	{
		return '<p>' . $employee->name() . '</p>';
	}
}

拆開後,薪資規則、儲存方式與顯示格式各自有清楚的責任邊界,也比較容易單獨測試。SRP 不是「每個類別只能有一個 public method」,而是「不要把不同的變動原因綁在一起」。

O:開放封閉原則(OCP)

開放封閉原則是:軟體實體應該對擴充開放,對修改封閉。意思不是原始碼永遠不能改,而是當你新增一種行為時,最好能透過新增實作來完成,避免反覆修改已經穩定的核心流程。

if 越長,咖啡越苦

假設結帳服務用條件判斷處理每種折扣:

function discountFor(string $memberType, int $amount): int
{
	if ($memberType === 'regular') {
		return 0;
	}

	if ($memberType === 'vip') {
		return (int) ($amount * 0.1);
	}

	if ($memberType === 'staff') {
		return (int) ($amount * 0.3);
	}

	return 0;
}

剛開始只有兩種會員,看起來很方便。等到加入生日折扣、活動折扣、合作夥伴折扣,這個函式就會變成一條需要小心穿越的 if 迷宮。迷宮本身沒有錯,但維護它通常不會讓人心情變好。

將變動的規則抽成策略

interface DiscountPolicy
{
	public function discountFor(int $amount): int;
}

final class VipDiscount implements DiscountPolicy
{
	public function discountFor(int $amount): int
	{
		return (int) ($amount * 0.1);
	}
}

final class StaffDiscount implements DiscountPolicy
{
	public function discountFor(int $amount): int
	{
		return (int) ($amount * 0.3);
	}
}

final class Checkout
{
	public function __construct(
		private DiscountPolicy $discountPolicy,
	) {
	}

	public function total(int $amount): int
	{
		return $amount - $this->discountPolicy->discountFor($amount);
	}
}

新增折扣規則時,可以新增另一個 DiscountPolicy 實作,Checkout 不必跟著修改。這就是常見的 Strategy(策略)做法,也是 OCP 的一種實現方式。

不過,OCP 不代表每一個小函式都要先抽介面。尚未出現變動點時過度抽象,反而會增加理解成本。先觀察真正會變的地方,再決定要不要抽象,通常比較務實。

L:里氏替換原則(LSP)

里氏替換原則要求:如果程式碼期待的是某個父型別或介面,那麼換成它的子型別或實作後,原本的正確性與使用方式仍然成立。

重點不是「有 extends 就算繼承成功」,而是子類別必須遵守父類別對外做出的契約。若父類別承諾「呼叫 withdraw() 會扣款」,子類別就不能突然改成「遇到某種帳戶就丟出不支援例外」,否則呼叫端就得先猜對方是哪一種物件。

會讓呼叫端踩雷的繼承

class Bird
{
	public function fly(): void
	{
		// 飛行
	}
}

class Penguin extends Bird
{
	public function fly(): void
	{
		throw new LogicException('企鵝不會飛');
	}
}

function letBirdFly(Bird $bird): void
{
	$bird->fly();
}

letBirdFly() 接受 Bird,照它的契約自然會呼叫 fly()。傳入 Penguin 後卻必定失敗,代表這個繼承關係只是在語法上成立,在行為上卻不成立。

讓抽象符合真正能力

class Animal
{
}

interface Flyable
{
	public function fly(): void;
}

class Sparrow extends Animal implements Flyable
{
	public function fly(): void
	{
		// 飛行
	}
}

class Penguin extends Animal
{
	// 企鵝是動物,但不是 Flyable
}

這裡不是把企鵝排除在鳥類之外,而是避免對「會飛」做出錯誤承諾。設計抽象時,先問「這個型別真的能保證什麼行為?」比先問「它是不是某某的子類別?」更重要。

I:介面隔離原則(ISP)

介面隔離原則主張:不要讓使用者依賴它用不到的方法。這裡的使用者可以是實作介面的類別,也可以是呼叫該介面的其他程式碼。

一個什麼都管的介面

interface Worker
{
	public function code(): void;

	public function test(): void;

	public function cook(): void;

	public function clean(): void;
}

如果 Developer 只需要寫程式與測試,卻被迫實作 cook()clean(),介面就太胖了。更麻煩的是,介面新增一個方法時,所有實作者都可能被迫同步修改。

拆成能組合的小介面

interface Coder
{
	public function code(): void;
}

interface Tester
{
	public function test(): void;
}

final class Developer implements Coder, Tester
{
	public function code(): void
	{
		// 寫程式
	}

	public function test(): void
	{
		// 執行測試
	}
}

小介面讓依賴關係更精準,也讓類別可以用組合表達能力。介面不是自助餐,不需要把所有方法都端上桌;端太多,最後通常只會剩下沒人想吃的抽象。

D:依賴反轉原則(DIP)

依賴反轉原則有兩層意思:

  1. 高層模組不應直接依賴低層模組;兩者都應依賴抽象。
  2. 抽象不應依賴細節;細節應依賴抽象。

高層模組是業務規則,例如「註冊使用者」;低層模組則可能是 MySQL、檔案系統或第三方 API。業務規則不需要知道資料是存在哪一台資料庫,才能完成自己的工作。

把具體資料庫寫死

class MySqlUserRepository
{
	public function save(string $email): void
	{
		// 寫入 MySQL
	}
}

class RegisterUser
{
	private MySqlUserRepository $repository;

	public function __construct()
	{
		$this->repository = new MySqlUserRepository();
	}

	public function handle(string $email): void
	{
		$this->repository->save($email);
	}
}

RegisterUser 自己建立 MySqlUserRepository,所以測試時很難替換成記憶體版本,未來改用其他儲存方式也得修改業務類別。

讓高層依賴抽象

interface UserRepository
{
	public function save(string $email): void;
}

final class MySqlUserRepository implements UserRepository
{
	public function save(string $email): void
	{
		// 寫入 MySQL
	}
}

final class RegisterUser
{
	public function __construct(
		private UserRepository $repository,
	) {
	}

	public function handle(string $email): void
	{
		$this->repository->save($email);
	}
}

現在 RegisterUser 只依賴 UserRepository 這個抽象,具體實作透過建構子注入。測試時可以傳入一個測試用 repository;正式環境則傳入 MySqlUserRepository。這通常也被稱為依賴注入,但要注意:依賴注入是達成解耦的一種手法,DIP 是設計原則,兩者不是同義詞。

SOLID 與設計模式怎麼分?

SOLID 是設計時的原則;設計模式則是針對常見問題整理出的解法。兩者可以互相呼應,但不是一對一的配對表。

原則常見呼應方式重點
SRPFacade、Repository把不同變動原因分開
OCPStrategy、Decorator新增行為時減少修改核心
LSP多型、合約測試子型別遵守抽象契約
ISP小型角色介面只依賴真正需要的能力
DIPDependency Injection、Factory高層依賴抽象

例如,Factory 解決的是「物件怎麼建立」,Singleton 解決的是「限制實例數量」。它們都可能出現在某個遵循 DIP 的設計裡,但使用 Singleton 不會自動讓程式變得 SOLID;全域狀態該難測,還是會難測。

實務上怎麼使用 SOLID?

SOLID 最適合拿來處理「已經看見的變動」與「正在造成痛苦的耦合」,不適合當成開工前的儀式。你可以用下面幾個問題做快速檢查:

  1. 這個類別是否同時受到不同角色的需求影響?如果是,回頭看 SRP。
  2. 每次新增類型都要修改同一串條件判斷嗎?如果是,考慮 OCP。
  3. 子類別是否需要丟出「這個方法我不支援」的例外?如果是,檢查 LSP。
  4. 類別是否被迫實作或依賴一大堆用不到的方法?如果是,看看 ISP。
  5. 業務邏輯是否直接 new 出資料庫、郵件服務或第三方 SDK?如果是,考慮 DIP。

最後也別忘了 YAGNI:還沒有需求、沒有變動跡象時,先寫清楚簡單的程式碼,通常比預先蓋一座抽象城堡更好維護。SOLID 的目標是降低變更成本,不是增加類別數量。類別多到需要地圖才能找到入口,那就不是 SOLID,是迷宮設計。

總結

SOLID 五原則可以濃縮成一句話:讓程式碼的責任清楚、變動可控、依賴可替換,並且讓抽象真正符合它承諾的行為。

你不需要一次重寫整個專案。下一次看到超長類別、巨型 if、不自然的繼承,先挑一個最痛的地方改善,跑測試,再觀察設計是否更容易理解。好的設計通常不是一杯咖啡喝完突然出現,而是每次修改少一點心驚膽跳。

參考資料