PHP 面向对象与类型系统
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
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是所有类型声明生效的前提。