获得“Indirect modification of overloaded property has no effect”错误提示

48

我想使用注册表来存储一些对象。这是一个简单的注册表类实现。

<?php
  final class Registry
  {
    private $_registry;
    private static $_instance;

    private function __construct()
    {
      $this->_registry = array();
    }

    public function __get($key)
    {
      return
        (isset($this->_registry[$key]) == true) ?
        $this->_registry[$key] :
        null;
    }

    public function __set($key, $value)
    {
      $this->_registry[$key] = $value;
    }

    public function __isset($key)
    {
      return isset($this->_registry[$key]);
    }

    public static function getInstance()
    {
      if (self::$_instance == null) self::$_instance = new self();
      return self::$_instance;
    }
}

?>

当我尝试访问这个类时,我收到了“间接修改重载属性没有效果”的通知。

Registry::getInstance()->foo   = array(1, 2, 3);   // Works
Registry::getInstance()->foo[] = 4;                // Does not work

我做错了什么?

3个回答

121
我知道这个话题现在已经很老了,但这是我今天第一次遇到,我觉得如果我能进一步阐述上面所说的并分享我的发现,可能对其他人有所帮助。
据我所知,这并不是PHP的一个bug。事实上,我怀疑PHP解释器必须特别努力地检测和报告这个问题。它与你访问"foo"变量的方式有关。
Registry::getInstance()->foo

当PHP看到您的语句的这部分时,它首先会检查对象实例是否有一个名为"foo"的公共可访问变量。在这种情况下,没有,所以下一步是调用其中一个魔术方法,要么是__set()(如果您尝试替换"foo"的当前值),要么是__get()(如果您尝试访问该值)。
Registry::getInstance()->foo   = array(1, 2, 3);

在这个语句中,你试图用array(1, 2, 3)来替换“foo”的值,所以PHP会调用你的__set()方法,$key = "foo",$value = array(1, 2, 3),一切都正常运行。
Registry::getInstance()->foo[] = 4;

然而,在这个语句中,你正在检索“foo”的值,以便你可以修改它(在这种情况下,将其视为数组并追加一个新元素)。代码暗示你想要修改实例中保存的“foo”的值,但实际上你实际上是在修改由__get()返回的“foo”的临时副本,因此PHP会发出警告(如果你通过引用而不是值将Registry::getInstance()->foo传递给函数,也会出现类似的情况)。

你有几种解决这个问题的选择。

方法1

你可以将“foo”的值写入一个变量中,修改该变量,然后将其写回,即

$var = Registry::getInstance()->foo;
$var[] = 4;
Registry::getInstance()->foo = $var;

功能性的,但是非常冗长,不推荐使用。
方法2
让你的__get()函数通过引用返回,正如cillosis所建议的(由于__set()函数根本不需要返回值,所以没有必要让它通过引用返回)。在这种情况下,你需要注意PHP只能返回已经存在的变量的引用,如果违反了这个约束,可能会发出通知或者表现得奇怪。如果我们看一下cillosis为你的类适配的__get()函数(如果你选择这条路线,出于下面解释的原因,请坚持使用这个__get()的实现,并在从你的注册表中读取之前,坚决进行存在性检查):
function &__get( $index )
{
    if( array_key_exists( $index, $this->_registry ) )
    {
        return $this->_registry[ $index ];
    }
   
    return;
}

只要您的应用程序从注册表中获取的值不存在,那么这是可以的。但是一旦您尝试获取一个尚不存在的值,您将会遇到"return;"语句并收到一个"Only variable references should be returned by reference"的警告。您不能通过创建一个备用变量并返回它来解决这个问题,因为这将再次导致"Indirect modification of overloaded property has no effect"的警告,原因与之前相同。如果您的程序不能有任何警告(警告是一件坏事,因为它们可能会污染您的错误日志并影响您的代码在其他版本/配置的PHP中的可移植性),那么您的__get()方法在返回之前必须创建不存在的条目。
function &__get( $index )
{
    if (!array_key_exists( $index, $this->_registry ))
    {
        // Use whatever default value is appropriate here
        $this->_registry[ $index ] = null;
    }
    
    return $this->_registry[ $index ];
}

顺便说一下,PHP本身似乎与此类似,它对数组的处理方式是:
$var1 = array();
$var2 =& $var1['foo'];
var_dump($var1);

