PHP 值对象比较困难详解与解决方案

一、值对象(Value Object)的核心特征

1.1 什么是值对象?

值对象是领域驱动设计(DDD)中的概念,它通过属性值定义自身,而非通过唯一标识(ID)。典型示例:MoneyEmailAddressCoordinate

特征:

  • 不可变性:创建后不可修改
  • 无身份标识:相等性基于所有属性值
  • 自包含:行为与数据绑定
class Money {
    private float $amount;
    private string $currency;
    
    public function __construct(float $amount, string $currency) {
        $this->amount = $amount;
        $this->currency = $currency;
    }
    
    public function getAmount(): float { return $this->amount; }
    public function getCurrency(): string { return $this->currency; }
}

1.2 比较困难的根源

PHP 是对象引用语言,默认比较机制与值对象哲学直接冲突

$money1 = new Money(100, 'USD');
$money2 = new Money(100, 'USD');

var_dump($money1 == $money2);  // true? false? 取决于PHP版本和属性可见性
var_dump($money1 === $money2); // false(不同对象实例)

开发者期望逻辑相等(所有属性相等),但 PHP 提供的是:

  • ==:比较对象时,若属于同一类且所有属性值相等(包括私有属性? PHP 7+ 会递归比较,但依赖反射,有性能开销且易混淆)
  • ===:比较引用(是否同一实例)

二、PHP 对象比较的隐藏陷阱

2.1 == 的不稳定性

class Email {
    private string $address;
    public function __construct(string $address) { $this->address = $address; }
}

$e1 = new Email('a@b.com');
$e2 = new Email('a@b.com');
var_dump($e1 == $e2); // true(PHP 7+)

// 添加一个无关属性会影响比较
class EmailWithFlag {
    private string $address;
    private bool $verified = false;
    public function __construct(string $address) { $this->address = $address; }
    public function markVerified(): void { $this->verified = true; }
}

$v1 = new EmailWithFlag('a@b.com');
$v2 = new EmailWithFlag('a@b.com');
var_dump($v1 == $v2); // true(属性值相同)
$v1->markVerified();
$v2->markVerified();
var_dump($v1 == $v2); // true(仍相等)

问题== 依赖属性全等比较,但无法表达“排除某些属性”的业务意图。

2.2 === 永远按引用

$m1 = new Money(100, 'USD');
$m2 = new Money(100, 'USD');
$m3 = $m1;

var_dump($m1 === $m2); // false
var_dump($m1 === $m3); // true

问题:完全不符合值对象的语义。

2.3 继承破坏比较

class DiscountedMoney extends Money {
    private float $discount;
    // ...
}

$base = new Money(100, 'USD');
$discounted = new DiscountedMoney(100, 'USD', 0.1);
var_dump($base == $discounted); // false(不同类)

三、核心解决方案:显式 equals() 方法

3.1 基础实现模式

class Money {
    private float $amount;
    private string $currency;
    
    public function __construct(float $amount, string $currency) {
        $this->amount = $amount;
        $this->currency = $currency;
    }
    
    public function equals(self $other): bool {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;
    }
}

// 使用
if ($money1->equals($money2)) { ... }

优点:明确意图,可自定义规则
缺点:需手动为每个值对象实现,容易遗漏

3.2 使用 Trait 复用比较逻辑

trait ComparableValueObject {
    public function equals(self $other): bool {
        // 比较所有公共属性
        foreach ($this as $property => $value) {
            $property = (string) $property; // 确保字符串
            if (isset($other->$property)) {
                if ($value instanceof self) {
                    if (!$value->equals($other->$property)) {
                        return false;
                    }
                } elseif ($value !== $other->$property) {
                    return false;
                }
            } else {
                return false;
            }
        }
        return true;
    }
}

class Email {
    use ComparableValueObject;
    private string $address;
    public function __construct(string $address) { $this->address = $address; }
}

问题:反射性能,且处理嵌套对象需递归。

3.3 受控属性白名单

trait Equalizable {
    public function equals($other): bool {
        if (get_class($this) !== get_class($other)) {
            return false;
        }
        
        $properties = $this->equalityProperties();
        foreach ($properties as $prop) {
            $getter = 'get' . ucfirst($prop);
            if (method_exists($this, $getter)) {
                if ($this->$getter() !== $other->$getter()) {
                    return false;
                }
            }
        }
        return true;
    }
    
    abstract protected function equalityProperties(): array;
}

class Address {
    use Equalizable;
    
    private string $street;
    private string $city;
    
    protected function equalityProperties(): array {
        return ['street', 'city'];
    }
}

四、高级比较策略

4.1 支持嵌套值对象

class OrderLine {
    private Product $product;
    private int $quantity;
    private Money $price;
    
    public function equals(OrderLine $other): bool {
        return $this->product->equals($other->product)
            && $this->quantity === $other->quantity
            && $this->price->equals($other->price);
    }
}

4.2 处理集合/数组

class Order {
    /** @var OrderLine[] */
    private array $lines;
    
    public function equals(Order $other): bool {
        if (count($this->lines) !== count($other->lines)) {
            return false;
        }
        
        // 假设顺序无关,需要更复杂的比较策略
        foreach ($this->lines as $line) {
            if (!$this->containsEqualLine($line, $other->lines)) {
                return false;
            }
        }
        return true;
    }
    
    private function containsEqualLine(OrderLine $needle, array $haystack): bool {
        foreach ($haystack as $line) {
            if ($needle->equals($line)) {
                return true;
            }
        }
        return false;
    }
}

4.3 不可变性与 with 方法

class Email {
    public function __construct(private string $address) {}
    
    public function withAddress(string $newAddress): self {
        return new self($newAddress);
    }
    
