Why does PHP's foreach advance the pointer of its array (only) once?
Asked Answered
R

4

21

This is a question of curiosity about the reasons behind the way foreach is implemented within PHP.

Consider:

$arr = array(1,2,3);
foreach ($arr as $x) echo current($arr) . PHP_EOL;

which will output:

2
2
2

I understand that foreach rewinds array pointers to the beginning; however, why does it then increment it only once? What is happening inside the magic box?? Is this just an (ugly) artefact?


Thanks @NickC -- for anyone else curious about zval and refcount, you can read up on the basics here

Redding answered 24/11, 2011 at 23:4 Comment(13)
How does that code even work? $arr doesn't look like it's defined anywhere.Halcyon
Whats with the echo current($arr)? You are not using $arr in the foreach loop. foreach($arr as $x) echo current($arr).PHP_EOL;Worth
Ha -- yes I was trying to optimize my loop for pretty-ness sake, but then took out a critical piece!Redding
foreach operates on a copy of the array. I'm not sure why it alters the array pointer at all actually.Wheelsman
@Boann, yes it almost seems like something is unoptimised (or maybe hyper optimised with hacks) in the coreRedding
@Wheelsman could you point us to the documentation, which explains that foreach operates on array copies? I don't think so.Jeaz
I'd expected it to produce 1 1 1 as i thought it would operate on a copy. But then i reread de.php.net/manual/en/control-structures.foreach.php and http://nikic.github.com/2011/11/11/PHP-Internals-When-does-foreach-copy.html but than the output should have been 1 2 3 or 1 1 1 but not 2 2 2. Very nice question!Emeliaemelin
@edorian: Yes, an interesting question! I tend to think it is a bug, but I may be wrong. Do you have any clues why this is happening?Wisdom
@Wisdom The answer from NikiC seems fine to meEmeliaemelin
This is not a duplicate. The linked question is "I did array stuff inside foreach and everything breaks?!? make it go away" and this is "I want a technical explanation of the inner workings of PHP regarding foreach loop behavior"Emeliaemelin
@edorian: Agreed. It seemed to be a duplicate, but indeed it is a more interesting question, a lot cleaner, aimed at getting clarification instead of getting working solution.Wisdom
@edorian: Actually it's asking for a technical explanation of undefined behaviour. Doesn't make it less interesting though. The suggested link wasn't the best obviously (surprising hurry with closevotes today); we had a better previous discussion about it somewhere... - Not that it needs more explanation anymore.Weir
Interestingly, although the question focus on foreach, it seems the answer relies in current function behaviour!Cornhusk
S
19

Right before the first iteration the $array is "soft copied" for use in foreach. This means that no actual copy is done, but only the refcount of the zval of $array is increased to 2.

On the first iteration:

  1. The value is fetched into $x.
  2. The internal array pointer is moved to the next element, i.e. now points to 2.
  3. current is called with $array passed by reference. Due to the reference PHP cannot share the zval with the loop anymore and it needs to be separated ("hard copied").

On the following iterations the $array zval thus isn't anymore related the the foreach zval anymore. Thus its array pointer isn't modified anymore and current always returns the same element.

By the way, I have written a small summary on foreach copying behavior. It might be of interest in the context, but it does not directly relate to the issue as it talks mostly about hard copying.

