Сравнение производительности перебора массивов в цикле через for() и foreach()

в 14:35, , рубрики: php, zend, производительность, метки: , , ,

Я хотел бы обратить внимание на одну не очевидную особенность php.
Допустим у нас есть массив с целочисленными индексами

$arr = array( $val1, $val2, ..., $valn );

Этот массив можно перебрать в цикле двумя способами

foreach($arr as $k => $v ) {...}

и

$n = count( $arr );
for($k = 0; $k < $n; $k++ ) {...}

Кажется вполне очевидным, что второй способ должен быть, если и не быстрее, то уж точно не медленнее.
Давайте разберемся.
— Нет. Никаких бенчмарков. Только код!

На самом деле, второй способ работает медленнее! Особенно это кажется не очевидным для тех, кто имеет опыт программирования на C и C++, где индекс массива — это простое смещение указателя.
Я обнаружил эту особенность довольно давно, но все ни как не доходили руки посмотреть с чем это связано

Итак, почему так происходит

Давайте посмотрим на внутренне устройство массива в php.
Внутренне, в ядре Zend, массив представлен несколькими взаимосвязанными структурами:

Структура Bucket описывает «указатель» на текущую позицию массива

typedef struct bucket {
	ulong h;                   // целочисленное значение индекса
                               // (если индекс целочисленный)
	uint nKeyLength;           // длина строки (строкового ключа)
	void *pData;               // значение элемента массива 
                               // [ извлечь можно так: (zval **)pos->pData ]
	void *pDataPtr;
	struct bucket *pListNext;  // Указатель на следующий элемент массива
	struct bucket *pListLast;  // Указатель на предыдущий элемент массива
	struct bucket *pNext;      // используется для внутренних операций с arBuckets
	struct bucket *pLast;      // используется для внутренних операций с arBuckets
	const char *arKey;         // строковое представление ключа, если ключ строковой
} Bucket;
typedef Bucket* HashPosition;

Структура HashTable — это и есть сам массив во внутреннем представлении:

typedef struct _hashtable {
	uint nTableSize;
	uint nTableMask;
	uint nNumOfElements;       // количество элементов в массиве
	ulong nNextFreeElement;
	Bucket *pInternalPointer;
	Bucket *pListHead;         // Указатель на первый элемент массива
	Bucket *pListTail;         // Указатель на последний элемент массива
	Bucket **arBuckets;        // собственно, сама ХэшТаблица.
                               // Используется для индексации как строковых
                               // так и целочисленных ключей
	dtor_func_t pDestructor;
	zend_bool persistent;
	unsigned char nApplyCount;
	zend_bool bApplyProtection;
} HashTable;

Даже представленной уже информации достаточно, что бы понять причину. Массивы реализованы в виде двусвязных списков.
Двусвязные списки позволяют осуществлять быструю вставку, достаточно быстрый перебор, но произвольный доступ в них — медленная операция.

Давайте посмотрим как, все-таки, Zend осуществляет итерацию элементов массива

// Переход к следующему элементу массива.
int zend_hash_move_forward_ex(HashTable *ht, HashPosition *pos)
{
	HashPosition *current = pos ? pos : &ht->pInternalPointer;
	if (*current) {
		*current = (*current)->pListNext;
		return SUCCESS;
	} else
		return FAILURE;
}

// Переход к предыдущему элементу массива. 
// В php нет обратной итерации, но она поддерживается Zend
// и может быть использована при написании расширений.
int zend_hash_move_backwards_ex(HashTable *ht, HashPosition *pos)
{
	HashPosition *current = pos ? pos : &ht->pInternalPointer;
	if (*current) {
		*current = (*current)->pListLast;
		return SUCCESS;
	} else
		return FAILURE;
}

Здесь, я думаю, все понятно без дополнительных объяснений — указатель на текущий элемент заменяется указателем на следующий элемент, ссылка на который хранится в текущем.

Теперь посмотрим как осуществляется доступ по индексу.

int zend_hash_index_find(const HashTable *ht, ulong h, void **pData)
{
	uint nIndex;
	Bucket *p;
	nIndex = h & ht->nTableMask;
	p = ht->arBuckets[nIndex];
	while (p != NULL) {
		if ((p->h == h) && (p->nKeyLength == 0)) {
			*pData = p->pData;
			return SUCCESS;
		}
		p = p->pNext;
	}
	return FAILURE;
}

Здесь нужны некоторые пояснения.
Я не хотел бы утомлять читателей излишне подробным описанием того, как устроен массив arBuckets (C массив из HashPosition — указателей на Bucket).
Скажу только, что здесь осуществляется последовательный перебор части внутренней хэш таблицы arBuckets до поиска нужного значения.

Выводы

foreach($arr as $i => $v ){...}

Достаточно быстро перебирает все элементы массива, получая их один за другим. Сложность этой операции O(n).

for($i = 0; $i < $n; $i++ ){...}

На каждой итерации ищет индекс во внутренней хэш таблице.

Оценочно, сложность последней операции выражалась бы суммой арифметической прогрессии n*(n+1)/2 т.е. и имела бы порядок O(n2), если бы при поиске значения по индексу перебиралась бы вся хэш таблица. На самом деле, перебираются не все значения, а только часть их, поэтому сложность будет варьироваться от O(n) до O(n2). И для больших массивов, она будет ближе к, O(n). Но даже в этом случае, перебор массива с доступом по ключу — более ресурсоемкая операция.

Что касается массивов со строковыми ключами (или смешанными — целочисленными и строковыми).
Все вышесказанное справедливо и для них с той разницей, что скорость доступа к строковому ключу в среднем в 8 (максимум в 16) раз больше чем к целочисленному

Автор: rotor

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js