返回

PHP 如何让同一个地方创建的匿名类不同?(含代码)

php

PHP 里怎么让同一个地方创建的匿名类不一样?

写 PHP 时,你可能碰到过这么个事儿:用同一个方法创建的匿名类,它们居然是同一个“类”(class)。

看下面这段代码:

<?php

// 定义一个函数,返回一个匿名类的实例
function test($test) {
    return new class($test) {
        public function __construct(public $test) {}
    };
}

// 定义另一个结构几乎一样的函数
function test2($test) {
    return new class($test) {
        public function __construct(public $test) {}
    };
}

// 比较函数
function compare($a, $b) {
    var_dump([
        '$a == $b' => $a == $b,          // 比较值 (松散比较)
        '$a === $b' => $a === $b,         // 比较引用和类型 (严格比较)
        'get_class($a) == get_class($b)' => get_class($a) == get_class($b), // 比较类名
    ]);
    echo "Class A: " . get_class($a) . "\n";
    echo "Class B: " . get_class($b) . "\n\n";
}

echo "比较 test(1) 和 test(1):\n";
compare(test(1), test(1));
/* 输出类似:
array(3) {
  '$a == $b' => bool(true) // 值相同,松散比较为 true
  '$a === $b' => bool(false) // 不同实例,严格比较为 false
  'get_class($a) == get_class($b)' => bool(true) // 类名相同!
}
Class A: class@anonymous/path/to/your/script.php:6$a6b...
Class B: class@anonymous/path/to/your/script.php:6$a6b...
*/

echo "比较 test(['abc']) 和 test(1):\n";
compare(test(['abc']), test(1));
/* 输出类似:
array(3) {
  '$a == $b' => bool(false) // 值不同
  '$a === $b' => bool(false) // 不同实例
  'get_class($a) == get_class($b)' => bool(true) // 类名还是相同!
}
Class A: class@anonymous/path/to/your/script.php:6$a6b...
Class B: class@anonymous/path/to/your/script.php:6$a6b...
*/

echo "比较 test(1) 和 test2('xx'):\n";
compare(test(1), test2('xx'));
/* 输出类似:
array(3) {
  '$a == $b' => bool(false)
  '$a === $b' => bool(false)
  'get_class($a) == get_class($b)' => bool(false) // 类名不同了!
}
Class A: class@anonymous/path/to/your/script.php:6$a6b...
Class B: class@anonymous/path/to/your/script.php:12$cde... // 注意行号和后面的哈希可能不同
*/

echo "比较 test('xx') 和 test2('xx'):\n";
compare(test('xx'), test2('xx'));
/* 输出类似:
array(3) {
  '$a == $b' => bool(true) // 注意:这里值可能相同
  '$a === $b' => bool(false)
  'get_class($a) == get_class($b)' => bool(false) // 类名还是不同
}
Class A: class@anonymous/path/to/your/script.php:6$a6b...
Class B: class@anonymous/path/to/your/script.php:12$cde...
*/

从结果能看出来:

  • test(1)test(1) 创建了两个不同的对象实例(===false),但它们属于同一个匿名类 (get_class() 返回值相同)。对象的值可能相同 (==true)。
  • test(1)test2('xx') 创建的对象不仅实例不同,连类也不同 (get_class() 返回值不同)。这是因为匿名类的定义在不同的函数 (testtest2) 里。

这就引出了问题:如果我需要用 同一段代码逻辑(比如同一个函数)生成很多实例,但又希望 PHP 把它们看作是 不同的类,该怎么办?总不能像上面那样,手动复制粘贴搞出 test3(), test4(), ..., test50() 吧?这也太蠢了。

就像提问者遇到的场景:在写测试时,需要给一个方法传入实现了某个接口的对象。为了隔离测试,他用了匿名类来实现这个接口,接口方法返回一个动态生成的值。但测试框架或被测代码内部依赖 ::class 来区分不同的输入类型。结果创建了 50 个对象,虽然返回值都不同,但因为它们来自同一个 test() 函数,所以类名都一样,导致测试达不到预期效果。

为啥会这样?匿名类的“身份”是怎么定的?

简单说,PHP 解释器在碰到 new class(...) { ... } 这种语法时,会偷偷给这个匿名类生成一个内部名字。这个名字通常和它在哪个文件、哪一行被定义的有关系。你可以从 get_class() 的输出看到类似 class@anonymous/path/to/your/script.php:6$a6b... 的格式。

