HardТеория9 min

SPL Итераторы

FilterIterator, LimitIterator, RecursiveIteratorIterator, RegexIterator и другие итераторы SPL

SPL (Standard PHP Library) предоставляет богатую коллекцию итераторов, позволяющих обрабатывать данные лениво, эффективно и с минимальным потреблением памяти. Итераторы SPL реализуют паттерн Iterator и могут комбинироваться друг с другом, образуя мощные конвейеры обработки данных.

Иерархия итераторов SPL

Все SPL-итераторы реализуют интерфейс Iterator или OuterIterator. Вот ключевые группы:

  • Фильтрующие: FilterIterator, CallbackFilterIterator, RegexIterator
  • Ограничивающие: LimitIterator, NoRewindIterator, InfiniteIterator
  • Рекурсивные: RecursiveIteratorIterator, RecursiveDirectoryIterator
  • Агрегирующие: AppendIterator, MultipleIterator
  • Кэширующие: CachingIterator, RecursiveCachingIterator
  • Файловые: DirectoryIterator, FilesystemIterator, GlobIterator

FilterIterator -- фильтрация элементов

FilterIterator -- абстрактный класс. Необходимо реализовать метод accept(), который возвращает true для элементов, проходящих фильтр.

<?php
declare(strict_types=1);

// Filter even numbers from an array
class EvenFilter extends FilterIterator
{
    public function accept(): bool
    {
        return $this->current() % 2 === 0;
    }
}

