[源码分析系列] 不要在循环体中使用 array_merge ()

原文链接

标题是不要在循环体中使用 array_merge(),其实这只是本篇文章的结论之一

下面我们一起研究一下 php 语言中数组的合并(这里先不考虑递归合并)

四种合并数组的方式对比

四种常见的合并数组的方式对比

写代码

我们知道 array_merge() 和 运算符 + 都可以拼接数组

创建一个类

ArrayMerge()

  • eachOne() 循环体使用 array_merge() 合并
  • eachTwo() 循环体结束后使用 array_merge() 合并
  • eachThree() 循环体嵌套实现数组合并
  • eachFour() 循环体使用 运算符 + 拼接合并
  • getNiceFileSize() 将内存占用转化成人类可读的方式
/**
 * Class ArrayMerge
 */
class ArrayMerge
{

    /**
     * @param int $times
     * @return array
     */
    public static function eachOne(int $times): array
    {
        $a = [];
        $b = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
        for ($i = 0; $i < $times; $i++) {
            $a = array_merge($a, $b);
        }
        return $a;
    }

    /**
     * @param int $times
     * @return array
     */
    public static function eachTwo(int $times): array
    {
        $a = [[]];
        $b = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
        for ($i = 0; $i < $times; $i++) {
            $a[] = $b;
        }
        return array_merge(...$a);
    }

    /**
     * @param int $times
     * @return array
     */
    public static function eachThree(int $times): array
    {
        $a = [];
        $b = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
        for ($i = 0; $i < $times; $i++) {
            foreach ($b as $item) {
                $a[] = $item;
            }
        }
        return $a;
    }

    /**
     * @param int $times
     * @return array
     */
    public static function eachFour(int $times): array
    {
        $a = [];
        $b = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
        for ($i = 0; $i < $times; $i++) {
            $a = $b + $a;
        }
        return $a;
    }

    /**
     * 转化内存信息
     * @param      $bytes
     * @param bool $binaryPrefix
     * @return string
     */
    public static function getNiceFileSize(int $bytes, $binaryPrefix = true): ?string
    {
        if ($binaryPrefix) {
            $unit = array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB');
            if ($bytes === 0) {
                return '0 ' . $unit[0];
            }
            return @round($bytes / (1024 ** ($i = floor(log($bytes, 1024)))),
                    2) . ' ' . ($unit[(int)$i] ?? 'B');
        }

        $unit = array('B', 'KB', 'MB', 'GB', 'TB', 'PB');
        if ($bytes === 0) {
            return '0 ' . $unit[0];
        }
        return @round($bytes / (1000 ** ($i = floor(log($bytes, 1000)))),
                2) . ' ' . ($unit[(int)$i] ?? 'B');
    }
}

使用

先分配多点内存

输出内存占用,合并后的数组长度,并记录每一步的用时

ini_set('memory_limit', '4000M');
$timeOne = microtime(true);
$a       = ArrayMerge::eachOne(10000);
echo 'count eachOne Result | ' . count($a) . PHP_EOL;
echo 'memory eachOne Result | ' . ArrayMerge::getNiceFileSize(memory_get_usage(true)) . PHP_EOL;
$timeTwo = microtime(true);
$b       = ArrayMerge::eachTwo(10000);
echo 'count eachTwo Result | ' . count($b) . PHP_EOL;
echo 'memory eachTwo Result | ' . ArrayMerge::getNiceFileSize(memory_get_usage(true)) . PHP_EOL;
$timeThree = microtime(true);
$c         = ArrayMerge::eachThree(10000);
echo 'count eachThree Result | ' . count($c) . PHP_EOL;
echo 'memory eachThree Result | ' . ArrayMerge::getNiceFileSize(memory_get_usage(true)) . PHP_EOL;
$timeFour = microtime(true);
$d        = ArrayMerge::eachFour(10000);
echo 'count eachFour Result | ' . count($d) . PHP_EOL;
echo 'memory eachFour Result | ' . ArrayMerge::getNiceFileSize(memory_get_usage(true)) . PHP_EOL;
$timeFive = microtime(true);
echo PHP_EOL;
echo 'eachOne | ' . ($timeTwo - $timeOne) . PHP_EOL;
echo 'eachTwo | ' . ($timeThree - $timeTwo) . PHP_EOL;
echo 'eachThree | ' . ($timeFour - $timeThree) . PHP_EOL;
echo 'eachFour | ' . ($timeFive - $timeFour) . PHP_EOL;
echo PHP_EOL;

