<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · MIDDLE

Які інтерфейси стандартної бібліотеки варто знати: Iterator, Countable, ArrayAccess, Stringable, JsonSerializable?

Мінімум - `IteratorAggregate` (обхід у `foreach` через `getIterator()`), `Countable` (`count()`), `JsonSerializable` (контроль над `json_encode()`) і `Stringable` (типізація `string|Stringable`). `Iterator` пишуть вручну лише коли треба керувати станом обходу, а `ArrayAccess` дає синтаксис `$obj['key']`, який ламає типізацію і статичний аналіз.

Ваш клас-колекція. Що треба реалізувати, щоб він працював у `foreach` і в `count()`?
Чим `IteratorAggregate` відрізняється від `Iterator` і що ви візьмете для колекції сутностей?
У вас DTO з `DateTimeImmutable` усередині. Як зробити, щоб `json_encode()` віддавав дату в ISO-8601, а не обʼєкт із полем `date`?
Навіщо `Stringable`, якщо `__toString()` і так працює без нього?
SPL IteratorAggregate Countable ArrayAccess JsonSerializable Stringable interfaces

Коли клас не реалізує Traversable, foreach не спирається на жоден контракт обходу, а просто перебирає властивості, видимі в поточній області: ззовні це public-поля, зсередини методу того ж класу - ще й приватні. Сам Traversable з userland-коду реалізувати не можна, це маркер, тож вибір зводиться до двох нащадків. Iterator вимагає пʼяти методів (current, key, next, rewind, valid) і змушує тримати позицію обходу в самому обʼєкті. IteratorAggregate обмежується одним getIterator(): Traversable, який повертає готовий ітератор, і всю механіку віддає йому.

За замовчуванням беріть IteratorAggregate, а Iterator - коли позиція обходу справді є частиною стану обʼєкта. Якщо всередині вже лежить масив, питання закриває один рядок: yield from $this->items або new ArrayIterator($this->items). Генератор тут дає бонус: IteratorAggregate викликає getIterator() на кожен новий foreach, тому щоразу народжується свіжий генератор і повторний обхід не падає з Cannot traverse an already closed generator. Ручний Iterator виправданий у курсорах над потоком, парсерах і всюди, де rewind() має нетривіальний сенс, скажімо перечитати файл з початку. І пильнуйте key(): якщо ключі повторюються, iterator_to_array() мовчки склеїть елементи, поки не передати другим аргументом false.

Countable виглядає дрібницею, поки не згадати зміну поведінки. У PHP 5 і 7.0-7.1 count() на звичайному обʼєкті повертав 1, у 7.2 почав попереджати, а з 8.0 сигнатура функції - count(Countable|array $value), і невідповідність дає TypeError. На булевий контекст інтерфейс при цьому не впливає. Порожня колекція з count() === 0 в if ($collection) усе одно істинна, а empty($collection) дає false, бо обʼєкт у PHP завжди truthy і перевизначити це нічим. Тому count($cart) === 0 або власний isEmpty(), і ніяких скорочень.

JsonSerializable - найбільш прикладний з пʼятірки, бо стандартна серіалізація обʼєкта бере лише публічні властивості. Приватні поля з конструктора зникають, DateTimeImmutable розгортається у {"date": "...", "timezone_type": 3, "timezone": "..."}, а порядок ключів у відповіді диктується порядком оголошення властивостей, а не контрактом API. Метод jsonSerialize() повертає mixed, найзручніше - асоціативний масив: json_encode() пройде по ньому рекурсивно й обробить вкладені обʼєкти, які теж реалізують інтерфейс. Спотикаються тут постійно на двох речах: порожній масив стає [], а не {} (лікується приведенням (object)), і json_encode() без JSON_THROW_ON_ERROR при помилці мовчки повертає false замість винятку.

З PHP 8.0 клас отримує Stringable автоматично, щойно в ньому оголошено __toString(), тому явний implements лишається питанням читабельності. Користь інтерфейсу в іншому: тип string|Stringable у сигнатурі дозволяє приймати і рядок, і value object на кшталт Email чи Money, не сподіваючись на неявне приведення при strict_types=1. Всередині все одно потрібен явний (string) $message.

ArrayAccess стоїть окремо, бо це єдиний з пʼяти інтерфейсів, який частіше шкодить. Він дає синтаксис $obj['key'], але не робить обʼєкт масивом: array_map(), array_filter(), spread ... і list() з ним не працюють. Типізація зникає (offsetGet(): mixed), опечатка в ключі не ловиться ні IDE, ні PHPStan, а isset($order['totl']) тихо повертає false замість помилки. Для контейнерів із динамічним набором ключів (ArrayObject, конфіги, обгортка над $_SESSION) він доречний. Для сутності з фіксованими полями геттери завжди виграють: вони типізовані, автодоповнюються і не приховують друкарську помилку.