$numbers = new ArrayIterator([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
$even = new EvenFilter($numbers);

foreach ($even as $number) {
    echo $number . ' '; // 2 4 6 8 10
}

Практический пример: фильтрация файлов по расширению

<?php
declare(strict_types=1);

class ExtensionFilter extends FilterIterator
{
    /** @param list<string> $extensions */
    public function __construct(
        Iterator $iterator,
        private readonly array $extensions,
    ) {
        parent::__construct($iterator);
    }

    public function accept(): bool
    {
        $file = $this->current();

        if ($file instanceof SplFileInfo) {
            return in_array(
                strtolower($file->getExtension()),
                $this->extensions,
                true,
            );
        }

        return false;
    }
}

$directory = new DirectoryIterator('/path/to/project');
$phpFiles = new ExtensionFilter($directory, ['php', 'phtml']);

foreach ($phpFiles as $file) {
    echo $file->getPathname() . PHP_EOL;
}

CallbackFilterIterator -- фильтрация замыканием

Начиная с PHP 5.4, вместо создания отдельного класса можно использовать CallbackFilterIterator с анонимной функцией.

<?php
declare(strict_types=1);

$data = new ArrayIterator([
    ['name' => 'Alice', 'age' => 30, 'active' => true],
    ['name' => 'Bob', 'age' => 17, 'active' => true],
    ['name' => 'Charlie', 'age' => 25, 'active' => false],
    ['name' => 'Diana', 'age' => 22, 'active' => true],
]);

// Filter active adults
$filtered = new CallbackFilterIterator($data, function (array $item): bool {
    return $item['active'] && $item['age'] >= 18;
});

foreach ($filtered as $person) {
    echo $person['name'] . PHP_EOL; // Alice, Diana
}

Callback получает три аргумента

<?php
declare(strict_types=1);

$items = new ArrayIterator(['apple', 'banana', 'avocado', 'blueberry']);

// callback(current, key, iterator)
$startsWithA = new CallbackFilterIterator(
    $items,
    function (string $value, int $key, Iterator $iterator): bool {
        return str_starts_with($value, 'a');
    },
);

foreach ($startsWithA as $fruit) {
    echo $fruit . PHP_EOL; // apple, avocado
}

LimitIterator -- пагинация и ограничение

LimitIterator извлекает подмножество элементов из итератора, аналогично LIMIT и OFFSET в SQL.

<?php
declare(strict_types=1);

$all = new ArrayIterator(range(1, 100));

// LimitIterator(iterator, offset, count)
$page = new LimitIterator($all, 20, 10); // Skip 20, take 10

foreach ($page as $item) {
    echo $item . ' '; // 21 22 23 24 25 26 27 28 29 30
}

Реализация пагинации

<?php
declare(strict_types=1);

function paginate(Iterator $data, int $page, int $perPage): LimitIterator
{
    $offset = ($page - 1) * $perPage;

    return new LimitIterator($data, $offset, $perPage);
}

$items = new ArrayIterator(range(1, 50));

// Page 3, 10 items per page
$page3 = paginate($items, 3, 10);

foreach ($page3 as $key => $value) {
    echo "[$key] => $value" . PHP_EOL;
    // [20] => 21, [21] => 22, ..., [29] => 30
}

CachingIterator -- кэширование и предпросмотр

CachingIterator обёртывает итератор и позволяет заглядывать вперёд (lookahead) и кэшировать строковое представление текущего элемента.

<?php
declare(strict_types=1);

$data = new ArrayIterator(['first', 'second', 'third', 'last']);

$caching = new CachingIterator($data, CachingIterator::FULL_CACHE);

foreach ($caching as $item) {
    // hasNext() checks if there is a next element
    if ($caching->hasNext()) {
        echo $item . ', ';
    } else {
        echo $item . '.'; // No comma for last element
    }
}
// Output: first, second, third, last.

Флаги CachingIterator

<?php
declare(strict_types=1);

// CALL_TOSTRING — call __toString() on current element and cache
// TOSTRING_USE_KEY — __toString returns the key
// TOSTRING_USE_CURRENT — __toString returns the current value
// TOSTRING_USE_INNER — __toString returns inner iterator's __toString
// FULL_CACHE — cache all visited elements (accessible via getCache())

$items = new ArrayIterator(['a', 'b', 'c', 'd']);
$cached = new CachingIterator($items, CachingIterator::FULL_CACHE);

// Must iterate to fill cache
foreach ($cached as $item) {
    // processing...
}

// Access full cache after iteration
$cache = $cached->getCache();
print_r($cache); // [0 => 'a', 1 => 'b', 2 => 'c', 3 => 'd']

RecursiveIteratorIterator -- обход деревьев

RecursiveIteratorIterator -- один из самых мощных итераторов SPL. Он «расплющивает» рекурсивную структуру в плоскую последовательность.

Режимы обхода

<?php
declare(strict_types=1);

$tree = new RecursiveArrayIterator([
    'fruits' => ['apple', 'banana'],
    'vegetables' => [
        'root' => ['carrot', 'beet'],
        'leaf' => ['spinach'],
    ],
    'grains' => ['rice'],
]);

// LEAVES_ONLY (default) — only leaf elements
$leaves = new RecursiveIteratorIterator(
    $tree,
    RecursiveIteratorIterator::LEAVES_ONLY,
);

foreach ($leaves as $leaf) {
    echo $leaf . ' ';
}
// apple banana carrot beet spinach rice

// SELF_FIRST — parent before children
$selfFirst = new RecursiveIteratorIterator(
    $tree,
    RecursiveIteratorIterator::SELF_FIRST,
);

foreach ($selfFirst as $key => $value) {
    $depth = $selfFirst->getDepth();
    $indent = str_repeat('  ', $depth);

    if (is_array($value)) {
        echo "{$indent}{$key}:" . PHP_EOL;
    } else {
        echo "{$indent}{$key}: {$value}" . PHP_EOL;
    }
}

// CHILD_FIRST — children before parent
$childFirst = new RecursiveIteratorIterator(
    $tree,
    RecursiveIteratorIterator::CHILD_FIRST,
);

Обход файловой системы

<?php
declare(strict_types=1);

// Walk entire directory tree
$directory = new RecursiveDirectoryIterator(
    '/path/to/project/src',
    RecursiveDirectoryIterator::SKIP_DOTS,
);

$files = new RecursiveIteratorIterator(
    $directory,
    RecursiveIteratorIterator::SELF_FIRST,
);

foreach ($files as $file) {
    /** @var SplFileInfo $file */
    $depth = $files->getDepth();
    $indent = str_repeat('  ', $depth);
    $type = $file->isDir() ? '[DIR]' : '[FILE]';

    echo "{$indent}{$type} {$file->getFilename()}" . PHP_EOL;
}

Поиск PHP-файлов рекурсивно

<?php
declare(strict_types=1);

function findPhpFiles(string $directory): Generator
{
    $dir = new RecursiveDirectoryIterator(
        $directory,
        RecursiveDirectoryIterator::SKIP_DOTS,
    );

    $iterator = new RecursiveIteratorIterator($dir);

    $regex = new RegexIterator(
        $iterator,
        '/\.php$/i',
        RegexIterator::MATCH,
    );

    foreach ($regex as $file) {
        yield $file->getPathname();
    }
}

foreach (findPhpFiles('/path/to/src') as $phpFile) {
    echo $phpFile . PHP_EOL;
}

AppendIterator -- объединение итераторов

AppendIterator последовательно склеивает несколько итераторов в один.

<?php
declare(strict_types=1);

$first = new ArrayIterator([1, 2, 3]);
$second = new ArrayIterator([4, 5, 6]);
$third = new ArrayIterator([7, 8, 9]);

$combined = new AppendIterator();
$combined->append($first);
$combined->append($second);
$combined->append($third);

foreach ($combined as $value) {
    echo $value . ' '; // 1 2 3 4 5 6 7 8 9
}

Практический пример: объединение источников данных

<?php
declare(strict_types=1);

// Combine data from multiple CSV files
function readCsvFiles(array $filePaths): AppendIterator
{
    $combined = new AppendIterator();

    foreach ($filePaths as $path) {
        $file = new SplFileObject($path);
        $file->setFlags(
            SplFileObject::READ_CSV
            | SplFileObject::SKIP_EMPTY
            | SplFileObject::DROP_NEW_LINE,
        );

        $combined->append($file);
    }

    return $combined;
}

$allRows = readCsvFiles([
    '/data/january.csv',
    '/data/february.csv',
    '/data/march.csv',
]);

foreach ($allRows as $row) {
    // Process each row from all files
    echo implode(' | ', $row) . PHP_EOL;
}

MultipleIterator -- параллельная итерация

MultipleIterator позволяет итерироваться по нескольким итераторам одновременно (как zip в Python).

<?php
declare(strict_types=1);

$names = new ArrayIterator(['Alice', 'Bob', 'Charlie']);
$ages = new ArrayIterator([30, 25, 35]);
$cities = new ArrayIterator(['Moscow', 'London', 'Berlin']);

$multi = new MultipleIterator(MultipleIterator::MIT_KEYS_ASSOC);
$multi->attachIterator($names, 'name');
$multi->attachIterator($ages, 'age');
$multi->attachIterator($cities, 'city');

foreach ($multi as $data) {
    echo sprintf(
        '%s, %d years old, from %s',
        $data['name'],
        $data['age'],
        $data['city'],
    ) . PHP_EOL;
}
// Alice, 30 years old, from Moscow
// Bob, 25 years old, from London
// Charlie, 35 years old, from Berlin

Флаги MultipleIterator

<?php
declare(strict_types=1);

// MIT_NEED_ALL (default) — stop when ANY iterator ends
// MIT_NEED_ANY — continue until ALL iterators end
// MIT_KEYS_NUMERIC — keys as [0, 1, 2, ...]
// MIT_KEYS_ASSOC — keys as attached names

$short = new ArrayIterator([1, 2]);
$long = new ArrayIterator([10, 20, 30, 40]);

// MIT_NEED_ALL: stops at the shortest
$multi = new MultipleIterator(MultipleIterator::MIT_NEED_ALL);
$multi->attachIterator($short);
$multi->attachIterator($long);

foreach ($multi as $pair) {
    echo $pair[0] . '-' . $pair[1] . ' '; // 1-10 2-20
}

NoRewindIterator -- запрет перемотки

NoRewindIterator предотвращает вызов rewind() на внутреннем итераторе. Полезно для итераторов, которые нельзя перемотать (потоки, генераторы).

<?php
declare(strict_types=1);

function generateNumbers(): Generator
{
    yield 1;
    yield 2;
    yield 3;
}

// Generators cannot be rewound — NoRewindIterator makes this explicit
$noRewind = new NoRewindIterator(generateNumbers());

foreach ($noRewind as $number) {
    echo $number . ' '; // 1 2 3
}

// Second iteration produces nothing (no rewind)
foreach ($noRewind as $number) {
    echo $number; // Never reached
}

InfiniteIterator -- циклическая итерация

InfiniteIterator бесконечно повторяет внутренний итератор. Обязательно используйте с LimitIterator или break.

<?php
declare(strict_types=1);

$colors = new ArrayIterator(['red', 'green', 'blue']);
$infinite = new InfiniteIterator($colors);

// Without limit — infinite loop!
// Use LimitIterator to constrain
$limited = new LimitIterator($infinite, 0, 7);

foreach ($limited as $color) {
    echo $color . ' ';
}
// red green blue red green blue red

Round-robin распределение задач

<?php
declare(strict_types=1);

$workers = new ArrayIterator(['Worker-A', 'Worker-B', 'Worker-C']);
$roundRobin = new InfiniteIterator($workers);

$tasks = ['task1', 'task2', 'task3', 'task4', 'task5', 'task6', 'task7'];

$roundRobin->rewind();

foreach ($tasks as $task) {
    $worker = $roundRobin->current();
    echo "{$worker} => {$task}" . PHP_EOL;
    $roundRobin->next();
}
// Worker-A => task1
// Worker-B => task2
// Worker-C => task3
// Worker-A => task4
// Worker-B => task5
// Worker-C => task6
// Worker-A => task7

RegexIterator -- фильтрация по регулярным выражениям

<?php
declare(strict_types=1);

$logs = new ArrayIterator([
    '[ERROR] Database connection failed',
    '[INFO] User logged in',
    '[ERROR] File not found',
    '[WARNING] Low memory',
    '[INFO] Request completed',
]);

// Match only ERROR lines
$errors = new RegexIterator($logs, '/^\[ERROR\]/');

foreach ($errors as $line) {
    echo $line . PHP_EOL;
}
// [ERROR] Database connection failed
// [ERROR] File not found

Режимы RegexIterator

<?php
declare(strict_types=1);

$data = new ArrayIterator(['foo123', 'bar456', 'baz789', 'hello']);

// MATCH (default) — filter elements matching the pattern
$matched = new RegexIterator($data, '/\d+/', RegexIterator::MATCH);
// foo123, bar456, baz789

// GET_MATCH — return only the matched part
$getMatch = new RegexIterator($data, '/(\d+)/', RegexIterator::GET_MATCH);
foreach ($getMatch as $match) {
    echo $match[0] . ' '; // 123 456 789
}

// REPLACE — replace matched part
$replace = new RegexIterator($data, '/\d+/', RegexIterator::REPLACE);
$replace->replacement = 'NUM';
foreach ($replace as $item) {
    echo $item . ' '; // fooNUM barNUM bazNUM hello
}

// ALL_MATCHES — return all matches per element
// SPLIT — split each element by pattern

DirectoryIterator vs FilesystemIterator

Два похожих итератора для работы с каталогами, но с важными различиями.

<?php
declare(strict_types=1);

// DirectoryIterator — basic directory listing
$dir = new DirectoryIterator('/path/to/dir');
foreach ($dir as $fileInfo) {
    // Includes . and ..
    if ($fileInfo->isDot()) {
        continue;
    }
    echo $fileInfo->getFilename() . PHP_EOL;
}

// FilesystemIterator — enhanced version
// Automatically skips . and ..
// Keys are pathnames (not sequential integers)
$fs = new FilesystemIterator('/path/to/dir');
foreach ($fs as $path => $fileInfo) {
    echo "{$path} => {$fileInfo->getFilename()}" . PHP_EOL;
}

Ключевые различия

Характеристика DirectoryIterator FilesystemIterator
Пропуск . и .. Нет (вручную) Да (SKIP_DOTS по умолчанию)
Ключи при итерации Числовые (0, 1, 2...) Полный путь к файлу
current() возвращает Тот же объект (!) Новый SplFileInfo
Флаги Нет Множество (KEY_AS_PATHNAME, CURRENT_AS_FILEINFO и др.)

Внимание! DirectoryIterator возвращает один и тот же объект при каждой итерации, просто перемещая внутренний указатель. Если вы сохраняете элементы в массив, все элементы будут указывать на последний файл. Используйте clone или FilesystemIterator.

GlobIterator -- поиск по паттерну

<?php
declare(strict_types=1);

// Find all PHP files in a directory
$glob = new GlobIterator('/path/to/src/*.php');

echo "Found {$glob->count()} PHP files" . PHP_EOL;

foreach ($glob as $file) {
    /** @var SplFileInfo $file */
    echo $file->getFilename() . ' (' . $file->getSize() . ' bytes)' . PHP_EOL;
}

Практические примеры

Сканер файлов проекта

<?php
declare(strict_types=1);

final class ProjectScanner
{
    /** @param list<string> $excludeDirs */
    public function __construct(
        private readonly string $rootPath,
        private readonly array $excludeDirs = ['vendor', 'node_modules', '.git'],
    ) {}

    /** @return Generator<string, SplFileInfo> */
    public function findFiles(string $pattern = '/\.php$/i'): Generator
    {
        $directory = new RecursiveDirectoryIterator(
            $this->rootPath,
            RecursiveDirectoryIterator::SKIP_DOTS,
        );

        // Filter out excluded directories
        $filtered = new RecursiveCallbackFilterIterator(
            $directory,
            function (SplFileInfo $file, string $key, RecursiveDirectoryIterator $iterator): bool {
                if ($file->isDir()) {
                    return !in_array($file->getFilename(), $this->excludeDirs, true);
                }
                return true;
            },
        );

        $flattened = new RecursiveIteratorIterator($filtered);
        $matched = new RegexIterator($flattened, $pattern);

        foreach ($matched as $file) {
            yield $file->getPathname() => $file;
        }
    }

    /** @return array{files: int, dirs: int, totalSize: int} */
    public function getStats(): array
    {
        $files = 0;
        $dirs = 0;
        $totalSize = 0;

        foreach ($this->findFiles('/./') as $file) {
            if ($file->isFile()) {
                $files++;
                $totalSize += $file->getSize();
            } else {
                $dirs++;
            }
        }

        return compact('files', 'dirs', 'totalSize');
    }
}

Парсер лог-файлов

<?php
declare(strict_types=1);

final class LogParser
{
    public function __construct(
        private readonly string $logPath,
    ) {}

    /** @return Generator<int, array{level: string, message: string, timestamp: string}> */
    public function parse(string $levelFilter = 'ERROR'): Generator
    {
        $file = new SplFileObject($this->logPath);
        $file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY);

        // Filter by log level using RegexIterator
        $pattern = '/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \[(' . $levelFilter . ')\] (.+)$/';
        $filtered = new RegexIterator($file, $pattern, RegexIterator::GET_MATCH);

        foreach ($filtered as $match) {
            yield [
                'timestamp' => $match[1],
                'level' => $match[2],
                'message' => $match[3],
            ];
        }
    }

    /** @return Generator<int, array<string>> */
    public function getLastLines(int $count): Generator
    {
        $file = new SplFileObject($this->logPath);
        $file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY);

        // Seek to end, then count back
        $file->seek(PHP_INT_MAX);
        $totalLines = $file->key();

        $offset = max(0, $totalLines - $count);
        $limited = new LimitIterator($file, $offset, $count);

        foreach ($limited as $line) {
            yield $line;
        }
    }
}

