PHP 基本语法 08|接口、Trait、抽象类、魔术方法与枚举

上一篇讲的是"一个类怎么写",这一篇讲"类之间怎么组织"。

接口:定义契约

<?php
interface Repository
{
    public function find(int $id): ?array;
    public function save(array $data): bool;
}

class UserRepository implements Repository
{
    public function find(int $id): ?array
    {
        return ['id' => $id];
    }

    public function save(array $data): bool
    {
        return true;
    }
}

接口里只能声明方法签名和常量,不能有实现。实现类必须提供签名兼容的方法。

接口的三个实用价值:

  1. 类型提示:function handle(Repository $repo) 让实现可以随时替换。
  2. 多接口:一个类可以 implements A, B, C,这是 PHP 实现"多继承能力"的方式。
  3. 接口可继承接口:interface B extends A。
<?php
interface HasId { public function id(): int; }
interface Jsonable { public function toJson(): string; }

class Post implements HasId, Jsonable
{
    public function id(): int { return 1; }
    public function toJson(): string { return '{"id":1}'; }
}

接口常量在 PHP 8.1 之后还能被实现类用 static:: 访问,用法和类常量一样。

抽象类:只差实现的一半

<?php
abstract class Controller
{
    abstract public function handle(array $req): array;

    public function run(array $req): string
    {
        $res = $this->handle($req);          // 模板方法:流程固定,实现可变
        return json_encode($res, JSON_UNESCAPED_UNICODE);
    }
}

abstract class Base {}                        // 抽象类可以没有抽象方法

class UserController extends Controller
{
    public function handle(array $req): array
    {
        return ['ok' => true];
    }
}

抽象类与接口的分工:

接口 抽象类
能否有实现 不能(纯契约) 能(部分实现)
能否有属性 不能 能
继承数量 可以多实现 只能单继承
用途 定义"是什么能力" 定义"如何共同完成一件事"

判断方法:如果你只需要"这是一个什么东西",用接口;如果你要"一堆子类共享同一套骨架,只需要各自填一处",用抽象类。

Trait:横向复用的补丁

trait 是 PHP 里最有争议、也最实用的东西。它解决的问题是:单继承限制下,多个类需要共享同一段实现。

<?php
trait Singleton
{
    private static ?self $instance = null;

    public static function getInstance(): static
    {
        return static::$instance ??= new static();
    }
}

class Config
{
    use Singleton;
}

常见的 trait 用途:日志、缓存、时间戳、软删除、序列化。框架里大量的"能力"都是 trait(Laravel 的 HasMany、SoftDeletes 就是)。

冲突处理:insteadof 和 as

<?php
trait A { public function hello() { return "A"; } }
trait B { public function hello() { return "B"; } }

class C
{
    use A, B {
        A::hello insteadof B;      // 冲突时用 A 的实现
        B::hello as helloB;        // 给 B 的实现起个别名
    }
}

$c = new C();
echo $c->hello();    // A
echo $c->helloB();   // B

as 还能改可见性:

<?php
class D
{
    use A { hello as protected; }
}

Trait 的真实限制

  1. trait 不是类,不能 new,也不能被继承。
  2. trait 里的 $this 指向"使用它的那个类的实例",所以 trait 方法可以访问类里的属性——但这层依赖是隐式的,别人读类定义时看不出来。
  3. trait 之间可以 use 彼此,容易套娃。
  4. trait 里的抽象方法可以用来约束宿主类:
<?php
trait Loggable
{
    abstract protected function logger(): Logger;
    // 使用这个 trait 的类必须提供 logger()
}

第 2 条是 trait 被批评的核心:它把"类必须有哪些属性"变成了隐式约定。适度使用没问题,超过三四个 trait 混在一个类里,就该考虑重构了。

魔术方法:PHP 的钩子

魔术方法以 __ 开头,在特定时机被引擎自动调用。

__get / __set / __isset / __unset

<?php
class Config
{
    private array $data = [];

    public function __get(string $name): mixed
    {
        return $this->data[$name] ?? null;
    }

    public function __set(string $name, mixed $value): void
    {
        $this->data[$name] = $value;
    }

    public function __isset(string $name): bool
    {
        return isset($this->data[$name]);
    }
}

只有在访问"不可见或不存在的属性"时才会触发。属性本身是 public 就不触发。这一条是理解魔术方法的关键。

一个重要的坑:__get 返回数组时,$obj->items[] = 'x' 不会生效(因为 __get 返回的是值不是引用)。要支持这种写法得让 __get 返回引用:

<?php
public function &__get(string $name): mixed
{
    return $this->data[$name];
}

