在PHP里,有一类很特殊的“半成品”类,它就是抽象类(Abstract Class)。你可以把它理解成一个模具的半成品——它定义了基本形状,但缺少关键的细节,所以不能直接拿来“浇筑”出对象。一旦尝试这样做,PHP 解释器会立刻抛出一个致命错误:PHP Fatal error: Uncaught Error: Cannot instantiate abstract class。
这个设计并不是缺陷,而是一种刻意为之的约束。它的核心思想可以用两句话概括:抽象类不能自我实例化,必须被继承。它的价值在于为子类提供统一的结构模板。
定义抽象类的语法很直接,使用 abstract 关键词即可:
abstract class MyClass {
// 类体
}
任何试图 $obj = new MyClass(); 的操作都是无效的。这就是抽象类最根本的规则。
抽象方法:抽象类的灵魂
一个抽象类里可以包含属性、常量、普通方法,就像任何普通类一样。但它真正区别于普通类的地方,在于可以定义抽象方法(Abstract Method)。抽象方法只有方法签名(名字和参数),没有具体实现,它仅仅是一个需要被履行的“契约”。
这里有一个非常重要的规则:一旦一个类包含了至少一个抽象方法,那么这个类本身也必须被声明为抽象类。 反过来,在普通类里声明抽象方法是语法错误。
看下面这个会出错的例子,它能帮你理解这条规则的强制性:
class myclass {
abstract function absmethod($arg1, $arg2);
function method() {
echo "Hello";
}
}
执行这段代码,你会看到类似这样的错误信息:PHP Fatal error: Class myclass contains 1 abstract method and must therefore be declared abstract。这很明确地告诉我们,只要类里存在未实现的抽象方法,这个类就必须用 abstract 标记,不允许有“漏网之鱼”。
继承与实现:子类的责任
当一个子类通过 extends 关键字继承一个抽象类时,它实际上与父类签订了一份“合约”:父类中所有抽象方法,子类都必须提供具体的实现。如果子类遗漏了任何一个抽象方法的实现,那么在尝试实例化它时,同样会触发致命错误。
我个人在早期学习时,习惯先写一个什么都不做的空实现,来通过语法检查,然后再逐步填充业务逻辑。这虽然是个取巧的办法,但能避免被频繁的致命错误打断思路。
来看下面这个典型的错误示例:
abstract class BaseClass {
abstract public function performAction($param1, $param2);
public function greet() {
echo "Hello";
}
}
class DerivedClass extends BaseClass {
public function showMessage() {
echo "World";
}
}
$instance = new DerivedClass();
$instance->greet();
这段代码会抛出错误,因为 DerivedClass 继承了抽象的 performAction() 方法,却没有实现它。正确的做法是,在 DerivedClass 中把 performAction() 补全。修正后的代码应该是这样:
abstract class BaseClass {
abstract public function performAction($param1, $param2);
public function greet() {
echo "Hello";
}
}
class DerivedClass extends BaseClass {
public function performAction($param1, $param2) {
echo "Action performed with $param1 and $param2";
}
public function showMessage() {
echo "World";
}
}
$instance = new DerivedClass();
$instance->greet(); // 输出: Hello
这里 performAction() 至少有了一个基础的函数体,合约才算履行完毕。它不像接口那样严格要求方法签名一致(虽然遵循一致是好的实践),但你必须提供一个同名方法。
一个贴近计算的例子
抛开理论,我们看一个更实用的场景:计算百分比。假设我们做一个小工具,需要根据不同的分数制度计算百分比,但所有制度都需要一个基础的分数存储和计算框架。
为此,我们编写一个 Score 抽象类:
abstract class Score {
protected int $subject1, $subject2, $subject3;
abstract public function calculatePercentage(): float;
}
Score 类拥有三个受保护的属性来存放科目分数,并声明了一个名为 calculatePercentage() 的抽象方法,要求返回浮点数。至于怎么算,它不管,这是子类的职责。
接着,让 Learner 类去继承并实现它:
class Learner extends Score {
public function __construct($a, $b, $c) {
$this->subject1 = $a;
$this->subject2 = $b;
$this->subject3 = $c;
}
public function calculatePercentage(): float {
return ($this->subject1 + $this->subject2 + $this->subject3) * 100 / 300;
}
}
$student = new Learner(50, 60, 70);
echo "Overall Percentage: " . $student->calculatePercentage() . PHP_EOL;
// 输出: Overall Percentage: 60
在这个例子里,Score 抽象类定义了数据结构和能力契约,Learner 类则专注于实现一种特定的百分比算法。如果未来我们需要另一种计算方式,例如加权计算,只需新建一个子类,修改 calculatePercentage() 的实现即可,Score 和 Learner 的代码不用动。
我们为什么需要抽象类?
可能有人会问:普通类加上方法重写不也能实现类似效果吗?为什么非要用抽象类?
答案在于强制性和设计意图的表达。
-
表达意图并施加约束
当我把一个方法标记为abstract时,我在向团队(以及未来的自己)传递一个强烈的信号:“这个方法在每个子类里的行为都不同,你们必须根据自己的场景去实现它,不能依赖父类的任何默认逻辑。” 普通父类可以提供默认实现,子类可以选择覆盖,但抽象方法不具备这个选项,它强制执行。如果不主动使用abstract关键词,这个约束在代码层面是缺失的,只能在文档或口头约定里体现,可靠性大打折扣。 -
提升代码的组织和复用性
抽象类像一个高阶的代码收纳盒。把共同的状态(比如Score里的$subject1, $subject2, $subject3)和共用方法(比如通用的greet()方法)放在抽象父类里,所有子类就天然拥有了这些能力,避免了在每个子类里重复声明相同的属性或写一遍相同的代码。
抽象类与接口:抉择时刻
这是 PHP 面向对象编程里一个经典的比较。两者都不能直接实例化,都用于定义规范,但它们的能力范围截然不同,选择哪个,取决于你的具体需求。
| 特性 | 抽象类 (Abstract Class) | 接口 (Interface) |
|---|---|---|
| 定义方式 | 使用 abstract 关键词 |
使用 interface 关键词 |
| 实例化 | 不允许 | 不允许 |
| 方法实现 | 可以包含已实现的普通方法和未实现的抽象方法 | 只能声明方法签名,不能有方法体(PHP 8.0后可有默认静态方法) |
| 实现规则 | 子类必须实现父类中所有抽象方法 | 实现类必须定义接口中声明的所有方法 |
| 属性 | 可以声明属性,并设定 public、protected、private 可见性 |
不能声明属性(只能声明常量) |
| 使用场景 | 多个类之间存在清晰的父子关系,共享代码的同时又要求子类定制特定行为 | 定义一种与具体实现无关的能力或契约,可以被许多不相关的类采纳 |
一个我个人比较遵循的原则是:当需要为子类提供一个共同的“骨骼”(比如共享属性、构造方法)时,优先选抽象类。如果只是单纯定义“你能做什么”的行为契约,接口无疑更灵活。
本节课程知识要点
-
使用
abstract关键词声明抽象类,它无法直接实例化。 -
包含抽象方法的类,必须声明为抽象类。
-
继承抽象类的子类,负有实现父类所有抽象方法的合约义务。
-
通过抽象类,可以集中管理共享的属性和方法,减少代码冗余。
-
抽象类侧重共同血缘下的代码复用与约束,接口侧重跨类别的能力定义。