    public function equals(self $other): bool {
        return $this->address === $other->address;
    }
}

五、PHP 8 新特性带来的改进

5.1 只读属性(Readonly Properties)

readonly class Money {
    public function __construct(
        public float $amount,
        public string $currency
    ) {}
    
    public function equals(self $other): bool {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;
    }
}

好处:天然不可变,可公开属性直接比较。

5.2 枚举(Enum)作为值对象

enum Currency: string {
    case USD = 'USD';
    case EUR = 'EUR';
    case GBP = 'GBP';
}

readonly class Money {
    public function __construct(
        public float $amount,
        public Currency $currency
    ) {}
    
    public function equals(self $other): bool {
        return $this->amount === $other->amount
            && $this->currency === $other->currency; // 枚举比较是值比较
    }
}

5.3 参数列表提升与 __serialize

class UserId {
    public function __construct(public readonly int $id) {}
    
    public function equals(self $other): bool {
        return $this->id === $other->id;
    }
    
    public function __toString(): string {
        return (string) $this->id;
    }
}

六、利用第三方库解决比较问题

6.1 myclabs/php-enum - 枚举风格值对象

use MyCLabs\Enum\Enum;

class Currency extends Enum {
    private const USD = 'USD';
    private const EUR = 'EUR';
}

class Money {
    public function __construct(private float $amount, private Currency $currency) {}
    
    public function equals(self $other): bool {
        return $this->amount === $other->amount
            && $this->currency->equals($other->currency);
    }
}

6.2 webmozart/assert - 运行时断言

use Webmozart\Assert\Assert;

class Email {
    private string $address;
    public function __construct(string $address) {
        Assert::email($address);
        $this->address = $address;
    }
    
    public function equals(self $other): bool {
        return $this->address === $other->address;
    }
}

6.3 laminas/laminas-codedoctrine/instantiator 生成比较器(高级)


七、序列化与哈希码

7.1 基于序列化的快速比较(慎用

class Money {
    // ...
    public function equals(self $other): bool {
        return serialize($this) === serialize($other);
    }
}

缺点:包含类名,若类名不同则不等价,性能差。

7.2 实现 __toString() 辅助比较

class Email {
    public function __toString(): string {
        return $this->address;
    }
}

// 但需要显式调用比较
if ((string) $email1 === (string) $email2) { ... }

7.3 哈希码方法(用于集合)

class Money {
    public function hashCode(): string {
        return md5($this->amount . '|' . $this->currency);
    }
}

配合 SplObjectStorage 不能直接使用,需自定义集合类。


八、单元测试中的值对象比较

8.1 PHPUnit 内置断言

$this->assertEquals($money1, $money2); // 使用 ==,不可靠
$this->assertTrue($money1->equals($money2)); // 推荐

8.2 自定义断言

trait ValueObjectAssertions {
    public static function assertValueObjectEquals($expected, $actual, string $message = ''): void {
        if (!$expected->equals($actual)) {
            // 提供详细差异
        }
    }
}

九、性能考量与最佳实践

9.1 何时必须实现 equals

  • 所有值对象都应实现,哪怕只是 public readonly 属性直接比较
  • 提供统一的 equals 接口,而不是依赖 ==

9.2 性能优化

  • 避免在循环中反射
  • 缓存哈希码(如果对象不可变)
  • 优先比较最可能不等的属性

9.3 约定优于配置

interface ValueObject {
    public function equals(ValueObject $other): bool;
}

abstract class AbstractValueObject implements ValueObject {
    // 通用实现,子类覆盖
}

十、完整示例:一个健壮的值对象基类

abstract class ValueObject
{
    public function equals(?ValueObject $other): bool
    {
        if ($other === null || get_class($this) !== get_class($other)) {
            return false;
        }

        $properties = $this->getEqualityProperties();
        foreach ($properties as $property) {
            $thisValue = $this->$property;
            $otherValue = $other->$property;

            if ($thisValue instanceof ValueObject && $otherValue instanceof ValueObject) {
                if (!$thisValue->equals($otherValue)) {
                    return false;
                }
            } elseif ($thisValue !== $otherValue) {
                return false;
            }
        }

        return true;
    }

    /**
     * @return string[] 属性名称列表
     */
    abstract protected function getEqualityProperties(): array;
}

final class Email extends ValueObject
{
    public function __construct(private readonly string $address) {}
    
    protected function getEqualityProperties(): array
    {
        return ['address'];
    }
}

final class Money extends ValueObject
{
    public function __construct(
        private readonly float $amount,
        private readonly Currency $currency
    ) {}
    
    protected function getEqualityProperties(): array
    {
        return ['amount', 'currency'];
    }
}

// 枚举类 Currency 也应实现 ValueObject 或使用 PHP 8.1 Enum

十一、总结:从“比较困难”到“优雅相等”

策略 优点 缺点 适用场景
默认 == 无编码成本 语义模糊、不可靠 避免使用
显式 equals() 清晰可控 需要手动实现 所有值对象
序列化比较 通用 性能差、类名敏感 临时调试
只读属性 + 直接比较 极致简单 PHP 8.1+、无业务逻辑 纯数据容器
库(如 voku/value-objects 功能完整 额外依赖 大型项目

最终建议:

  1. 为每个值对象显式实现 equals() 方法,这是最稳妥的实践。
  2. 利用 PHP 8.1 的 readonly 修饰符,使属性可公开直接访问,简化比较。
  3. 统一继承 ValueObject 抽象类或使用 Comparable Trait,确保一致性。
  4. 在团队代码规范中禁止对值对象使用 ==/===,强制使用 equals()

通过以上方案,PHP 开发者可以彻底摆脱值对象比较的困境,写出既符合领域驱动设计、又健壮可维护的代码。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