上述代码将(至少在某些版本的PHP上)输出类似于"array(1) { ["foo"]=> &NULL }"的内容,这意味着"$var2 =& $var1['foo'];"语句可能会影响表达式的两侧。然而,我认为允许通过读取操作来改变变量的内容在本质上是不好的,因为它可能导致一些非常严重的错误(因此我认为上述数组行为PHP的一个bug)。
例如,假设您只打算在注册表中存储对象,并且修改了您的__set()函数,如果$value不是对象,则引发异常。存储在注册表中的任何对象还必须符合特殊的"RegistryEntry"接口,该接口声明必须定义"someMethod()"方法。因此,您的注册表类的文档说明指出,调用者可以尝试访问注册表中的任何值,结果要么是检索到有效的"RegistryEntry"对象,要么是null(如果该对象不存在)。还假设您进一步修改注册表以实现Iterator接口,以便人们可以使用foreach结构循环遍历所有注册表条目。现在想象以下代码:
function doSomethingToRegistryEntry($entryName)
{
    $entry = Registry::getInstance()->$entryName;
    if ($entry !== null)
    {
        // Do something
    }
}

...

foreach (Registry::getInstance() as $key => $entry)
{
    $entry->someMethod();
}

这里的理由是,doSomethingToRegistryEntry()函数知道从注册表中读取任意条目是不安全的,因为它们可能存在也可能不存在,所以它会检查"null"情况并相应地处理。一切都很好。相比之下,循环"知道"只有符合"RegistryEntry"接口的对象才能成功写入注册表,所以它不会费心去检查$entry是否确实是这样的对象,以避免不必要的开销。现在假设在尝试读取尚不存在的任何注册表条目之后的某个时刻,出现了非常罕见的情况。糟糕!
在上述场景中,循环将生成一个致命错误"在非对象上调用成员函数someMethod()"(如果警告是坏事,致命错误就是灾难)。发现这实际上是由程序中其他地方添加的一个看似无害的读操作引起的,这个读操作是上个月的更新添加的,这并不容易。
个人而言,我也会避免使用这种方法,因为虽然它在大多数情况下表现良好,但如果被激怒,它可能会给你带来很大的麻烦。幸运的是,有一个更简单的解决方案可供选择。
方法3
只需不定义__get()、__set()或__isset()!然后,PHP会在运行时为您创建属性,并使其公开访问,这样您只需在需要时直接访问它们即可。完全不用担心引用问题,如果您希望您的注册表可迭代,仍然可以通过实现IteratorAggregate接口来实现。根据您在原始问题中提供的示例,我认为这绝对是您最好的选择。
final class Registry implements IteratorAggregate
{
    private static $_instance;

    private function __construct() { }
    
    public static function getInstance()
    {
        if (self::$_instance == null) self::$_instance = new self();
        return self::$_instance;
    }
    
    public function getIterator()
    {
        // The ArrayIterator() class is provided by PHP
        return new ArrayIterator($this);
    }
}

实现__get()和__isset()的时机是当您想要为调用者提供对特定私有/受保护属性的只读访问权限时,此时您不希望通过引用返回任何内容。
希望这对您有所帮助。 :)

1
这是最好的答案。它值得更多的点赞。 - David Lin
感谢您对问题进行了全面的解释!现在我对它的理解更加清晰了。 - Scott
感谢 @indigo866 非常感谢! - Dev Null
第三种方法非常简单易懂,而且推理充分。回答得很好! - Ярослав Рахматуллин

21

这种行为已经被报告了几次作为一个bug:

我不清楚讨论的结果是什么,尽管它似乎与传递“按值”和“按引用”的值有关。 我在一些类似的代码中找到的解决方案是这样的:

function &__get( $index )
{
   if( array_key_exists( $index, self::$_array ) )
   {
      return self::$_array[ $index ];
   }
   return;
}

function &__set( $index, $value )
{
   if( !empty($index) )
   {
      if( is_object( $value ) || is_array( $value) )
      {
         self::$_array[ $index ] =& $value;
      }
      else
      {
         self::$_array[ $index ] =& $value;
      }
   }
}

注意到他们使用 &__get&__set,并且在赋值时使用 & $value。我认为这是使它工作的方法。


我发现了一个情况,即使这样也无法解决问题:使用ifsetor实现可能会在测试存在性时创建虚假的NULL值条目,请参见此被拒绝的PHP.net RFC。感谢PHP核心开发人员没有实现正确的ifsetor运算符,让我的生活更加艰难,让我在工作中加班更多,并使我的代码更容易出错。 - mirabilos
2
在我的情况下,使用&__get代替__get可以避免“间接修改重载属性没有效果”的警告,但是使用&__set会触发另一个错误(“警告:只有变量引用应该通过引用返回”),所以我将其保留为__set - CragMonkey
1
__set never returns anything. You only need to do that for __get - bg17aw

0

在不起作用的示例中

Registry::getInstance()->foo[] = 4;                // Does not work

你首先执行 __get,然后使用返回的值来向数组添加内容。因此,您需要通过引用传递从 __get 返回的结果:

public function &__get($key)
{
  $value = NULL;
  if ($this->__isset($key)) {
    $value = $this->_registry[$key];
  }
  return $value;
}

我们需要使用$value,因为只有变量可以通过引用传递。 我们不需要在__set中添加&符号,因为此函数不应返回任何内容,因此没有任何内容可供引用。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接