前言
中文资料里常把 PHP 的魔术方法(magic methods)称作"拦截器"或"重载",因为这些方法的作用是在某个操作发生时被引擎自动调用,从而"拦截"掉默认行为。这个叫法在描述用途上是贴切的,但要知道它的正式名称是魔术方法,对应的overloading在 PHP 手册里指的是属性与方法的重载,也就是__get/__set/__call这一组——和 C++ 那种"同名函数不同签名"的重载完全不是一回事。
这批魔术方法里,被讲错最多的是一条规则:
__get()和__set()只在访问"不可访问的属性"时触发。
"不可访问"包括两种情况:属性根本不存在,或者属性存在但当前作用域看不到(private/protected而从外部访问)。关键推论是:如果一个属性是public的,那么访问它时__get()永远不会被调用,赋值时__set()也永远不会被调用。很多人写了__get想给所有属性加上日志或懒加载,结果发现部分属性"不生效"——原因就是那几个属性是 public 的。
第二条常被讲错的是可见性:除__construct()、__destruct()、__clone()之外,其他魔术方法必须声明为public,否则 PHP 会发出E_WARNING("The magic method X::__get() must have public visibility")。PHP 8.0 起这条检查的范围还扩大到了__sleep()、__wakeup()、__serialize()、__unserialize()、__set_state()这五个(在此之前它们不产生任何诊断)。
一、属性拦截:__get/__set/__isset/__unset
这四个方法是一组,对应外部对属性的四种操作:
| 魔术方法 | 触发时机 | 对应操作 |
|---|
__get($name) | 读取不可访问属性 | $obj->prop |
__set($name, $value) | 写入不可访问属性 | $obj->prop = $v |
__isset($name) | 对不可访问属性判存在 | isset($obj->prop)、empty($obj->prop) |
__unset($name) | 销毁不可访问属性 | unset($obj->prop) |
<?php
// 适用于 PHP 8.0+
class User
{
/** @var array<string, mixed> */
private array $data = [];
public function __get(string $name): mixed
{
return $this->data[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
$this->data[$name] = $value;
}
public function __isset(string $name): bool
{
return isset($this->data[$name]);
}
public function __unset(string $name): void
{
unset($this->data[$name]);
}
}
$u = new User();
$u->name = 'Ada'; // 属性不存在 → 触发 __set,写进 $data
echo $u->name, PHP_EOL; // Ada,触发 __get
var_dump(isset($u->name)); // true,触发 __isset
unset($u->name); // 触发 __unset
var_dump(isset($u->name)); // false下面这个对照实验能直观说明"public 属性不触发"这条规则:
<?php
// 适用于 PHP 8.0+
class Mixed
{
public string $real = 'public 属性的值';
/** @var array<string, mixed> */
private array $bag = [];
public function __get(string $n): mixed
{
return $this->bag[$n] ?? "[由 __get 生成: {$n}]";
}
public function __set(string $n, mixed $v): void
{
$this->bag[$n] = $v;
}
}
$m = new Mixed();
echo $m->real, PHP_EOL; // public 属性的值 —— __get 没有被调用
$m->real = '改了'; // 直接改属性 —— __set 没有被调用
echo $m->real, PHP_EOL; // 改了
$m->virtual = 'v'; // 不存在 → 触发 __set
echo $m->virtual, PHP_EOL; // v还有一个同样重要的推论:在类的内部访问private/protected属性是"可访问"的,所以不触发魔术方法。也就是说,同一个类的方法里写$this->data,无论data是否存在,都走正常属性查找,不会进__get。这让"在类内部也要走一遍拦截逻辑"的想法无法通过魔术方法实现——那需要显式调用。
想要$obj->items[] = 1也能生效,__get必须返回引用:
<?php
// 适用于 PHP 5.3+
class Bag
{
/** @var array<string, array<int, mixed>> */
private array $data = [];
// 注意方法名前面的 & :返回引用
public function &__get(string $name): mixed
{
if (!isset($this->data[$name])) {
$this->data[$name] = [];
}
return $this->data[$name];
}
}
$b = new Bag();
$b->items[] = 'a'; // 只有 &__get 才能让这种"间接修改"落回内部数组
$b->items[] = 'b';
var_dump($b->items); // ['a', 'b']如果__get不返回引用,$b->items[] = 'a'这一句会收到提示Indirect modification of overloaded property ... has no effect(PHP 8 下是 Warning 级别)。原因是 PHP 拿到的是__get返回的临时值,对它做数组追加不会写回对象,这个修改被直接丢弃。这是非常隐蔽的一类 bug——尤其当错误提示被日志级别过滤掉时,代码看起来"什么都没发生"。
二、方法拦截:__call与__callStatic
| 魔术方法 | 触发时机 | 声明要求 |
|---|
__call($name, $arguments) | 调用不可访问(不存在或不可见)的实例方法 | 必须 public,不能 static |
__callStatic($name, $arguments) | 调用不可访问的静态方法 | 必须public static |
和属性拦截一样,只有"不可访问"的方法才会触发:方法存在且是 public,就直接调用;方法是private/protected而从外部调用,则触发__call(并在其中可以再$this->$name()调用到它)。
$arguments是一个索引数组,键是0, 1, 2...,命名参数的信息不会传进来——如果调用方用了命名参数(PHP 8.0+),在__call里拿到的仍是按位置排列的参数数组。
<?php
// 适用于 PHP 8.0+
class Proxy
{
/** @var array<string, callable> */
private array $handlers = [];
public function register(string $name, callable $fn): void
{
$this->handlers[$name] = $fn;
}
public function __call(string $name, array $arguments): mixed
{
if (!isset($this->handlers[$name])) {
// 抛 BadMethodCallException 比抛 Exception 更贴切
throw new BadMethodCallException("调用了未定义的方法: {$name}");
}
return ($this->handlers[$name])(...$arguments);
}
public static function __callStatic(string $name, array $arguments): mixed
{
throw new BadMethodCallException("调用了未定义的静态方法: {$name}");
}
}
$p = new Proxy();
$p->register('greet', fn(string $who) => "你好,{$who}");
echo $p->greet('Ada'), PHP_EOL; // 你好,Ada__callStatic必须是静态方法。常见的真实用途是"门面(Facade)":把DB::table('users')这样的静态调用转发到某个实例上。
需要注意的一点:__call会掩盖拼写错误。方法名打错时不会得到"未定义方法"的报错,而是进入__call并可能静默返回null。所以在__call里对未知方法抛出异常,是比返回null更负责的做法。
三、字符串、调用与序列化拦截
__toString()在对象被当作字符串使用(echo、字符串拼接、strlen()等)时触发,必须返回string。
关于它最重要的一条版本差异是:
- PHP 7.4 之前,
__toString()里抛异常会导致致命错误("Method X::__toString() must not throw an exception")。原因是引擎无法保证字符串转换过程中抛出的异常能被正确处理。 - PHP 7.4.0 起允许在
__toString()中抛异常。如果你要兼容 7.4 以下的环境,就不能这么做。
PHP 8.0 起,任何定义了__toString()的类都会自动实现Stringable接口,可以用$obj instanceof Stringable来判断"这个对象能不能转字符串"。
<?php
// 适用于 PHP 8.0+
final class Money implements Stringable
{
public function __construct(
private readonly int $amount,
private readonly string $currency
) {
}
public function __toString(): string
{
// 返回类型只能是 string;返回非字符串会抛 Error
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
echo new Money(12345, 'CNY'), PHP_EOL; // 123.45 CNY__invoke(...$args)让对象可以像函数一样被调用:
<?php
// 适用于 PHP 5.3+
class Doubler
{
public function __invoke(int $n): int
{
return $n * 2;
}
}
$d = new Doubler();
echo $d(21), PHP_EOL; // 42
var_dump(is_callable($d)); // true
var_dump(array_map($d, [1, 2, 3])); // [2, 4, 6]实现了__invoke的对象可以直接当回调传给array_map()、usort()等函数,这是把复杂逻辑包装成"可调用对象"的常用手法。
序列化与调试拦截
| 魔术方法 | 触发点 | 出现版本 |
|---|
__sleep() | serialize()之前,返回要序列化的属性名数组 | 古老 |
__wakeup() | unserialize()之后,用于恢复资源连接 | 古老 |
__serialize() | serialize()之前,返回值数组 | PHP 7.4+ |
__unserialize() | unserialize()之后,接收__serialize返回的数组 | PHP 7.4+ |
__set_state() | var_export()生成的代码被eval时 | 古老 |
__debugInfo() | var_dump()一个对象时,决定输出哪些属性 | PHP 5.6+ |
优先级规则:PHP 7.4 起,如果类同时实现了__serialize()/__unserialize()和__sleep()/__wakeup(),前者优先,后者会被忽略。新代码应当只实现前者——它的返回值是一个普通数组,语义比"返回一串属性名"清晰得多。
<?php
// 适用于 PHP 7.4+
final class Session
{
/** @var resource|null 不可序列化的资源句柄 */
private $socket = null;
private string $token = '';
public function __construct(string $token)
{
$this->token = $token;
}
public function __serialize(): array
{
// 只返回需要持久化的部分;资源句柄被排除在外
return ['token' => $this->token];
}
public function __unserialize(array $data): void
{
$this->token = $data['token'] ?? '';
$this->socket = null; // 明确重置不可序列化的状态
}
public function __debugInfo(): array
{
// 控制 var_dump 的输出,避免把敏感字段或资源打印出来
return ['token' => '***', 'connected' => $this->socket !== null];
}
}安全提醒:unserialize()处理不可信数据是危险的,因为反序列化会自动触发__wakeup()/__unserialize(),可能被用来构造非预期的对象状态(这就是所谓的"对象注入")。防御方式是限制可反序列化的类:
<?php
// 适用于 PHP 7.0+
// 只允许还原成已知的安全类;不需要对象时直接禁止所有类
$data = unserialize($input, ['allowed_classes' => false]);
// 或者只放行白名单
$data = unserialize($input, ['allowed_classes' => [CacheEntry::class]]);如果传输的数据本来就不需要携带对象(绝大多数情况),用 JSON 更合适——json_decode()永远不会实例化任意类。
四、可见性与静态规则
把规则集中列一遍,这是最容易出警告的地方:
| 魔术方法 | 可见性要求 | 能否 static |
|---|
__construct/__destruct/__clone | 可以是 public / protected / private | 否 |
__get/__set/__isset/__unset | 必须 public | 否 |
__call | 必须 public | 否 |
__callStatic | 必须 public | 必须 static |
__set_state | 必须 public | 必须 static |
__toString/__invoke/__debugInfo | 必须 public | 否 |
__sleep/__wakeup/__serialize/__unserialize | 必须 public(PHP 8.0 起检查) | 否 |
另外,魔术方法名是保留的:__get、__set这类名字不能被用作普通方法。它们是为特定调用场景预留的钩子,不能按你需要的方式自由调用。
常见坑点
- ❌ 以为
__get()会对所有属性触发,包括 public 属性
✅__get/__set只对不可访问的属性触发——即"不存在"或"private/protected 而从外部访问"。public 属性直接读写的路径上根本没有魔术方法的介入。
- ❌ 在类的方法内部写
$this->data,期望它走一遍__get()
✅ 类内部访问自己的private/protected属性是"可访问"的,不触发魔术方法。要让内部逻辑也走拦截,只能显式调用方法。
- ❌ 用
__set()拦截$obj->items[] = 'x'这种数组追加
✅__set不会为间接修改触发(PHP 会先__get拿到值再写回)。要让这类写法生效,__get必须声明为返回引用:public function &__get($name)。
- ❌ 把
__call()声明成protected或private
✅ 除__construct/__destruct/__clone外,魔术方法必须是 public,否则 PHP 发出E_WARNING。__callStatic和__set_state还必须是static。
- ❌ 在
__toString()里抛异常并期望兼容所有版本
✅ PHP 7.4之前在__toString()中抛异常是致命错误;7.4.0 起才允许。要兼容老版本就只能返回一个错误提示字符串。
- ❌ 用
serialize()/unserialize()处理用户可控的数据
✅ 反序列化会自动触发__wakeup()/__unserialize(),不可信输入下存在对象注入风险。必须传['allowed_classes' => false]或白名单;不需要对象就改用 JSON。
- ❌ 同时实现
__sleep()和__serialize(),以为两个都会执行
✅ PHP 7.4 起__serialize()/__unserialize()优先,__sleep()/__wakeup()会被忽略。新代码只实现前一组。
- ❌ 用魔术方法实现一个"万能属性"却没有任何约束,导致拼写错误静默通过
✅__get/__set/__call会吞掉拼写错误。应当在拦截逻辑里维护一个已知属性/方法白名单,遇到预期外的名字抛BadMethodCallException或InvalidArgumentException,而不是静默返回null。
总结
| 魔术方法 | 触发条件 | 最关键的约束 |
|---|
__get/__set | 读写不可访问的属性 | public 属性不触发;间接修改需要&__get |
__isset/__unset | 对不可访问属性isset()/unset() | 与isset()、empty()、unset()一一对应 |
__call | 调用不可访问的实例方法 | 会掩盖拼写错误,应对未知方法抛异常 |
__callStatic | 调用不可访问的静态方法 | 必须声明为public static |
__toString | 对象被当字符串用 | 只能返回string;7.4 起才允许抛异常;8.0 起自动实现Stringable |
__invoke | 对象被当函数调用 | 使对象可直接作为回调 |
__serialize/__unserialize | 序列化 / 反序列化 | PHP 7.4+;优先于__sleep/__wakeup |
__debugInfo | var_dump()对象 | 可隐藏敏感字段 |
结论:魔术方法的全部难点,都集中在"什么时候不触发"上。记住三条判定线——属性/方法必须是"不可访问"才触发、类内部访问自己的私有成员不算不可访问、public 成员永远走正常路径——绝大多数"为什么我的__get没生效"的困惑就迎刃而解了。至于__serialize这一组,新代码只用 7.4+ 的新接口,并且永远不要拿unserialize()去处理用户可控的数据。