在foreach循环中,使用&符号还是基于键重新分配哪个更好?

20

请考虑以下 PHP 代码:

//Method 1
$array = array(1,2,3,4,5);
foreach($array as $i=>$number){
  $number++;
  $array[$i] = $number;
}
print_r($array);


//Method 2
$array = array(1,2,3,4,5);
foreach($array as &$number){
  $number++;
}
print_r($array);

这两种方法都能完成相同的任务,一种是通过分配引用,另一种是基于键重新分配。我希望在我的工作中使用良好的编程技术,不知道哪种方法更好?还是说这只是那种无论怎样都没关系的事情之一?


4
@RobW,我不同意,这是使用foreach的完全正常用法。这甚至是手册中最早的示例之一(http://php.net/manual/zh/control-structures.foreach.php)。 - Luke Mills
1
使用foreach来递增一个数字?不,这不是foreach的用途。该示例旨在向您展示可以通过引用使用值。可以认为array_maparray_walk等是更好的解决方案;但这实际上取决于上下文... - Rob W
2
@RobW,是的,它确实取决于上下文。在问题的上下文中,问题不是要增加一个数字,而是要增加数组中的每个数字,这是完全有意义的。在这种情况下,array_map或array_walk将具有更多的开销,因为它们使用回调函数。与迭代循环相比,函数调用非常昂贵。但是,在更复杂的上下文中,它们确实有意义,但在这里不是。 - Luke Mills
@Elias Van Ootegem,我在我的回答中提到了gotcha。我意识到我回答晚了,但我觉得问题没有得到解决,所以为了完整性,我加入了我的回答。 - Jason McCreary
1
@JasonMcCreary:别误会我的意思:你说得对,起初我的回答并没有真正关注问题本身,而是关注了Orangepill的回答所说的内容。我只是想知道是否有重要的事情被我忽略了。但自那以后,我已经编辑了我的回答,反思了可读性,并处理了哪种做法是“好的实践”,以及为什么……因此,在这方面,我现在已经“完成”了我的回答。 - Elias Van Ootegem
显示剩余6条评论
8个回答

25
由于最高得分的答案表明第二种方法在各方面都更好,因此我感到有必要在此发布一个答案。的确,通过引用循环更具性能,但并非没有风险和陷阱。
底线始终如一:“哪个更好X或Y”,您可以得到唯一真正的答案是:

  • 这取决于你想要什么/你正在做什么
  • 如果您知道自己在做什么,则两种方法都可以
  • X适用于这样的事情,Y则更适合那样的事情
  • 不要忘记Z,即使这样...(“哪个更好X、Y还是Z”是相同的问题,因此相同的答案适用:这取决于,如果两者都可以...)

尽管如此,就像Orangepill展示的那样,引用方法提供了更好的性能。在这种情况下,权衡是性能与代码之间易于出错、易于阅读/维护。通常认为,更安全、更可靠、更易于维护的代码更好:

'调试比首次编写代码困难两倍。因此,如果您尽可能聪明地编写代码,则从定义上来说,您不够聪明来调试它。' —— Brian Kernighan

我想这意味着第一种方法必须被认为是最佳实践。但这并不意味着第二种方法应该始终避免,因此下面列出的是在foreach循环中使用引用时需要考虑的缺点、陷阱和怪癖:

作用域:
首先,PHP并不像C(++), C#, Java, Perl或(运气好的话)ECMAScript6那样真正具有块作用域...这意味着$value变量在循环结束后不会被取消设置。当通过引用循环时,这意味着对于你正在迭代的任何对象/数组的最后一个值的引用正在浮动。应该想到的一个短语是“等待发生意外”
考虑以下代码中$value和随后的$array会发生什么:

$array = range(1,10);
foreach($array as &$value)
{
    $value++;
}
echo json_encode($array);
$value++;
echo json_encode($array);
$value = 'Some random value';
echo json_encode($array);

这段代码的输出结果将会是:
[2,3,4,5,6,7,8,9,10,11]
[2,3,4,5,6,7,8,9,10,12]
[2,3,4,5,6,7,8,9,10,"Some random value"]

换句话说,通过重复使用$value变量(该变量引用数组中的最后一个元素),实际上是在操作数组本身。这会导致容易出错的代码和难以调试。相反,可以这样做:
$array = range(1,10);
$array[] = 'foobar';
foreach($array as $k => $v)
{
    $array[$k]++;//increments foobar, to foobas!
    if ($array[$k] === ($v +1))//$v + 1 yields 1 if $v === 'foobar'
    {//so 'foobas' === 1 => false
        $array[$k] = $v;//restore initial value: foobar
    }
}

可维护性/傻瓜式操作:
当然,你可能会说悬空引用是容易修复的问题,而且你是正确的:

foreach($array as &$value)
{
    $value++;
}
unset($value);

但是,当你写了100个带有引用的循环后,你真的相信你没有忘记取消一个引用吗?当然不是!这么不常见的情况下,去 unset 在循环中使用过的变量(我们假设垃圾回收会为我们处理),所以大多数时候,你都不会费心。但是当涉及到引用时,这是一种令人沮丧的来源,神秘的错误报告或者 旅行值,在其中你使用复杂的嵌套循环,可能还带有多个引用……恐怖,恐怖。
此外,随着时间的推移,谁能保证下一个工作在你代码上的人不会忘记 unset 呢?谁知道他甚至可能不知道引用,或者看到你众多的 unset 调用,认为它们是多余的,是你过于紧张的标志,并将它们全部删除。仅靠注释是不够的:它们需要被阅读,每个与你的代码一起工作的人都应该接受彻底的指导,也许让他们阅读一篇关于此主题的完整文章。链接文章中列举的示例很糟糕,但我见过更糟糕的:

foreach($nestedArr as &$array)
{
    if (count($array)%2 === 0)
    {
        foreach($array as &$value)
        {//pointless, but you get the idea...
            $value = array($value, 'Part of even-length array');
        }
        //$value now references the last index of $array
    }
    else
    {
        $value = array_pop($array);//assigns new value to var that might be a reference!
        $value = is_numeric($value) ? $value/2 : null;
        array_push($array, $value);//congrats, X-references ==> traveling value!
    }
}

这是一个简单的旅行价值问题示例。顺便说一下,我并没有编造这个代码,我曾经遇到过这样的代码……老实说,除了发现错误和理解代码(参考已经增加了难度),在这个例子中仍然很明显,主要是因为它只有15行长度,即使使用了宽敞的Allman代码风格…… 现在想象一下这个基本结构被用于实际上执行稍微复杂和有意义的代码。祝你调试愉快。

副作用:
通常人们说函数不应该具有副作用,因为副作用被(正确地)认为是代码异味。虽然foreach是语言构造,而不是函数,在您的示例中,应该采用相同的思维方式。当使用过多的引用时,你就会变得过于聪明,可能会发现自己必须在循环中一步一步查找哪个变量引用了哪个变量以及何时引用了它。
第一种方法没有这个问题:你有钥匙,所以你知道自己在数组中的位置。更重要的是,使用第一种方法,你可以对值进行任意数量的操作,而不改变数组中的原始值(没有副作用):

function recursiveFunc($n, $max = 10)
{
    if (--$max)
    {
        return $n === 1 ? 10-$max : recursiveFunc($n%2 ? ($n*3)+1 : $n/2, $max);
    }
    return null;
}
$array = range(10,20);
foreach($array as $k => $v)
{
    $v = recursiveFunc($v);//reassigning $v here
    if ($v !== null)
    {
        $array[$k] = $v;//only now, will the actual array change
    }
}
echo json_encode($array);

这将生成输出:
[7,11,12,13,14,15,5,17,18,19,8]

正如您所看到的,第一个、第七个和第十个元素已被更改,其他元素没有被更改。如果我们使用引用循环重写此代码,则循环看起来要小得多,但输出将不同(我们有副作用):

$array = range(10,20);
foreach($array as &$v)
{
    $v = recursiveFunc($v);//Changes the original array...
    //granted, if your version permits it, you'd probably do:
    $v = recursiveFunc($v) ?: $v;
}
echo json_encode($array);
//[7,null,null,null,null,null,5,null,null,null,8]

为了解决这个问题,我们可以创建一个临时变量、调用函数两次、添加一个键并重新计算$v的初始值,但这只是徒劳无功(增加复杂性却不能修复本该不应出现的问题)。
foreach($array as &$v)
{
    $temp = recursiveFunc($v);//creating copy here, anyway
    $v = $temp ? $temp : $v;//assignment doesn't require the lookup, though
}
//or:
foreach($array as &$v)
{
    $v = recursiveFunc($v) ? recursiveFunc($v) : $v;//2 calls === twice the overhead!
}
//or
$base = reset($array);//get the base value
foreach($array as $k => &$v)
{//silly combine both methods to fix what needn't be a problem to begin with
    $v = recursiveFunc($v);
    if ($v === 0)
    {
        $v = $base + $k;
    }
}

无论如何,添加分支、临时变量等都与初衷背道而驰。首先它会增加额外的开销,这将消耗掉引用带给你的性能优势。
如果你必须在循环中添加逻辑来修复本不需要修复的东西,那么你应该退一步,考虑一下你使用的工具。9/10的情况下,你选择了错误的工具解决问题。
对我而言,最令人信服的第一种方法是简单易读:引用运算符(&)容易被忽视,特别是当你进行一些快速修补或尝试添加功能时。你可能会在原本正常运行的代码中引入bug。更重要的是,因为它本来就很好用,你可能不会仔细测试现有功能,因此忽略操作符而导致生产环境中出现bug听起来可能很傻,但你不会是第一个遇到这个问题的人。
注意:自5.4以来,在调用时通过引用传递已被删除。请小心使用可能会发生变化的功能。数组的标准迭代已经多年没有改变。我想这就是所谓的“成熟技术”。它做到了承诺,并且是更安全的做法。即使它速度较慢,那又怎样?如果速度是一个问题,你可以优化你的代码,并在循环中使用引用。编写新代码时,请选择易于阅读和最安全的选项。优化可以(并且确实应该)等到一切都经过尝试和测试之后再进行。
同时:过早地优化是万恶之源。选择正确的工具,不要只因为它是新的或闪亮的。

所有的关切都是合理的。我会修改我在第一句话中过于粗暴的说法。 - Orangepill
1
有关unset()的重要说明,请注意 + 符号。 - Jason McCreary
1
这是一个使用foreach循环中引用时出现神秘错误报告的好例子:http://schlueters.de/blog/archives/141-References-and-foreach.html。 - Charlie Vieillard
1
@CharlieVieillard:这是一个典型的「移动值」设置的好例子! - Elias Van Ootegem
完全同意。我曾经因为一个愚蠢的遗漏或重复使用变量而浪费了更多的时间。 - Luca Rainone

6
就性能而言,方法2更佳,特别是如果您拥有大型数组和/或正在使用字符串键。虽然这两种方法使用相同的内存量,但第一种方法需要搜索数组,即使这个搜索由索引完成,查找仍有开销。 给定此测试脚本:
$array = range(1, 1000000);

$start = microtime(true);
foreach($array as $k => $v){
    $array[$k] = $v+1;
}
echo "Method 1: ".((microtime(true)-$start));

echo "\n";

$start = microtime(true);
foreach($array as $k => &$v){
    $v+=1;
}
echo "Method 2: ".((microtime(true)-$start));

平均输出量为
Method 1: 0.72429609298706
Method 2: 0.22671484947205

如果我将测试缩小到只运行十次而不是一百万次,我会得到以下结果:
Method 1: 3.504753112793E-5
Method 2: 1.2874603271484E-5

使用字符串键时,性能差异更为明显。所以运行中。

$array = array();
for($x = 0; $x<1000000; $x++){
    $array["num".$x] = $x+1;
}

$start = microtime(true);
foreach($array as $k => $v){
    $array[$k] = $v+1;
}
echo "Method 1: ".((microtime(true)-$start));

echo "\n";

$start = microtime(true);
foreach($array as $k => &$v){
    $v+=1;
}
echo "Method 2: ".((microtime(true)-$start));

产生像...这样的性能
Method 1: 0.90371179580688
Method 2: 0.2799870967865

这是因为通过字符串键进行搜索比通过数组索引有更多的开销。
值得注意的是,如Elias Van Ootegem的答案所建议的那样,在循环完成后应该取消引用。即unset($v);并且应该对性能增益与可读性的损失进行衡量。

请注意,性能不是问题所在。直到你的最后一行才开始回答这个问题。 - Jason McCreary
@JasonMcCreary 假设一个解决方案与其等效解决方案相比,资源利用率(在这种情况下是时间)不是代码质量的度量标准。但我承认我的初始答案不完整,更新答案的功劳归Elias所有。 - Orangepill

3

有一些轻微的性能差异,但它们不会产生任何重大影响。

我会选择第一个选项,原因有两个:

  1. It's more readable. This is a bit of a personal preference, but at first glance, it's not immediately obvious to me that $number++ is updating the array. By explicitly using something like $array[$i]++, it's much clearer, and less likely to cause confusion when you come back to this code in a year.

  2. It doesn't leave you with a dangling reference to the last item in the array. Consider this code:

    $array = array(1,2,3,4,5);
    foreach($array as &$number){
        $number++;
    }
    
    // ... some time later in an unrelated section of code
    $number = intval("100");
    
    // now unexpectedly, $array[4] == 100 instead of 6
    

2

我想这取决于你更注重代码可读性/可维护性还是最小化内存使用。第二种方法会稍微节省一些内存,但我实际上更喜欢第一种用法,因为在PHP中,在foreach定义中通过引用赋值似乎不是常见的做法。

个人而言,如果我想要像这样就地修改一个数组,我会选择第三种选项:

array_walk($array, function(&$value) {
    $value++;
});  

1
这解决了作用域问题,并且不会让你留下一个危险的引用,但我认为每个元素都需要调用函数的开销使其成为一个不太理想的解决方案。我也不确定它是否回答了问题,问题是关于比较两种foreach用法的。 - jcsanyi
你是不是想说“最小化”内存使用? - jcsanyi
Closure类的一个实例?除了array_map之外,那不需要最多的内存吗... - Elias Van Ootegem

0
你考虑过使用 array_map 吗?它的设计初衷是用来修改数组内部的值的。
$array = array(1,2,3,4,5);
$new = array_map(function($number){
  return $number++ ;
}, $array) ;
var_dump($new) ;

3
虽然这是一个可行的解决方案,但我认为最初的意图是原地改变数组,而不是复制到新的数组中。 - Mike Brant

0
第一种方法会稍微慢一些,因为每次循环时都会将一个新值分配给$number变量。第二种方法直接使用变量,因此不需要为每个循环分配新值。
但是,正如我所说,差异并不显著,主要考虑的是可读性。
在我看来,当您不需要在循环中修改值时,第一种方法更有意义,$number变量只会被读取。
当您需要经常修改$number变量时,第二种方法更有意义,因为您不需要每次想要修改它时重复键,并且它更易读。

0

我会选择#2,但这是个人偏好。

我不同意其他答案,使用foreach循环中数组项的引用是相当常见的,但这取决于你使用的框架。像往常一样,在项目或框架中尽量遵循现有的编码约定。

我也不同意其他答案建议使用array_map或array_walk。这会为每个数组元素引入函数调用的开销。对于小型数组,这不会有太大影响,但对于大型数组,这将为这样一个简单的函数增加重大开销。但如果你正在执行更重要的计算或操作,则它们是适当的——你需要根据情况决定使用哪种方法,可能通过基准测试来决定。


1
我同意你关于使用函数的开销的评论 - 但我认为这里应该考虑的不仅仅是个人偏好 - 特别是第二个选项留下的悬空引用是危险的。 - jcsanyi
您的个人偏好是第二种方法。这是没有争议的(个人=>不容辩论)。您说它很常见,这很模糊,并且这并不能支持使用引用的论点。实际问题是:哪种做法更好。面条代码也很常见,但这并不意味着它是良好的实践,对吧?通过基准测试来决定使用什么是我们都会做的事情,但大多数情况下,您首先编写易于阅读的代码,然后再基准测试任何可能的改进。在我看来,这使得第一个循环更为安全... - Elias Van Ootegem

0

大多数答案都将您的问题解释为关于性能的问题。

这不是您所问的。您所问的是:

我想知道哪种方法是更好的编程实践?

正如您所说,两者都可以做同样的事情。两者都有效。最终,更好通常是一种观点。

还是这是那种并不重要的事情之一?

我不会说它不重要。正如您所看到的,Method 1可能存在性能考虑,而Method 2可能存在参考陷阱

我可以说更重要的是可读性和一致性。虽然在PHP中有数十种递增数组元素的方法,但其中一些看起来像行噪声或代码高尔夫。

确保你的代码对未来的开发人员易读,并且你始终应用你解决问题的方法,这比在这个foreach代码中存在的任何微小差异更好地体现了宏观编程实践。


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