结果

count eachOne Result | 100000
memory eachOne Result | 9 MiB
count eachTwo Result | 100000
memory eachTwo Result | 14 MiB
count eachThree Result | 100000
memory eachThree Result | 18 MiB
count eachFour Result | 10           #注意这里
memory eachFour Result | 18 MiB

eachOne | 5.21253490448                 # 循环体中使用array_merge()最慢,而且耗费内存
eachTwo | 0.0071840286254883            # 循环体结束后使用array_merge()最快
eachThree | 0.037622928619385           # 循环体嵌套比循环体结束后使用array_merge()慢三倍
eachFour | 0.0072360038757324           # 看似也很快,但是合并的结果有问题
  • 循环体中使用 array_merge()最慢,而且耗费内存
  • 循环体结束后使用 array_merge()最快
  • 循环体嵌套比循环体结束后使用 array_merge()慢三倍
  • 看似也很快,但是合并的结果有问题

合并数组的坑

我们注意到刚刚的 eachFour 的结果长度只有 10
下面探究为什么会出现这样的结果

这里拿递归合并一起做下对比

代码

public static function test(): void
{
    $testA = [
        '111' => 'testA1',
        'abc' => 'testA1',
        '222' => 'testA2',
    ];

    $testB = [
        '111' => 'testB1',
        'abc' => 'testB1',
        '222' => 'testB2',
        'www' => 'testB1',
    ];

    echo 'array_merge($testA, $testB) | ' . PHP_EOL;
    print_r(array_merge($testA, $testB));
    echo '$testA + $testB | ' . PHP_EOL;
    print_r($testA + $testB);
    echo '$testB + $testA | ' . PHP_EOL;
    print_r($testB + $testA);
    echo 'array_merge_recursive($testA, $testB) | ' . PHP_EOL;
    print_r(array_merge_recursive($testA, $testB));
}

结果

+ 号拼接两个数组,后者只会补充前者没有的 key,但是会保留数字索引
array_merge()array_merge_recursive() 会抹去数字索引,所有的数字索引按顺序从 0 开始了

array_merge($testA, $testB) |    #数字索引强制从0开始了 字符key相同的以后者为准
Array
(
    [0] => testA1
    [abc] => testB1
    [1] => testA2
    [2] => testB1
    [3] => testB2
    [www] => testB1
)

$testA + $testB |        #testA得到保留,testB补充了testA中没有的key,数字索引得到保留
Array
(
    [111] => testA1
    [abc] => testA1
    [222] => testA2
    [www] => testB1
)

$testB + $testA |        #testB得到保留,testA补充了testB中没有的key,数字索引得到保留
Array
(
    [111] => testB1
    [abc] => testB1
    [222] => testB2
    [www] => testB1
)

array_merge_recursive($testA, $testB) |   #数字索引从0开始连续了,但数组的顺序没有被破坏,相同的字符串 `key` 合并为一个数组
Array
(
    [0] => testA1
    [abc] => Array
        (
            [0] => testA1
            [1] => testB1
        )

    [1] => testA2
    [2] => testB1
    [3] => testB2
    [www] => testB1
)

分析

看到这里,你一定非常疑惑,没想到 array_merge() 还有这样的坑
我们先来看一看官方的手册

