☰
php类中的各种拦截器用法分析
2026/10/10 3:36:48 网站建设 项目流程

前言


中文资料里常把 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这类名字不能被用作普通方法。它们是为特定调用场景预留的钩子,不能按你需要的方式自由调用。


常见坑点



  1. ❌ 以为__get()会对所有属性触发,包括 public 属性


✅__get/__set只对不可访问的属性触发——即"不存在"或"private/protected 而从外部访问"。public 属性直接读写的路径上根本没有魔术方法的介入。



  1. ❌ 在类的方法内部写$this->data,期望它走一遍__get()


✅ 类内部访问自己的private/protected属性是"可访问"的,不触发魔术方法。要让内部逻辑也走拦截,只能显式调用方法。



  1. ❌ 用__set()拦截$obj->items[] = 'x'这种数组追加


✅__set不会为间接修改触发(PHP 会先__get拿到值再写回)。要让这类写法生效,__get必须声明为返回引用:public function &__get($name)。



  1. ❌ 把__call()声明成protected或private


✅ 除__construct/__destruct/__clone外,魔术方法必须是 public,否则 PHP 发出E_WARNING。__callStatic和__set_state还必须是static。



  1. ❌ 在__toString()里抛异常并期望兼容所有版本


✅ PHP 7.4之前在__toString()中抛异常是致命错误;7.4.0 起才允许。要兼容老版本就只能返回一个错误提示字符串。



  1. ❌ 用serialize()/unserialize()处理用户可控的数据


✅ 反序列化会自动触发__wakeup()/__unserialize(),不可信输入下存在对象注入风险。必须传['allowed_classes' => false]或白名单;不需要对象就改用 JSON。



  1. ❌ 同时实现__sleep()和__serialize(),以为两个都会执行


✅ PHP 7.4 起__serialize()/__unserialize()优先,__sleep()/__wakeup()会被忽略。新代码只实现前一组。



  1. ❌ 用魔术方法实现一个"万能属性"却没有任何约束,导致拼写错误静默通过


✅__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

__debugInfovar_dump()对象可隐藏敏感字段



结论:魔术方法的全部难点,都集中在"什么时候不触发"上。记住三条判定线——属性/方法必须是"不可访问"才触发、类内部访问自己的私有成员不算不可访问、public 成员永远走正常路径——绝大多数"为什么我的__get没生效"的困惑就迎刃而解了。至于__serialize这一组,新代码只用 7.4+ 的新接口,并且永远不要拿unserialize()去处理用户可控的数据。




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

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

立即咨询