最近在工作中碰到一個挺有意思的問題,線上輸入是一串排好序的關聯陣列,經過一系列處理後輸出的陣列卻是亂序,且本地執行無法復現。檢視相關程式碼後,最讓人在意的是這一段:
$categories = Arr::sort($categories, function ($node) { return $node['default']; }, true);
作用是按default
優先順序將元素提到前面,首先確認了下線上的illuminate/support
版本和本地一致,檢視Arr::sort()
原始碼:
... $descending ? arsort($results, $options) : asort($results, $options);
最終還是呼叫 php 的asort
,線上是 php5 而本地和測試因為最近考慮升級都換成了 php7,最後在 php5 環境下成功復現,確定出是sort
問題。
在排序前後相等的元素相對位置不變即說這個演演算法是穩定的。
對快速排序有一定了解的話可以知道,快速排序是不穩定的所以這段程式碼在元素default
優先順序都相同的情況下輸出將不會是之前的順序,但在 php7 環境下測試結果為什麼會保留原來的順序呢。難道關於我之前理解的天底下所有預設排序都是快速排序這一點是錯的嗎?
好吧,讓我們來快速看看 php 原始碼是如何解決這個問題的。到 github 官方的 php-src 直接搜尋asort
in this repository,找c檔案,找到這個函數定義在 arr.c:814
PHP_FUNCTION(asort) { zval *array; zend_long sort_type = PHP_SORT_REGULAR; bucket_compare_func_t cmp; ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_ARRAY_EX(array, 0, 1) Z_PARAM_OPTIONAL Z_PARAM_LONG(sort_type) ZEND_PARSE_PARAMETERS_END(); // 設定比較函數 cmp = php_get_data_compare_func(sort_type, 0); zend_hash_sort(Z_ARRVAL_P(array), cmp, 0); RETURN_TRUE; }
可以看到最終呼叫的是zend_hash_sort()
,我們繼續搜尋:
發現這個函數是zend_hash_sort_ex()
的套娃,最後找到zend_hash.c:2497
ZEND_API void ZEND_FASTCALL zend_hash_sort_ex(HashTable *ht, sort_func_t sort, bucket_compare_func_t compar, zend_bool renumber) { Bucket *p; uint32_t i, j; IS_CONSISTENT(ht); HT_ASSERT_RC1(ht); if (!(ht->nNumOfElements>1) && !(renumber && ht->nNumOfElements>0)) { /* Doesn't require sorting */ return; } // 這裡的hole指陣列元素被unset掉產生的洞 if (HT_IS_WITHOUT_HOLES(ht)) { /* Store original order of elements in extra space to allow stable sorting. */ for (i = 0; i < ht->nNumUsed; i++) { Z_EXTRA(ht->arData[i].val) = i; } } else { /* Remove holes and store original order. */ for (j = 0, i = 0; j < ht->nNumUsed; j++) { p = ht->arData + j; if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) continue; if (i != j) { ht->arData[i] = *p; } Z_EXTRA(ht->arData[i].val) = i; i++; } ht->nNumUsed = i; } sort((void *)ht->arData, ht->nNumUsed, sizeof(Bucket), (compare_func_t) compar, (swap_func_t)(renumber? zend_hash_bucket_renum_swap : ((HT_FLAGS(ht) & HASH_FLAG_PACKED) ? zend_hash_bucket_packed_swap : zend_hash_bucket_swap))); ...
好耶!,官方註釋裡就有說明是怎麼實現排序的穩定性,在排序之前用這個Z_EXTRA
保留了原陣列元素到下標的對映。
但當我搜尋這塊程式碼提交資訊時發現了一個問題:穩定排序相關的 rfc 在https://wiki.php.net/rfc/stable_sorting,可以看到是發生在今年五月份且針對 php8.0 的。
?? 那之前的 php7 排序穩定是怎麼回事。
馬上構造個例子:
$count = 10; $cc = []; for ($i=0; $i<$count; $i++) { $cc[] = [ 'id' => $i, 'default' => rand(0, 10), ]; } $cc = Arr::sort($cc, function ($i) { return $i['default']; }); dd($cc);
經過多次在 php7 下的測試發現:當$count
比較小的時候,排序才是穩定的,但$count
較大情況下的排序又變成不穩定。也就是線上排序不穩定而本地無法復現其實是用例的陣列長度太短所致。
看到這裡可以確定是快速排序長度閾值優化的問題,最後查了下相關資料。php7 中的sort
是基於LLVM
中libc++
的std::sort
實現。當元素數量小於等於16時候有特殊優化,大於16才走快速排序,而 php5 是直接就走快速排序的。
<?php $count = 100; $cc = []; for ($i=0; $i<$count; $i++) { $cc[] = [ 'id' => $i, 'default' => rand(0, 10), ]; } usort($cc, function($a, $b){ if ($a['default'] == $b['default']) return 0; return ($a['default'] < $b['default']) ? 1 : -1; }); print_r($cc);
最後在 php8 環境下試了試,排序絕對穩定