关键在于,PHP 认为在 同一个地方(同一个文件、同一个函数或方法内部、同一行代码)定义的匿名类,就是 同一个类定义。所以,你每次调用 test() 函数,虽然都 new 了一个新对象,但这个 new 操作是基于 同一个 匿名类的定义的。PHP 不会因为你调用了多次函数,就为 test() 函数内部第 6 行的那个 new class 生成不同的类定义。

test()test2() 里的 new class,虽然长得一模一样,但它们位于 不同的函数(不同的代码上下文)里。所以 PHP 会给它们生成不同的内部类名,自然就被当作不同的类了。

怎么破?让同一个“模子”刻出不同的“类”

既然问题的根源在于 PHP 依据匿名类定义的“位置”来确定其身份,那我们想办法让每次创建时,这个“位置”看起来不一样不就行了?

下面介绍两种主要方法。

方法一:试试 eval(),简单粗暴但有效

eval() 函数能把一个字符串当作 PHP 代码来执行。我们可以利用它来动态生成包含匿名类定义的代码字符串,然后执行。

原理:

每次调用 eval() 执行含有 new class(...) 的代码时,PHP 可能会认为这是一个新的、动态的代码执行上下文。在这个上下文里定义的匿名类,即使结构和之前 eval() 调用里的完全一样,PHP 也可能给它分配一个新的内部类名。这就达到了我们想要的效果。

代码示例:

<?php

interface MyInterface {
    public function getValue(): string;
}

// 使用 eval 创建实现了 MyInterface 的匿名类实例
function create_unique_anonymous_class_via_eval(string $value): MyInterface {
    // 构造包含匿名类定义的 PHP 代码字符串
    // 注意:这里简单拼接字符串。如果 $value 来自不可信来源,会有安全风险!
    // 这里 $value 仅用于构造函数,相对安全些,但仍需谨慎。
    $classDefinition = '
        return new class("' . addslashes($value) . '") implements MyInterface {
            private string $internalValue;

            public function __construct(string $val) {
                $this->internalValue = $val;
            }

            public function getValue(): string {
                return $this->internalValue;
            }
        }; // 注意最后的分号
    ';

    // 执行字符串代码,eval 会返回 return 语句的值
    // 使用 @ 抑制可能的解析错误或其他警告,生产环境应有更健壮的错误处理
    $instance = @eval($classDefinition);

    if ($instance === false || !($instance instanceof MyInterface)) {
        // eval 失败或返回的不是期望的类型
        // 在实际应用中应该抛出异常或记录错误
        throw new \RuntimeException("Failed to create anonymous class via eval for value: " . $value);
    }

    return $instance;
}

// 测试一下
$obj1 = create_unique_anonymous_class_via_eval("hello");
$obj2 = create_unique_anonymous_class_via_eval("world");
$obj3 = create_unique_anonymous_class_via_eval("world"); // 再创建一个值相同的

echo "比较 obj1 和 obj2:\n";
var_dump([
    '$obj1 == $obj2' => $obj1 == $obj2,
    '$obj1 === $obj2' => $obj1 === $obj2,
    'get_class($obj1) == get_class($obj2)' => get_class($obj1) == get_class($obj2),
]);
echo "Class Obj1: " . get_class($obj1) . "\n";
echo "Class Obj2: " . get_class($obj2) . "\n\n";
/* 输出类似:
array(3) {
  '$obj1 == $obj2' => bool(false)
  '$obj1 === $obj2' => bool(false)
  'get_class($obj1) == get_class($obj2)' => bool(false) // 类名不同了!
}
Class Obj1: class@anonymous/.../script.php(32) : eval()'d code:2$XXX...
Class Obj2: class@anonymous/.../script.php(32) : eval()'d code:2$YYY...
*/

echo "比较 obj2 和 obj3:\n";
var_dump([
    '$obj2 == $obj3' => $obj2 == $obj3, // 值相同,松散比较可能为 true
    '$obj2 === $obj3' => $obj2 === $obj3, // 不同实例
    'get_class($obj2) == get_class($obj3)' => get_class($obj2) == get_class($obj3), // 类名还是不同!
]);
echo "Class Obj2: " . get_class($obj2) . "\n";
echo "Class Obj3: " . get_class($obj3) . "\n\n";
/* 输出类似:
array(3) {
  '$obj2 == $obj3' => bool(true)
  '$obj2 === $obj3' => bool(false)
  'get_class($obj2) == get_class($obj3)' => bool(false) // 类名依旧不同!
}
Class Obj2: class@anonymous/.../script.php(32) : eval()'d code:2$YYY...
Class Obj3: class@anonymous/.../script.php(32) : eval()'d code:2$ZZZ...
*/