Smalt answered 24/11, 2011 at 23:25 Comment(11)
On first iteration: reset + next. That's documented. But tried to fool that by iteration by ref and using reset() inside the loop but was not able to. I guess it's a protection against that.Snack
@MarkTomlin It wouldn't because foreach would then just treat it as an expression instead of a variable. I'm not exactly sure why hakre added it to the answer though.Smalt
I think the refcount is actually increased to 3 because of $arr inside the foreach-head (foreach ($arr ....Numerology
@PhilippeGerber Yes, debug_zval_dump in the loop (before current) gives refcount 4, so it must be 3 after the start of the loop. Do you know where the additional reference comes from? I only found one ADDREF in FE_RESETSmalt
@Smalt I get a refcount of 3 before the current(). I'm don't know the PHP source, but my guess would be: 1. original $arr declaration 2. $arr in the foreach()-head 3. $arr in the foreach()-body. :SNumerology
@PhilippeGerber Just checked and it looks like the refcount decreased from 5.3 to 5.4. 5.3 had a refcount(3) and 5.4 has refcount(2). (You always need to -1 the debug_zval_dump refcount due to the function call.)Smalt
Good to know, thx! Was already wondering whether function parameters have their own symbol (separated from the one used in the function body)...Numerology
I think you can narrow the quest for that unexpected/undefined behaviour down to changes between PHP 5.2.2 and 5.2.4 - Might have something to do with all the references about Fixed bug #41372 (Internal pointer of source array resets during array copying). - Before that all versions returned 1 1 1 for OPs example.Weir
@Smalt Thanks. One followup: why would the internal array pointer get moved (step 2 in your answer)? Is this a by-product of fetching the value into $x?Redding
@NikiC, in the first paragraph, you said the refcount is increased to 2. But my tests show a refcount of 3. Why is it so? See https://mcmap.net/q/376061/-why-does-foreach-increase-refcount-by-2-instead-of-1/632951Grinder
I am now trying to "shift" an element off of an Iterator by advancing it once in a foreach with break inside. The pointer isn't advancing, as Iterator::key() returns 0 before and after the loop. Now, when I try to accept the $item byref, I get "An iterator cannot be used with foreach by reference". So, how can I ensure that the iterator actually gets advanced once and its item is retrieved?Consignment
C
3

See how interesting, if we change the code just a little bit:

$arr = array(1,2,3);
foreach ($arr as &$x) echo current($arr) . PHP_EOL;

We got this output:

2
3

Some interesting references:

http://nikic.github.com/2011/11/11/PHP-Internals-When-does-foreach-copy.html

http://blog.golemon.com/2007/01/youre-being-lied-to.html

Now, try this:

$arr = array(1,2,3);
foreach ($arr as $x) { $arr2 = $arr; echo current($arr2) . PHP_EOL; }

Output:

2
3
1

This is very curious indeed.

And what about this:

$arr = array(1,2,3);
foreach ($arr as $x) { $arr2 = $arr; echo current($arr) . ' / ' . current($arr2) . PHP_EOL; }
echo PHP_EOL;
foreach ($arr as $x) { $arr2 = $arr; echo current($arr2) . ' / ' . current($arr2) . PHP_EOL; }

Output:

2 / 2
2 / 2
2 / 2

2 / 2
3 / 3
1 / 1

It seems what happens is just as written in NickC answer, plus the fact that when passing an array as an argument to current function, as it is passed by reference, something inside there does modify the array passed as argument to it...

Cornhusk answered 24/11, 2011 at 23:29 Comment(0)
M
1

This is the results of your code opcode analysis with php 5.3.

See this example : http://php.net/manual/en/internals2.opcodes.fe-reset.php

number of ops: 15 compiled vars: !0 = $arr, !1 = $x

line     # *  op                           fetch          ext  return  operands
---------------------------------------------------------------------------------
   2     0  >   INIT_ARRAY                                       ~0      1
   1      ADD_ARRAY_ELEMENT                                ~0      2
   2      ADD_ARRAY_ELEMENT                                ~0      3
   3      ASSIGN                                                   !0, ~0
   3     4    > FE_RESET                                   $2      !0, ->13
   5  > > FE_FETCH                                         $3      $2, ->13
   6  >   ZEND_OP_DATA                                             
   7      ASSIGN                                                   !1, $3
   8      SEND_REF                                                 !0
   9      DO_FCALL                                      1          'current'
  10      CONCAT                                           ~6      $5, '%0A'
  11      ECHO                                                     ~6
  12    > JMP                                                      ->5
  13  >   SWITCH_FREE                                              $2
  14    > RETURN                                                   1

See NikiC's answer for details, but you see at line #8 that !0 never change in the loop.(5-12)

Mascara answered 25/11, 2011 at 0:36 Comment(0)
P
1

It doesn't answer the question, but you can use a workaround using \ArrayIterator

$arr = new ArrayIterator(array(1,2,3));
foreach ($arr as $x) echo $arr->current() . PHP_EOL;
1
2
3

You can even use Iterator.next() to advance the iteration.

Psychokinesis answered 11/8, 2023 at 12:9 Comment(3)
This doesn't actually answer the question, which was about mechanisms underlying the specific behavior and not about how to get a different answer.Klee
@JaredSmith Granted. But people might come here looking for ways to use \current() or \next() while iterating - I know, because I did. For the sake of help for those users, I posted the answer.Psychokinesis
That's totally fair, and I didn't downvote your answer or anything (although it looks like someone else did) because I agree it adds value, but you may want to point out your motivation for posting it even though it doesn't strictly speaking answer the question.Klee

© 2022 - 2024 — McMap. All rights reserved.