Перейти к содержимому
Фёдор Башинский

Итератор

5 min read
Обложка статьи «Итератор»

Итератор (Iterator) — это поведенческий паттерн проектирования, который даёт единообразный способ обходить элементы коллекции, не раскрывая, как она устроена внутри, — массив это, хеш-таблица или дерево.

Проблема

Парк автомобилей корпоративного клиента хранится в классе Fleet в виде обычного публичного массива:

final class Fleet
{
    /** @var Car[] */
    public array $cars = [];
 
    public function add(Car $car): void
    {
        $this->cars[] = $car;
    }
}

Клиентский код по всему приложению обходит парк напрямую:

foreach ($fleet->cars as $car) {
    // ...
}

Появляется новое требование: при заезде машины на ТО нужно находить её в парке по VIN за O(1), а не перебором всего массива. Естественное решение — сменить внутреннее хранилище на ассоциативный массив, где ключ — VIN:

public array $cars = []; // теперь ключ — VIN, значение — Car
 
public function add(Car $car): void
{
    $this->cars[$car->vin] = $car;
}

Это ломает код, который полагался на числовые индексы вроде $fleet->cars[0]. Хуже того, появляется требование планировать ТО по пробегу — обходить машины от самой изношенной к самой свежей. Раз подходящего порядка Fleet не предоставляет, сортировку приходится писать в каждом месте, где она нужна:

$sorted = $fleet->cars;
usort($sorted, fn (Car $a, Car $b) => $b->mileage <=> $a->mileage);
 
foreach ($sorted as $car) {
    // ...
}

Каждый такой участок кода знает, что $fleet->cars — это именно массив, который можно сортировать через usort(). Внутреннее устройство Fleet перестало быть деталью реализации и стало контрактом, на который завязан код по всему приложению, — и любое дальнейшее изменение структуры хранения снова требует переписывать все места, где $fleet->cars используется напрямую.

Решение

Итератор скрывает внутреннее хранилище коллекции за общим интерфейсом обхода. В PHP для этого есть встроенные интерфейсы Iterator и IteratorAggregateFleet реализует второй, и foreach продолжает работать как раньше, ничего не зная о том, что изменилось внутри:

final class Fleet implements IteratorAggregate
{
    /** @var array<string, Car> ключ — VIN, для O(1) поиска при заезде на ТО */
    private array $cars = [];
 
    public function add(Car $car): void
    {
        $this->cars[$car->vin] = $car;
    }
 
    public function getIterator(): Iterator
    {
        return new ArrayIterator(array_values($this->cars));
    }
}

Хранилище стало приватным и сменилось на ассоциативный массив по VIN — но клиентский код этого не заметил:

foreach ($fleet as $car) {
    // порядок добавления, внутреннее устройство Fleet скрыто
}

Для обхода по пробегу заводится отдельный итератор, реализующий встроенный интерфейс Iterator вручную — current(), key(), next(), rewind(), valid():

final class MileageDescIterator implements Iterator
{
    /** @var Car[] */
    private array $sorted;
    private int $position = 0;
 
    /** @param array<string, Car> $cars */
    public function __construct(array $cars)
    {
        $this->sorted = array_values($cars);
        usort($this->sorted, fn (Car $a, Car $b) => $b->mileage <=> $a->mileage);
    }
 
    public function current(): Car
    {
        return $this->sorted[$this->position];
    }
 
    public function key(): int
    {
        return $this->position;
    }
 
    public function next(): void
    {
        $this->position++;
    }
 
    public function rewind(): void
    {
        $this->position = 0;
    }
 
    public function valid(): bool
    {
        return isset($this->sorted[$this->position]);
    }
}

Fleet отдаёт этот итератор отдельным методом, не трогая getIterator(), который отвечает за порядок по умолчанию:

final class Fleet implements IteratorAggregate
{
    // ...
 
    public function byMileageDesc(): Iterator
    {
        return new MileageDescIterator($this->cars);
    }
}
foreach ($fleet->byMileageDesc() as $car) {
    // от самого изношенного к самому свежему — для планирования ТО
}

Логика сортировки по пробегу теперь существует в одном месте вместо повторения в каждом участке кода, которому она нужна, а внутреннее хранилище Fleet можно менять и дальше, не трогая ни один из вызывающих его мест.

Из чего состоит паттерн

  • Итератор (встроенный интерфейс PHP Iterator) — общий контракт обхода: текущий элемент, ключ, переход к следующему, сброс, проверка на конец.
  • Конкретный итератор (MileageDescIterator, встроенный ArrayIterator) — хранит состояние обхода (текущую позицию) отдельно от самой коллекции.
  • Агрегат (IteratorAggregate, реализован Fleet) — предоставляет способ получить итератор, не раскрывая, как коллекция хранит элементы.
  • Клиент — обходит коллекцию через foreach, не заботясь о её внутреннем устройстве.

Итератор vs Компоновщик

Компоновщик описывает, как построить рекурсивную структуру «часть — целое», где отдельный элемент и группа элементов имеют общий интерфейс. Сам по себе он ничего не говорит о том, как эту структуру обходить, — просто определяет её форму. Итератор, наоборот, не имеет отношения к тому, как структура устроена: плоский массив, ассоциативная таблица или дерево, построенное Компоновщиком, — его задача только в том, чтобы дать единообразный способ пройтись по уже существующей структуре. Дерево, собранное Компоновщиком, обычно нуждается в собственном итераторе, который умеет рекурсивно спускаться по узлам, — эта логика обхода не имеет отношения к тому, как Компоновщик определяет саму форму дерева.

Когда применять

  • Коллекция хранит элементы во внутренней структуре, которая может меняться со временем (массив → хеш-таблица → дерево), а клиентский код не должен зависеть от конкретной реализации.
  • Нужно несколько независимых способов обхода одной и той же коллекции — по умолчанию, по пробегу, по дате следующего ТО — без дублирования логики сортировки в каждом месте использования.
  • Обход должен поддерживать несколько одновременных независимых проходов по одной коллекции: у каждого итератора своё состояние, и два foreach по одной и той же коллекции не мешают друг другу.

Плюсы и минусы

Итератор скрывает внутреннее устройство коллекции от клиентского кода — оно перестаёт быть контрактом, от которого зависят другие классы, и остаётся деталью реализации, которую можно менять свободно. Новые способы обхода добавляются новыми классами итераторов, не трогая ни коллекцию, ни существующий клиентский код.

Минус — для простых плоских массивов, где обычный foreach и так прекрасно работает, отдельный класс итератора на каждый вид обхода — лишняя обёртка ради гибкости, которая может никогда не понадобиться. Каждый новый вариант обхода — это ещё один класс, и их число растёт линейно с числом нужных порядков.

Итог

Итератор нужен, когда способ хранения коллекции — деталь реализации, которая должна быть скрыта от клиента, а вариантов обхода этой коллекции больше одного. В PHP это устроено идиоматично через Iterator и IteratorAggregate: клиент продолжает писать обычный foreach, не зная, какая структура и какой порядок обхода стоят за ней на самом деле.