codeisart.

PHP

Создание объектно-ориентированного аналога массива в PHP. Часть 1

Разделяем вставку под явным ключом, добавление в конец и замену. Нулевой ключ не означает отсутствие ключа; сохранённый null не означает отсутствие записи. Эти различия…

В этой статье

Ключи, значения и проверяемый контракт коллекции

Разделяем вставку под явным ключом, добавление в конец и замену. Нулевой ключ не означает отсутствие ключа; сохранённый null не означает отсутствие записи. Эти различия важны для клиентских идентификаторов и необязательных полей.

/**
 * A small array-backed collection for PHP 8.3+.
 * Keys follow PHP array rules: integer 0 and string '0' name the same entry.
 * Explicit keys must be int|string; null is reserved for [] append syntax.
 * Iteration and clone copy the container shallowly; object values stay shared.
 * @implements ArrayAccess<int|string, mixed>
 * @implements IteratorAggregate<int|string, mixed>
 */
class ObjectsArr implements ArrayAccess, Countable, IteratorAggregate
{
    /** @var array<int|string, mixed> */
    private array $items = [];

    protected function validateValue(mixed $value): void
    {
        // Subclasses may enforce a value policy without narrowing public APIs.
    }

    private function checkedKey(mixed $key): int|string
    {
        if (!is_int($key) && !is_string($key)) {
            throw new InvalidArgumentException('Key must be an integer or a string.');
        }
        return $key;
    }

    public function has(mixed $key): bool
    {
        return array_key_exists($this->checkedKey($key), $this->items);
    }

    public function get(mixed $key): mixed
    {
        $key = $this->checkedKey($key);
        if (!array_key_exists($key, $this->items)) {
            throw new OutOfBoundsException('No entry for this key.');
        }
        return $this->items[$key];
    }

    /** Add an explicit key. Duplicates are errors, including null-valued entries. */
    public function add(mixed $value, mixed $key): void
    {
        $key = $this->checkedKey($key);
        if (array_key_exists($key, $this->items)) {
            throw new LogicException('Key is already in use.');
        }
        $this->validateValue($value);
        $this->items[$key] = $value;
    }

    /** Append under PHP's next integer key, returning the assigned key. */
    public function append(mixed $value): int
    {
        $this->validateValue($value);
        $this->items[] = $value;
        return array_key_last($this->items);
    }

    /** Replace an existing value; a missing key is an error. */
    public function replace(mixed $value, mixed $key): void
    {
        $key = $this->checkedKey($key);
        if (!array_key_exists($key, $this->items)) {
            throw new OutOfBoundsException('No entry to replace.');
        }
        $this->validateValue($value);
        $this->items[$key] = $value;
    }

    public function remove(mixed $key): void
    {
        $key = $this->checkedKey($key);
        if (!array_key_exists($key, $this->items)) {
            throw new OutOfBoundsException('No entry to remove.');
        }
        unset($this->items[$key]);
    }

    /** @return list<int|string> */
    public function keys(): array
    {
        return array_keys($this->items);
    }

    public function count(): int
    {
        return count($this->items);
    }

    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->items);
    }

    /** Match isset(array[key]): null is a stored value, but is not "set". */
    public function offsetExists(mixed $offset): bool
    {
        return $this->has($offset) && $this->get($offset) !== null;
    }

    public function offsetGet(mixed $offset): mixed
    {
        return $this->get($offset);
    }

    /** [] appends; [key] assignment inserts or replaces, like an ordinary array. */
    public function offsetSet(mixed $offset, mixed $value): void
    {
        if ($offset === null) {
            $this->append($value);
        } elseif ($this->has($offset)) {
            $this->replace($value, $offset);
        } else {
            $this->add($value, $offset);
        }
    }

    public function offsetUnset(mixed $offset): void
    {
        $this->remove($offset);
    }
}

Только клиенты, без нарушения сигнатур

Фильтр значения вызывается базовым классом до записи. Публичные сигнатуры остаются одинаковыми у родителя и наследника; конструктор и строковое представление Client определены явно.

final class Client
{
    public function __construct(public string $name)
    {
    }

    public function __toString(): string
    {
        return 'Клиент — "' . $this->name . '"';
    }
}

final class Clients extends ObjectsArr
{
    protected function validateValue(mixed $value): void
    {
        if (!$value instanceof Client) {
            throw new InvalidArgumentException('Clients accepts only Client objects.');
        }
    }
}

$clients = new Clients();
$clients->add(new Client('Ваня'), 'ivan');
$clients->add(new Client('Саша'), 'alexander');
$clients[] = new Client('Петя');

echo $clients->get('ivan'), PHP_EOL;
$clients['ivan'] = new Client('Иван Иванович'); // Explicit replacement via [].
foreach ($clients as $key => $client) {
    echo $key, ' -> ', $client, PHP_EOL;
}
echo 'Всего: ', count($clients), PHP_EOL;
$clients->remove('alexander');

try {
    $clients->add(new Client('Другой Иван'), 'ivan');
} catch (LogicException $error) {
    echo 'Повторный add отклонён: ', $error->getMessage(), PHP_EOL;
}