CSV-процессор с конвейером итераторов

<?php
declare(strict_types=1);

final class CsvPipeline
{
    /**
     * Read CSV, filter rows, limit results — all lazy.
     *
     * @return Iterator<int, array<string>>
     */
    public static function process(
        string $filePath,
        callable $filter,
        int $offset = 0,
        int $limit = 100,
    ): Iterator {
        // Step 1: Read CSV file lazily
        $file = new SplFileObject($filePath);
        $file->setFlags(
            SplFileObject::READ_CSV
            | SplFileObject::SKIP_EMPTY
            | SplFileObject::DROP_NEW_LINE
            | SplFileObject::READ_AHEAD,
        );

        // Step 2: Filter rows
        $filtered = new CallbackFilterIterator($file, $filter);

        // Step 3: Paginate
        return new LimitIterator($filtered, $offset, $limit);
    }
}

// Usage: process 10M-row CSV with constant memory
$results = CsvPipeline::process(
    '/data/huge-dataset.csv',
    fn(array $row): bool => isset($row[2]) && (float) $row[2] > 1000.0,
    offset: 0,
    limit: 50,
);

foreach ($results as $row) {
    echo implode(', ', $row) . PHP_EOL;
}

Запомни: SPL-итераторы работают лениво -- данные обрабатываются по одному элементу, без загрузки всей коллекции в память. Это делает их идеальными для обработки больших файлов и потоков данных. Комбинируя итераторы (фильтр + лимит + regex), вы создаёте эффективные конвейеры обработки данных.


Проверь себя

5 из 12

Какой режим `RegexIterator` позволяет заменять совпадения?

Сколько аргументов получает callback в `CallbackFilterIterator`?

Какой флаг `MultipleIterator` остановит итерацию когда хотя бы один итератор закончится?

Какой итератор позволяет итерироваться по нескольким итераторам параллельно?

Что произойдёт при использовании `InfiniteIterator` без `LimitIterator` или `break`?