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

jlb
  • 19,090
  • 8
  • 34
  • 65
  • How does that code even work? $arr doesn't look like it's defined anywhere. – GordonM Nov 24 '11 at 23:06
  • 2
    Whats with the echo current($arr)? You are not using $arr in the foreach loop. foreach($arr as $x) echo current($arr).PHP_EOL; – Cyclonecode Nov 24 '11 at 23:07
  • 1
    Ha -- yes I was trying to optimize my loop for pretty-ness sake, but then took out a critical piece! – jlb Nov 24 '11 at 23:08
  • 2
    `foreach` operates on a copy of the array. I'm not sure why it alters the array pointer at all actually. – Boann Nov 24 '11 at 23:15
  • @Boann, yes it almost seems like something is unoptimised (or maybe hyper optimised with hacks) in the core – jlb Nov 24 '11 at 23:18
  • @Boann could you point us to the documentation, which explains that foreach operates on array copies? I don't think so. – SteAp Nov 24 '11 at 23:20
  • 4
    I'd expected it to produce `1 1 1` as i thought it would operate on a copy. But then i reread http://de.php.net/manual/en/control-structures.foreach.php and [http://nikic.github.com/2011/11/11/PHP-Internals-When-does-foreach-copy.html](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! – edorian Nov 24 '11 at 23:22
  • @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? – Tadeck Nov 24 '11 at 23:26
  • @Tadeck The answer from NikiC seems fine to me – edorian Nov 24 '11 at 23:29
  • 4
    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"` – edorian Nov 24 '11 at 23:38
  • @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. – Tadeck Nov 24 '11 at 23:51
  • @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...](http://stackoverflow.com/search?q=php%20foreach%20array%20pointer) - Not that it needs more explanation anymore. – mario Nov 24 '11 at 23:55
  • Interestingly, although the question focus on `foreach`, it seems the answer relies in `current` function behaviour! – J. Bruni Nov 25 '11 at 00:06

4 Answers4

18

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.

alex
  • 479,566
  • 201
  • 878
  • 984
NikiC
  • 100,734
  • 37
  • 191
  • 225
  • 1
    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. – hakre Nov 24 '11 at 23:29
  • @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. – NikiC Nov 25 '11 at 00:05
  • I think the `refcount` is actually increased to 3 because of $arr inside the `foreach`-head (`foreach ($arr ...`. – Philippe Gerber Nov 25 '11 at 00:14
  • @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_RESET – NikiC Nov 25 '11 at 00:16
  • @NikiC 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. :S – Philippe Gerber Nov 25 '11 at 00:26
  • @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.) – NikiC Nov 25 '11 at 00:28
  • Good to know, thx! Was already wondering whether function parameters have their own symbol (separated from the one used in the function body)... – Philippe Gerber Nov 25 '11 at 00:33
  • 1
    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. – mario Nov 25 '11 at 00:36
  • @NikiC 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? – jlb Nov 25 '11 at 10:06
  • @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 http://stackoverflow.com/q/18158487/632951 – Pacerier Aug 10 '13 at 04:01
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...

J. Bruni
  • 20,322
  • 12
  • 75
  • 92
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.

Danon
  • 2,771
  • 27
  • 37
  • 1
    This doesn't actually answer the question, which was about mechanisms underlying the specific behavior and not about how to get a different answer. – Jared Smith Aug 11 '23 at 12:15
  • 1
    @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. – Danon Aug 11 '23 at 12:33
  • 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. – Jared Smith Aug 11 '23 at 12:39
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)

malletjo
  • 1,766
  • 16
  • 18