Знаете это чувство, когда в проекте чего-то не хватает, а существующие решения не совсем то, что нужно? Именно с этим мы столкнулись, когда искали инструмент для работы с коллекциями в PHP в духе функционального программирования. В итоге решили: раз нет подходящего инструмента для автоматического улучшения кода — создадим его сами. И вот что из этого вышло.
Унаследованный код и вечная борьба с техническим долгом
На большинстве проектов нам приходится сталкиваться с унаследованной кодовой базой. Работа с унаследованным кодом — это непростая задача. Почти всегда это означает проблемы с внутренней структурой. В такой код тяжело вносить изменения, доработки занимают много времени. При этом даже самый преданный заказчик редко выделяет время на рефакторинг. Команде приходится жить с техническим долгом.
Техдолг влияет не только на скорость написания новой функциональности, но и на подключение новых людей — тимлид вынужден объяснять, как работает тот или иной участок кода. Те, кто с проектом работает дольше, со временем превращаются в «хранителей знаний», но даже им приходится каждый раз тратить повторно время на анализ, потому что всплывают новые бизнес-кейсы.
Рефакторинг вслепую
Когда код написан плохо — его сложно читать. Из этого вытекает основная масса проблем. Рассмотрим реальный пример:
Сразу бросаются в глаза огромный уровень вложенности и отсутствие сторожевых пунктов:
Из-за множества циклов расширяется контекст переменных — приходится именовать их длинными строками. Чтобы грамотно разделить такой участок на методы, нужно тщательно разобраться, как он устроен. Работать с таким сложно, фрагмент кода нужно переписывать. Но как это сделать, если проект не знаком? Можно избавиться от лишней вложенности, не погружаясь в детали бизнес-логики:
Полностью избавиться от вложенностей не получилось, но уже появилась возможность расставить по порядку сторожевые пункты. А вот выделить методы и классы, следуя принципам SOLID, без понимания бизнес-логики проекта не получится. Как же тогда переписать этот код с минимальными затратами, чтобы сразу стало понятно, что в нем происходит? Чтобы ответить на этот вопрос, обратимся к принципам функционального программирования.
Декларативный подход, FP и универсальный конструктор функций
Существуют различные подходы к программированию. Например, императивный подход отвечает на вопрос «Как делать?». Мы говорим программе, какие действия нужно совершить, чтобы что-то получить на выходе. Декларативный подход отвечает на вопрос «Что делать?». Хороший пример последнего — CSS: мы указываем, что кнопка должна быть зеленого цвета, а дальше браузер сам знает как ее покрасить.
Мы еще вернемся к запутанному участку кода и попытаемся переписать его на декларативный стиль, чтобы он стал понятным. Но пока давайте обратимся к другому примеру, чтобы рассмотреть некоторые постулаты из функционального программирования.
Представим, что есть строка с марками машин. Нужно получить массив с уникальными значениями — это императивный подход. Сначала нужно преобразовать строку в массив, исключить пустые значения и отфильтровать уникальность. Чтобы на выходе получить уникальные значения, можно составить такой конвейер:
Однако от произведенных действий императивный подход не станет декларативным.
Теперь давайте рассмотрим практику композиции из функционального программирования на следующем примере:
Есть функция compose(), которая подает на вход несколько функций. Получается своеобразный конвейер, который идет снизу вверх. Выполняется функция, и результат передается в следующую функцию. Нужно перечислить ряд функций, чтобы на выходе вернулся нужный результат.
Однако есть ограничение: мы передаем функции (compose()) только один аргумент. А что если потребуется в этой строке разделять не по запятым, а по точке с запятой? В таком случае придется добавить новый параметр функции separator(). Но если мы его добавим, то получим ошибку, что естественно, потому что compose() ожидает только один аргумент.
В функциональном программировании есть практика каррирования — это преобразование функции со множеством аргументов в набор вложенных функций, каждая из которых принимает один аргумент. Это позволяет вызывать функцию поэтапно, передавая аргументы по одному. Ниже представлено частичное применение:
Каррирование позволяет гибко управлять входными параметрами. При этом подходе мы создаем новую реализацию, передавая в нее исходный метод вместе с дополнительным аргументом (разделителем). На выходе получаем модифицированную версию, которую можно использовать вместо первоначальной. В результате такого преобразования код, изначально принимающий два аргумента, теперь работает с одним, что решает проблему совместимости с compose().
У данного подхода есть обратная сторона, связанная с мутабельностью данных. При передаче объекта в качестве входного параметра любое его изменение внутри метода повлияет на исходный объект. Такое поведение может привести к неожиданным побочным эффектам и усложняет отладку кода.
Второе негативное последствие связано с операцией вывода-ввода (I/O). Например, мы не можем быть уверены в выполнении http-запроса:
Чтобы решать такие вопросы, необходимо понимать, что такое чистая функция. Разберем характерные свойства:
- Детерминированность. Для одних и тех же аргументов, они возвращают один и тот же результат;
- Кешируемость. Есть замыкание, которое хранит кеш. Позволяет сэкономить на вычислениях и сделать код более производительным.
- Простота. Легко дебажить. Легко понять, что производит, не запуская код.
- Изолированность. Не используют нечистые функции и операции ввода / вывода.
- Безопасность. Не обладают побочными эффектами.
Функции, обладающие побочным эффектом — это методы, которые в процессе выполнения вычислений могут осуществлять операции ввода-вывода, а также менять значения глобальных объектов.
Важно понимать, что программы пишут ради побочных эффектов. Без побочных эффектов программы бесполезны. Поэтому мы не ставим перед собой задачу полностью избавиться от побочных эффектов. Наша задача свести их количество к минимуму, а оставшуюся логику убрать и поработать над операциями ввода / вывода.
Все эти примеры мы рассмотрели, чтобы продемонстрировать как собрать конструктор, в котором функции — это детали, которыми удобно манипулировать.
Вернемся обратно к нашей бизнес-задаче — нам нужно упростить тот участок кода, который мы описали в самом начале. Для того, чтобы его упростить, одного желания применить императивный подход недостаточно. Нужно внедрить какое-то готовое решение.
Поиски готового решения
Для решения задачи нужна была готовая библиотека, построенная на описанных практиках. Мы исходили из убеждения, что кто-то уже создал такую, поэтому начали с поиска подходящего решения.
У PHP есть встроенная библиотека SPL с весьма ограниченными возможностями. Некоторые популярные фреймворки содержат собственные коллекции. У Laravel, например, богатый набор функций в библиотеке Illuminate, но она не позволяет писать свои функции и связывать их с бизнес-процессом. Doctrine-collections от Symfony не подошли из-за отсутствия структур данных, а также слабых методов обхода. Есть независимые библиотеки PHP-коллекций. Но, к сожалению, ни одно из решений не подходило под наши требования: где-то все написано в одном классе, где-то слишком скромный функционал и т.д.
Главная сила и одновременно проклятие опенсорс-технологий заключается в том, что они часто держатся на энтузиазме отдельного человека. Далеко не всем удается собрать команду единомышленников. Поэтому многие решения быстро перестают поддерживаться. Чтобы ими пользоваться, нужно становиться контрибьютором и развивать инструмент. Это непростой процесс, не все готовы пойти на такое и соблюдать всевозможные code of conduct, следовать шаблонам реквестов и так далее.
Только DIY, только хардкор: WS-PHP-Collections
В итоге мы решили написать собственную библиотеку коллекций для PHP. Мы работаем в команде, поэтому можем легко контролировать ее развитие.
За вдохновением мы обращались к другим языкам, в частности к Java из-за строгой типизации. Если вы знакомы с Java, то заметите, что некоторые методы библиотеки заимствованы у этого языка.
В основе использования WS-PHP-Collections лежит последовательный подход обработки и преобразования данных. Создание конвейера преобразования, где можно последовательно выполнять определенные шаги.
Библиотека состоит из нескольких базовых, но крайне важных для нас элементов.
Структура данных
Список (ListSequence). Строго определенный порядок элементов.
Реализация ArrayList опирается на порядок элементов. Особенность заключается в следующем: если мы сравним два ArrayList с одинаковым наполнением, но разной последовательностью, они будут не равны.
Второй тип коллекций — StrictList, который в отличие от ArrayList контролирует типы данных. При добавлении элемента неверного типа система выбросит исключение.
Также есть неизменяемый ImmutableList. При попытке добавить такой элемент у нас выпадет TimeException.
Множество (Set). Множество содержит только уникальные элементы, порядок следования элементов может быть любым.
При попытке добавить существующий элемент множество игнорирует операцию, сохраняя только одну копию значения. В отличие от ArrayList, порядок элементов не влияет на равенство множеств — два HashSet считаются равными, если содержат одинаковый набор элементов независимо от их последовательности.
Очередь (Queue). Первый элемент, который попал в очередь — первым ее и покинет.
ArrayQueue реализует принцип FIFO (First In, First Out). Метод offer() добавляет новые элементы в конец очереди, а peek() позволяет получить доступ к первому элементу без его удаления. При извлечении элементов из очереди они выходят в том же порядке, в котором были добавлены.
Стек (Stack). Первый элемент попавшей в стек будет последним извлечен из него.
ArrayStack работает по принципу LIFO (Last In, First Out). Метод push() помещает новый элемент на вершину стека, а peek() возвращает последний добавленный элемент без его удаления. В отличие от очереди, стек извлекает элементы в обратном порядке их добавления.
Карта (Map). Карта представляет из себя словарь, где каждый элемент представляет пару «ключ-значение». Ключи карты уникальны по значению и могут быть объектами.
HashMap удобен для связывания данных через ключи. Метод put() добавляет новые пары ключ-значение, а get() извлекает значение по ключу. Эта структура данных особенно полезна в бизнес-кейсах — например, для хранения каталога товаров, где ключом может быть артикул товара, а значением — информация об остатках на складе.
Использование фабрики не через конструктор
На проектах структуры данных имеют свои фабрики. Чтобы нам получить из какого-то набора элементов коллекцию, нужно воспользоваться методами from(). То есть у нас на выходе будет объект из ArrayList, fromStrict() из StrictList и fromIterable()..
Потоки обхода коллекций
Самое интересное, на наш взгляд — потоки обхода коллекций. Мы даем возможность обходить элементы и гибко преобразовывать.
Поток (Stream): главный поток обхода и преобразования коллекций. Все вычисления должны производиться через него.
Stream обеспечивает последовательную обработку данных через цепочку преобразований. В примере показан типичный сценарий работы с потоком: создание из коллекции через CollectionFactory, фильтрация null-значений, преобразование данных через map(), реорганизация структуры с помощью reorganize() и получение финального результата методом getCollection().
Предикат (Predicate) Используется для фильтра элементов filter. В фильтр передаем какой-то предикат, который заготовлен в фреймворке.
Библиотека предоставляет базовый набор предикатов, но их можно расширять под конкретные задачи. Например, есть проект, в котором сугубая бизнес-логика. В таком случае, достаточно создать новый предикат, при необходимости фильтрации, не затрагивая структуру самих коллекций.
Функции сравнения (Comparators). Функции сравнения элементов необходимы при использовании методов сортировки. Сравнивает по значению, по свойству объекта и можно накрутить свою логику с помощью отдельной функции.
Comparators предоставляют гибкие механизмы для сортировки элементов коллекции. В примере показан базовый scalarComparator() для сравнения скалярных значений, но библиотека позволяет создавать собственные компараторы для сложных объектов. Можно сравнивать элементы по конкретным свойствам или реализовать произвольную логику сравнения через отдельную функцию.
Преобразователи элементов (Converters). Функции этого типа используются для преобразования коллекции потока map. Это наиболее часто используемый функционал. У нас есть задача максимально компактно описать код. Например, есть коллекция из объектов, у которой есть свойство Value. Мы не будем писать внутри Map какую-то функцию и вызывать Value. Мы просто возьмем и передадим нужный конвертор, тогда все получится.
На одном нашем проекте есть удобная фича — мы написали свой конвертер. Называется он toDto(). По факту берет и переконвертирует все имеющиеся элементы в DTO.
Преобразователи потоков (Reorganizers) Методы преобразования потоков создают производную новую коллекцию с произвольным количеством элементов.
В отличие от конвертеров, здесь мы не влияем на количество выходных элементов . Например, у нас есть коллекция из трех элементов. На выходе мы можем получить коллекцию из девяти элементов. Мы берем по два элемента, делаем chunk(), и на выходе у нас получается то, что не получилось бы с Map.
.
Например, есть реорганайзер Collapse, который схлопывает все элементы, и комната, у которой несколько окон. И для того, чтобы нам проитерироваться по окнам, мы можем вызвать реорганайзер. Далее он схлопнется, и мы проитерируемся по окнам этих комнат.
Потребители (Consumers). В основном каждая функция потребитель разрабатывается индивидуально в исходном коде проекта. Это терминальный метод — после него ничего нельзя вызвать. Мы здесь можем просто написать какой-то побочный эффект, который нам нужен.
В примере показан стандартный dump() для вывода элементов, но основное применение потребителей — реализация специфичных для проекта побочных эффектов. После вызова метода each() с потребителем дальнейшие операции с потоком становятся недоступны. Это позволяет четко обозначить точку, где поток данных завершает свою работу.
Функции сбора данных (Collector). Функции сбора данных завершают цепочку преобразований потока как терминальный метод, формируя итоговый результат.
Для чего они нужны? На примере показали группировку массива (но это может быть и объект) по ключу groupkey с параллельным расчетом агрегированных значений для каждой группы. Это удобно при работе со сложными структурами данных.
В нашем примере есть два элемента в ‘test’ и один элемент в ‘other’. Что получаем на выходе? Мы перечисляем декларативно функции, которые нам нужны, и на выходе получаем совсем другой результат, который нам нужен. Получается достаточно гибко.
Мы собрались для того, чтобы в результате получить код, который легко прочитать: для этого создаем ArrayList, фильтруем его по наличию комнат. Далее получаем комнаты этих вариантов, схлопываем их с помощью реорганайзера, фильтруем. Затем фильтруем по отдельному массиву, который как-то к нам попал в этот фрагмент кода. Потом сравниваем следующим образом: на вход подаем еще один ArrayList, и проверяем на наличие этого объекта. В итоге, мы не не делаем еще один цикл, чтобы достичь результат.
Но можно заметить, что помимо всего этого есть другие переменные. Если мы их возьмем и уберем, то все разрушится. В таких случаях мы просто делаем отдельную коллекцию. Затем с помощью метода collect() высчитываем необходимые суммы, которые нам требовались в бизнес-логике. В итоге получается понятный код, который читается в одну линию и не требует слишком больших разбирательств.
Вместо заключения
Разработка собственного решения вместо развития существующих может вызвать логичный вопрос у читателя: «Почему вы не стали развивать чужое решение, а сделали свое?». Это справедливый вопрос, и у нас нет четкого ответа, но было несколько причин:
- Простая и компактная библиотека, где есть только то, что нам нужно;
- В основе лежат подходы из других языков, в особенности Java, а не эволюция существующих PHP-решений;
- Собственная библиотека позволяет быстро добавлять новые функции и поддерживать совместимость;
- Независимость от процессов согласования с другими разработчиками;
- Желание сделать свое.
Это не первый наш проект с открытым исходным кодом. На нашем гитхабе можно найти другие инструменты на PHP и React. Но WS-PHP-Collection мы активно применяем в продакшене последние несколько лет. У нас есть дорожная карта развития и готовые задачи в бэклоге.
Мы рады поддержке PHP-сообщества в любом виде — звездочка репозиторию, комментарий с обратной связью, любая конструктивная критика и предложения по улучшению. Если захотите стать контрибьютором, то напишите нам, и мы обязательно что-нибудь придумаем.