前言封装encapsulation和继承inheritance经常被一起提但它们在解决完全不同的问题。封装回答「谁能动这份数据」继承回答「哪些类共享同一套契约」。把两者混在一起的后果很典型为了「复用代码」而随便extends最后得到一个class User extends Database这种关系上根本不成立的类层次。第二个高频误解是「protected就是给子类用的public比private更灵活所以更好」。实际上protected意味着任何子类都能直接改父类的内部状态——子类一旦多起来父类的封装就名存实亡。PHP 官方手册对可见性的表述很明确private成员只在声明它的那个类里可见子类完全看不到。本文先讲清三档可见性的真实语义再讲继承的硬性规则单继承、可见性不能收窄、构造器不自动调用最后给出封装与继承配合使用的正确姿势以及 PHP 8.1 引入的readonly如何让「不可变值对象」既封装又简洁。一、可见性封装的全部实现手段修饰符本类内部子类外部代码public可见可见可见protected可见可见不可见private可见不可见不可见var遗留写法可见可见可见等价于publicprivate的「不可见」需要理解到一个关键程度私有成员不参与继承。子类里如果写了一个与父类私有属性同名的属性那是两个互不相干的属性?php // 适用于 PHP 7.4class 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.0class 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.1declare(strict_types1);abstract class Repository{/** var arrayint, arraystring, 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; // 1var_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.1declare(strict_types1);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.4class 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同时使用致命错误readonlyPHP 8.1初始化一次必须带类型想「更新」只读属性结论封装的抓手是可见性继承的边界是「能不能替换父类」——判断标准是里氏替换子类对象放到任何期待父类的地方都必须正常。实践中父类的内部状态用private守住把扩展点收窄成少数几个protected抽象方法再用final锁住不该变的主流程值对象则一律用readonlyPHP 8.1做成不可变这样「谁改了它」这类问题就从根上消失了。