echo $obj1->getValue() . "\n"; // 输出 "hello"
echo $obj2->getValue() . "\n"; // 输出 "world"

注意事项和安全建议:

  • eval() 是魔鬼! 这是最重要的提醒。eval() 执行任意字符串代码,如果这个字符串的内容受到外部输入(比如用户请求、数据库内容)的影响,而且没有经过极其严格的过滤和转义,就可能导致 严重的安全漏洞 (代码注入)。攻击者可能执行任意 PHP 代码。
  • 仅在绝对信任代码来源时使用。 在上面例子里,我们构造的 $classDefinition 字符串只包含我们自己写的、固定的类结构,以及一个经过 addslashes 处理的 $value。相对可控,但仍需警惕。如果类结构本身需要根据外部输入动态生成,风险会指数级增加。
  • 性能开销。 每次调用 eval(),PHP 都需要解析、编译、执行这段代码,比直接调用函数或方法开销大。如果需要大量生成,性能影响可能比较明显。
  • 调试困难。 eval() 内部如果出错,错误信息可能指向 eval() 调用的那一行,而不是 eval 代码内部真正出错的位置,给调试带来麻烦。上面示例用了 @ 抑制符,实际应用中最好不用,或者配合 try...catch 和错误处理机制。

进阶使用:

你可以让 create_unique_anonymous_class_via_eval 函数接受更多参数,动态构建更复杂的匿名类结构字符串。比如,根据参数决定实现哪些接口、包含哪些方法等。但这会进一步增加构造代码字符串的复杂度和潜在风险。

方法二:生成临时 PHP 文件再 include

如果你对 eval() 的安全性非常担心(这是应该的),可以考虑另一种稍微麻烦但更安全的方式:动态生成一个包含匿名类定义的临时 PHP 文件,然后 include 这个文件。

原理:

includerequire 一个 PHP 文件时,PHP 会执行这个文件里的代码。每个临时文件都相当于一个独立的“代码位置”。我们在每个临时文件里定义那个匿名类结构,并让文件执行后返回 new 出来的实例。这样,每次 include 一个新生成的临时文件,PHP 都会认为是从一个新位置加载类定义,从而得到不同的匿名类。

代码示例:

<?php

interface MyInterface {
    public function getValue(): string;
}

// 使用临时文件创建实现了 MyInterface 的匿名类实例
function create_unique_anonymous_class_via_file(string $value): MyInterface {
    // 定义匿名类代码模板
    // 使用 var_export 确保 $value 被安全地嵌入到 PHP 代码字符串中
    $classCode = '<?php 
        // 文件被 include 时执行这里
        
        interface MyInterface { // 重新定义接口以确保文件独立性,或者确保已加载
             public function getValue(): string;
        }

        return new class(' . var_export($value, true) . ') implements MyInterface {
            private string $internalValue;

            public function __construct(string $val) {
                $this->internalValue = $val;
            }

            public function getValue(): string {
                return $this->internalValue;
            }
        };';

    // 创建一个唯一的临时文件名
    $tempFilePath = tempnam(sys_get_temp_dir(), 'anon_class_');
    if ($tempFilePath === false) {
        throw new \RuntimeException("无法创建临时文件");
    }

    // 将代码写入临时文件
    if (file_put_contents($tempFilePath, $classCode) === false) {
        // 尝试删除已创建但写入失败的文件
        unlink($tempFilePath); 
        throw new \RuntimeException("无法写入临时文件: " . $tempFilePath);
    }

    // 使用 try...finally 确保临时文件总能被删除
    try {
        // include 临时文件,它会返回匿名类的实例
        $instance = include $tempFilePath;

        if (!($instance instanceof MyInterface)) {
             throw new \RuntimeException("临时文件未返回预期的 MyInterface 实例");
        }

        return $instance;

    } finally {
        // 无论 include 是否成功或抛出异常,都尝试删除临时文件
        if (file_exists($tempFilePath)) {
            unlink($tempFilePath);
        }
    }
}

// 测试一下
$obj4 = create_unique_anonymous_class_via_file("apple");
$obj5 = create_unique_anonymous_class_via_file("banana");
$obj6 = create_unique_anonymous_class_via_file("banana"); // 再创建一个值相同的

