Столкнулся с проблемой нормальной реализации коллекций в 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)
rpsv
27.08.2021 06:49+1А зачем вообще в целом нужны не пустые коллекции, и что будет с не пустой коллекций когда в ней не станет элеметов (clear вызову)?
Типобезопасность - это конечно великолепно что на doc-блоках держится "типа безопасность", но в чем проблема (хотя бы опционально) добавить проверку типа при обновлении коллекции (set, update)?
filterNotNull - если уже делать по аналогии с имеющимися функциями, то логично было бы сделать логику array_filter, когда при вызове без callback все пустые (да не только null) элементы удаляются
Ковариантность - а что простите в доктрине не так? Объявите такой же докблок и норм, нет?
Иммутабельность - старый добрый clone видимо уже не катит? А то что на каждом вызове будет новая коллекция память засирать, это норм?
HashMap - очередной неоправданный велосипед - SplObjectStorage
wh26sv Автор
27.08.2021 11:08+21) У непустых коллекций отличается сигнатура некоторых методов. Например, возвразаемый тип head и reduce не содержит ни
null
, ниOption
т.к. непустая коллекция гарантирует, что хотя бы один элемент в коллекции присутствует. Непустые коллекции позволяют пользоваться такими операциями без каких-либо проверок на null. clear - это по сути тот жеfilter(fn() => false)
. В статье есть пример, где после операцииfilter
NonEmpty
префикс коллекции пропадает из-за того, что коллекция может стать пустой.3) Такое поведение array_filter лично мне кажется не особо явным
4) Ковариантность сама по себе опасна с точки зрения типов. Из-за этого псалм требует иммутабельности класса, чтобы иметь возможность использовать темплейт-параметры в качестве типа передаваемых аргументов методов. Обоснование есть в документации псалма. Доктриновские коллекции мутабельны и в них не получится использовать ковариантность.
5) Увеличенный расход по памяти конечно стоит учитывать при работе с иммутабельными коллекциями. Но в первую очередь библиотека пропагандирует функциональный подход, а иммутабельность - это один из главных столпов.
6) С помощью
SplObjectStorage
не получится чейнить операции и там нельзя хранить скалярные типы. Так же,HashMap
используется внутриHashSet
.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
?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);
rjhdby
28.08.2021 02:21то чаще всего в операциях типа map и фильтр используется именно значение.
Для Map-ов обычно используется Entry, у которой можно достать ключ и значение по необходимости, а если используется деструкция, то сохраняется семантика коллекции ключ->значение
wh26sv Автор
28.08.2021 12:47Поменял на Entry в 3.0.0. Рассматривал ещё вариант с массивом, но из-за того, что деструктуризация не работает как например в
foreach ($pairs as [$key, $value])
, то сделал Entry класс, объекты которого живут только в рамках одной итерации.
zim32
27.08.2021 19:06-3Вообще не пойму какого ляда тащить иммутабельные коллекции в язык, который не является функциональным по своей природе, в котором редко кто пишет многопоточные приложения и который память жрет как не в себя
rjhdby
28.08.2021 02:24Вот не надо на счёт памяти, с этим в пхп всё неплохо, но на счёт иммутабельных коллекций в языке со временем жизни рантайма в один реквест (демоны не рассматриваем) я с вами согласен.
batyrmastyr
30.08.2021 16:07Только язык активно уходит от срока жизни в один запрос - многие библиотеки/каркасы уже пишутся держа в уме Swoole, AmPHP, RoadRunner, Workerman или PHP-PM.
skiedr
30.08.2021 16:19+2Интересный проект. Надо будет оценить производительность. Я в свое время сравнивал nikic/iter (построенной на генераторах) с cakephp/collection (построенной на итераторах) и результат был в пользу последней.
Проект, в целом, показался мне сильно продвинутой функциональной версией того, что не хватает в cakephp/collection.
salopot
У вас в библиотеке composer.lock закоммичен
wh26sv Автор
Спасибо, удалил.
dimuska139
А почему composer.lock должен быть не закоммичен? Если его не будет, как тогда composer install выполнить?
WebSpider
Вы случаем не путаете его с composer.json?
dimuska139
Нет, не путаю. И composer.json, и composer.lock должны быть в системе контроля версий. Почитайте, чем команда composer install отличается от composer update.
Maksclub
вы ошибаетесь
composer.lock комитится ТОЛЬКО на финальном проекте, библиотеки не должны его коммитить!
UPD: тоже увидел отбой поздно
wh26sv Автор
composer.lock содержит зафиксированные версии пакетов. composer install в первую очередь смотрит на composer.lock файл. Если его нет, тогда решает какие версии пакетов ставить в зависимости от того, что указано в composer.json. composer.lock файл комитят в конечных проектах, чтобы вся команда разработчиков работала с одинаковыми версиями пакетов, но в библиотеках так делать не стоит, чтобы не навязывать конкретные версии пакетов проектам, которые будут пользоваться библиотекой.
dimuska139
Тогда какая гарантия, что библиотека вообще работать будет?
Upd: отбой, уже понял