Глава 69 · От понятного договора к реализации
Интерфейсы коллекций: foreach, скобки, count и строковое представление
Все методы Traversable, Iterator, IteratorAggregate, InternalIterator, Countable, ArrayAccess, Serializable и Stringable: от первой операции до правил собственного контейнера.
Зачем объекту уметь работать с foreach, count и квадратными скобками
Как count($basket) попадает в ваш класс
Корзина хранит две позиции. Интерфейс Countable связывает обычную функцию с вашим методом.
- echo count($basket)
Прикладной код запрашивает количество.
- Basket implements Countable
PHP узнаёт, что у объекта есть договор подсчёта.
- $basket->count()
Вызывается написанный вами метод корзины.
- return count($this->items)
Внутренний массив содержит Книгу и Лампу: результат 2.
- echo 2
Результат возвращается в место исходного вызова.
Countable не добавляет обход и доступ по ключу. Для них существуют независимые IteratorAggregate и ArrayAccess.
На сайте есть список товаров. Шаблону нужно вывести их, показать количество, иногда получить товар по артикулу. Пока данные лежат в массиве, подходят foreach, count и квадратные скобки. Когда вы переносите хранение и правила в класс, удобный внешний код хочется сохранить. Предопределённые интерфейсы позволяют договориться с PHP: «при такой операции вызови такие методы моего объекта».
Интерфейс — набор обязательных методов с согласованными параметрами и результатами. Он сам не хранит товары и не содержит вашу бизнес-логику. Слово implements после имени класса объявляет этот договор. public function объявляет доступный метод; $this обозначает текущий объект. private array $items — свойство с массивом, доступное только внутри класса; : int после скобок метода обещает целый результат, который return отдаёт вызывающему коду. Если это пока новые слова, сначала прочитайте класс и экземпляр и интерфейс на примере сервиса. Здесь каждый следующий договор изучаем отдельно.
count($basket)→ PHP видит Countable- Вызывает
$basket->count() - Метод считает выбранные вами элементы
- Возвращённое число попадает в место count($basket)
<?php
declare(strict_types=1);
class Basket implements Countable
{
private array $items = ['Чай', 'Кофе'];
public function count(): int { return count($this->items); }
}
$basket = new Basket();
echo 'В корзине ', count($basket), " позиции. Можно перейти к оформлению.\n";
Ожидаемый результат
В корзине 2 позиции. Можно перейти к оформлению.
count($basket) обращается к count() класса, потому что он выполняет договор Countable. Возвращается число позиций, а не число единиц товара: следующая часть сравнивает эти два смысла. Сам count не проверяет остаток и возможность оплаты.
Traversable: объект можно передать как последовательность
Сервис экспорта хочет принимать и готовый массив, и объект, который выдаёт записи постепенно. Тип iterable разрешает массив или Traversable. Сам Traversable — маркер без методов. Пользовательский конкретный класс реализует Iterator или IteratorAggregate, которые уже наследуют этот маркер. Одного implements Traversable для такого класса недостаточно.
| Вид значения | Поддерживаемый договор |
|---|---|
| Массив | iterable, но не объект Traversable |
| Iterator / IteratorAggregate / Generator | Объект Traversable и iterable |
| Обычный объект с публичными свойствами | foreach возможен по доступным свойствам; это не обещает iterable |
<?php
declare(strict_types=1);
function printNames(iterable $names): void
{
foreach ($names as $name) { echo 'Этикетка: ', $name, "\n"; }
}
printNames(['Чай']);
printNames(new ArrayIterator(['Кофе']));
$record = (object) ['title' => 'Сахар'];
try { printNames($record); }
catch (TypeError $error) { echo "Обычная запись объекта не выполняет договор iterable\n"; }
foreach ($record as $name => $value) { echo 'Доступное поле ', $name, ': ', $value, "\n"; }
Ожидаемый результат
Этикетка: Чай
Этикетка: Кофе
Обычная запись объекта не выполняет договор iterable
Доступное поле title: Сахар
iterable принимает массив или Traversable. Обычный stdClass допускает специальный foreach по доступным свойствам, но не становится от этого Traversable. Различие видно на реальном типизированном входе printNames. Напрямую пользовательский класс обычно реализует Iterator либо IteratorAggregate, а не один маркер Traversable.
В следующем классе __construct(private array $items) — сокращённое объявление свойства через параметр конструктора: при new ProductList([...]) PHP сохраняет переданный массив в закрытом $this->items. Это называется продвижением свойства; развёрнутая форма объясняется в главе о конструкторах. getIterator(): Traversable обещает вернуть объект обхода.
<?php
declare(strict_types=1);
class ProductList implements IteratorAggregate {
public function __construct(private array $items) {}
public function getIterator(): Traversable {
return new ArrayIterator($this->items);
}
}
function showNames(iterable $names): void {
foreach ($names as $name) { echo $name, "\n"; }
}
showNames(['Книга']);
showNames(new ProductList(['Лампа']));
Ожидаемый результат
Книга
Лампа
getIterator передаёт PHP объект обхода. Параметр iterable не требует именно ProductList и не загружает все записи заранее. Но он также не гарантирует повторный обход, известное количество или допустимый тип каждой записи: эти условия описывает ваш API.
С PHP 8.0 абстрактному классу разрешено объявить implements Traversable, но его конкретный потомок всё равно должен реализовать Iterator или IteratorAggregate. До PHP 8.0 это запрещалось и абстрактным пользовательским классам. Встроенные классы PHP могут иметь собственную реализацию Traversable без этих двух интерфейсов. Маркер не имеет конструктора и не создаёт экземпляр через new.
Iterator: одна закладка и пять методов обхода
- rewind ставит позицию на начало
- valid проверяет существование позиции, не значение элемента
- current и key дают товар и ключ
- Тело foreach использует запись
- next сдвигает позицию; valid решает, продолжать ли
Итератор можно представить как список с закладкой текущей позиции. current читает значение у закладки, key читает ключ, next передвигает её, valid проверяет существование позиции, rewind возвращает к началу. Аналогия не означает, что весь источник уже лежит в памяти: реальный итератор может читать файл, страницу API или вычислять значения.
Начнём со списка в памяти. $position = 0 хранит позицию, array_values убирает исходные ключи и делает последовательные номера. Поэтому эта конкретная реализация намеренно не сохраняет артикулы в ключах. array_key_exists проверяет наличие элемента даже со значением null. Оператор ?? выбирает запасной null при отсутствующем значении; это не проверка окончания обхода.
- rewind() → позиция 0
- valid() → есть элемент?
- Да: current() и key() → переменные цикла
- Выполнить тело → next() → снова valid()
- Нет: закончить цикл
<?php
declare(strict_types=1);
class ProductCursor implements Iterator {
private int $position = 0;
private array $items;
public function __construct(array $items) {
$this->items = array_values($items);
}
public function current(): mixed {
return $this->items[$this->position] ?? null;
}
public function key(): mixed {
return $this->valid() ? $this->position : null;
}
public function next(): void { ++$this->position; }
public function rewind(): void { $this->position = 0; }
public function valid(): bool {
return array_key_exists($this->position, $this->items);
}
}
$cursor = new ProductCursor(['Книга', null, 'Лампа']);
foreach ($cursor as $key => $value) {
echo $key, ':', $value ?? '(нет подписи)', "\n";
}
Ожидаемый результат
0:Книга
1:(нет подписи)
2:Лампа
Пять методов вызываются PHP автоматически. Если valid использовал бы isset для внутреннего массива, null ошибочно закончил бы обход на позиции 1. Наш current возвращает null и для существующего null, и вне списка, поэтому завершение определяет именно valid.
<?php
class TracedCursor implements Iterator {
private int $position = 0;
public function rewind(): void { echo "rewind\n"; $this->position = 0; }
public function valid(): bool { echo "valid\n"; return $this->position === 0; }
public function current(): mixed { echo "current\n"; return 'Книга'; }
public function key(): mixed { echo "key\n"; return 'A'; }
public function next(): void { echo "next\n"; ++$this->position; }
}
foreach (new TracedCursor() as $key => $value) {
echo $key, ':', $value, "\n";
}
Ожидаемый результат
rewind
valid
current
key
A:Книга
next
valid
Здесь обе переменные цикла используются, поэтому видны current и key. Диагностический echo нужен только для учебной трассировки; реальный курсор не должен загрязнять HTTP-ответ служебными строками. break заканчивает обход раньше, без обычного перехода next после прерванного тела.
<?php
declare(strict_types=1);
class ProductCursor implements Iterator {
private int $position = 0;
private array $items;
public function __construct(array $items) {
$this->items = array_values($items);
}
public function current(): mixed {
return $this->items[$this->position] ?? null;
}
public function key(): mixed {
return $this->valid() ? $this->position : null;
}
public function next(): void { ++$this->position; }
public function rewind(): void { $this->position = 0; }
public function valid(): bool {
return array_key_exists($this->position, $this->items);
}
}
$cursor = new ProductCursor(['Книга', 'Лампа']);
foreach ($cursor as $value) {
echo $value, "\n";
break;
}
echo $cursor->current(), "\n";
$cursor->next();
echo $cursor->current(), "\n";
Ожидаемый результат
Книга
Книга
Лампа
Iterator не содержит события «цикл прервали». Освобождение внешних ресурсов нельзя завязывать только на next после последней записи. Используйте явное закрытие и finally, а для потоковых последовательностей изучите Generator и его время жизни.
IteratorAggregate: коллекция выдаёт отдельную закладку каждому обходу
Два вложенных цикла по одному курсору используют одну позицию: внутренний цикл сдвигает её у внешнего. Коллекции обычно удобнее реализовать IteratorAggregate: она хранит данные и при каждом getIterator создаёт новый курсор. Сам интерфейс не обещает новую закладку — это решение реализации, поэтому ниже явно стоит new ArrayIterator.
- Внешний foreach → getIterator() → курсор A
- Внутренний foreach → getIterator() → курсор B
- Курсор B дошёл до конца → курсор A остаётся на своей позиции
- Внешний цикл продолжает следующий товар
<?php
declare(strict_types=1);
class ProductList implements IteratorAggregate {
public function __construct(private array $items) {}
public function getIterator(): Traversable {
return new ArrayIterator($this->items);
}
}
$products = new ProductList(['A', 'B']);
foreach ($products as $left) {
foreach ($products as $right) {
echo $left, '+', $right, "\n";
}
}
Ожидаемый результат
A+A
A+B
B+A
B+B
Четыре результата подтверждают независимые позиции. Повторное возвращение одного сохранённого Iterator из getIterator этой гарантии не дало бы. Сами объекты-товары при этом могут оставаться общими: отдельный курсор не означает глубокое клонирование содержимого.
<?php
declare(strict_types=1);
class ProductCursor implements Iterator {
private int $position = 0;
private array $items;
public function __construct(array $items) {
$this->items = array_values($items);
}
public function current(): mixed {
return $this->items[$this->position] ?? null;
}
public function key(): mixed {
return $this->valid() ? $this->position : null;
}
public function next(): void { ++$this->position; }
public function rewind(): void { $this->position = 0; }
public function valid(): bool {
return array_key_exists($this->position, $this->items);
}
}
$cursor = new ProductCursor(['A', 'B']);
foreach ($cursor as $left) {
foreach ($cursor as $right) { echo $left, '+', $right, "\n"; }
}
Ожидаемый результат
A+A
A+B
Внутренний foreach исчерпал ту же позицию. Внешний уже не попал на B. Это наблюдаемое поведение конкретного конечного курсора, а не универсальный рецепт: другие вложенные схемы с break способны зациклиться. Возвращайте свежий итератор или осознанно организуйте независимое состояние.
getIterator возвращает Traversable, а не массив. Можно вернуть ArrayIterator или Generator, который получается у метода с yield. Прямой return $this->items с типом Traversable вызовет TypeError. Интерфейс появился в PHP 5; современные возвращаемые типы в примерах соответствуют PHP 8. В старых библиотеках возможен атрибут ReturnTypeWillChange для переходного периода — собственный новый код лучше сразу объявлять с подходящими типами.
Countable: определить, что именно считает интерфейс
В корзине две позиции, но пять единиц товара. Оба числа полезны, поэтому договор нужно назвать и описать. count($basket) обычно считает позиции; totalQuantity() отдельно суммирует количество. Countable требует только count(): int и не устанавливает, насколько быстро он выполняется.
<?php
class Basket implements Countable {
private array $quantities = [2, 3];
public function count(): int { return count($this->quantities); }
public function totalQuantity(): int { return array_sum($this->quantities); }
}
$basket = new Basket();
echo 'Позиций: ', count($basket), "\n";
echo 'Единиц: ', $basket->totalQuantity(), "\n";
Ожидаемый результат
Позиций: 2
Единиц: 5
Два метода отвечают на разные вопросы. count с COUNT_RECURSIVE не передаёт режим вашему count(): параметров у интерфейсного метода нет. Он не заставляет пользовательский объект автоматически считать вложенные элементы.
Если count делает SQL-запрос или проходит весь генератор, его повторение в условии цикла может быть дорогим или менять состояние. Лучше явный метод получения количества, сохранённое число при неизменном наборе либо серверный COUNT по нужному фильтру. iterator_count проходит источник и может исчерпать его; наличие Traversable само по себе не означает Countable.
<?php
declare(strict_types=1);
class ProductList implements IteratorAggregate {
public function __construct(private array $items) {}
public function getIterator(): Traversable {
return new ArrayIterator($this->items);
}
}
$products = new ProductList(['Книга', 'Лампа']);
foreach ($products as $name) { echo "Товар: ", $name, "\n"; }
echo is_countable($products) ? "Есть договор подсчёта\n" : "Отдельного договора подсчёта нет\n";
try { count($products); }
catch (TypeError $error) { echo "Нужен Countable или массив\n"; }
Ожидаемый результат
Товар: Книга
Товар: Лампа
Отдельного договора подсчёта нет
Нужен Countable или массив
Коллекция умеет выдавать элементы, но не реализует Countable. Поэтому count вызывает TypeError, хотя foreach уже сработал. Для потока подсчёт через полный проход может выполнить всю дорогую работу и исчерпать источник; не добавляйте его незаметно ради бейджа.
ArrayAccess: четыре входа для квадратных скобок
Начните с привычного массива: $settings["theme"] = "dark" сохраняет настройку, чтение получает её, isset спрашивает наличие, unset удаляет. ArrayAccess позволяет объекту перехватить эти четыре операции и задать собственные проверки. Квадратные скобки сами не дают foreach или count — для них нужны другие договоры.
| Запись вызывающего | Какой метод PHP вызовет |
|---|---|
| $settings["theme"] | offsetGet("theme") |
| $settings["theme"] = "dark" | offsetSet("theme", "dark") |
| isset($settings["theme"]) | offsetExists("theme") |
| unset($settings["theme"]) | offsetUnset("theme") |
Контейнер настроек может дать привычное $settings["theme"], при этом проверить допустимость ключей и сообщить о пропущенной настройке. ArrayAccess предоставляет четыре входа. В полном примере ниже private $items хранит данные, requireKey проверяет int|string, throw прекращает ошибочный вызов с исключением. null у offsetSet означает добавление без явно заданного ключа.
| Запись в прикладном коде | Метод объекта | Что передаёт PHP |
|---|---|---|
$settings["theme"] | offsetGet | Ключ theme |
$settings["theme"] = "dark" | offsetSet | Ключ theme и значение dark |
isset($settings["theme"]) | offsetExists | Ключ theme |
unset($settings["theme"]) | offsetUnset | Ключ theme |
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['theme'] = 'dark';
echo "Тема: ", $settings['theme'], "\n";
echo isset($settings['theme']) ? "Тема задана\n" : "Будет использована тема по умолчанию\n";
unset($settings['theme']);
echo isset($settings['theme']) ? "Тема задана\n" : "Будет использована тема по умолчанию\n";
Ожидаемый результат
Тема: dark
Тема задана
Будет использована тема по умолчанию
Класс показывает полный договор четырёх операций. offsetSet получает null для записи через пустые скобки [] и добавляет следующий элемент; для именованной настройки принимает int/string. В этом варианте offsetExists использует isset, поэтому значение null считается отсутствующим. Такая политика должна быть явной.
empty сначала вызывает offsetExists; если тот вернул false, результат empty сразу true. Если true — PHP вызывает offsetGet и проверяет полученное значение на пустоту. Поэтому 0, "0", false и null требуют ясного контракта. В нашем контейнере isset внутреннего массива делает null «не установленным», но прямое чтение null разрешено через array_key_exists.
<?php
class Probe implements ArrayAccess {
public function offsetExists(mixed $offset): bool {
echo "exists:$offset\n"; return $offset === 'zero';
}
public function offsetGet(mixed $offset): mixed { echo "get:$offset\n"; return 0; }
public function offsetSet(mixed $offset, mixed $value): void {}
public function offsetUnset(mixed $offset): void {}
}
$probe = new Probe();
var_dump(isset($probe['zero']));
var_dump(empty($probe['zero']));
var_dump(empty($probe['missing']));
Ожидаемый результат
exists:zero
bool(true)
exists:zero
get:zero
bool(true)
exists:missing
bool(true)
isset не прочитал значение через get. empty прочитал только существующее zero. Пустые методы set/unset в этом диагностическом классе не сохраняют ничего и не подходят как готовый контейнер приложения; рабочая реализация находится выше.
Вложенное изменение: почему offsetSet иногда не вызывается
Запись $settings["mail"] = [...] полностью заменяет элемент и вызывает offsetSet. Запись $settings["mail"]["enabled"] = true сначала получает внутреннее значение через offsetGet, а затем пытается изменить его часть. При обычном возврате массива по значению изменение не дойдёт до хранилища и PHP сообщит об indirect modification. То же касается ++/-- и получения элемента по ссылке.
| Способ | Путь изменения | Где проверяется запись |
|---|---|---|
| Явная обратная запись | Прочитать → изменить локальный массив → записать весь элемент | Через offsetSet |
| Getter по ссылке | Получить ссылку на внутренний элемент → изменить часть | offsetSet не участвует; это часть договора контейнера |
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['mail'] = ['enabled' => false];
$mail = $settings['mail'];
$mail['enabled'] = true;
$settings['mail'] = $mail;
echo $settings['mail']['enabled'] ? "Почтовые уведомления включены\n" : "Почтовые уведомления выключены\n";
Ожидаемый результат
Почтовые уведомления включены
offsetGet вернул массив по значению: $mail — изменяемая локальная копия. Последняя запись $settings["mail"] = $mail вызывает offsetSet и сохраняет результат. Это ясная граница проверки всего нового значения. Прямая вложенная запись не обязана вызвать offsetSet.
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function &offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['mail'] = ['enabled' => false];
$settings['mail']['enabled'] = true;
echo $settings['mail']['enabled'] ? "Почтовые уведомления включены напрямую\n" : "Почтовые уведомления выключены\n";
Ожидаемый результат
Почтовые уведомления включены напрямую
&offsetGet возвращает связь с хранимым элементом, поэтому вложенная запись изменяет его на месте. Она обходит целостную проверку в offsetSet. Это осознанный более открытый договор; для настроек с инвариантами предпочтительнее явный метод enableMail() или прочитать → изменить → записать обратно.
Возврат ссылки из offsetGet совместим с интерфейсом в современных PHP; старые обсуждения до PHP 5.3.4 содержат уже неактуальные ограничения. Ссылка на отсутствующий элемент может незаметно создать его, если просто вернуть $this->items[$key]. Наш вариант сначала проверяет наличие и выбрасывает исключение. Подробнее: семантика ссылок.
InternalIterator: курсор, который выдаёт встроенный объект
Некоторые встроенные классы предоставляют обход через final InternalIterator. Создавать его вручную не нужно и нельзя: конструктор private. Получите его у объекта-владельца. В примере WeakMap хранит подписи по объектам-ключам, а getIterator возвращает готовый курсор. Переменные $first/$second намеренно сохраняют ключи живыми.
<?php
declare(strict_types=1);
$first = new stdClass();
$second = new stdClass();
$map = new WeakMap();
$map[$first] = 'Первый';
$map[$second] = 'Второй';
$cursor = $map->getIterator();
echo get_class($cursor), "\n";
$cursor->rewind();
while ($cursor->valid()) {
echo $cursor->current(), "\n";
$cursor->next();
}
Ожидаемый результат
InternalIterator
Первый
Второй
Методы знакомы по Iterator. key этого курсора возвращает объект, потому что источник — WeakMap, а не массив. Не преобразуйте такой обход в массив с сохранением ключей: объекты не могут быть ключами обычного массива. Поведение и изменение источника определяет сам встроенный контейнер.
Класс доступен с PHP 8.0 и не предназначен для наследования. current и key возвращают mixed; next и rewind ничего не возвращают, valid — bool. Проверяйте valid перед чтением позиции: не опирайтесь на случайное значение вне диапазона. Общая оболочка не обещает кэширование, сортировку, независимые копии или пригодность для многократного чтения любого источника.
Serializable: прочитать старые данные и перейти к современному договору
Сериализация превращает состояние объекта в строку, чтобы сохранить его и позже восстановить. Она не равна выводу подписи пользователю и не заменяет JSON-контракт внешнего API. Исторический интерфейс Serializable использует методы serialize(): ?string и unserialize(string $data): void. Современный новый класс обычно использует магические __serialize(): array и __unserialize(array $data): void, доступные с PHP 7.4.
С PHP 8.1 класс, реализующий Serializable без обоих современных магических методов, получает уведомление deprecated. Если объявлены все четыре метода, новая serialize($object) предпочитает __serialize. Но старый сохранённый формат C по-прежнему требует Serializable::unserialize. Современный формат O направляется в __unserialize. Поэтому при миграции недостаточно просто удалить интерфейс, если в базе ещё лежат старые строки.
| Операция в классе с четырьмя методами | Какой путь выбирает PHP |
|---|---|
| Новая serialize($task) | __serialize() → современный формат O |
| Чтение формата O | __unserialize(array) → проверка и восстановление |
| Чтение старого формата C | Интерфейсный unserialize(string) → проверка и восстановление |
<?php
declare(strict_types=1);
class SavedTask implements Serializable {
public static int $constructed = 0;
public function __construct(public string $label) {
++self::$constructed;
}
public function __serialize(): array {
return ['label' => $this->label];
}
public function __unserialize(array $data): void {
if (!isset($data['label']) || !is_string($data['label'])) {
throw new UnexpectedValueException('Нет строковой метки');
}
$this->label = $data['label'];
}
public function serialize(): ?string {
return json_encode($this->__serialize(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
public function unserialize(string $data): void {
$decoded = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new UnexpectedValueException('Ожидался объект данных');
}
$this->__unserialize($decoded);
}
}
$task = new SavedTask('Заказ');
$payload = serialize($task);
echo substr($payload, 0, 2), "\n";
$restored = unserialize($payload, ['allowed_classes' => [SavedTask::class]]);
echo $restored->label, "\n";
echo SavedTask::$constructed, "\n";
Ожидаемый результат
O:
Заказ
1
Конструктор вызван только у исходного new. Восстановление проверяет и присваивает поля через __unserialize, не вызывает __construct автоматически. Пример работает только с собственной строкой, созданной выше. allowed_classes ограничивает классы, но не превращает произвольный внешний unserialize в безопасный JSON-парсер.
<?php
declare(strict_types=1);
class SavedTask implements Serializable {
public static int $constructed = 0;
public function __construct(public string $label) {
++self::$constructed;
}
public function __serialize(): array {
return ['label' => $this->label];
}
public function __unserialize(array $data): void {
if (!isset($data['label']) || !is_string($data['label'])) {
throw new UnexpectedValueException('Нет строковой метки');
}
$this->label = $data['label'];
}
public function serialize(): ?string {
return json_encode($this->__serialize(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
public function unserialize(string $data): void {
$decoded = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new UnexpectedValueException('Ожидался объект данных');
}
$this->__unserialize($decoded);
}
}
$body = '{"label":"Архив"}';
$name = SavedTask::class;
$payload = 'C:' . strlen($name) . ':"' . $name . '":' . strlen($body) . ':{' . $body . '}';
$restored = unserialize($payload, ['allowed_classes' => [SavedTask::class]]);
echo $restored->label, "\n";
echo SavedTask::$constructed, "\n";
Ожидаемый результат
Архив
0
Строка C специально построена из контролируемых локальных данных для демонстрации совместимости; размеры измеряются в байтах strlen. В прикладном коде не собирайте формат PHP вручную. Интерфейсный unserialize действует как восстановитель объекта; обычный конструктор не запускается.
Serializable заменяет __sleep/__wakeup старым собственным протоколом. Сериализация сама по себе не вызывает деструктор; дополнительные действия происходят только если ваш код их выполняет. Метод serialize должен вернуть string или null. Метод unserialize получает строковое содержимое и его возвращаемое значение не используется. Магические методы не имеют отдельного интерфейса: класс только с __serialize не становится Serializable. Для нового API лучше явный собственный контракт, а не проверка устаревшего интерфейса.
Stringable: понятная строка из объекта без потери самого объекта
Номер заказа можно хранить объектом с правилами, но выводить как ORD-42. Метод __toString возвращает эту строку. Stringable появился в PHP 8.0; класс с __toString автоматически его реализует, хотя явное implements Stringable помогает читателю увидеть договор. Это не выгрузка всех полей и не HTML-экранирование.
- Объект OrderNumber хранит число 42
- echo или (string) требует строку
- __toString() возвращает "ORD-42"
- Исходный объект остаётся объектом
<?php
declare(strict_types=1);
class OrderNumber implements Stringable
{
public function __construct(private int $number) {}
public function __toString(): string { return 'ORD-' . $this->number; }
}
function receiptTitle(string|Stringable $number): string
{
return 'Чек для ' . (string) $number;
}
$number = new OrderNumber(42);
echo receiptTitle($number), "\n";
echo receiptTitle('Внешний заказ B-7'), "\n";
Ожидаемый результат
Чек для ORD-42
Чек для Внешний заказ B-7
string|Stringable принимает готовый текст или объект со строковым представлением. Явное implements здесь документирует намерение; PHP также связывает подходящий __toString со Stringable. В strict_types чистый параметр string не обязан принимать объект автоматически — это следующий отдельный опыт.
<?php
declare(strict_types=1);
class OrderNumber implements Stringable {
public function __construct(private int $number) {}
public function __toString(): string {
return 'ORD-' . $this->number;
}
}
function showText(string $text): void { echo $text, "\n"; }
function showLabel(string|Stringable $text): void { echo (string) $text, "\n"; }
$number = new OrderNumber(42);
try { showText($number); }
catch (TypeError $error) { echo "Нужно явное преобразование\n"; }
showText((string) $number);
showLabel($number);
Ожидаемый результат
Нужно явное преобразование
ORD-42
ORD-42
Этот самостоятельный пример начинается с declare(strict_types=1). string|Stringable явно разрешает обе формы. В нестрогом вызове допустимо скалярное преобразование объекта с __toString к string; тип Stringable сам по себе при этом не принимает обычную строку, потому что она не объект.
Не выполняйте скрытые HTTP-запросы или тяжёлые вычисления в __toString: случайный вывод объекта тогда становится дорогой операцией. С PHP 7.4 __toString может выбросить исключение; в PHP 8 неправильный результат приводит к ошибке типа при строгом объявлении. Держите представление предсказуемым и отдельно предоставляйте сложную загрузку/форматирование.
Выбрать договор под потребность и не обещать лишнего
| Задача | Договор | Чего он сам не добавляет |
|---|---|---|
| Передать последовательность из массива или объекта | iterable / Traversable | Количество, повторяемость, доступ по ключу |
| Управлять одной текущей позицией | Iterator | Независимые вложенные обходы |
| Выдавать отдельный обход коллекции | IteratorAggregate | Обязательное создание свежего курсора |
| Получить количество объектов/позиций | Countable | Дешевизну подсчёта, foreach |
| Работать с объектом через квадратные скобки | ArrayAccess | Остальные функции массива |
| Читать исторический PHP-формат C | Serializable + современные magic-методы | Безопасность произвольных внешних данных |
| Получить строковую подпись объекта | Stringable | JSON/HTML/полный снимок состояния |
Один класс может реализовать несколько независимых интерфейсов. Для каталога часто достаточно IteratorAggregate и Countable, а изменение лучше оставить явным addProduct(), если требуются проверки. Для простой последовательности расчёта генератор короче собственного Iterator. Для массива без собственных правил дополнительный класс может вообще не потребоваться.
<?php
class Catalog implements IteratorAggregate, Countable {
public function __construct(private array $names) {}
public function getIterator(): Traversable { return new ArrayIterator($this->names); }
public function count(): int { return count($this->names); }
}
$catalog = new Catalog(['Книга', 'Лампа']);
echo 'Найдено: ', count($catalog), "\n";
foreach ($catalog as $name) { echo '- ', $name, "\n"; }
Ожидаемый результат
Найдено: 2
- Книга
- Лампа
В шаблоне остаются знакомые операции, а объект хранит данные закрыто. В реальном HTML дополнительно экранируйте $name. Этот учебный контейнер не проверяет тип каждого элемента входного массива; проверка каталожных записей — отдельный шаг, не свойство интерфейса.
Дальше: проектирование интерфейсов, потоковая обработка, WeakMap, исследование методов и интерфейсов объекта.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
Iterator::currentПрочитать значение у текущей закладки, не передвигая её.
public function current(): mixedПрочитать значение у текущей закладки, не передвигая её.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Значение любого типа; завершение проверяйте отдельно через valid. |
| Положение | Чтение не должно само переходить на следующую запись. |
- Позиция 0 → current() → Книга
- Снова current() → всё ещё Книга
- next() → позиция 1 → current() → Лампа
<?php
declare(strict_types=1);
$cursor = new ArrayIterator(['Чай', 'Кофе']);
echo 'На этикетке: ', $cursor->current(), "\n";
echo 'Повторное чтение: ', $cursor->current(), "\n";
$cursor->next();
echo 'Следующий товар: ', $cursor->current(), "\n";
Ожидаемый результат
На этикетке: Чай
Повторное чтение: Чай
Следующий товар: Кофе
current читает значение текущей позиции; повторное чтение не двигает позицию. next выполняет отдельный переход. ArrayIterator — готовая реализация Iterator; собственная реализация пяти методов разобрана в главе.
<?php
declare(strict_types=1);
class ProductCursor implements Iterator {
private int $position = 0;
private array $items;
public function __construct(array $items) {
$this->items = array_values($items);
}
public function current(): mixed {
return $this->items[$this->position] ?? null;
}
public function key(): mixed {
return $this->valid() ? $this->position : null;
}
public function next(): void { ++$this->position; }
public function rewind(): void { $this->position = 0; }
public function valid(): bool {
return array_key_exists($this->position, $this->items);
}
}
$cursor = new ProductCursor([null]);
var_dump($cursor->valid(), $cursor->current());
$cursor->next();
var_dump($cursor->valid(), $cursor->current());
Ожидаемый результат
bool(true)
NULL
bool(false)
NULL
Одинаковый результат current не различает элемент null и конец. Нельзя писать while ($cursor->current()) для обхода данных, где допустимы null, 0, false или пустая строка. Поведение чтения вне позиции выбирает реализация, здесь она возвращает null.
Связанные методы: Iterator::valid, Iterator::next, Iterator::key. Карта интерфейсов.
Iterator::keyПрочитать идентификатор текущего элемента.
public function key(): mixedПрочитать идентификатор текущего элемента.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Ключ текущей записи. Для обычной совместимости с массивами выбирайте int|string. |
| Вне позиции | В справке описан null и возможный E_NOTICE при неудаче; конкретная реализация определяет поведение. Проверяйте valid. |
- key() → где/под каким именем
- current() → какие данные
- next() меняет текущую пару
<?php
declare(strict_types=1);
$cursor = new ArrayIterator(['Чай', 'Кофе']);
echo 'Позиция ', $cursor->key(), ': ', $cursor->current(), "\n";
$cursor->next();
echo 'Позиция ', $cursor->key(), ': ', $cursor->current(), "\n";
Ожидаемый результат
Позиция 0: Чай
Позиция 1: Кофе
key возвращает ключ текущего элемента. В этом списке ключи 0 и 1; в карте артикулов они могли бы быть строками. Интерфейс не обязывает ключ быть целым; следующий пример показывает артикул как ключ.
<?php
$cursor = new ArrayIterator(['A-10' => 'Книга', 'B-20' => 'Лампа']);
$cursor->rewind();
while ($cursor->valid()) {
echo $cursor->key(), ':', $cursor->current(), "\n";
$cursor->next();
}
Ожидаемый результат
A-10:Книга
B-20:Лампа
Тип mixed в интерфейсе шире int|string. Некоторые источники, например WeakMap, дают объект-ключ через InternalIterator; его нельзя автоматически сохранить как ключ обычного массива. Ожидаемый тип ключа включайте в описание своей коллекции.
Связанные методы: Iterator::current, Iterator::valid, InternalIterator::key. Карта интерфейсов.
Iterator::nextПередвинуть закладку на следующую запись.
public function next(): voidПередвинуть закладку на следующую запись.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Значения нет; это действие, а не чтение следующего элемента. |
| В foreach | Вызывается после завершённого тела итерации, затем valid. При break обычный следующий шаг не выполняется. |
- next() → перемещение
- valid() → существует ли позиция
- current() → прочитать данные
<?php
declare(strict_types=1);
$cursor = new ArrayIterator(['Чай', 'Кофе']);
echo 'Уже обработан: ', $cursor->current(), "\n";
$cursor->next();
if ($cursor->valid()) { echo 'Теперь обработать: ', $cursor->current(), "\n"; }
Ожидаемый результат
Уже обработан: Чай
Теперь обработать: Кофе
next меняет позицию и возвращает void. Проверка valid перед чтением предотвращает попытку использовать несуществующий элемент после конца. Для обычного полного прохода эти действия делает foreach.
<?php
declare(strict_types=1);
class ProductCursor implements Iterator {
private int $position = 0;
private array $items;
public function __construct(array $items) {
$this->items = array_values($items);
}
public function current(): mixed {
return $this->items[$this->position] ?? null;
}
public function key(): mixed {
return $this->valid() ? $this->position : null;
}
public function next(): void { ++$this->position; }
public function rewind(): void { $this->position = 0; }
public function valid(): bool {
return array_key_exists($this->position, $this->items);
}
}
$cursor = new ProductCursor(['A', 'B']);
$cursor->rewind();
while ($cursor->valid()) {
echo $cursor->current(), "\n";
$cursor->next();
}
var_dump($cursor->valid());
Ожидаемый результат
A
B
bool(false)
Забытый next сделал бы этот цикл бесконечным. Обычному выводу списка лучше подходит foreach; ручное управление полезно при пошаговой обработке, объединении потоков и отладке курсора.
Связанные методы: Iterator::current, Iterator::valid, Iterator::rewind. Карта интерфейсов.
Iterator::rewindУстановить начало обхода.
public function rewind(): voidУстановить начало обхода.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Значения нет. |
| Автоматический вызов | foreach вызывает rewind перед началом, а не после окончания. Ранее выбранная вручную позиция обычно сбрасывается. |
- Курсор был на позиции 1
- foreach → rewind() → позиция 0
- Первым снова читается A
<?php
declare(strict_types=1);
$cursor = new ArrayIterator(['Чай', 'Кофе']);
$cursor->next();
echo 'До прохода: ', $cursor->current(), "\n";
foreach ($cursor as $title) { echo 'Этикетка: ', $title, "\n"; }
Ожидаемый результат
До прохода: Кофе
Этикетка: Чай
Этикетка: Кофе
foreach вызывает rewind перед обходом, поэтому печать началась с Чая. Конкретный Iterator обязан определить допустимость перемотки; Generator нельзя считать произвольно перематываемым массивом. NoRewindIterator в следующем примере сохраняет текущую позицию.
<?php
declare(strict_types=1);
class ProductCursor implements Iterator {
private int $position = 0;
private array $items;
public function __construct(array $items) {
$this->items = array_values($items);
}
public function current(): mixed {
return $this->items[$this->position] ?? null;
}
public function key(): mixed {
return $this->valid() ? $this->position : null;
}
public function next(): void { ++$this->position; }
public function rewind(): void { $this->position = 0; }
public function valid(): bool {
return array_key_exists($this->position, $this->items);
}
}
$cursor = new ProductCursor(['A', 'B', 'C']);
$cursor->next();
foreach (new NoRewindIterator($cursor) as $value) {
echo $value, "\n";
}
Ожидаемый результат
B
C
NoRewindIterator из SPL подавляет сброс при начале foreach. Это полезно при продолжении уже начатого чтения. Он не клонирует источник: исходный курсор тоже продвигается.
Связанные методы: Iterator::next, Iterator::valid, IteratorAggregate::getIterator. Карта интерфейсов.
Iterator::validПроверить, есть ли элемент в текущей позиции.
public function valid(): boolПроверить, есть ли элемент в текущей позиции.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | true — позиция существует, false — закончить обход. |
| Вызов | После rewind и каждого next. Истинность самого значения не определяет существование позиции. |
- Позиция существует → valid true
- Значение равно null или 0 → это всё ещё элемент
- Позиция за концом → valid false
<?php
declare(strict_types=1);
class ProductCursor implements Iterator {
private int $position = 0;
private array $items;
public function __construct(array $items) {
$this->items = array_values($items);
}
public function current(): mixed {
return $this->items[$this->position] ?? null;
}
public function key(): mixed {
return $this->valid() ? $this->position : null;
}
public function next(): void { ++$this->position; }
public function rewind(): void { $this->position = 0; }
public function valid(): bool {
return array_key_exists($this->position, $this->items);
}
}
$cursor = new ProductCursor([0, false, null]);
foreach ($cursor as $value) { var_dump($value); }
Ожидаемый результат
int(0)
bool(false)
NULL
array_key_exists в реализации valid сохраняет все три позиции. isset вернул бы false для null, а проверка current на истинность отсеяла бы ещё и ноль/false.
<?php
declare(strict_types=1);
$cursor = new ArrayIterator([]);
$cursor->rewind();
echo $cursor->valid() ? "Есть товар\n" : "Каталог пока пуст\n";
foreach ($cursor as $title) { echo 'Товар: ', $title, "\n"; }
echo "Можно предложить добавить первый товар\n";
Ожидаемый результат
Каталог пока пуст
Можно предложить добавить первый товар
valid сообщает существование текущей позиции. Для пустого источника foreach не выполнит тело ни разу. Это отличается от существующего элемента 0, false или null; они показаны рядом, чтобы не подменять valid проверкой значения.
Связанные методы: Iterator::current, Iterator::next, Iterator::rewind. Карта интерфейсов.
Countable::countВернуть выбранное коллекцией количество.
public function count(): intВернуть выбранное коллекцией количество.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет, включая параметр режима. |
| Результат | Целое количество по договору класса. |
| Автоматический вызов | count($object) вызывает этот метод при реализации Countable. |
- count($catalog)
- Catalog::count() → count внутреннего массива
- Вернуть число в подпись интерфейса
<?php
declare(strict_types=1);
class Queue implements Countable
{
private array $jobs = ['Письмо по заказу 42', 'Отчёт о продажах'];
public function count(): int { return count($this->jobs); }
}
$queue = new Queue();
echo 'Метод очереди: ', $queue->count(), " задания\n";
echo 'Функция PHP: ', count($queue), " задания\n";
Ожидаемый результат
Метод очереди: 2 задания
Функция PHP: 2 задания
Вызов count($queue) направляется в Countable::count реализации Queue. Оба пути дают количество ожидающих элементов. Это осмысленный счётчик интерфейса, но он не оценивает длительность работы и не сохраняет очередь после завершения процесса.
<?php
class Sections implements Countable {
private array $items = [['A', 'B'], ['C']];
public function count(): int { return count($this->items); }
}
$sections = new Sections();
echo count($sections), "\n";
echo count($sections, COUNT_RECURSIVE), "\n";
Ожидаемый результат
2
2
Обе операции считают две верхние секции: метод не принимает режим и не стал рекурсивным. Если приложению нужен итог по вложенным товарам, создайте явно названный метод. Стоимость count у пользовательского класса зависит от реализации, в отличие от простого чтения количества элементов массива.
Связанные методы: IteratorAggregate::getIterator, Iterator::valid. Карта интерфейсов.
ArrayAccess::offsetExistsРешить, считается ли ключ установленным для isset и empty.
public function offsetExists(mixed $offset): boolРешить, считается ли ключ установленным для isset и empty.
| Часть договора | Что означает |
|---|---|
| offset | Проверяемый ключ; допустимые типы определяет контейнер. |
| Результат | bool. Для нетипизированных старых реализаций результат приводился к bool. |
| empty | Если false, get не вызывается; если true, empty дополнительно читает offsetGet. |
- offsetExists(key)
- false → empty сразу true
- true → offsetGet(key) → проверить значение
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['null'] = null;
$settings['zero'] = 0;
var_dump(isset($settings['null']), isset($settings['zero']), isset($settings['missing']));
var_dump(empty($settings['zero']));
Ожидаемый результат
bool(false)
bool(true)
bool(false)
bool(true)
Реализация через isset внутреннего массива отличает ноль от отсутствия, но считает null не установленным. empty нуля true. Это не обязательно семантика другого ArrayAccess-класса.
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return array_key_exists($this->requireKey($offset), $this->items);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['optional'] = null;
var_dump(isset($settings['optional']));
var_dump(empty($settings['optional']));
Ожидаемый результат
bool(true)
bool(true)
Здесь isset объекта отражает наш offsetExists через array_key_exists, поэтому возвращает true даже для null. empty читает null и возвращает true. Такой выбор нужно явно документировать, чтобы читатель не перенёс привычную семантику массивов без проверки.
Связанные методы: ArrayAccess::offsetGet, ArrayAccess::offsetSet. Карта интерфейсов.
ArrayAccess::offsetGetПолучить значение по ключу или сообщить о недопустимом чтении.
public function offsetGet(mixed $offset): mixedПолучить значение по ключу или сообщить о недопустимом чтении.
| Часть договора | Что означает |
|---|---|
| offset | Ключ читаемого элемента. |
| Результат | Любой тип значения. Правило отсутствующего ключа задаёт реализация. |
| Непрямое изменение | Для изменения массива внутри элемента, ++/-- или получения ссылки метод должен возвращать реальную ссылку. |
- $settings["theme"]
- offsetGet("theme") проверяет наличие
- Возвращает строку или выбрасывает оговорённое исключение
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['optional'] = null;
var_dump($settings['optional']);
try { $settings['missing']; }
catch (OutOfBoundsException $error) { echo "Настройка не найдена\n"; }
Ожидаемый результат
NULL
Настройка не найдена
array_key_exists в getter позволяет хранить null, даже если offsetExists использует семантику isset. Возвращать null при любом пропуске тоже возможно, но тогда приложению труднее отличить два случая.
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function &offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['attempts'] = 0;
++ $settings['attempts'];
echo $settings['attempts'], "\n";
Ожидаемый результат
1
Префиксный ++ изменяет значение, полученное по ссылке; setter здесь не выполняет проверку новой величины. Для ограниченного числа попыток лучше явный incrementAttempts, который не позволит обойти правило. Возврат ссылки — возможность, не обязательное улучшение.
Связанные методы: ArrayAccess::offsetExists, ArrayAccess::offsetSet, ArrayAccess::offsetUnset. Карта интерфейсов.
ArrayAccess::offsetSetЗаписать целое значение по ключу или обработать добавление без ключа.
public function offsetSet(mixed $offset, mixed $value): voidЗаписать целое значение по ключу или обработать добавление без ключа.
| Часть договора | Что означает |
|---|---|
| offset | Ключ, либо null при записи $object[] = value. |
| value | Новое значение произвольного типа; проверка входит в ответственность класса. |
| Результат | Значения нет. Непрямое изменение подэлемента вызывает getter, не этот метод. |
- $settings["theme"] = "dark" → offsetSet("theme", "dark")
- $settings[] = "extra" → offsetSet(null, "extra")
- Класс решает: добавить, заменить или отклонить
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings[] = 'Первая';
$settings[] = 'Вторая';
echo $settings[0], ':', $settings[1], "\n";
Ожидаемый результат
Первая:Вторая
Автоматические числовые ключи создаёт наш внутренний массив в ветке offset === null. Сам интерфейс не обязан поддерживать добавление: словарь фиксированных настроек может выбрасывать исключение.
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['theme'] = 'light';
try { $settings[new stdClass()] = 'dark'; }
catch (InvalidArgumentException $error) { echo "Неверный ключ\n"; }
echo $settings['theme'], "\n";
Ожидаемый результат
Неверный ключ
light
Наш контейнер принимает только int|string, объект-ключ отклонён. Явный $settings[null] тоже попадёт в ветку добавления и неотличим по параметру от []. Если вам нужно различать такие операции, используйте явные методы API.
Связанные методы: ArrayAccess::offsetGet, ArrayAccess::offsetExists, ArrayAccess::offsetUnset. Карта интерфейсов.
ArrayAccess::offsetUnsetУдалить запись через unset($object[$key]).
public function offsetUnset(mixed $offset): voidУдалить запись через unset($object[$key]).
| Часть договора | Что означает |
|---|---|
| offset | Ключ удаляемой записи. |
| Результат | Значения нет; правило отсутствия определяет класс. |
| Границы | Удаляет элемент, не сам контейнер. unset переменной объекта не вызывает этот метод. |
- unset($settings["theme"])
- offsetUnset("theme")
- Внутреннее хранилище больше не содержит запись
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['theme'] = 'dark';
unset($settings['theme']);
var_dump(isset($settings['theme']));
unset($settings['missing']);
echo "Повторное удаление допустимо\n";
Ожидаемый результат
bool(false)
Повторное удаление допустимо
В этой реализации удаление отсутствующего корректного ключа безвредно. Другой контейнер может требовать ошибку или регистрировать событие удаления; согласуйте это с остальными методами.
<?php
declare(strict_types=1);
class Settings implements ArrayAccess {
private array $items = [];
private function requireKey(mixed $offset): int|string {
if (!is_int($offset) && !is_string($offset)) {
throw new InvalidArgumentException('Ключ должен быть int или string');
}
return $offset;
}
public function offsetExists(mixed $offset): bool {
return isset($this->items[$this->requireKey($offset)]);
}
public function offsetGet(mixed $offset): mixed {
$key = $this->requireKey($offset);
if (!array_key_exists($key, $this->items)) {
throw new OutOfBoundsException('Настройка не найдена');
}
return $this->items[$key];
}
public function offsetSet(mixed $offset, mixed $value): void {
if ($offset === null) {
$this->items[] = $value;
return;
}
$this->items[$this->requireKey($offset)] = $value;
}
public function offsetUnset(mixed $offset): void {
unset($this->items[$this->requireKey($offset)]);
}
}
$settings = new Settings();
$settings['theme'] = 'dark';
$also = $settings;
unset($settings);
echo $also['theme'], "\n";
Ожидаемый результат
dark
$also по-прежнему держит тот же экземпляр. offsetUnset вызывается для элемента через скобки, а не при уничтожении имени переменной. Историческое приведение (unset) также не вызывало его; само (unset) удалено в PHP 8 и не является современным способом очистки.
Связанные методы: ArrayAccess::offsetSet, ArrayAccess::offsetExists, ArrayAccess::offsetGet. Карта интерфейсов.
InternalIterator::__constructЗакрытый конструктор запрещает создавать встроенный курсор напрямую.
private function __construct()Закрытый конструктор запрещает создавать встроенный курсор напрямую.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Доступ | private: new InternalIterator() из приложения вызовет Error. |
| Получение | Запросите итератор у поддерживающего встроенного объекта. Класс final, наследование запрещено. |
- Создать WeakMap
- WeakMap::getIterator()
- Получить InternalIterator, связанный с этим источником
<?php
try { new InternalIterator(); }
catch (Error $error) { echo "Конструктор закрыт\n"; }
Ожидаемый результат
Конструктор закрыт
Error перехвачен только для демонстрации ограничения. В рабочем коде просто не вызывайте закрытый конструктор; он не принимает массив или источник в качестве обходного параметра.
<?php
declare(strict_types=1);
$tea = (object) ['title' => 'Чай'];
$coffee = (object) ['title' => 'Кофе'];
$labels = new WeakMap();
$labels[$tea] = 'Готов к публикации';
$labels[$coffee] = 'Нужна фотография';
$cursor = $labels->getIterator();
echo 'Тип курсора: ', get_class($cursor), "\n";
foreach ($cursor as $product => $label) { echo $product->title, ': ', $label, "\n"; }
Ожидаемый результат
Тип курсора: InternalIterator
Чай: Готов к публикации
Кофе: Нужна фотография
InternalIterator создаёт встроенный контейнер, здесь WeakMap. Пользовательский код получает готовый курсор через getIterator, а не new InternalIterator. Карта использует объект как ключ, поэтому в foreach product — сам объект товара.
Связанные методы: InternalIterator::rewind, IteratorAggregate::getIterator. Карта интерфейсов.
InternalIterator::currentПрочитать текущий элемент встроенного источника.
public function current(): mixedПрочитать текущий элемент встроенного источника.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Текущее значение любого типа. |
| Граница | Сначала проверяйте valid; поведение вне позиции не является универсальным результатом для всех источников. |
- rewind()
- valid() подтверждает позицию
- current() возвращает значение WeakMap
<?php
declare(strict_types=1);
$first = new stdClass();
$second = new stdClass();
$map = new WeakMap();
$map[$first] = 'Первый';
$map[$second] = 'Второй';
$cursor = $map->getIterator();
$cursor->rewind();
if ($cursor->valid()) { echo $cursor->current(), "\n"; }
Ожидаемый результат
Первый
В примере порядок задаётся вставкой двух записей в WeakMap. Метод не отдаёт ключ и не меняет позицию.
<?php
$key = new stdClass();
$map = new WeakMap();
$map[$key] = null;
$cursor = $map->getIterator();
$cursor->rewind();
var_dump($cursor->valid(), $cursor->current());
Ожидаемый результат
bool(true)
NULL
В карте существует запись со значением null. Проверка через current на истинность потеряла бы её, а valid корректно сообщает наличие.
Связанные методы: InternalIterator::key, InternalIterator::next, InternalIterator::valid. Карта интерфейсов.
InternalIterator::keyПолучить ключ текущего элемента встроенного источника.
public function key(): mixedПолучить ключ текущего элемента встроенного источника.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Ключ в модели данного источника, не обязательно int|string. |
| Для WeakMap | Возвращается сам объект-ключ, не его идентификатор и не копия. |
- WeakMap хранит запись по $first
- key() возвращает тот же объект
- === подтверждает идентичность
<?php
declare(strict_types=1);
$first = new stdClass();
$second = new stdClass();
$map = new WeakMap();
$map[$first] = 'Первый';
$map[$second] = 'Второй';
$cursor = $map->getIterator();
$cursor->rewind();
var_dump($cursor->key() === $first);
echo $cursor->current(), "\n";
Ожидаемый результат
bool(true)
Первый
Идентичность ключа полезна для поиска служебных сведений об объекте. Это не числовая позиция массива.
<?php
$key = new stdClass();
$map = new WeakMap();
$map[$key] = 'Подпись';
$cursor = $map->getIterator();
$cursor->rewind();
$savedKey = $cursor->key();
unset($cursor, $key);
echo count($map), "\n";
unset($savedKey);
echo count($map), "\n";
Ожидаемый результат
1
0
Полученный объект-ключ стал обычной сильной ссылкой в $savedKey. Диагностический сбор ключей способен продлить жизнь объектов и изменить ожидаемую очистку WeakMap.
Связанные методы: InternalIterator::current, InternalIterator::valid. Карта интерфейсов.
InternalIterator::nextПередвинуть встроенный курсор к следующей записи.
public function next(): voidПередвинуть встроенный курсор к следующей записи.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Значения нет. |
| После вызова | Проверьте valid, затем читайте current/key. |
- Текущая запись Первая
- next()
- Вторая запись или конец
<?php
declare(strict_types=1);
$first = new stdClass();
$second = new stdClass();
$map = new WeakMap();
$map[$first] = 'Первый';
$map[$second] = 'Второй';
$cursor = $map->getIterator();
$cursor->rewind();
$cursor->next();
if ($cursor->valid()) { echo $cursor->current(), "\n"; }
Ожидаемый результат
Второй
next управляет позицией того же курсора; он не возвращает второй элемент и не создаёт копию коллекции.
<?php
declare(strict_types=1);
$first = new stdClass();
$second = new stdClass();
$map = new WeakMap();
$map[$first] = 'Первый';
$map[$second] = 'Второй';
$cursor = $map->getIterator();
$cursor->rewind();
$cursor->next();
var_dump($cursor->valid());
$cursor->next();
var_dump($cursor->valid());
Ожидаемый результат
bool(true)
bool(false)
Не обращаемся к current после false. В производственном источнике ошибки чтения должны обрабатываться по договору контейнера, а не предположением, что любой next всегда успешен.
Связанные методы: InternalIterator::valid, InternalIterator::current, InternalIterator::rewind. Карта интерфейсов.
InternalIterator::rewindВернуть встроенный курсор в начало.
public function rewind(): voidВернуть встроенный курсор в начало.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Значения нет. |
| Назначение | Подготовить начало обхода данного источника. |
- next() сдвинул позицию
- rewind() возвращает в начало
- valid/current читают первую запись
<?php
declare(strict_types=1);
$first = new stdClass();
$second = new stdClass();
$map = new WeakMap();
$map[$first] = 'Первый';
$map[$second] = 'Второй';
$cursor = $map->getIterator();
$cursor->rewind();
$cursor->next();
echo $cursor->current(), "\n";
$cursor->rewind();
echo $cursor->current(), "\n";
Ожидаемый результат
Второй
Первый
Работает для этой карты в памяти. Не переносите стоимость и повторяемость такого чтения на каждый потоковый источник.
<?php
declare(strict_types=1);
$first = new stdClass();
$second = new stdClass();
$map = new WeakMap();
$map[$first] = 'Первый';
$map[$second] = 'Второй';
$cursor = $map->getIterator();
$cursor->rewind();
$cursor->next();
foreach ($cursor as $value) { echo $value, "\n"; }
Ожидаемый результат
Первый
Второй
Сдвиг перед foreach не заставил цикл начать со второй записи. Для продолжения позиции используйте ручной цикл или подходящую оболочку NoRewindIterator.
Связанные методы: InternalIterator::next, InternalIterator::valid, Iterator::rewind. Карта интерфейсов.
InternalIterator::validПроверить существование текущей позиции встроенного курсора.
public function valid(): boolПроверить существование текущей позиции встроенного курсора.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | true для существующей позиции, false при отсутствии. |
| Применение | Перед чтением key/current и после next/rewind. |
- rewind()
- valid false → тело не выполняется
- valid true → прочитать и перейти дальше
<?php
$map = new WeakMap();
$cursor = $map->getIterator();
$cursor->rewind();
var_dump($cursor->valid());
Ожидаемый результат
bool(false)
Пустой источник не является ошибкой; интерфейс сообщает о нём обычным false.
<?php
declare(strict_types=1);
$tea = (object) ['title' => 'Чай'];
$coffee = (object) ['title' => 'Кофе'];
$labels = new WeakMap();
$labels[$tea] = 'Готов';
$labels[$coffee] = 'Черновик';
$cursor = $labels->getIterator();
$cursor->rewind();
while ($cursor->valid()) {
echo $cursor->key()->title, ': ', $cursor->current(), "\n";
$cursor->next();
}
echo "Все подписи просмотрены\n";
Ожидаемый результат
Чай: Готов
Кофе: Черновик
Все подписи просмотрены
valid проверяется перед каждым чтением. key возвращает объект товара, current — связанную подпись, next продвигает курсор. Важно не забыть next: иначе цикл будет повторять одну запись. Для простого вывода foreach короче.
Связанные методы: InternalIterator::current, InternalIterator::next, Countable::count. Карта интерфейсов.
Serializable::serializeИсторический метод возвращает собственную строку состояния или null.
public function serialize(): ?stringИсторический метод возвращает собственную строку состояния или null.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | string или null, не массив. Для __serialize современного протокола результат, напротив, array. |
| Ошибки/версии | В справке исторического протокола описано Exception для неподходящего результата. При явном ?string и strict_types неверный return даёт TypeError. Без обоих magic-методов класс deprecated с PHP 8.1. |
<?php
declare(strict_types=1);
class SavedTask implements Serializable {
public static int $constructed = 0;
public function __construct(public string $label) {
++self::$constructed;
}
public function __serialize(): array {
return ['label' => $this->label];
}
public function __unserialize(array $data): void {
if (!isset($data['label']) || !is_string($data['label'])) {
throw new UnexpectedValueException('Нет строковой метки');
}
$this->label = $data['label'];
}
public function serialize(): ?string {
return json_encode($this->__serialize(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
public function unserialize(string $data): void {
$decoded = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new UnexpectedValueException('Ожидался объект данных');
}
$this->__unserialize($decoded);
}
}
$task = new SavedTask('Заказ');
echo $task->serialize(), "\n";
echo substr(serialize($task), 0, 2), "\n";
Ожидаемый результат
{"label":"Заказ"}
O:
Прямой метод всё ещё вызывается обычным способом. Глобальная функция предпочла __serialize, поэтому современная запись не содержит историческую C-оболочку. Сам интерфейс не требует JSON внутри: это выбранное нами содержимое.
<?php
class OptionalSnapshot implements Serializable {
public function serialize(): ?string { return null; }
public function unserialize(string $data): void {}
public function __serialize(): array { return []; }
public function __unserialize(array $data): void {}
}
$snapshot = new OptionalSnapshot();
var_dump($snapshot->serialize());
echo substr(serialize($snapshot), 0, 2), "\n";
Ожидаемый результат
NULL
O:
Этот диагностический класс показывает тип результата и приоритет magic-метода, не механизм хранения полезных данных. В новом коде оформляйте снимок через __serialize; наличие Serializable здесь нужно только для разбора исторического договора.
Связанные методы: Serializable::unserialize, Stringable::__toString. Карта интерфейсов.
Serializable::unserializeВосстановить объект из содержимого исторической C-записи.
public function unserialize(string $data): voidВосстановить объект из содержимого исторической C-записи.
| Часть договора | Что означает |
|---|---|
| data | Строковое содержимое, ранее созданное интерфейсным serialize. |
| Результат | Возвращаемое значение не используется; метод восстанавливает состояние экземпляра. |
| Жизненный цикл | Обычный __construct не вызывается автоматически. Можно вызвать его явно, только если это осознанно соответствует восстановлению. |
- Строка состояния
- Декодирование + проверка формы/типов
- Присвоение допустимых полей объекта
<?php
declare(strict_types=1);
class SavedTask implements Serializable {
public static int $constructed = 0;
public function __construct(public string $label) {
++self::$constructed;
}
public function __serialize(): array {
return ['label' => $this->label];
}
public function __unserialize(array $data): void {
if (!isset($data['label']) || !is_string($data['label'])) {
throw new UnexpectedValueException('Нет строковой метки');
}
$this->label = $data['label'];
}
public function serialize(): ?string {
return json_encode($this->__serialize(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
public function unserialize(string $data): void {
$decoded = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new UnexpectedValueException('Ожидался объект данных');
}
$this->__unserialize($decoded);
}
}
$task = new SavedTask('Старая метка');
$task->unserialize('{"label":"Новая метка"}');
echo $task->label, "\n";
echo SavedTask::$constructed, "\n";
Ожидаемый результат
Новая метка
1
Прямой метод не создаёт другой экземпляр. При обработке C-формата PHP выделяет объект и вызывает этот метод без обычного конструктора. В обоих случаях проверка данных остаётся вашей обязанностью.
<?php
declare(strict_types=1);
class SavedTask implements Serializable {
public static int $constructed = 0;
public function __construct(public string $label) {
++self::$constructed;
}
public function __serialize(): array {
return ['label' => $this->label];
}
public function __unserialize(array $data): void {
if (!isset($data['label']) || !is_string($data['label'])) {
throw new UnexpectedValueException('Нет строковой метки');
}
$this->label = $data['label'];
}
public function serialize(): ?string {
return json_encode($this->__serialize(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
public function unserialize(string $data): void {
$decoded = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new UnexpectedValueException('Ожидался объект данных');
}
$this->__unserialize($decoded);
}
}
$task = new SavedTask('Сохранить');
try { $task->unserialize('{"label":42}'); }
catch (UnexpectedValueException $error) { echo "Неверный формат метки\n"; }
echo $task->label, "\n";
Ожидаемый результат
Неверный формат метки
Сохранить
Проверка идёт до изменения свойства, поэтому старое состояние осталось. JSON_THROW_ON_ERROR отдельно отличает сломанный JSON от корректного JSON неправильной формы. Этот код не оправдывает unserialize внешнего пользовательского PHP-payload.
Связанные методы: Serializable::serialize. Карта интерфейсов.
Stringable::__toStringВернуть удобное строковое представление объекта.
public function __toString(): stringВернуть удобное строковое представление объекта.
| Часть договора | Что означает |
|---|---|
| Аргументы | Нет. |
| Результат | Строка для echo, конкатенации и явного (string). |
| Тип | Класс с этим методом в PHP 8 автоматически Stringable. Обычная строка Stringable не является; совместный параметр — string|Stringable. |
- OrderNumber(42)
- __toString()
- Строка ORD-42 в выводе или логе
<?php
class ShipmentCode {
public function __toString(): string { return 'SHIP-7'; }
}
$code = new ShipmentCode();
var_dump($code instanceof Stringable);
echo 'Отправление: ', $code, "\n";
Ожидаемый результат
bool(true)
Отправление: SHIP-7
Явное implements Stringable всё равно полезно как документация намерения. Метод не должен возвращать массив сведений: для него нужен другой протокол, например JsonSerializable.
<?php
declare(strict_types=1);
class InvalidLabel implements Stringable {
public function __toString(): string { return 42; }
}
try { echo new InvalidLabel(); }
catch (TypeError $error) { echo "Нужен строковый результат\n"; }
Ожидаемый результат
Нужен строковый результат
Преобразуйте число в строку внутри метода осознанно или соберите подпись конкатенацией. Ошибка перехвачена для обучения; корректный метод должен соблюдать обещанный тип. Исключения из __toString разрешены начиная с PHP 7.4, но обычный вывод объекта лучше делать предсказуемым.
Связанные методы: Serializable::serialize. Карта интерфейсов.
Источники и обсуждения
- PHP: Traversable
- PHP: Iterator
- PHP: IteratorAggregate
- PHP: InternalIterator
- PHP: Countable
- PHP: ArrayAccess
- PHP: Serializable
- PHP: Stringable
- Два foreach делят одну позицию — обсуждение проблемы
- Выбор Iterator или IteratorAggregate — обсуждение проблемы
- Ссылка и непрямое изменение ArrayAccess — обсуждение проблемы
- Возврат переменной вместо выражения по ссылке — обсуждение проблемы
- Различие сериализованных форматов — обсуждение проблемы
- Устаревший Serializable в библиотеке — обсуждение проблемы