Столкнулся с проблемой нормальной реализации коллекций в PHP. Доктриновские коллекции мутабельны и инвариантны. PSL коллекции инвариантны. Нигде не видел непустых коллекций. Везде меня что-то не устраивало и было принято решение написать свою open source реализацию иммутабельных коллекций с ковариантными темплейт-параметрами и выстроенной иерархией пустых и непустых коллекций. В качестве статического анализатора был выбран Psalm.


Иерархия коллекций

Коллекции делятся по возможности содержать пустой список элементов на Collection и NonEmptyCollection.

Получается две иерархии коллекций: обычные коллекции и непустые коллекции.

Обычные коллекции (интерфейс Collection) делятся на интерфейсы Seq, Map и Set.

  • Seq - это сокращение от Sequence. Упорядоченный набор элементов с порядковым номером в рамках коллекции. Типичный представитель Seq - это связный список LinkedList.

  • Map представляет набор пар ключ-значение. Типичный представитель HashMap.

  • Set - это множество уникальных элементов. Типичный представитель HashSet.

Непустые коллекции (интерфейс NonEmptyCollection) делятся на интерфейсы по такому же принципу, как и обычные коллекции, но у них добавляется префикс NonEmpty к соответствующим названиям классов и интерфейсов.

Обычные коллекции

Collection<TV> -> Seq<TV>     -> LinearSeq<TV>   -> LinkedList<TV>
Collection<TV> -> Seq<TV>     -> IndexedSeq<TV>  -> ArrayList<TV>
Collection<TV> -> Set<TV>     -> HashSet<TV>
Collection<TV> -> Map<TK, TV> -> HashMap<TK, TV>

Непустые коллекции

NonEmptyCollection<TV> -> NonEmptySeq<TV> -> NonEmptyLinearSeq<TV>  -> NonEmptyLinkedList<TV>
NonEmptyCollection<TV> -> NonEmptySeq<TV> -> NonEmptyIndexedSeq<TV> -> NonEmptyArrayList<TV>
NonEmptyCollection<TV> -> NonEmptySet<TV> -> NonEmptyHashSet<TV>

Типобезопасность

В библиотеке написаны плагины для статического анализатора Psalm, которые позволяют производить рефайнинг типов элементов и ключей коллекций при выполнении таких операций, как filter и filterNotNull.

<?php

/**
 * Выведенный тип NonEmptyLinkedList<1|2|3>
 */
$collection = NonEmptyLinkedList::collectNonEmpty([1, 2, 3]);

/**
 * Выведенный тип NonEmptyLinkedList<int>
 * 
 * Литеральные типы пропали после трансформации элементов коллекции 
 * Но NonEmpty префикс коллекции сохранился, 
 * Т.к. количество элементов в коллекции не изменилось
 */
$mappedCollection = $collection->map(fn($elem) => $elem - 1);

/**
 * Выведенный тип LinkedList<positive-int>
 * 
 * NonEmpty префикс пропал после фильтрации
 * Т.к. количество элементов могло уменьшиться 
 * И коллекция могла стать пустой 
 */
$filteredCollection = $mappedCollection->filter(fn(int $elem) => $elem > 0);

<?php

/**
 * Выведенный тип non-empty-list<Foo|null|Bar>
 */
$source = [new Foo(1), null, new Bar(2)];

/**
 * Выведенный тип ArrayList<Foo|Bar>
 * 
 * Null был исключен из типа элементов коллекции
 * 
 * NonEmpty префикс коллекции так же пропал после фильтрации
 * Т.к. количество элементов могло уменьшиться 
 * И коллекция могла стать пустой 
 */
$withoutNulls = NonEmptyArrayList::collectNonEmpty($source)->filterNotNull();

/**
 * Выведенный тип ArrayList<Foo>
 * 
 * Bar был исключен из типа элементов коллекции
 */
$onlyFoos = $withoutNulls->filter(fn($elem) => $elem instanceof Foo);

Ковариантность

Тайп-параметры коллекций ковариантны, в отличие от коллекций доктрины и PHP Standart Library (PSL).

<?php

class User {}
class Admin extends User {}

/**
* @param NonEmptyCollection<User> $collection
*/
function acceptUsers(NonEmptyCollection $collection): void {}

/** 
 * @var NonEmptyLinkedList<Admin> $collection 
 */
$collection = NonEmptyLinkedList::collectNonEmpty([new Admin()]);

/**
 * Можно передавать коллекцию админов вместо коллекции пользователей
 * Из-за ковариантности тайп-параметра коллекции
 */
acceptUsers($collection);

Иммутабельность

Коллекции не изменяются после создания. Все мутирующие операции над коллекциями возвращают новые независимые коллекции.

<?php

$originalCollection = LinkedList::collect([1, 2, 3]);

/**
 * $originalCollection не модифицируется
 * И создаётся новая коллекция на основе предыдущей
 */
$prependedCollection = $originalCollection->prepended(0);

/**
 * $prependedCollection не модифицируется
 * И создаётся новая коллекция на основе предыдущей
 */