这就是为什么很多框架宁可显式声明属性,也不用 __get:魔术属性在 IDE、静态分析、$obj->items[] 这些地方都会打折扣。

__call / __callStatic

<?php
class Api
{
    public function __call(string $method, array $args): mixed
    {
        // 例:$api->getUser(1) 转发到 HTTP 请求
        return $this->request(strtoupper($method), "/{$method}", $args);
    }

    public static function __callStatic(string $m, array $a): mixed
    {
        return (new static())->$m(...$a);
    }
}

__call 是"方法不存在时的兜底"。它让 API 客户端能写成 $client->getUsers() 这种漂亮形式,代价是 IDE 完全不认识这些方法(要靠 @method 注释补)。

__toString

<?php
class Money
{
    public function __construct(private int $cents) {}
    public function __toString(): string
    {
        return number_format($this->cents / 100, 2);
    }
}

echo new Money(1990);    // 19.90

__toString 抛异常会变成致命错误,所以里面不要写可能失败的逻辑。

__invoke:让对象可以当函数用

<?php
class Multiplier
{
    public function __construct(private int $factor) {}
    public function __invoke(int $x): int { return $x * $this->factor; }
}

$m = new Multiplier(3);
echo $m(5);                                        // 15
echo implode(',', array_map($m, [1, 2, 3]));       // 3,6,9  能直接当回调

生命周期相关的两个

<?php
class Conn
{
    public function __construct() { echo "构造"; }
    public function __destruct() { echo "析构"; }
}

__destruct 不保证在什么时候调用,只在"对象不再被引用且 GC 回收时"触发。所以不要依赖它在请求结束时做"一定要做的事"(比如关文件、提交事务)——用显式的 close() 更可靠。

枚举(PHP 8.1)

这是 PHP 近年最受欢迎的语法加法之一。

<?php
enum Status
{
    case Pending;
    case Paid;
    case Shipped;
}

$s = Status::Paid;
var_dump($s === Status::Paid);     // true  —— 枚举实例是单例,可以直接 ===

带值的枚举(backed enum):

<?php
enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid    = 'paid';
    case Shipped = 'shipped';

    public function label(): string
    {
        return match ($this) {
            self::Pending => '待支付',
            self::Paid    => '已支付',
            self::Shipped => '已发货',
        };
    }

    public static function fromCode(string $code): self
    {
        return self::from($code);          // 找不到抛 ValueError
        // return self::tryFrom($code);     // 找不到返回 null
    }
}

echo OrderStatus::Paid->value;              // paid
echo OrderStatus::Paid->label();            // 已支付
echo OrderStatus::fromCode('shipped')->name; // Shipped

几个关键事实:

  • 枚举可以带方法,这是它比常量组强的地方。
  • 枚举可以实现接口,但不能继承类。
  • from() / tryFrom() 只在有 backed 类型时存在。
  • 枚举实例可以当数组键、可以做 match 的分支。
  • Status::cases() 返回全部实例,常用于生成下拉选项:
<?php
foreach (OrderStatus::cases() as $c) {
    echo "{$c->value} => {$c->label()}\n";
}

用枚举替换一堆 const 是值得的:类型安全、能带方法、IDE 能提示、match 能穷尽检查。

接口 + 枚举 + match 的一个完整例子

<?php
interface Payable
{
    public function pay(int $cents): bool;
}

final class Alipay implements Payable
{
    public function pay(int $cents): bool
    {
        // 调支付宝
        return true;
    }
}

final class WechatPay implements Payable
{
    public function pay(int $cents): bool
    {
        return true;
    }
}

final class PayFactory
{
    public static function make(string $channel): Payable
    {
        return match ($channel) {
            'alipay' => new Alipay(),
            'wechat' => new WechatPay(),
            default  => throw new InvalidArgumentException("未知通道:{$channel}"),
        };
    }
}

这个结构就是"策略模式"的最小实现:接口定义契约、具体类实现、match 做分发。比一堆 if ($channel === 'alipay') 好维护得多,新增通道只加一个类加一个 case。

坑清单

  1. __get 不返回引用时,$obj->arr[] = x 静默失效。
  2. 魔术方法只在访问不可见属性时触发,public 属性不触发。
  3. __call 让 IDE 和静态分析失效,需要 @method 注释补救。
  4. trait 里的隐式属性依赖,读类定义时看不出来。
  5. 多个 trait 同名方法冲突,必须 insteadof 或 as 解决,否则是致命错误。
  6. __destruct 不保证时机,别在里面写关键业务。
  7. 枚举不能 new,也不能继承。
  8. from() 在值不存在时抛 ValueError,处理外部输入应改用 tryFrom()。