先聊一个我天天都能见到的现象:很多人写 PHP 类,能把class、new、public、private、protected这几个词背得滚瓜烂熟,但一到了具体项目里,要么把所有属性都做成 public,要么把abstract和interface混为一谈,要么喜欢在static和self之间反复横跳。这些关键字不是拿来背的,它们是设计工具,每一个都在回答同一个问题:这个对象的边界到底划在哪。
这篇文章不是语法手册,而是一份按真实项目习惯整理的关键字使用指南。我会用一个图书管理领域的模型贯穿全文,把extends、parent、static、self、abstract、implements、namespace、use、trait、clone、instanceof这些高频关键字逐个放到场景里讲清楚,包括它们的坑和我个人踩过的雷。适合刚接触 OOP 的入门者,也适合平时只写业务、想系统补一遍语法语义的 PHP 工程师。
1. 关键字全景图:先把 OOP 关键字按职责分类
1.1 关键字的四个阵营
PHP 面向对象相关的关键字数量其实不多,但如果按“用途”而不是“字母顺序”去拆,思路会清晰很多。我习惯把跟对象相关的关键字分成四类:
| 职责 | 关键字 | 典型作用 |
|---|---|---|
| 声明与实例化 | class、new、clone、instanceof、readonly | 定义类、创建对象、复制对象、判断对象类型 |
| 可见性控制 | public、protected、private | 控制属性方法与类外部的访问边界 |
| 继承与多态 | extends、parent、implements、interface、abstract | 建立类之间“是什么”“能做什么”的关系 |
| 复用与命名 | use、namespace、trait、static、self、const、final | 组织代码、共享逻辑、约束修改 |
这不是教科书里的分类,而是我在实际排查代码时的习惯。见到一个类,你首先要回答:它被谁访问?它从哪来?它能不能被继承?它复用了一段什么逻辑?这四件事正好对应上面四列。
理解了分类,再看任何一个关键字都不会孤立。比如final看起来跟实例化无关,但它会影响“能不能被继承”;static看起来是访问控制,但它其实改变的是成员属性和类空间的绑定关系。把它们分好组,后面实际开发时就知道去哪里找答案。
1.2 一个贯穿全文的图书管理模型
我准备用一个非常常见的业务领域来演示:图书管理。先看第一版领域模型,后面所有关键字都会从它出发。
namespace App\Domain; use App\Library\LoggableTrait; use App\Library\HasLabel; enum LoanStatus: string implements HasLabel { case Available = 'available'; case Loaned = 'loaned'; case Disabled = 'disabled'; public function label(): string { return match ($this) { self::Available => '可借', self::Loaned => '已借出', self::Disabled => '已下架', }; } } interface Borrowable { public function borrow(\DateTimeInterface $when): bool; public function giveBack(\DateTimeInterface $when): bool; public function currentStatus(): LoanStatus; } abstract class Content { protected string $title; private \DateTimeImmutable $createdAt; public function __construct(string $title) { $this->title = $title; $this->createdAt = new \DateTimeImmutable(); } abstract public function getSummary(): string; public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } } trait LoggableTrait { public function log(string $message): void { echo '[log] ' . $message . PHP_EOL; } } final class Book extends Content implements Borrowable { use LoggableTrait; public const MAX_BORROW_DAY = 30; private ?string $isbn = null; private LoanStatus $status = LoanStatus::Available; public function __construct( string $title, private string $author, private int $pages, ) { parent::__construct($title); } public function getSummary(): string { return sprintf('%s(作者:%s)', $this->title, $this->author); } public function borrow(\DateTimeInterface $when): bool { if ($this->status !== LoanStatus::Available) { return false; } $this->status = LoanStatus::Loaned; $this->log($this->title . ' 被借出'); return true; } public function giveBack(\DateTimeInterface $when): bool { $this->status = LoanStatus::Available; $this->log($this->title . ' 已归还'); return true; } public function currentStatus(): LoanStatus { return $this->status; } }这段代码几乎用了 OOP 里一半以上的关键字:namespace、use、enum、interface、implements、abstract、extends、trait、final、const、private、protected、public、parent、self、match、构造函数属性提升。先不用全部弄懂,接下来我会按它出现的顺序逐个拆开。
注意:这段代码是合并展示的,实际开发里建议“一个文件放一个类或接口”,并配合命名空间自动加载。
2. 访问控制边界:private、protected、public 到底怎么划
2.1 private 的边界是“类”而不是“对象”
这是新手最容易忽略的一个点。很多人以为private意味着“这个对象自己才能访问”,其实不对,准确说法是“这个类内部才能访问”。同一个类的两个不同实例之间,是可以互相读取对方的 private 属性的。
看个例子:
class Price { public function __construct(private float $amount) {} public function compare(Price $other): int { // $other 是另一个对象,但同样属于 Price 类 return $this->amount <=> $other->amount; } } $priceA = new Price(88.0); $priceB = new Price(108.0); echo $priceA->compare($priceB); // -1,能直接读 $priceB->amount这段代码合法,因为$other的类型是Price,编译器不会拦你。理解了这一点,你就知道private保护的并不是“对象本身”,而是“类以外的访问者”。
另外一个重要边界:子类不能直接访问父类的 private 属性。如果你希望子类能读取某个字段,就必须把它设计成protected或通过public方法暴露。很多人把属性设成 public,纯粹是因为懒,这会让所有约束都失效。
2.2 protected 是继承链上的通道,但不是放任自由的公地
protected比private多开了一个口子:它允许子类访问,但外部仍然禁止。看起来是很合理的折中,但我见过很多项目的状态字段全部是protected,理由都是“子类可能要扩展”。
这里有一个实际教训:当子类可以随意改写父类的protected状态字段时,父类就失去了对自身数据完整性的控制。举个网购订单的例子,订单里有总金额字段,如果所有子类都能直接改金额,那优惠、运费这些规则就没人能保证。更好的做法是把金额设计成 private,父类提供applyDiscount()这样的受控方法来修改,而不是让子类直接赋值。
所以我的推荐是:状态字段优先 private,行为方法优先 public,需要让子类复写的模板方法才用 protected。protected不是权限大放送,它只是给继承设计留一条明确的道路。
2.3 构造函数可见性也能被利用:单例与私有构造
访问控制不仅作用在普通属性和方法上,构造函数也能声明成private或protected。一旦构造函数是 private,外部就不能直接new这个类,只能通过类内部的静态方法创建对象,这是单例模式的根基。
class Database { private static ?Database $instance = null; private function __construct() { // 私有构造函数:外部无法 new } public static function getInstance(): Database { return self::$instance ??= new self(); } } $db = Database::getInstance();这里有两个点要细心。第一,self::$instance是静态属性,它不属于任何具体对象,而是属于类空间,所以所有实例共享同一份。第二,我使用了new self(),如果这里写成new static(),当getInstance()被子类继承调用时,static会指向子类,行为完全不同。下一节会重点讲self和static的差异。
实际操作中你还会遇到一种情况:父类构造函数是 private,子类想实例化怎么办?结论是子类无法调用父类私有构造函数,所以单例类基本都要配合final或保持类自身封闭来使用,避免被继承后产生混乱。
3. 继承与静态绑定:extends、parent、self、static 的差异
3.1 extends 和 parent 的基本约定
extends是 PHP 实现单继承的唯一关键字。一个类只能有一个父类,但可以通过接口弥补多重能力的缺失,这一点后面会再讲。
子类会自动继承父类的 public 和 protected 成员,但不继承 private 成员。如果子类没有定义构造函数,实例化时会自动调用父类构造函数;一旦子类自己定义了构造函数,父类构造函数不会自动执行,必须手动调用parent::__construct()。
class BaseProduct { public function __construct(protected float $price) {} public function total(): float { return $this->price * 1.1; } } class VipProduct extends BaseProduct { public function __construct( float $price, private float $discount, ) { parent::__construct($price); } public function total(): float { return parent::total() * $this->discount; } }写这段代码的意义不是展示语法,而是说明一个原则:子类扩展父类之前,先问自己是不是真的需要“继承”。很多代码里子类只是想让父类的方法多一点,其实用“组合”一个组件对象效果更好。继承关系一旦搭错,后面重构成本极高。
方法覆盖也是高频踩坑点。子类重写父类方法时,不能降低可见性,比如父类方法是 protected,子类重写时必须保持 protected 或变成 public,不能改成 private。参数也需要保持兼容,PHP 8 以后还会检查参数名和类型,不一致容易直接抛致命错误或警告。
3.2 self、static、parent 三者结果不同
这三兄弟经常被搞混,其实一条规则就能说明白:
self::指向“当前代码所在的那个类”static::指向“最终被调用的那个类”parent::指向“当前代码所在类的父类”
看这个例子:
class ParentA { public static function who(): string { return 'ParentA'; } public static function callSelf(): string { return self::who(); } public static function callStatic(): string { return static::who(); } } class ChildB extends ParentA { public static function who(): string { return 'ChildB'; } } echo ChildB::callSelf(); // ParentA echo ChildB::callStatic(); // ChildBcallSelf()里的self::who()在编译期就绑死了ParentA,无论你怎么调用,它都会执行父类的who()。callStatic()里的static::who()是延迟到运行时才决定,真正调用方是ChildB,所以执行的是子类的实现。
这个特性叫“后期静态绑定”,PHP 5.3 引入。单例模式里new self()和new static()的区别就在这里:一个固定创建当前类,一个允许创建调用者对应的子类。
3.3 final 是反向约束,用来锁住变化
final是一个很容易被低估的关键字。final class表示该类禁止被继承,final method表示该方法禁止被重写。
什么时候该用 final?我的经验是:当类的行为不希望被外部扩展,或者你不打算为它的扩展性做任何承诺时,就应该 final。比如一个纯数值对象、一个安全校验器、一个门面类,直接 final 能减少很多无意义的继承。
final class Money { public function __construct( private int $amount, private string $currency, ) {} public function amount(): int { return $this->amount; } public function currency(): string { return $this->currency; } }final 有两个需要避开的点:第一,不能同时把类声明为 abstract 和 final,抽象类本身就是为了继承而存在的;第二,PHP 8.1 之后类常量也可以加 final,用于禁止子类重定义常量。不要在项目初期就把所有类都 final,那会限制后续迭代,最理想的是类 API 稳定后再加 final。
4. 契约设计:abstract、interface、implements 怎么用
4.1 abstract 类表达“不完整的基类”
抽象类是不能被实例化的类,它里面可以有完整的普通方法,也可以有只有签名没有实现的抽象方法。抽象方法使用abstract修饰,并且不能有方法体;子类继承后必须实现这些抽象方法。
abstract class Payment { public function invoiceNo(): string { return uniqid('INV-', true); } abstract public function pay(float $amount): bool; } class AlipayPayment extends Payment { public function pay(float $amount): bool { // 真实支付逻辑 return true; } }抽象类适合表达“它们是一类东西,但每个子类都有一点差别”。比如Payment是所有支付渠道的父类,都有一个公共的生成单号方法,但支付逻辑每个渠道不同。注意,抽象方法不能声明为 private,因为 private 方法无法被子类继承,那“抽象”就没有意义了;抽象方法也不建议有方法体,一旦写了实现它就不是抽象方法。
4.2 interface 与 implements 定义“能做什么”
接口比抽象类更严格:接口里的方法默认就是 public 抽象方法,它只描述能力,不描述内部实现。一个类可以同时实现多个接口,这个特性正好弥补了 PHP 单继承的不足。
回到图书模型的Borrowable接口:
interface Borrowable { public function borrow(\DateTimeInterface $when): bool; public function giveBack(\DateTimeInterface $when): bool; public function currentStatus(): LoanStatus; }只要一个类实现了Borrowable,外部就可以统一用这三个方法操作它,而不用关心它到底是书、是 DVD 还是电子阅读器。这是面向接口编程的意义:消费方依赖的是行为契约,而不是具体类型。
实现类必须实现接口里的所有方法,除非这个实现类本身也是 abstract。接口还可以继承接口,比如interface DigitalBorrowable extends Borrowable,也可以声明常量,但接口里不能定义实例属性。
4.3 抽象类与接口怎么选:一张对照表
| 维度 | abstract 类 | interface |
|---|---|---|
| 能否实例化 | 不能 | 不能 |
| 是否可以有完整方法实现 | 可以 | 不可以,方法都是抽象签名 |
| 方法可见性 | public / protected 都可以 | 默认 public |
| 能否定义属性 | 可以 | 不能 |
| 能否定义常量 | 可以 | 可以 |
| 继承数量 | 只能单继承 | 可实现多个接口 |
| 典型语义 | “是什么” | “能做什么” |
实际项目里,我一般这样定:如果多个类之间确实有公共属性、公共逻辑,并且将来要复用具体代码,用抽象类;如果只是约束行为,希望不同的类都响应同一组调用,用接口。优先使用接口,需要共享实现时再考虑抽象类,这是比较稳妥的组合方式。
4.4 枚举与接口结合的新玩法
PHP 8.1 之后enum成了关键字,枚举也可以实现接口。比如图书模型里的LoanStatus实现了HasLabel接口:
interface HasLabel { public function label(): string; }这样状态枚举不仅能当成类型约束用在参数和返回值里,还能拥有自己的方法。开发者永远不需要再维护一份“状态字符串到中文名称”的映射表,状态和它的展示逻辑天然放在了一起。
5. 命名空间与导入:namespace、use 背后的编译期机制
5.1 namespace 解决的是“全限定名”问题
老项目里经常能看到一堆带前缀的类名,比如Mall_Order_Service、Mall_User_Controller,本质就是在手动模拟命名空间。引入namespace之后,类名可以短很多,结构也更贴近目录。
命名空间有几个硬性规范:必须在文件的第一个语句位置声明(唯一例外是最前面的declare(strict_types=1)),并且一个文件里建议只放一个命名空间。命名空间本身不负责加载文件,它只是给类、函数、常量一个完整的名称标识。
namespace App\Domain; // 这个类的完全限定名是 App\Domain\Book class Book {}在项目里,命名空间通常和目录对应,典型映射是App\Domain对应src/Domain。这个约定是 PSR-4 自动加载的基础。
5.2 use 的四种常用形态
use是在当前文件里为某个类、接口、函数、常量建立别名,而不是真正把文件包含进来。它作用于编译期,作用范围是当前文件。
use App\Domain\Book; use App\Domain\Content as BaseContent; use function App\Library\formatPrice; use const App\Library\MAX_PAGE; // PHP 7 起支持分组导入 use App\Domain\{Book, Content, LoanStatus};这里要注意:use只解决“这个文件里怎么写名字”的问题,和文件加载没有关系。如果你没有写use,直接new Book(),PHP 会在当前命名空间里查找 Book 类,找不到就报 class not found,而不是自动去全局空间找。
5.3 use 与自动加载的真实关系
很多新手以为写了use App\Domain\Book;之后这个类就被加载了。真实情况是,use只是把Book这个短名字映射到了App\Domain\Book这个全限定名。真正触发类加载的是new Book()那一刻,由自动加载器根据类名找到文件路径,再 require 进来。
所以如果你写了一个类但没被自动加载,就算 use 写了也照样报错。反过来,即使不写 use,直接写完整限定名new \App\Domain\Book(),自动加载器也能通过完整类名定位文件。use 的意义更多是让代码可读、可维护。
这一点在排查“类找不到”的问题时非常关键:先看命名空间对不对,再看自动加载配置,最后才看 use 写没写。
6. 复用利器:trait、const、static 的高频用法
6.1 trait 解决“横向复用”
继承是纵向的,trait 是横向的。trait 可以理解为一个“代码片段集合”,它可以包含属性、方法、静态方法,甚至抽象方法,但不能被实例化,只能被类 use 进去。
trait 带来一个非常关键的优先级规则:当前类中定义的方法 > trait 中的方法 > 父类继承的方法。看代码:
trait SayHello { public function greet(): string { return 'hello from trait'; } } class Base { public function greet(): string { return 'hello from base'; } } class User extends Base { use SayHello; // 不重写 greet,实际调用 trait 里的版本 } class Admin extends Base { use SayHello; public function greet(): string { return 'hello from admin'; } } echo (new User())->greet(); // hello from trait echo (new Admin())->greet(); // hello from admin如果两个 trait 里有同名方法,PHP 不会自动按顺序覆盖,而是直接报致命错误,必须用insteadof和as手动解决。
trait A { public function say(): string { return 'A'; } } trait B { public function say(): string { return 'B'; } } class Speaker { use A, B { A::say insteadof B; B::say as sayB; } } echo (new Speaker())->say(); // A echo (new Speaker())->sayB(); // Binsteadof指定保留谁,as给被淘汰的方法取个别名,这样两个方法都能继续用。trait 适合放日志、缓存、权限判断这类横向能力,但不要过度使用,trait 放多了类之间的关系会变得隐晦,排查问题时反而不直观。
6.2 const 类常量与不变约束
类常量用const定义,属于类而不是对象,访问方式统一是类名::常量名。PHP 7.1 开始给类常量加可见性,PHP 8.1 开始支持final const,意思是子类不能重写这个常量。
class Book { public const MAX_BORROW_DAY = 30; private const INTERNAL_PREFIX = 'BK-'; final public const DEFAULT_CURRENCY = 'CNY'; }类常量适合放那些“一期需求里确定不会变”的值。如果以后要针对不同渠道出不同常量,那就应该设计成public static属性并提供修改入口。这一点要提前想清楚,把可能变化的东西声明成 const,后面改起来非常痛苦。
还容易混淆的是全局常量和类常量。全局的define('FOO', 'bar')不属于任何类,可以在任何地方直接访问;类常量必须带上类名或self::/static::前缀。
6.3 static 静态成员与访问方式速查
static关键字可以修饰属性、方法,也可以用在闭包上,但 OOP 里最常接触的是静态属性和静态方法。静态属性属于类空间,所有实例共享同一份数据;静态方法属于类,可以通过类名直接调用,不需要先 new 一个对象。
静态方法内部不能使用$this,因为它不依赖对象上下文。你可以在静态方法里用self::或static::访问其他静态成员,也可以实例化其他对象。
我把三种成员放在一起对比一下,方便记忆:
| 成员类型 | 属于 | 访问方式示例 | 是否需要实例 |
|---|---|---|---|
| 实例属性 | 单个对象 | $obj->name | 需要 |
| 静态属性 | 整个类空间 | Class::$name或$obj::$name | 不强制 |
| 类常量 | 整个类空间 | Class::NAME或$obj::NAME | 不强制 |
注意:静态属性不能通过
$obj->name方式访问,虽然 PHP 允许$obj::$name,但常规写法还是用类名,更清晰。
静态方法适合做工具类、工厂方法、单例入口。但静态成员不能被继承和重写得太随意,它天然带“全局状态”,用多了会增加测试难度。即使要用,也建议把状态收敛到静态方法内部,而不是散落一堆公开静态属性。
7. 对象生命周期:new、clone、instanceof 与魔术方法
7.1 new 的多种形态
new是创建对象的入口,看似简单,实际上有不少细节:
new ClassName()标准实例化new $className()按变量动态实例化new class(参数) {}匿名类,PHP 7 起可用- PHP 8.1 起允许在参数默认值和属性默认值里直接
new
匿名类在需要一次性对象时很好用,比如给某个接口写一个临时实现:
$handler = new class('alipay') implements PaymentHandler { public function __construct(private string $code) {} public function handle(): string { return 'handle: ' . $this->code; } };PHP 8.1 的构造参数默认值里放 new,可以把依赖注入写得更紧凑:
class ReportService { public function __construct( private Logger $logger = new FileLogger(), ) {} }对象变量在 PHP 里是引用语义:$a = new Book(); $b = $a;之后$a和$b指向同一个对象,修改其中一个,另一个也会变。想复制真正的独立副本,必须用clone。
7.2 clone 与浅拷贝的深坑
clone会创建一个新对象,并把已有对象的属性值复制过去。默认是浅拷贝:如果属性本身是一个对象,复制过去的是同一个对象引用。
比如订单里有商品列表对象,clone一个订单后,新旧订单共享同一批商品对象。此时需要重写__clone魔术方法来手动深拷贝:
class PurchaseOrder { public function __construct(public array $items) {} public function __clone() { $this->items = array_map( fn(LineItem $item) => clone $item, $this->items ); } } $orderA = new PurchaseOrder([new LineItem('book', 2)]); $orderB = clone $orderA; $orderB->items[0]->quantity = 5; echo $orderA->items[0]->quantity; // 如果不深拷贝,这里会变成 5__clone里可以访问被复制对象的属性,也可以访问新对象的属性,因为__clone是在复制完成后调用的。记住:凡是属性里嵌了对象,clone 之后都要检查是否需要深拷贝。
7.3 instanceof 与多态检查
instanceof用来判断一个对象是不是某个类、父类或接口的实现。它比get_class()更符合多态思路,因为父类和实现接口的类都能识别。
function dumpBorrowable(Borrowable $item): void { if ($item instanceof Book) { echo '这是一本书'; } elseif ($item instanceof DVD) { echo '这是一张光盘'; } }右侧也可以接变量:$item instanceof $className,这在写通用处理逻辑时很有用。注意,instanceof判断的是类继承关系和接口实现关系,不是“属性是否一样”。两个独立的类哪怕结构完全一样,互相也不属于对方的实例。
7.4 魔术方法:哪些该用,哪些要少用
PHP 里以双下划线开头的方法被称作魔术方法,常见的有__construct、__destruct、__get、__set、__isset、__unset、__call、__callStatic、__toString、__invoke、__clone、__debugInfo。它们的作用是在特殊时机自动触发。
其中__invoke最容易被忽略,它让一个对象可以被当成函数来调用:
class Formatter { public function __invoke(string $text): string { return '[' . $text . ']'; } } $format = new Formatter(); echo $format('hello'); // [hello]__toString用来定义对象转字符串时的行为,但必须返回字符串,否则会有类型错误。
__get和__set则要谨慎。很多人为了“动态扩属性”滥用它们,把所有未知字段塞到一个数组里。这样做的代价是 IDE 无法静态分析、类型无从保证、性能还会下降。只有真正做动态字段适配(比如读取外部 API 返回的未知字段)时才值得使用。
8. PHP 8 新关键字与语法糖:strict_types、属性提升、readonly、enum
8.1 declare(strict_types=1) 的生效位置
严格类型是最容易被忽视的关键字之一。很多人以为在文件顶部写了declare(strict_types=1),这个文件里所有函数调用都会强制检查类型。实际上严格类型检查看的是调用发生位置所在文件的 declare 设置,而不是被调函数定义文件的设置。
// strict_caller.php declare(strict_types=1); require 'functions.php'; formatPrice(19.9); // 如果 formatPrice 期望 float,没问题 formatPrice('19.9'); // 这里会抛 TypeError也就是说,如果你在一个开了严格模式的文件里调用另一个没开严格模式文件的函数,类型同样会被严格检查。反之,在被调函数的文件里开严格模式,并不等于所有外部调用都必须严格。这个机制容易造成“为什么换了一台环境就报 TypeError”的坑,排查时先看触发调用的那个文件声明了什么。
8.2 构造器属性提升:缩写属性声明
PHP 8 的构造函数里可以直接用可见性关键字声明并赋值属性,这就是构造器属性提升。图书模型里的Book构造函数就是这么写的:
public function __construct( string $title, private string $author, private int $pages, ) {}它等价于先声明三个属性,再在构造函数里赋值的旧写法:
private string $author; private int $pages; public function __construct( string $title, string $author, int $pages, ) { $this->title = $title; $this->author = $author; $this->pages = $pages; }提升语法能减少大量样板代码,但要注意:提升的属性不能在类体里再次声明同名属性,也不能给同一个属性混用多种写法。它只是语法糖,不改变访问控制语义,该 private 还是 private,该 protected 还是 protected。
8.3 readonly 与值对象
readonly属性一旦初始化就不能再改变,适用于那些希望保持不可变的对象,比如金额、坐标、配置项。PHP 8.2 还支持readonly class,一个类声明为 readonly 后,所有属性都会变成 readonly。
readonly class Point { public function __construct( public float $x, public float $y, ) {} } $p = new Point(3.0, 4.0); $p->x = 5.0; // Error: Cannot modify readonly property这个设计非常贴合值对象场景。不可变对象天然线程安全,在共享环境里不容易被意外篡改,也方便缓存和使用哈希比较。
8.4 enum 枚举关键字:状态机的更优解
PHP 8.1 的enum是一个全新的关键字,它弥补了之前只能用class和const模拟枚举的尴尬。枚举可以带值,称为 backed enum,也可以实现接口、定义方法。
enum LoanStatus: string { case Available = 'available'; case Loaned = 'loaned'; case Disabled = 'disabled'; public function isBorrowable(): bool { return $this === self::Available; } }使用枚举后,方法参数可以直接收枚举类型,传入非法状态会在编译阶段就暴露问题。图书模型里的currentStatus(): LoanStatus就保证了状态一定是三个合法值之一,不再需要到处判断字符串。
9. 高频陷阱与避坑速查
9.1 访问控制的五个真实反例
我整理了几个很容易踩的坑,这些都是在真实代码评审里反复出现的:
- 所有属性一律 public。短期的确快,但三周后没人知道哪个字段被哪些地方改过,业务规则全面失控。正确方向:属性默认 private,通过受控方法改值。
- 子类里大量直接操作父类 protected 属性。这等于把封装拆了。父类应提供 protected 方法帮助子类安全更新状态。
- 子类重写父类方法时降低了可见性。比如父类是 public,子类改成 protected,PHP 会直接报错。记住只能放宽,不能收紧。
- 在静态方法里使用
$this。静态方法没有对象上下文,必须通过self::或static::访问静态成员。 - 父类构造函数是 private,却希望通过继承来复用单例逻辑。这个在语法层面就行不通,单例类通常要结合 final 设计。
9.2 实例属性、静态属性、类常量访问方式混淆
三种成员长得像,访问方式完全不同,看这段代码:
class Foo { public int $value = 1; public static int $count = 1; public const LIMIT = 10; } $foo = new Foo(); echo $foo->value; // 1,实例属性,箭头访问 echo Foo::$count; // 1,静态属性,双冒号加 $ 符号 echo Foo::LIMIT; // 10,类常量,双冒号不加 $ echo $foo::LIMIT; // 10,PHP 允许通过实例访问类常量,但不推荐最容易出错的是把静态属性写成$foo->count,这会直接报错。类常量和静态属性的共性是都属于类本身,但语法上一个需要$前缀,另一个不需要,别记反。
9.3 魔术方法的性能与设计代价
魔术方法(尤其__get、__set、__call)不是不能用,而是要明白它的代价。正常情况下,每个属性访问都是编译期确定的事,引擎可以优化;一旦引入魔术方法,每次访问未知字段都要走一遍动态分发,速度明显变慢。更麻烦的是,IDE 无法对这些动态成员做类型检查。
我在一个历史项目里就见过这种写法:一个基础 Model 类把__get和__set做成数据库字段的通用入口,结果每个字段的读写都依赖魔法方法。后来数据库表加了新字段,IDE 不会提示,只能在运行时发现,排查困难。这种设计更适合在框架底层封装,业务模型里要尽量少用。
9.4 最终自查表
写完一个类后,可以按下面这个清单快速过一遍:
- 属性都声明可见性了,并且尽量 private 了吗?
- 不变量(金额、状态、库存)是通过方法修改,还是裸字段直接改?
- 需要继承吗?如果需要,父类到底该是 abstract 类还是 interface?
- 多个类共享同一段代码,用 trait 还是组合?
- 常量用 const 能表达“不变”,用 readonly 能表达“只读”,选对了吗?
- 静态成员是否真的属于类级别?会不会造成测试之间的全局污染?
- 对象的引用关系里有没有浅拷贝问题?
- 打开 strict_types 之前,想好这个文件所有调用方的类型语义了吗?
这份清单没法保证代码完美,但能逼你把边界想清楚。
最后分享一条我自己特别受用的实践。前几年维护一个老系统时,最痛苦的不是关键字不会用,而是所有字段全是 public,订单金额随手就能被改,库存数量在十个地方加减。当时我做的第一件事不是引入设计模式,而是把所有属性先改成 private,然后开着 IDE 的报错一个个收敛外部调用。整个过程很痛,但每一处报错都在告诉我:这个依赖不该存在,这个逻辑应该由领域对象自己完成。
如果你现在写 PHP 类还没有这种“哪里至少要加个 private”的感觉,我建议也试一次这种反向重构。关键字真的不难,难的是你愿不愿意用它把边界划出来。边界划清楚了,类自然就稳了。