$mappedCollection = $prependedCollection->map(fn(int $elem) => $elem + 1);

Null-безопасность

Использование монады Option позволяет избежать null pointer исключений. В либе вместо null возвращается всегда либо Some, либо None .

<?php

/**
 * @var Collection<int> $emptyCollection 
 */
$emptyCollection = getEmptyCollection();

/**
 * Операция reduce предполагает работу с непустой коллекцией
 * 
 * Вместо того, чтобы кидать исключение или возвращать null, 
 * если в коллекции нет элементов, 
 * в библиотеке в таких случаях возвращается 
 * экземпляр монады Option<T> (Some<T>|None)
 *
 * Такой подход позволяет получить значение 
 * или продолжить вычисление без проверок на null  
 */
$resultWithDefaultValue = $emptyCollection
    ->reduce(fn(int $accumulator, int $element) => $accumulator + $element)
    ->getOrElse(0);

<?php

class Order
{
    public function __construct(public int $id, public int $price) {}
}

/**
 * In-memory хранилище проиндексированных по ID заказов
 */
$ordersInMemoryStorage = HashMap::collect([
    [$orderId1 = rand(), new Order(id: $orderId1, price: 0)],
    [$orderId2 = rand(), new Order(id: $orderId2, price: 999)],
]);

/**
 * Безопасное деление
 * 
 * Эту операцию можно вставить в качестве промежуточного элемента 
 * цепочки вычислений с помощью метода Option::flatMap
 * 
 * @return Option<float>
 */
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, чтобы находить элементы коллекции по ключам.

<?php

/**
 * Если реализовать HashContract (методы hashCode и equals),
 * то можно использовать объекты класса
 * в качестве ключей в HashMap 
 * и в качестве элементов коллекции HashSet
 */
class Foo implements HashContract
{
    public function __construct(public int $a, public bool $b = true)
    {
    }

    /**
     * Сравнивает на равенство текущий объект 
     * с переданным аргументом
     * по типу и значимым полям класса
     *
     * Используется, чтобы находить элементы в бакете 
     * по ключу-объекту данного класса
     */
    public function equals(mixed $rhs): bool
    {
        return $rhs instanceof self
            && $this->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 

Комментарии (18)


  1. salopot
    26.08.2021 14:49
    +1

    У вас в библиотеке composer.lock закоммичен


    1. wh26sv Автор
      26.08.2021 15:03

      Спасибо, удалил.


    1. dimuska139
      27.08.2021 00:55
      +2

      А почему composer.lock должен быть не закоммичен? Если его не будет, как тогда composer install выполнить?


      1. WebSpider
        27.08.2021 02:32
        +1

        Вы случаем не путаете его с composer.json?


        1. dimuska139
          27.08.2021 10:04
          +2

          Нет, не путаю. И composer.json, и composer.lock должны быть в системе контроля версий. Почитайте, чем команда composer install отличается от composer update.


          1. Maksclub
            03.10.2021 01:08

            вы ошибаетесь

            composer.lock комитится ТОЛЬКО на финальном проекте, библиотеки не должны его коммитить!

            UPD: тоже увидел отбой поздно


      1. wh26sv Автор
        27.08.2021 10:36
        +5

        composer.lock содержит зафиксированные версии пакетов. composer install в первую очередь смотрит на composer.lock файл. Если его нет, тогда решает какие версии пакетов ставить в зависимости от того, что указано в composer.json. composer.lock файл комитят в конечных проектах, чтобы вся команда разработчиков работала с одинаковыми версиями пакетов, но в библиотеках так делать не стоит, чтобы не навязывать конкретные версии пакетов проектам, которые будут пользоваться библиотекой.


        1. dimuska139
          27.08.2021 12:35

          Тогда какая гарантия, что библиотека вообще работать будет?

          Upd: отбой, уже понял


  1. rpsv
    27.08.2021 06:49
    +1

    1. А зачем вообще в целом нужны не пустые коллекции, и что будет с не пустой коллекций когда в ней не станет элеметов (clear вызову)?

    2. Типобезопасность - это конечно великолепно что на doc-блоках держится "типа безопасность", но в чем проблема (хотя бы опционально) добавить проверку типа при обновлении коллекции (set, update)?

    3. filterNotNull - если уже делать по аналогии с имеющимися функциями, то логично было бы сделать логику array_filter, когда при вызове без callback все пустые (да не только null) элементы удаляются

    4. Ковариантность - а что простите в доктрине не так? Объявите такой же докблок и норм, нет?

    5. Иммутабельность - старый добрый clone видимо уже не катит? А то что на каждом вызове будет новая коллекция память засирать, это норм?

    6. HashMap - очередной неоправданный велосипед - SplObjectStorage


    1. wh26sv Автор
      27.08.2021 11:08
      +2

      1) У непустых коллекций отличается сигнатура некоторых методов. Например, возвразаемый тип head и reduce не содержит ни null, ни Option т.к. непустая коллекция гарантирует, что хотя бы один элемент в коллекции присутствует. Непустые коллекции позволяют пользоваться такими операциями без каких-либо проверок на null. clear - это по сути тот же filter(fn() => false). В статье есть пример, где после операции filter NonEmpty префикс коллекции пропадает из-за того, что коллекция может стать пустой.

