Дженерик коллекции в PHP

49c78c45517275bcfa49e5c7e3cf08ff

Столкнулся с проблемой нормальной реализации коллекций в 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 

© Habrahabr.ru