From 7a6c78ca5b64de5c2a6e4824efd8e630a2a7ef99 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Sat, 10 Oct 2026 20:47:34 +0100 Subject: [PATCH] Zend: add_function_array() fast paths for an empty operand. Skip the duplicate and merge when either operand is empty, unless sharing would change the internal pointer or next free index. --- Zend/tests/array_addition_empty_operand.phpt | 125 +++++++++++++++++++ Zend/zend_operators.c | 28 +++++ 2 files changed, 153 insertions(+) create mode 100644 Zend/tests/array_addition_empty_operand.phpt diff --git a/Zend/tests/array_addition_empty_operand.phpt b/Zend/tests/array_addition_empty_operand.phpt new file mode 100644 index 000000000000..1e65bd82f8c0 --- /dev/null +++ b/Zend/tests/array_addition_empty_operand.phpt @@ -0,0 +1,125 @@ +--TEST-- +Array addition with an empty operand +--FILE-- + 1, 'y' => 2]; +show('hash', $hash + $empty); +next($hash); +show('hash, moved pointer', $hash + $empty); +next($hash); +show('hash, exhausted pointer', $hash + $empty); +$list = [1, 2, 3]; +unset($list[2]); +show('list, stale next index', $list + $empty); +$emptied = [1]; +unset($emptied[0]); +show('emptied list', $emptied + $empty); + +echo '-- empty left operand', PHP_EOL; +$hash = ['x' => 1, 'y' => 2]; +show('hash', $empty + $hash); +next($hash); +show('hash, moved pointer', $empty + $hash); +$hash = ['x' => 1, 5 => 2]; +show('hash, integer key', $empty + $hash); +unset($hash[5]); +show('hash, removed integer key', $empty + $hash); +$list = [1, 2, 3]; +show('list', $empty + $list); +next($list); +show('list, moved pointer', $empty + $list); +unset($list[2]); +show('list, stale next index', $empty + $list); +$list = [1, 2, 3]; +unset($list[0]); +show('list, leading hole', $empty + $list); +show('emptied list on the left', $emptied + ['x' => 1]); + +echo '-- both empty', PHP_EOL; +$popped = [1]; +array_pop($popped); +show('empty + popped', $empty + $popped); +show('popped + empty', $popped + $empty); +show('empty + emptied', $empty + $emptied); +show('emptied + empty', $emptied + $empty); + +echo '-- compound assignment', PHP_EOL; +$target = []; +$target += $popped; +show('empty += popped', $target); +$target = []; +$target += ['x' => 1, 'y' => 2]; +show('empty += hash', $target); +$target = ['x' => 1]; +$target += $empty; +show('hash += empty', $target); + +echo '-- the operands are left untouched', PHP_EOL; +const DEFAULTS = ['a' => 1, 'b' => 2]; +$result = $empty + DEFAULTS; +$result['c'] = 3; +$other = DEFAULTS + $empty; +$other['c'] = 3; +$target = []; +$target += DEFAULTS; +$target['c'] = 3; +echo json_encode(DEFAULTS), ' ', json_encode($result), ' ', json_encode($other), ' ', json_encode($target), PHP_EOL; + +$source = ['k' => 'v']; +$source['l'] = 'w'; +$copy = $source; +$source += $empty; +$source['m'] = 'x'; +$result = $empty + $copy; +$result['k'] = 'changed'; +echo json_encode($copy), ' ', json_encode($source), ' ', json_encode($result), PHP_EOL; + +$value = 1; +$withReference = ['r' => &$value]; +$result = $empty + $withReference; +$result['r'] = 2; +echo $value, PHP_EOL; + +?> +--EXPECT-- +-- empty right operand +hash: current="x" keys=["x","y",-5,-4] +hash, moved pointer: current="y" keys=["x","y",-5,-4] +hash, exhausted pointer: current="x" keys=["x","y",-5,-4] +list, stale next index: current=0 keys=[0,1,-5,3] +emptied list: current=null keys=[-5,1] +-- empty left operand +hash: current="x" keys=["x","y",-5,-4] +hash, moved pointer: current="x" keys=["x","y",-5,-4] +hash, integer key: current="x" keys=["x",5,-5,6] +hash, removed integer key: current="x" keys=["x",-5,-4] +list: current=0 keys=[0,1,2,-5,3] +list, moved pointer: current=0 keys=[0,1,2,-5,3] +list, stale next index: current=0 keys=[0,1,-5,2] +list, leading hole: current=1 keys=[1,2,-5,3] +emptied list on the left: current="x" keys=["x",-5,1] +-- both empty +empty + popped: current=null keys=[-5,-4] +popped + empty: current=null keys=[-5,0] +empty + emptied: current=null keys=[-5,-4] +emptied + empty: current=null keys=[-5,1] +-- compound assignment +empty += popped: current=null keys=[-5,-4] +empty += hash: current="x" keys=["x","y",-5,-4] +hash += empty: current="x" keys=["x",-5,-4] +-- the operands are left untouched +{"a":1,"b":2} {"a":1,"b":2,"c":3} {"a":1,"b":2,"c":3} {"a":1,"b":2,"c":3} +{"k":"v","l":"w"} {"k":"v","l":"w","m":"x"} {"k":"changed","l":"w"} +2 diff --git a/Zend/zend_operators.c b/Zend/zend_operators.c index 9f1e18b5ca49..874c346feede 100644 --- a/Zend/zend_operators.c +++ b/Zend/zend_operators.c @@ -1198,6 +1198,34 @@ static zend_never_inline void ZEND_FASTCALL add_function_array(zval *result, con /* $a += $a */ return; } + if (zend_hash_num_elements(Z_ARRVAL_P(op2)) == 0 && + Z_ARRVAL_P(op1)->nInternalPointer < Z_ARRVAL_P(op1)->nNumUsed) { + /* $a += [] or $a + [], unless the internal pointer is past the + * end as zend_array_dup() would reset it */ + if (result != op1) { + ZVAL_COPY(result, op1); + } + return; + } + if (zend_hash_num_elements(Z_ARRVAL_P(op1)) == 0 && + zend_hash_num_elements(Z_ARRVAL_P(op2)) != 0) { + /* [] + $a or $a += $b with an empty $a, $b is shared only if a + * merge would give the same array: op1 never had an integer key, + * $b has its pointer on the first element and its next free + * index is not stale */ + const HashTable *src = Z_ARRVAL_P(op2); + zend_long next_free = HT_IS_PACKED(src) ? (zend_long) src->nNumUsed : ZEND_LONG_MIN; + if (Z_ARRVAL_P(op1)->nNextFreeElement == ZEND_LONG_MIN && + src->nInternalPointer == 0 && + src->nNextFreeElement == next_free) { + zend_array *old = result == op1 ? Z_ARR_P(result) : NULL; + ZVAL_COPY(result, op2); + if (old) { + zend_array_release(old); + } + return; + } + } if (result != op1) { ZVAL_ARR(result, zend_array_dup(Z_ARR_P(op1))); } else {