array_merge ( array $array1 [, array $... ] ) : array
array_merge() 将一个或多个数组的单元合并起来,一个数组中的值附加在前一个数组的后面。返回作为结果的数组。

如果输入的数组中有相同的字符串键名,则该键名后面的值将覆盖前一个值。然而,如果数组包含数字键名,后面的值将不会覆盖原来的值,而是附加到后面。

如果只给了一个数组并且该数组是数字索引的,则键名会以连续方式重新索引。

只有相同的字符串键名,后边的值才会覆盖前面的值。(但是手册中没有解释为什么数字键名的索引被重置了)
那么我们来看一下源码

PHPAPI int php_array_merge(HashTable *dest, HashTable *src)
{
    zval *src_entry;
    zend_string *string_key;

    if ((dest->u.flags & HASH_FLAG_PACKED) && (src->u.flags & HASH_FLAG_PACKED)) {
        // 自然数组的合并,HASH_FLAG_PACKED表示数组是自然数组([0,1,2])   参考http://ju.outofmemory.cn/entry/197064
        zend_hash_extend(dest, zend_hash_num_elements(dest) + zend_hash_num_elements(src), 1);
        ZEND_HASH_FILL_PACKED(dest) {
            ZEND_HASH_FOREACH_VAL(src, src_entry) {
                if (UNEXPECTED(Z_ISREF_P(src_entry)) &&
                    UNEXPECTED(Z_REFCOUNT_P(src_entry) == 1)) {
                    ZVAL_UNREF(src_entry);
                }
                Z_TRY_ADDREF_P(src_entry);
                ZEND_HASH_FILL_ADD(src_entry);
            } ZEND_HASH_FOREACH_END();
        } ZEND_HASH_FILL_END();
    } else {
        //遍历获取key和vaule
        ZEND_HASH_FOREACH_STR_KEY_VAL(src, string_key, src_entry) {
            if (UNEXPECTED(Z_ISREF_P(src_entry) &&
                Z_REFCOUNT_P(src_entry) == 1)) {
                ZVAL_UNREF(src_entry);
            }
            Z_TRY_ADDREF_P(src_entry);
            //  参考https://github.com/pangudashu/php7-internal/blob/master/7/var.md
            if (string_key) {
                // 字符串key(zend_string)  插入或者更新元素,会增加key的计数
                zend_hash_update(dest, string_key, src_entry);
            } else {
                //插入新元素,使用自动的索引值(破案了,索引被重置的原因在此)
                zend_hash_next_index_insert_new(dest, src_entry);
            }
        } ZEND_HASH_FOREACH_END();
    }
    return 1;
}

总结

综上所述,合并数组的不同方式都存在一定的缺陷,但是通过我们上面的探究,我们了解到

  • 循环体中使用 array_merge() 合并数组不可取,速度差距达百倍
  • array_merge() 合并数组要慎用,如果重视 key ,且 key 可能为数字,不能使用 array_merge() 来合并,我们可以采用循环体嵌套的方式(注意内层循环使用 key 进行赋值操作)
  • 如果重视 key ,且 key 可能为数字,简单合并数组可以使用运算符 + ,但是一定不要在循环体中使用,因为每次运算的的结果都是生成了一个新的数组

参考

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 4年前 自动加精
一冉再
讨论数量: 2

https://blog.yiranzai.cn/posts/991/ MySQL分组查询TOP N的实践和踩坑

整理测试的很好,如果是只考虑 SQL 的情况下。变量最优。

这个综合考虑下,union all 可能会更优

1、union all 在约 1.8 万的数据下与临时变量,性能相差约 0.03,很小
2、union all 在后期维护下,更直观方便
3、1.8 万的数据查询出来就要 0.41 的时间,如果数据量稍微大点,就无法使用 SQL 直接查了。
3、如果数据量增大了,union all 性能下降,可以使用 redis 等来操作

5年前 评论
一冉再

@FreeMason 老哥你挖的够深啊,我博客的文章都被你扒出来了。
学习了

5年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!