      3) Такое поведение array_filter лично мне кажется не особо явным

      4) Ковариантность сама по себе опасна с точки зрения типов. Из-за этого псалм требует иммутабельности класса, чтобы иметь возможность использовать темплейт-параметры в качестве типа передаваемых аргументов методов. Обоснование есть в документации псалма. Доктриновские коллекции мутабельны и в них не получится использовать ковариантность.

      5) Увеличенный расход по памяти конечно стоит учитывать при работе с иммутабельными коллекциями. Но в первую очередь библиотека пропагандирует функциональный подход, а иммутабельность - это один из главных столпов.

      6) С помощью SplObjectStorage не получится чейнить операции и там нельзя хранить скалярные типы. Так же, HashMap используется внутри HashSet.


      1. rjhdby
        27.08.2021 12:20
        -1

        Дисклеймер: не, в целом норм, так держать, лучше делать, чем не делать, лови плюсик и вот это всё :)
        Но.

        5) Увеличенный расход по памяти конечно стоит учитывать при работе с иммутабельными коллекциями. Но в первую очередь библиотека пропагандирует функциональный подход, а иммутабельность - это один из главных столпов.

        $a = [];
        
        for ($i=0; $i<1_000_000; $i++){
            $a[] = [$i, "$i"];
        }
        var_dump(memory_get_usage());
        $map = \Fp\Collections\HashMap::collect($a);
        unset($a);
        var_dump(memory_get_usage());
        $map->map(fn($value, $key) => $value+2)
            ->map(fn($value, $key) => $value/2)
            ->map(fn($value, $key) => $value + 1)
            ->map(fn($value, $key) => $value + 1)
            ->filter(fn($value, $key) => $value % 2 === 0);

        В map добавил тот-же var_dump(memory_get_usage());
        Результат.

        int(441961296)
        int(802031336)
        int(1603587320)
        
        Fatal error: Allowed memory size of 2147483648 bytes exhausted

        Рили?


        Второй момент, который лично меня сильно огорчил - это документирование и оригинальный подход к порядку параметров.

        callable - нууу, ок. Зайду внутрь, посмотрю, что за callable тут ожидается.

        ээээ... ШТА!? value вперёд key?


        1. wh26sv Автор
          27.08.2021 13:56
          +2

          Для каждой операции над коллекцией написана дока в соответствующем интерфейсе с суффиксом Ops. Например LinkedList -> Seq -> SeqOps.

          Там есть короткие примеры использования в формате REPL.

          В конкретных реализациях коллекций просто используется ссылка на доку из интерфейса с помощью inheritDoc, чтобы избежать дублирования документации.

          Что касается порядка аргументов, то чаще всего в операциях типа map и фильтр используется именно значение. Ключ можно использовать, но это редкий кейс.

          Такой порядок аргументов позволяет в вашем примере написать вот так и не указывать вообще ключ как второй аргумент.

          $map->map(fn($value) => $value + 2)
              ->map(fn($value) => $value / 2)
              ->map(fn($value) => $value + 1)
              ->map(fn($value) => $value + 1)
              ->filter(fn($value) => $value % 2 === 0);


          1. rjhdby
            28.08.2021 02:21

            то чаще всего в операциях типа map и фильтр используется именно значение.

            Для Map-ов обычно используется Entry, у которой можно достать ключ и значение по необходимости, а если используется деструкция, то сохраняется семантика коллекции ключ->значение


            1. wh26sv Автор
              28.08.2021 12:47

              Поменял на Entry в 3.0.0. Рассматривал ещё вариант с массивом, но из-за того, что деструктуризация не работает как например в foreach ($pairs as [$key, $value]), то сделал Entry класс, объекты которого живут только в рамках одной итерации.


  1. zim32
    27.08.2021 19:06
    -3

    Вообще не пойму какого ляда тащить иммутабельные коллекции в язык, который не является функциональным по своей природе, в котором редко кто пишет многопоточные приложения и который память жрет как не в себя


    1. rjhdby
      28.08.2021 02:24

      Вот не надо на счёт памяти, с этим в пхп всё неплохо, но на счёт иммутабельных коллекций в языке со временем жизни рантайма в один реквест (демоны не рассматриваем) я с вами согласен.


      1. batyrmastyr
        30.08.2021 16:07

        Только язык активно уходит от срока жизни в один запрос - многие библиотеки/каркасы уже пишутся держа в уме Swoole, AmPHP, RoadRunner, Workerman или PHP-PM.


  1. skiedr
    30.08.2021 16:19
    +2

    Интересный проект. Надо будет оценить производительность. Я в свое время сравнивал nikic/iter (построенной на генераторах) с cakephp/collection (построенной на итераторах) и результат был в пользу последней.

    Проект, в целом, показался мне сильно продвинутой функциональной версией того, что не хватает в cakephp/collection.