echo "比较 obj4 和 obj5:\n";
var_dump([
    '$obj4 == $obj5' => $obj4 == $obj5,
    '$obj4 === $obj5' => $obj4 === $obj5,
    'get_class($obj4) == get_class($obj5)' => get_class($obj4) == get_class($obj5),
]);
echo "Class Obj4: " . get_class($obj4) . "\n";
echo "Class Obj5: " . get_class($obj5) . "\n\n";
/* 输出类似:
array(3) {
  '$obj4 == $obj5' => bool(false)
  '$obj4 === $obj5' => bool(false)
  'get_class($obj4) == get_class($obj5)' => bool(false) // 类名不同!
}
// 类名会包含临时文件的路径,每次都不同
Class Obj4: class@anonymous/.../tmp/anon_class_XXXXXX:8$... 
Class Obj5: class@anonymous/.../tmp/anon_class_YYYYYY:8$...
*/

echo "比较 obj5 和 obj6:\n";
var_dump([
    '$obj5 == $obj6' => $obj5 == $obj6, // 值相同,松散比较可能为 true
    '$obj5 === $obj6' => $obj5 === $obj6, // 不同实例
    'get_class($obj5) == get_class($obj6)' => get_class($obj5) == get_class($obj6), // 类名还是不同!
]);
echo "Class Obj5: " . get_class($obj5) . "\n";
echo "Class Obj6: " . get_class($obj6) . "\n\n";
/* 输出类似:
array(3) {
  '$obj5 == $obj6' => bool(true)
  '$obj5 === $obj6' => bool(false)
  'get_class($obj5) == get_class($obj6)' => bool(false) // 类名依旧不同!
}
Class Obj5: class@anonymous/.../tmp/anon_class_YYYYYY:8$...
Class Obj6: class@anonymous/.../tmp/anon_class_ZZZZZZ:8$...
*/

echo $obj4->getValue() . "\n"; // 输出 "apple"
echo $obj5->getValue() . "\n"; // 输出 "banana"

注意事项:

  • 文件系统操作开销: 相比 eval(), 这种方法涉及磁盘 I/O(创建、写入、读取、删除文件),在高频率调用下,性能开销可能会更大。
  • 临时文件管理: 需要确保临时文件在使用后被妥善删除,即使发生错误。上面的 try...finally 结构是推荐做法。也可以使用 register_shutdown_function() 来注册一个清理函数,作为最后的保障。
  • 临时目录权限: PHP 进程需要有在系统临时目录(sys_get_temp_dir() 返回的路径)创建和删除文件的权限。
  • 依赖接口/类定义: 如果匿名类需要实现某个接口或继承某个基类,这个接口/基类的定义必须在 include 临时文件时是可用的。一种方法是在临时文件代码里也包含接口/类的定义(如示例中对 MyInterface 的处理,但这可能引入重复定义问题,如果接口已在别处加载),或者确保在调用 include 之前,所需的定义已经通过 require_once 或自动加载机制加载好了。
  • Opcache 影响? 在某些 PHP Opcache 配置下,对 include 的文件可能会有缓存。由于我们每次都用 tempnam 生成新文件,理论上不会命中缓存。但如果复用文件名(不推荐),需要注意缓存可能导致的问题。

进阶使用:

可以将类定义的代码模板做得更复杂,通过占位符替换等方式,根据传入 create_unique_anonymous_class_via_file 的参数动态生成更定制化的类结构,然后写入临时文件。使用 var_export($value, true) 来生成嵌入到代码中的数据,通常比手动字符串拼接和转义更安全可靠。

总结一下

当需要让 PHP 在同一个代码点(比如同一个函数内部)创建的匿名类实例被识别为 不同的类 时,核心思路是打破 PHP 基于“定义位置”判断类身份的常规机制。

  1. eval() :通过在运行时执行字符串代码创建匿名类。简单直接,但有 严重的安全风险 ,性能开销中等,调试相对困难。只在完全信任输入或代码生成逻辑非常简单可控时考虑。
  2. 临时文件 + include :将匿名类定义写入临时文件再包含进来。避免了 eval 的安全问题,但引入了文件 I/O 开销,需要处理好临时文件的创建和清理。相对更安全,但实现稍复杂。

选择哪种方法取决于你的具体场景、对安全性的要求、性能敏感度以及愿意付出的实现复杂度。对于测试场景,如果能保证输入是可控的内部数据,eval 可能是个便捷的选择。如果环境要求更严格或涉及外部输入,用临时文件会是更稳妥的做法。