declare(strict_types=1);

/**
 * @implements IteratorAggregate<int, LineItem>
 */
final class Cart implements IteratorAggregate, Countable, JsonSerializable
{
    /** @param list<LineItem> $items */
    public function __construct(private array $items = []) {}

    // один метод замість пʼяти: стан обходу тримає генератор
    public function getIterator(): Generator
    {
        yield from $this->items;      // новий генератор на кожен foreach
    }

    public function count(): int
    {
        return count($this->items);   // без Countable count($cart) - TypeError
    }

    /** @return array{items: list<LineItem>, total: string, created_at: string} */
    public function jsonSerialize(): array
    {
        return [
            'items' => $this->items,  // вкладені JsonSerializable теж обробляться
            'total' => $this->total()->amount(),
            'created_at' => $this->createdAt->format(DATE_ATOM),
        ];
    }
}

$cart = new Cart([new LineItem('Книга', 2)]);

foreach ($cart as $item) { /* Traversable, а не властивості обʼєкта */ }
echo count($cart);                    // 1
echo json_encode($cart, JSON_THROW_ON_ERROR);

// Stringable: приймаємо і рядок, і будь-який обʼєкт з __toString()
function logLine(string|Stringable $message): void
{
    error_log((string) $message);     // приведення обовʼязкове
}
Що `IteratorAggregate` вимагає одного методу `getIterator(): Traversable`, і генератор із `yield` усередині нього закриває 90% випадків, тоді як `Iterator` - це пʼять методів і ручний стан.
Що `foreach` приймає лише `Traversable` (тобто `Iterator` або `IteratorAggregate`), напряму `Traversable` реалізувати не можна, а без нього `foreach` обходить публічні властивості обʼєкта.
Що `count($obj)` без `Countable` у PHP 8 кидає `TypeError`, а не повертає 1, як було до 8.0.
Що `json_encode()` без `JsonSerializable` бере лише публічні властивості, тому приватні поля зникають, а вкладений `DateTimeImmutable` перетворюється на `{"date":...,"timezone_type":...}`.
Що `Stringable` з PHP 8.0 додається класу автоматично при наявності `__toString()`, і його справжня цінність - тип `string|Stringable` у сигнатурі.
Що `ArrayAccess` не робить обʼєкт масивом: `array_map()`, `array_filter()`, spread і `list()` з ним не працюють, а `isset($obj['k'])` іде в `offsetExists()`.
Писати `Iterator` вручну там, де достатньо `IteratorAggregate` з `yield from $this->items` - пʼять методів замість одного, і `rewind()` майже завжди реалізований неправильно.
Реалізувати `Iterator` і потім здивуватися, що `iterator_to_array()` склеїв елементи: за замовчуванням функція зберігає ключі, а якщо `key()` повертає однакові значення, частина даних губиться. Рятує другий аргумент `false`.
Вважати, що `count()` на обʼєкті без `Countable` поверне 1: у PHP 8.0+ це `TypeError`, а `sizeof()` - той самий `count()`, псевдонім нічого не змінює.
Повертати з `jsonSerialize()` обʼєкт, який сам не серіалізується коректно, і отримувати порожній `{}` або рекурсію.
Чіпляти `ArrayAccess` на сутність, щоб «було зручно», і потім ловити `null` замість помилки, бо `offsetGet()` мовчки повернув значення за неіснуючим ключем.
Плутати `Traversable` з `Iterator` у type hint: параметр `iterable` приймає і масив, і `Traversable`, а `Iterator` відсіче звичайний `IteratorAggregate`.
ПОРАДА

Проведіть межу за кількістю роботи: «`IteratorAggregate` - коли обхід можна віддати генератору або вже наявному масиву; `Iterator` - коли позиція обходу є частиною стану обʼєкта, наприклад курсор до бази або парсер потоку». І одразу додайте, що `count()` на обʼєкті без `Countable` у PHP 8 - це `TypeError`: це показує, що ви бачили міграцію з сімки.

оновлено 9 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

До PHP 7.2 `count()` на не-`Countable` обʼєкті мовчки повертав 1, у 7.2 зʼявилося попередження, а з 8.0 сигнатура вимагає `Countable|array` і невідповідність дає `TypeError`. Публічні властивості рахує `count(get_object_vars($obj))`, а не сам `count()`.