☰
PHP类的封装与继承详解
2026/10/10 12:44:25 网站建设 项目流程

前言


封装(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; }
}

常见坑点



  1. ❌ 为了复用几个方法就extends(比如让User extends Database)—— ✅ 继承应当表达 is-a;关系不成立时改用组合:把依赖对象通过构造器注入进来。

  2. ❌ 子类把父类方法从public收窄为protected或private—— ✅ 致命错误;重写只能保持或放宽可见性。

  3. ❌ 子类定义了构造器却忘了parent::__construct()—— ✅ 只要子类有自己的构造器,父类构造器就不会自动执行;需要时必须显式调用。

  4. ❌ 对没有构造器的父类写parent::__construct()—— ✅ 会报Cannot call constructor;调用前先确认父类确实声明了构造器。

  5. ❌ 以为protected比private更「安全地灵活」 —— ✅protected会把内部状态交给所有子类;内部数据优先private,只把真正的扩展点设为protected。

  6. ❌ 属性声明为public,同时又写一对不做任何事的 getter/setter —— ✅ 两者自相矛盾;封装就老老实实private+ 受控方法。

  7. ❌ 试图修改readonly属性(如$m->amount = 10;或$m->amount += 1;)—— ✅ 只读属性只能在声明它的类作用域内初始化一次;要改就造新对象。

  8. ❌ 在父类里用self::调用希望被子类重写的方法 —— ✅self::是编译期绑定,走父类实现;需要多态用$this->,静态上下文用static::。


总结




主题规则常见错误



private仅本类可见,不参与继承以为子类能「重写」父类私有属性

protected本类与子类可见用得太多,把内部状态全暴露给子类

public/var所有作用域可见用var以为是老式封装

继承单继承;可见性不可收窄;静态/非静态不可互换为复用代码而继承

构造器子类有自己的构造器时不自动调用父类忘记parent::__construct()

final封闭类或方法与abstract同时使用(致命错误)

readonlyPHP 8.1+,初始化一次,必须带类型想「更新」只读属性



结论:封装的抓手是可见性,继承的边界是「能不能替换父类」——判断标准是里氏替换:子类对象放到任何期待父类的地方都必须正常。实践中,父类的内部状态用private守住,把扩展点收窄成少数几个protected抽象方法,再用final锁住不该变的主流程;值对象则一律用readonly(PHP 8.1+)做成不可变,这样「谁改了它」这类问题就从根上消失了。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询