值物件模式:以值而非身份區分物件,避免共享引用副作用
值物件 沒有身份識別,只由屬性值定義。兩個值物件若屬性相同則視為相等。PHP 5+ 物件預設引用語義導致值物件共享引用 Bug,值物件模式透過不可變性與值比較解決此問題。
問題重現:BadDollar 引用共享 Bug
<?php
class BadDollar {
public function __construct(public float $amount = 0) {}
public function add(BadDollar $dollar): void {
$this->amount += $dollar->amount; // 修改自身狀態!
}
}
class Work {
public function __construct(public BadDollar $salary = new BadDollar(200)) {}
public function payDay(): BadDollar { return $this->salary; }
}
class Person {
public ?BadDollar $wallet = null;
}
// 測試
$job = new Work();
$p1 = new Person();
$p2 = new Person();
$p1->wallet = $job->payDay(); // 兩人共享同一 BadDollar 實例!
$p2->wallet = $job->payDay();
$p1->wallet->add($job->payDay()); // p1 加薪
// Bug: p2 錢包也變了!Work 的 salary 也被修改!
// 因為 PHP 物件賦值是引用複製
?>
值物件正確實作:不可變 + 值比較
<?php
class Money {
private float $amount;
private string $currency;
public function __construct(float $amount, string $currency = 'USD') {
if ($amount < 0) throw new InvalidArgumentException('Amount cannot be negative');
$this->amount = $amount;
$this->currency = strtoupper($currency);
}
// 不可變:操作回傳新實例
public function add(Money $other): Money {
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new Money($this->amount + $other->amount, $this->currency);
}
public function subtract(Money $other): Money {
return new Money($this->amount - $other->amount, $this->currency);
}
public function multiply(float $factor): Money {
return new Money($this->amount * $factor, $this->currency);
}
// 值比較
public function equals(Money $other): bool {
return $this->amount === $other->amount && $this->currency === $other->currency;
}
public function __toString(): string {
return sprintf('%.2f %s', $this->amount, $this->currency);
}
// Getter
public function getAmount(): float { return $this->amount; }
public function getCurrency(): string { return $this->currency; }
}
// 修正後的 Work
class Work {
public function __construct(private Money $salary) {}
public function payDay(): Money { return $this->salary; } // 回傳值物件 (不可變安全)
}
// 測試
$job = new Work(new Money(200));
$p1 = new Person();
$p2 = new Person();
$p1->wallet = $job->payDay();
$p2->wallet = $job->payDay();
// p1 加薪:產生新 Money 實例,不影響 p2 或 job
$p1->wallet = $p1->wallet->add($job->payDay());
// p1: 400, p2: 200, job: 200 ✅ 正確!
?>
值物件核心特徵
| 特徵 |
說明 |
實作要點 |
| 不可變性 |
建立後狀態不變 |
無 setter、操作回傳新實例、屬性 private/final |
| 值相等性 |
屬性相同即相等 |
實作 equals()、PHP 8.1+ 可用 readonly class |
| 無身份識別 |
不區分「哪一個」實例 |
不需 ID、不追蹤生命週期 |
| 自我驗證 |
建構子保證有效狀態 |
建構子驗證、拋出異常 |
| 可替換性 |
相等值物件可自由互換 |
無副作用、線程安全 |
常見值物件範例
<?php
// 1. Email 地址
readonly class Email {
public function __construct(public string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email: $value");
}
}
public function equals(Email $other): bool { return $this->value === $other->value; }
}
// 2. 日期範圍
readonly class DateRange {
public function __construct(
public DateTimeImmutable $start,
public DateTimeImmutable $end
) {
if ($start > $end) throw new InvalidArgumentException('Start must be before end');
}
public function contains(DateTimeImmutable $date): bool {
return $date >= $this->start && $date <= $this->end;
}
public function overlaps(DateRange $other): bool {
return $this->start <= $other->end && $other->start <= $this->end;
}
}
// 3. 地址
readonly class Address {
public function __construct(
public string $street,
public string $city,
public string $state,
public string $postalCode,
public string $country
) {}
public function equals(Address $other): bool {
return $this->street === $other->street
&& $this->city === $other->city
// ...
}
}
// 4. 數量 (防止負數)
readonly class Quantity {
public function __construct(public int $value) {
if ($value < 0) throw new InvalidArgumentException('Quantity cannot be negative');
}
public function add(Quantity $other): Quantity {
return new Quantity($this->value + $other->value);
}
}
?>
PHP 8.1+ readonly class 完美支援值物件
<?php
// PHP 8.1+ 唯讀類別:自動不可變、自動 getter
readonly class Money {
public function __construct(
public float $amount,
public string $currency = 'USD'
) {
if ($amount < 0) throw new InvalidArgumentException('Negative amount');
$this->currency = strtoupper($currency);
}
public function add(Money $other): Money {
if ($this->currency !== $other->currency) throw new InvalidArgumentException('Currency mismatch');
return new Money($this->amount + $other->amount, $this->currency);
}
// PHP 8.1 無自動 equals,需手動實作
public function equals(Money $other): bool {
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}
?>
值物件 vs 實體 比較
| 維度 |
值物件 |
實體 |
| 識別方式 |
屬性值 |
唯一 ID |
| 可變性 |
不可變 |
可變 |
| 生命週期 |
無 (值即存在) |
有 (建立/修改/刪除) |
| 比較方式 |
值比較 |
ID 比較 |
| 範例 |
Money、Email、Address、DateRange |
User、Order、Product、Account |
| 資料庫映射 |
嵌入式 (Embedded) / JSON 欄位 |
獨立表、主鍵 |
Doctrine ORM 中嵌入值物件
<?php
use Doctrine\ORM\Mapping as ORM;
#[ORM\Embeddable]
class Address {
#[ORM\Column(length: 100)] public string $street;
#[ORM\Column(length: 50)] public string $city;
// ...
}
#[ORM\Entity]
class User {
#[ORM\Id, ORM\GeneratedValue, ORM\Column] private int $id;
#[ORM\Embedded(class: Address::class, columnPrefix: 'address_')]
private Address $address;
public function getAddress(): Address { return $this->address; }
public function setAddress(Address $address): void { $this->address = $address; }
}
// 產生欄位: address_street, address_city, ...
?>