Skip to content

ext/standard: Fix array_merge() with an empty array argument - #1

Closed
GromNaN wants to merge 1 commit into
masterfrom
fix/array-merge-empty-array-result
Closed

GromNaN wants to merge 1 commit into
masterfrom
fix/array-merge-empty-array-result

Conversation

@GromNaN

@GromNaN GromNaN commented Oct 17, 2026

Copy link
Copy Markdown
Owner

array_merge() returns the non-empty argument as is when the other argument is empty, instead of building a copy. This shortcut dates back to commit 05f3706.

The shortcut was too broad. The returned array is the argument itself, shared through copy-on-write, so it also kept two pieces of internal state that array_merge() is expected to reset:

  • the next free index. array_merge() renumbers integer keys from zero, so the next append to the result must use the element count. An array whose next free index was stale, for example after unset() of its last element, made $result[] = $value use a wrong key.
  • the internal pointer. array_merge() returns an array whose pointer is on the first element. An argument whose pointer was moved made current($result) return a wrong element.

Both were observable, and they made array_merge([], $a) behave differently from array_merge($a, []):

$a = [5 => 'x'];
unset($a[5]);
$r = array_merge([], $a);
$r[] = 'y';
var_dump($r); // [6 => 'y'] before, [0 => 'y'] after

$a = [10, 20, 30];
next($a);
next($a);
var_dump(current(array_merge([], $a))); // 30 before, 10 after

The shortcut now applies only when the argument already behaves like the result of the general code path:

  • a packed array without holes, whose next free index is the element count and whose internal pointer is on the first element, or
  • an array with string keys only, whose next free index is unset and whose internal pointer is on the first element.

Everything else goes through the general path, which builds a fresh array.

The change only affects arrays with unusual internal state. Clean arrays are still returned as is, and the copy is still avoided:

call on a 2,000,000 element array general path shortcut
array_merge($list, []) 13.1 ms 0.29 µs
array_merge([], $list) 43.7 ms 0.19 µs
array_merge($map, []) 47.5 ms 4.5 ms
array_merge([], $map) 59.8 ms 4.4 ms

The general path column measures what the shortcut saves. The string keyed cases still walk the keys to check that they are strings, which is why they stay in the millisecond range. The shortcut also avoids allocating 33 MB for the list and 84 MB for the string keyed array. The two extra comparisons cost nothing measurable on the paths that take the shortcut.

array_merge_recursive() uses the same code path and gets the same fix.

A regression test is added in ext/standard/tests/array/array_merge_empty_array_result.phpt. It fails on the current master and passes with the fix.

When one of the two arguments of array_merge() is empty, the other one is
returned as is instead of being copied. The returned array is the argument
itself, shared through copy-on-write, so it also kept two pieces of internal
state that array_merge() is expected to reset.

The next free index: array_merge() renumbers integer keys from zero, so the
next append to the result must use the element count. An argument whose next
free index was stale, for example after unset() of its last element, made
$result[] = $value use a wrong key.

The internal pointer: array_merge() returns an array whose pointer is on the
first element. An argument whose pointer had been moved made current($result)
return a wrong element.

Both were observable and made array_merge([], $a) behave differently from
array_merge($a, []). array_merge_recursive() shares the same code path and had
the same two bugs.

The shortcut now applies only when the argument already behaves like the result
of the general code path: a packed array without holes, or an array with string
keys only, whose next free index is the element count (or unset) and whose
internal pointer is on the first element. Everything else goes through the
general path, which builds a fresh array. Clean arrays are still returned as is,
so the copy is still avoided.
@GromNaN
GromNaN force-pushed the fix/array-merge-empty-array-result branch from db7056e to 47ea5d5 Compare October 18, 2026 10:21
@GromNaN

GromNaN commented Oct 18, 2026

Copy link
Copy Markdown
Owner Author

Superseded by php#24404

@GromNaN GromNaN closed this Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant