PHP 面向对象与类型系统

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

PHP 8.4

PHP 8 之后,类型声明、构造属性提升、枚举、只读属性都进了语言。写业务代码时,先把类型写严,再谈设计模式。

环境准备

示例在 PHP 8.4 下验证。每个文件顶部建议加 declare(strict_types=1);,让类型不匹配直接抛 TypeError,而不是悄悄转换。

类与构造属性提升

<?php
declare(strict_types=1);

final class Post
{
    public function __construct(
        public readonly int $id,
        public string $title,
        private string $body = '',
    ) {}

    public function excerpt(int $length = 20): string
    {
        return mb_strimwidth($this->body, 0, $length, '…');
    }
}

$post = new Post(1, '标题', '这是一段很长的正文……');

public readonly int $id 同时完成声明、参数、赋值三件事。readonly 属性只能在构造函数里赋值一次,之后改就抛 Error——用来表达「不可变的标识」很合适。

final class 禁止继承。默认加上它,只有确实需要扩展时才去掉。

类型声明

function sum(int|float ...$nums): int|float
{
    return array_sum($nums);
}

function findUser(int $id): ?User      // 允许 null
{
    return null;
}

function logLine(string $msg): void {} // 无返回值

function fail(): never                // 一定抛异常或退出
{
    throw new RuntimeException('fail');
}
  • ?T 等价于 T|null,别和 T|false 混着用。
  • 参数默认值 = null 会让类型自动变 nullable(历史行为),显式写 ?T 更清楚。
  • mixed 表示任意类型,用它等于放弃检查,尽量只在边界(如 json_decode 结果)使用。

数组元素类型没法在签名里表达,只能靠注释或值对象:

/** @param list<Post> $posts */
function renderList(array $posts): string { /* ... */ }

枚举

状态字段不要再传字符串:

enum PostStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Archived = 'archived';

    public function label(): string
    {
        return match ($this) {
            self::Draft => '草稿',
            self::Published => '已发布',
            self::Archived => '已归档',
        };
    }

    public static function fromInput(string $value): self
    {
        return self::tryFrom($value) ?? throw new InvalidArgumentException("未知状态:{$value}");
    }
}

$status = PostStatus::Published;
echo $status->value;   // published
echo $status->label(); // 已发布

from() 失败抛 ValueError,tryFrom() 返回 null。从请求参数转换时用 tryFrom 加自己的错误信息更友好。

接口与抽象类

接口只声明能力,抽象类可以带实现:

interface Repository
{
    public function find(int $id): ?Post;
    /** @return list<Post> */
    public function all(): array;
}

abstract class BaseRepository implements Repository
{
    public function __construct(protected PDO $pdo) {}

    public function find(int $id): ?Post
    {
        $stmt = $this->pdo->prepare('SELECT * FROM posts WHERE id = :id');
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch();
        return $row ? new Post((int) $row['id'], $row['title'], $row['body']) : null;
    }

    abstract public function all(): array;
}

final class PdoPostRepository extends BaseRepository
{
    public function all(): array
    {
        return $this->pdo->query('SELECT * FROM posts ORDER BY id DESC')->fetchAll();
    }
}

选择标准:需要共享实现就用抽象类,只需要约定行为就用接口。PHP 单继承,接口可以多实现——所以「能否被替换」这个契约尽量放接口。

trait:横向复用

trait HasTimestamps
{
    public function touch(): void
    {
        $this->updatedAt = new DateTimeImmutable();
    }
}

final class Comment
{
    use HasTimestamps;

    public DateTimeImmutable $updatedAt;
}

trait 会把方法「复制」进类,适合日志、时间戳这类与继承体系无关的横切逻辑。别用它做隐式依赖:trait 里用到的属性,类里必须存在,编译期不检查。

静态成员与常量

final class Config
{
    public const DEFAULT_PAGE_SIZE = 20;

    private static ?self $instance = null;

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

self:: 指向定义它的类,static:: 指向运行时类(延迟静态绑定)。static 返回类型自 PHP 8.0 起可用,链式调用时很方便。

继承还是组合

// 继承:is-a,且父类的实现确实可复用
final class AdminUser extends User {}

// 组合:has-a,把行为作为依赖注入
final class PostPublisher
{
    public function __construct(
        private Repository $posts,
        private Mailer $mailer,
    ) {}

    public function publish(int $id): void
    {
        $post = $this->posts->find($id) ?? throw new RuntimeException('不存在');
        // ...
    }
}

三层以上的继承链几乎都会变难维护。把变化的部分抽成接口注入,测试时也更好替换。

常见错误与注意事项

1. 忘记 strict_types

function add(int $a) { return $a; }
add("3abc"); // 非严格模式下被转成 3,还顺手吞掉警告

加上 declare(strict_types=1); 后会抛 TypeError,问题在开发时就暴露。

2. readonly 属性想改值

需要「更新」时返回新对象,而不是改字段:

$updated = new Post($post->id, '新标题', $post->body);

3. 构造函数里做重活

查库、发请求会让对象难测试。构造函数只做赋值与最基本的不变量检查。

4. 用数组当数据传输对象

['title' => ..., 'status' => ...] 拼错键名编译器不管。字段超过三个就建值对象或 readonly class。

5. 枚举当字符串用

$status === 'published' 在类型声明为枚举后必然为 false。统一用 PostStatus::Published。

6. 在 trait 里访问未声明属性

运行到才报错。trait 依赖的属性写成抽象方法或接口约定,能在静态分析阶段发现。

7. __get / __call 魔法方法滥用

IDE 无法补全,静态分析也失效。真需要动态行为时,补 @property / @method 注释。

小结

  • 构造属性提升 + readonly 让值对象写起来很短。
  • 枚举替代状态字符串,tryFrom 处理外部输入。
  • 接口定契约,抽象类共享实现,trait 做横切关注点。
  • 默认 final,确需扩展再放开;优先组合而非深层继承。
  • strict_types=1 是所有类型声明生效的前提。

下一步