前言
封装(encapsulation)和继承(inheritance)经常被一起提,但它们在解决完全不同的问题。封装回答「谁能动这份数据」,继承回答「哪些类共享同一套契约」。把两者混在一起的后果很典型:为了「复用代码」而随便extends,最后得到一个class User extends Database这种关系上根本不成立的类层次。
第二个高频误解是「protected就是给子类用的public,比private更灵活所以更好」。实际上protected意味着任何子类都能直接改父类的内部状态——子类一旦多起来,父类的封装就名存实亡。PHP 官方手册对可见性的表述很明确:private成员只在声明它的那个类里可见,子类完全看不到。
本文先讲清三档可见性的真实语义,再讲继承的硬性规则(单继承、可见性不能收窄、构造器不自动调用),最后给出封装与继承配合使用的正确姿势,以及 PHP 8.1 引入的readonly如何让「不可变值对象」既封装又简洁。
一、可见性:封装的全部实现手段
| 修饰符 | 本类内部 | 子类 | 外部代码 |
|---|
public | 可见 | 可见 | 可见 |
protected | 可见 | 可见 | 不可见 |
private | 可见 | 不可见 | 不可见 |
var(遗留写法) | 可见 | 可见 | 可见,等价于public |
private的「不可见」需要理解到一个关键程度:私有成员不参与继承。子类里如果写了一个与父类私有属性同名的属性,那是两个互不相干的属性:
<?php // 适用于 PHP 7.4+
class ParentClass
{
private string $name = 'parent';
public function getName(): string
{
return $this->name; // 始终读父类自己的 $name
}
}
class ChildClass extends ParentClass
{
private string $name = 'child'; // 与父类那个没有任何关系
}
echo (new ChildClass())->getName(), PHP_EOL; // parent这一点在调试时会造成「明明赋值了却读不到」的诡异现象。要打破这个局面,getName()必须改成读写protected属性,或者提供一个protected的取值方法供子类重写。
另一个容易忽略的事实:var关键字到今天仍然合法,但它只是public的旧写法,不提供任何封装。看到var就该知道那是 PHP 4 时代的遗留代码。
二、继承的硬性规则
PHP 的继承有五条必须记住的规则:
1. 单继承。class C extends A, B是语法错误。需要多个契约就实现多个接口,需要复用多份方法就用 trait。
2. 只能保持或放宽可见性。父类方法是public,子类不能改成protected/private;父类protected不能改成private。反过来放宽是允许的。
<?php // 适用于 PHP 5.0+
class P
{
public function a(): void {}
protected function b(): void {}
}
class C extends P
{
public function b(): void {} // 合法:protected 放宽成 public
// protected function a(): void {} // 致命错误:不能收窄 public
}3. 静态与非静态不能互相重写。把父类的静态方法改成实例方法,或反过来,都会报Cannot make static method ... non static这类致命错误。
4. 构造器是普通方法,遵循同样的继承规则。子类没有定义自己的构造器时,父类构造器会被继承并自动执行;子类定义了构造器时,父类的构造器不会自动执行,需要手动parent::__construct()。这也是「子类里的初始化代码没跑」这类问题的根源。
5.final用于封闭。final class不能被继承,final public function不能在子类被重写。final与abstract不能同时修饰一个类,那样 PHP 会直接报致命错误。
三、封装与继承配合的正确姿势
既然protected会把内部状态暴露给所有子类,那正确的分工应该是:
- 父类的内部状态用
private,只通过受控方法暴露必要的读写; - 需要子类参与的扩展点用
protected方法,并且父类只调用这个方法的契约,不关心实现; - 不允许子类改动的流程步骤用
final锁死。
下面这段代码完整演示了这个模式(Template Method,模板方法),可以直接存成repo.php执行。
<?php // 适用于 PHP 8.1+
declare(strict_types=1);
abstract class Repository
{
/** @var array<int, array<string, mixed>> */
private array $rows = [];
// 对外接口是 final 的:子类无法绕过校验直接写入
final public function save(array $row): int
{
$this->validate($row);
$id = $this->nextId();
$this->rows[$id] = $row;
return $id;
}
final public function find(int $id): ?array
{
return $this->rows[$id] ?? null;
}
// 扩展点:子类必须实现自己的校验规则
abstract protected function validate(array $row): void;
// 扩展点:子类可以重写生成主键的方式
protected function nextId(): int
{
return $this->rows === [] ? 1 : max(array_keys($this->rows)) + 1;
}
}
final class UserRepository extends Repository
{
protected function validate(array $row): void
{
if (!isset($row['name']) || $row['name'] === '') {
throw new InvalidArgumentException('name 不能为空');
}
}
}
$repo = new UserRepository();
$id = $repo->save(['name' => 'alice']);
echo $id, PHP_EOL; // 1
var_dump($repo->find($id)['name']); // string(5) "alice"注意$rows是private:子类UserRepository拿不到它,也就没法绕过validate()直接写数据。子类能做的只有实现validate()和重写nextId(),这两处正是父类留出的扩展点。
self与static在这里也有讲究。父类里如果用self::调用一个被子类重写过的方法,走的是父类的实现(编译期绑定);要跟随实际的子类实现,得用$this->(实例方法)或static::(后期静态绑定,PHP 5.3+)。
四、PHP 8.1 的readonly:不可变值对象
readonly属性解决了「封装好了但对象仍可被内部改来改去」的问题:它只能被初始化一次,且必须声明类型。
<?php // 适用于 PHP 8.1+
declare(strict_types=1);
class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('金额不能为负');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('币种不一致');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
$total = (new Money(100, 'CNY'))->add(new Money(50, 'CNY'));
echo $total->amount, PHP_EOL; // 150「改不了」意味着所有修改都要造一个新对象(比如上面的add()),这正是值对象(Value Object)的行为方式:没有共享可变状态,就不会出现「谁把它改了」的排查难题。
PHP 8.2 进一步引入了只读类(readonly class),把它写在一个类上,等于给这个类的所有属性都加readonly。
PHP 7.4 上等价的写法是把属性声明为private,只提供 getter,并去掉构造器属性提升:
<?php // 适用于 PHP 7.4+
class Money7
{
private int $amount;
private string $currency;
public function __construct(int $amount, string $currency)
{
if ($amount < 0) {
throw new InvalidArgumentException('金额不能为负');
}
$this->amount = $amount;
$this->currency = $currency;
}
public function amount(): int { return $this->amount; }
public function currency(): string { return $this->currency; }
}常见坑点
- ❌ 为了复用几个方法就
extends(比如让User extends Database)—— ✅ 继承应当表达 is-a;关系不成立时改用组合:把依赖对象通过构造器注入进来。 - ❌ 子类把父类方法从
public收窄为protected或private—— ✅ 致命错误;重写只能保持或放宽可见性。 - ❌ 子类定义了构造器却忘了
parent::__construct()—— ✅ 只要子类有自己的构造器,父类构造器就不会自动执行;需要时必须显式调用。 - ❌ 对没有构造器的父类写
parent::__construct()—— ✅ 会报Cannot call constructor;调用前先确认父类确实声明了构造器。 - ❌ 以为
protected比private更「安全地灵活」 —— ✅protected会把内部状态交给所有子类;内部数据优先private,只把真正的扩展点设为protected。 - ❌ 属性声明为
public,同时又写一对不做任何事的 getter/setter —— ✅ 两者自相矛盾;封装就老老实实private+ 受控方法。 - ❌ 试图修改
readonly属性(如$m->amount = 10;或$m->amount += 1;)—— ✅ 只读属性只能在声明它的类作用域内初始化一次;要改就造新对象。 - ❌ 在父类里用
self::调用希望被子类重写的方法 —— ✅self::是编译期绑定,走父类实现;需要多态用$this->,静态上下文用static::。
总结
| 主题 | 规则 | 常见错误 |
|---|
private | 仅本类可见,不参与继承 | 以为子类能「重写」父类私有属性 |
protected | 本类与子类可见 | 用得太多,把内部状态全暴露给子类 |
public/var | 所有作用域可见 | 用var以为是老式封装 |
| 继承 | 单继承;可见性不可收窄;静态/非静态不可互换 | 为复用代码而继承 |
| 构造器 | 子类有自己的构造器时不自动调用父类 | 忘记parent::__construct() |
final | 封闭类或方法 | 与abstract同时使用(致命错误) |
readonly | PHP 8.1+,初始化一次,必须带类型 | 想「更新」只读属性 |
结论:封装的抓手是可见性,继承的边界是「能不能替换父类」——判断标准是里氏替换:子类对象放到任何期待父类的地方都必须正常。实践中,父类的内部状态用private守住,把扩展点收窄成少数几个protected抽象方法,再用final锁住不该变的主流程;值对象则一律用readonly(PHP 8.1+)做成不可变,这样「谁改了它」这类问题就从根上消失了。