Дженерик коллекции в PHP
Столкнулся с проблемой нормальной реализации коллекций в PHP. Доктриновские коллекции мутабельны и инвариантны. PSL коллекции инвариантны. Нигде не видел непустых коллекций. Везде меня что-то не устраивало и было принято решение написать свою open source реализацию иммутабельных коллекций с ковариантными темплейт-параметрами и выстроенной иерархией пустых и непустых коллекций. В качестве статического анализатора был выбран Psalm.
Иерархия коллекций
Коллекции делятся по возможности содержать пустой список элементов на Collection
и NonEmptyCollection
.
Получается две иерархии коллекций: обычные коллекции и непустые коллекции.
Обычные коллекции (интерфейс Collection) делятся на интерфейсы Seq
, Map
и Set
.
Seq — это сокращение от Sequence. Упорядоченный набор элементов с порядковым номером в рамках коллекции. Типичный представитель Seq — это связный список LinkedList.
Map представляет набор пар ключ-значение. Типичный представитель HashMap.
Set — это множество уникальных элементов. Типичный представитель HashSet.
Непустые коллекции (интерфейс NonEmptyCollection) делятся на интерфейсы по такому же принципу, как и обычные коллекции, но у них добавляется префикс NonEmpty к соответствующим названиям классов и интерфейсов.
Обычные коллекции
Collection -> Seq -> LinearSeq -> LinkedList
Collection -> Seq -> IndexedSeq -> ArrayList
Collection -> Set -> HashSet
Collection -> Map -> HashMap
Непустые коллекции
NonEmptyCollection -> NonEmptySeq -> NonEmptyLinearSeq -> NonEmptyLinkedList
NonEmptyCollection -> NonEmptySeq -> NonEmptyIndexedSeq -> NonEmptyArrayList
NonEmptyCollection -> NonEmptySet -> NonEmptyHashSet
Типобезопасность
В библиотеке написаны плагины для статического анализатора Psalm, которые позволяют производить рефайнинг типов элементов и ключей коллекций при выполнении таких операций, как filter
и filterNotNull
.
*/
$collection = NonEmptyLinkedList::collectNonEmpty([1, 2, 3]);
/**
* Выведенный тип NonEmptyLinkedList
*
* Литеральные типы пропали после трансформации элементов коллекции
* Но NonEmpty префикс коллекции сохранился,
* Т.к. количество элементов в коллекции не изменилось
*/
$mappedCollection = $collection->map(fn($elem) => $elem - 1);
/**
* Выведенный тип LinkedList
*
* NonEmpty префикс пропал после фильтрации
* Т.к. количество элементов могло уменьшиться
* И коллекция могла стать пустой
*/
$filteredCollection = $mappedCollection->filter(fn(int $elem) => $elem > 0);
*/
$source = [new Foo(1), null, new Bar(2)];
/**
* Выведенный тип ArrayList
*
* Null был исключен из типа элементов коллекции
*
* NonEmpty префикс коллекции так же пропал после фильтрации
* Т.к. количество элементов могло уменьшиться
* И коллекция могла стать пустой
*/
$withoutNulls = NonEmptyArrayList::collectNonEmpty($source)->filterNotNull();
/**
* Выведенный тип ArrayList
*
* Bar был исключен из типа элементов коллекции
*/
$onlyFoos = $withoutNulls->filter(fn($elem) => $elem instanceof Foo);
Ковариантность
Тайп-параметры коллекций ковариантны, в отличие от коллекций доктрины и PHP Standart Library (PSL).
$collection
*/
function acceptUsers(NonEmptyCollection $collection): void {}
/**
* @var NonEmptyLinkedList $collection
*/
$collection = NonEmptyLinkedList::collectNonEmpty([new Admin()]);
/**
* Можно передавать коллекцию админов вместо коллекции пользователей
* Из-за ковариантности тайп-параметра коллекции
*/
acceptUsers($collection);
Иммутабельность
Коллекции не изменяются после создания. Все мутирующие операции над коллекциями возвращают новые независимые коллекции.
prepended(0);
/**
* $prependedCollection не модифицируется
* И создаётся новая коллекция на основе предыдущей
*/
$mappedCollection = $prependedCollection->map(fn(int $elem) => $elem + 1);
Null-безопасность
Использование монады Option
позволяет избежать null pointer исключений. В либе вместо null
возвращается всегда либо Some,
либо None
.
$emptyCollection
*/
$emptyCollection = getEmptyCollection();
/**
* Операция reduce предполагает работу с непустой коллекцией
*
* Вместо того, чтобы кидать исключение или возвращать null,
* если в коллекции нет элементов,
* в библиотеке в таких случаях возвращается
* экземпляр монады Option (Some|None)
*
* Такой подход позволяет получить значение
* или продолжить вычисление без проверок на null
*/
$resultWithDefaultValue = $emptyCollection
->reduce(fn(int $accumulator, int $element) => $accumulator + $element)
->getOrElse(0);
*/
function div(int $dividend, int $divisor): Option {
return 0 === $divisor
? Option::none()
: Option::some($dividend / $divisor);
}
/**
* Поиск по ключу в Map-коллекции возвращает монаду Option
*
* Это позволяет продолжить описывать цепочку вычислений
* с помощью map, flatMap и подобных методов класса Option.
*
* Цепочка вычислений не продолжится,
* если элемент не найден в хранилище
* или если произошло деление на ноль
*
* В случае, если на каком-то этапе вычисления произошла ошибка,
* то при вызове getOrElse(0) возвратится 0,
* вместо успешного результата вычисления
*/
$ordersInMemoryStorage
->get($orderId1)
->map(fn(Order $order): int => $order->price)
->flatMap(fn(int $price): Option => div(1, $price))
->getOrElse(0);
Связь HashSet, HashMap и HashContract
HashSet
под капотом использует HashMap
.
Чем отличается HashMap
от обычного массива в PHP? Ключи массивов в PHP могут быть целыми числами или строками. Тип ключей в HashMap
ничем не ограничен. Это вполне могут быть экземпляры классов. Numeric-string ключи в массивах PHP преобразуются в целочисленные ключи. В HashMap
такого не происходит и элемент с ключем "1"
нельзя достать по ключу 1
.
HashMap
проверяет, не реализует ли объект, который используется в качестве ключа, интерфейс HashContract
. Если реализует, то используются реализуемые в классе ключа-объекта методы hashCode
и equals
, чтобы находить элементы коллекции по ключам.
a === $rhs->a
&& $this->b === $rhs->b;
}
/**
* Хэш значимых полей класса
*
* Используется, чтобы попадать в нужный бакет
* в HashMap'е
*/
public function hashCode(): string
{
return md5(implode(',', [$this->a, $this->b]));
}
}
/**
* Сборка коллекции происходит с помощью пар ключ-значение,
* которые передаются в виде массивов вида array{TKey, TValue}
*/
$hashMap = HashMap::collect([
[new Foo(1), 1], [new Foo(2), 2],
[new Foo(3), 3], [new Foo(4), 4]
]);
/**
* Пример использования коллекции HashMap
* и чейнинга операций над коллекцией
*/
$hashMap
->map(fn($elem) => $elem + 1)
->filter(fn($elem) => $elem > 2)
->reindex(fn($elem, Foo $key) => $key->a)
->fold(0, fn(int $acc, array $pair) => $acc + $pair[1]); // 3+4+5=12
/**
* При сборке коллекции
* дублирующиеся элементы будут удалены
*
* Останутся только Foo(1), Foo(2), Foo(3), Foo(4)
*/
$hashSet = HashSet::collect([
new Foo(1), new Foo(2), new Foo(2),
new Foo(3), new Foo(3), new Foo(4),
]);
/**
* Пример использования коллекции HashSet
* и чейнинга операций над коллекцией
*/
$hashSet
->map(fn(Foo $elem) => $elem->a)
->filter(fn(int $elem) => $elem > 1)
->reduce(fn($acc, $elem) => $acc + $elem)
->getOrElse(0); // 9