
当相互关联的对象在构造函数中彼此实例化时,容易陷入无限循环。本文探讨了这种循环依赖问题,并提出了一种优雅的解决方案:使用工厂方法结合实例缓存机制。通过将对象创建逻辑封装在静态工厂方法中,并维护一个已实例化对象的缓存,可以确保每个唯一id只对应一个对象实例,从而有效避免重复实例化和无限循环,同时优化资源利用。
理解循环引用问题
在面向对象编程中,当两个或多个类之间存在相互依赖关系时,尤其是在它们的构造函数中直接或间接地实例化对方,就可能导致所谓的“循环引用”或“无限循环”问题。设想有两个模型类 A 和 B,它们之间存在一对多关系:A 包含多个 B,而 B 属于一个 A。
问题的核心在于,如果 A 的构造函数尝试加载其关联的 B 实例,而 B 的构造函数又尝试加载其关联的 A 实例,就会形成一个无限递归的调用链:
- 创建 A 的实例。
- A 的构造函数调用 initB() 方法来加载其关联的 B 实例。
- initB() 方法通过 new B($id) 创建 B 的实例。
- B 的构造函数尝试获取其关联的 A 的ID (a_id),并使用 new A($a_id) 创建 A 的实例。
- 此时,又回到了第1步,导致无限循环。
以下是原始代码中导致问题的关键部分:
Class B 的构造函数:
立即学习“PHP免费学习笔记(深入)”;
public function __construct(int $id = null)
{
parent::__construct($id);
$a_id = $this->get('a_id'); // 获取关联A的ID
if ($a_id) {
$this->a = new A($a_id); // 在B的构造函数中创建A的实例
}
}Class A 的构造函数及 initB() 方法:
public function __construct(int $id = null)
{
parent::__construct($id);
$this->date = new CarbonPL($this->get('date'));
$this->initB(); // 在A的构造函数中初始化B的实例
}
private function initB()
{
if (!$this->isReferenced()) { // 检查实例是否存在于DB
return;
}
$query = B::getIDQuery();
$query .= ' WHERE is_del IS FALSE';
$query .= ' AND a_id = ' . $this->id;
$ids = Helper::queryIds($query);
foreach ($ids as $id) {
$this->B[] = new B($id); // 在A中创建B的实例
}
}这段代码清晰地展示了 A 依赖 B,B 又依赖 A 的循环。
解决方案:工厂方法与实例缓存
为了解决这种无限循环问题,一种高效且优雅的模式是使用“工厂方法结合实例缓存”。这种方法的核心思想是:
- 私有化构造函数: 阻止外部直接通过 new 关键字创建对象实例。
- 提供静态工厂方法: 替代 new 操作,作为统一的创建入口。
- 维护实例缓存: 在工厂方法内部,检查是否已经存在指定ID的对象实例。如果存在,则直接返回缓存中的实例;如果不存在,则创建新实例并将其存入缓存,然后返回。
这样可以确保对于任何给定的ID,系统中只会存在一个对应的对象实例,从而避免了重复创建和循环引用的发生。
实施步骤
1. 修改 Class A
首先,我们修改 A 类,使其构造函数变为私有,并添加一个静态工厂方法 create_for_id。
id = $id; // 假设id是对象的唯一标识
$this->date = new CarbonPL($this->get('date'));
$this->initB();
}
// 静态工厂方法,用于获取A的实例
public static function create_for_id( $id ) {
if ( isset( self::$cache[ $id ] ) ) {
// 如果缓存中已存在该ID的实例,则直接返回
$result = self::$cache[ $id ];
} else {
// 否则,创建新实例并存入缓存
$result = new A( $id );
self::$cache[ $id ] = $result; // 将新创建的实例存入缓存
}
return $result;
}
// 假设的辅助方法,用于从数据库获取数据
private function get(string $field) {
// 实际应用中这里会根据ID从数据库加载数据
// 简化示例,假设从一个模拟数据源获取
$data = [
1 => ['date' => '2023-01-01', 'a_id' => null],
2 => ['date' => '2023-01-02', 'a_id' => null],
];
return $data[$this->id][$field] ?? null;
}
private function isReferenced() {
// 检查实例是否存在于DB的逻辑
return true; // 简化示例
}
// initB() 方法现在将使用B的工厂方法
private function initB()
{
if (!$this->isReferenced()) {
return;
}
// 假设这里是获取关联B的ID的逻辑
// 实际应用中,会根据A的ID查询B的ID
$b_ids = [1, 2]; // 示例数据,假设A的实例ID为1时关联B的ID为1和2
foreach ($b_ids as $b_id) {
// 关键:这里不再使用 new B($id),而是使用 B::create_for_id($id)
$this->B[] = B::create_for_id($b_id);
}
}
}2. 修改 Class B
B 类也需要类似地修改,使其构造函数私有化,并提供一个静态工厂方法 create_for_id。
id = $id;
$a_id = $this->get('a_id'); // 获取关联A的ID
if ($a_id) {
// 关键:这里不再使用 new A($a_id),而是使用 A::create_for_id($a_id)
$this->a = A::create_for_id($a_id);
}
}
// 静态工厂方法,用于获取B的实例
public static function create_for_id( $id ) {
if ( isset( self::$cache[ $id ] ) ) {
$result = self::$cache[ $id ];
} else {
$result = new B( $id );
self::$cache[ $id ] = $result;
}
return $result;
}
// 假设的辅助方法,用于从数据库获取数据
private function get(string $field) {
// 实际应用中这里会根据ID从数据库加载数据
// 简化示例,假设从一个模拟数据源获取
$data = [
1 => ['a_id' => 1], // B的实例ID为1,关联A的实例ID为1
2 => ['a_id' => 1], // B的实例ID为2,关联A的实例ID为1
];
return $data[$this->id][$field] ?? null;
}
}如何使用
现在,无论何时你需要一个 A 或 B 的实例,都应该通过它们的静态工厂方法来获取,而不是直接使用 new 关键字:
// 获取ID为1的A实例 $instanceOfA = A::create_for_id(1); // 获取ID为1的B实例 $instanceOfB = B::create_for_id(1); // 此时,如果$instanceOfA在初始化时需要加载关联的B实例, // 它会调用 B::create_for_id()。 // 如果$instanceOfB在初始化时需要加载关联的A实例, // 它会调用 A::create_for_id()。 // 由于缓存机制,即使在A的构造函数中调用 A::create_for_id(1), // 也不会创建新的A实例,而是返回已存在的$instanceOfA。 // 同理,对于B也一样。
优点与注意事项
优点:
- 消除无限循环: 这是最直接的优点,通过缓存机制,确保每个唯一ID的对象只被实例化一次。
- 资源优化: 避免了重复创建相同的对象,减少了内存消耗和CPU开销。
- 单一实例管理: 对于特定ID的对象,系统中始终只有一个实例,这对于需要维护对象状态或共享数据的场景非常有用。
- 解耦创建逻辑: 将对象的创建逻辑集中在工厂方法中,使得对象的创建过程更加可控和灵活。
注意事项:
- 缓存管理: 静态缓存会一直存在于内存中,直到脚本执行结束。对于长时间运行的应用程序(如守护进程或常驻内存的PHP-FPM),需要考虑缓存的清理策略,以避免内存泄露或数据过时。
- 对象生命周期: 这种模式使得对象的生命周期与缓存绑定。如果需要更细粒度的对象生命周期管理(例如,在特定上下文中创建瞬态对象),可能需要结合其他模式(如依赖注入容器)。
- 测试: 由于构造函数是私有的,直接测试类的构造逻辑会变得困难。通常需要通过工厂方法进行测试,或者使用反射机制。
- 替代方案: 提问者提出的在构造函数中增加一个可选参数(例如 __construct(int $id = null, A $a = null))也是一种解决循环引用的方式。它通过在创建 B 时直接传入已存在的 A 实例来避免 B 再次创建 A。这种方法简单直接,但可能不如工厂模式在对象管理方面灵活和强大,尤其是在对象创建逻辑复杂或需要全局唯一实例的场景。
总结
工厂方法结合实例缓存模式是解决对象循环引用导致构造函数无限循环问题的有效策略。它通过控制对象的创建过程,确保每个唯一ID只对应一个对象实例,从而避免了递归实例化,并带来了资源优化的好处。在设计相互依赖的类时,应优先考虑这种模式,以构建更健壮、高效的应用程序。











