В то время как Java и Scala обитают на одних и тех же основаниях JVM, последний стал известен как гораздо более краткий и выразительный язык благодаря существенному принятию концепций функционального программирования и богатой стандартной библиотеки. В этой статье я представляю пример этой выразительности, представляя, как небольшой фрагмент кодовой базы может развиваться с ростом требований.

Первоначальная проблема

Допустим, мы отвечаем за магазин кошек, ведь кошки - самое популярное животное в экосистеме Scala. Однако из-за того, как магазин получает информацию о том, какие кошки выставлены на продажу, в некоторых случаях данные не поступают из базы данных, и их приходится сортировать вручную, прежде чем возвращать их в виде HTTP-ответа или обрабатывать каким-либо другим способом.

Объектом домена в этом сценарии, конечно же, является Cat, который в начале имеет только 3 поля примитивных типов. Цель состоит в том, чтобы разработать краткий API, с помощью которого можно было бы легко сортировать коллекцию Cat объектов с учетом следующих требований:

  • Порядок сортировки можно определить для каждого из полей.
  • Порядок сортировки не может быть определен ни для одного из полей.
  • Порядок должен быть стабильным для полей с неопределенным порядком сортировки.
  • Каждое поле имеет предопределенный статический приоритет (например, сортировка по полю age всегда имеет приоритет над сортировкой по полю name)

Первая итерация

Поскольку рассматриваемая проблема относительно проста, и у нас нет перспектив на другие части проекта, лучше сначала найти простое решение. К счастью, у scala.Ordering есть удобный метод Tuple3 для создания Ordering экземпляров кортежей с арностью 3, на который можно легко сопоставить любой Cat объект.

Однако прямое взаимодействие с Tuple3 довольно громоздко, поскольку требует ручного создания Ordering, если нам нужно изменить порядок сортировки. Более того, не существует краткого способа создания идентификатора Ordering для некоторых полей - вероятно, потребуется вручную переключаться между Tuple1, Tuple2 и Tuple3 и / или предоставить идентификатор Ordering для игнорируемых полей. Всего потребуется обработать 9 (3 порядка сортировки для 3 полей) возможных случаев из-за предопределенного приоритета сортировки.

Чтобы приблизить API к проблемной области, а также упростить ее, можно ввести сущность «порядок сортировки». Согласно требованиям, существует 3 возможных порядка: восходящий (естественный), нисходящий (обратный естественный) и нулевой (сохраняющий существующий порядок). Это изначально соответствует следующему алгебраическому типу данных (ADT):

Также требуется способ применить порядок сортировки к Ordering. Поскольку SortOrder выглядит как многоразовый класс, эту функцию лучше представить как расширение синтаксиса, чтобы она не мешала определению класса:

OrderingUtil.identity - это вспомогательная функция, которая обеспечивает упорядочивание идентификаторов для любого типа A и определяется как Ordering.by(_ => 0).

Теперь, когда основной домен настроен, остается только построить фактический Ordering[Cat]. Для этого создается объект CatOrdering:

Теперь любой желаемый Ordering[Cat] можно создать с помощью функции CatOrdering.of:

CatOrdering.of(SortOrder.Asc, SortOrder.Keep, SortOrder.Desc)

Поскольку количество случаев для тестирования относительно велико, для проверки правильности этой реализации полезно использовать комбинацию ScalaTest + ScalaCheck для тестирования на основе свойств. Он позволяет автоматически генерировать List[Cat], для которого можно проверить правильность Ordering. Набор тестов для итерации 1 доступен здесь.

Итерация 2

Магазин вырос с момента первоначальной реализации, и Cat получил новое необязательное поле:

Основное отличие от примитивных типов состоит в том, что теперь у них есть особый случай - пустое значение (не имеющее ничего общего с null!), Которое, в зависимости от предпочтения, может считаться наивысшим или наименьшим значением. Это означает, что всего существует 4 возможных порядка сортировки по этому полю:

  • По возрастанию, сначала пустые значения (естественный порядок)
  • По возрастанию, последние пустые значения
  • По убыванию, сначала пустые значения
  • По убыванию, последние пустые значения (обратный естественный порядок)

В то время как 2 из них неявно предоставляются scala.Ordering.Option, остальные 2 придется обрабатывать вручную. Поскольку SortOrder теперь отвечает за обработку необязательных значений, теперь он явно указывает их приоритет:

Соответствующие изменения внесены в синтаксис SortOrder. Метод optional предоставляет Ordering[Option[A]] для любого A при применении правила для пустых значений, указанных в объекте SortOrder. Обратите внимание, что, хотя можно создать Ordering[Option[A]] с помощью метода apply, для этого нужно явно указать Ordering[Option[A]], чтобы это не произошло случайно. Это незначительное несоответствие можно преодолеть, предоставив доказательство того, что A не является потомком Option, подробнее см. <:< class documentation и этот вопрос StackOverflow (обратите внимание, что Not доступен в Dotty как часть стандартной библиотеки) .

Ниже приводится пример определения Ordering[Cat]. Обратите внимание, что необходимо предоставить правила пустых значений даже для необязательных полей.

CatOrdering.toOrdering(
  SortOrder.Asc.emptyFirst, 
  SortOrder.Asc.emptyFirst, 
  SortOrder.Asc.emptyFirst,
  SortOrder.Asc.emptyFirst
)

С учетом корректировок для Option упорядочения тестов теперь необходимо охватить вышеупомянутые 4 порядка сортировки для Option полей. Набор тестов для итерации 2 доступен здесь.

Итерация 3

Теперь в проекте есть настройки даже для самых сложных Cat заказов. Тем не менее, у него все еще есть некоторые ограничения:

  1. Приоритет сортировки полей предопределен
  2. Если для некоторых полей не требуется сортировка, они все равно должны быть явно указаны с помощью SortOrder.Keep
  3. Cat класс может увеличиваться только до 9 полей. После этого не существует удобного Tuple10, определенного в Ordering, и реализация должна кардинально измениться.

Новые бизнес-требования на этот раз касаются последнего пункта. В магазине появилось столько информации о кошках, что у класса Cat теперь 10 полей! Учитывая, что полей так много, очевидно, что только некоторые из них будут использоваться для сортировки. Более того, гораздо сложнее предопределить разумные приоритеты упорядочения, а явное указание SortOrder для всех из них слишком многословно. На этот раз изменения будут более кардинальными.

Основная идея теперь состоит не только в том, чтобы указать порядок сортировки определенного поля, но и в предоставлении самих полей - затем набор полей и соответствующих порядков сортировки преобразуется в Cat упорядочение. Наличие упорядоченной коллекции означает, что приоритет сортировки полей может быть переопределен (ограничение №1), поля, которых нет в коллекции, игнорируются (ограничение №2), а для добавления нового поля требуется всего несколько строк кода и никогда не изменится реализация (ограничение №3). Реализация следующая (SortOrder и соответствующий синтаксис опущены для краткости из-за практически без изменений):

Реализация в основном использует метод orElse, который пытается применить другой порядок, если сравнение с текущим приводит к равенству. Он очень похож на метод thenComparing в Java класса Comparator. Этот метод также доступен для Ordering, поскольку последний расширяет Comparator в целях совместимости. orElse, однако, намного лучше работает с классами Scala, а также имеет хорошую orElseBy альтернативу для удобства.

Обратите внимание, что поскольку функция byFields применяется к произвольной коллекции без правил уникальности ее элементов, повторяющиеся записи обрабатываются вручную. Более того, чтобы избежать ненужных сравнений с OrderingUtil.identity, случай непустой коллекции обрабатывается отдельно. Эти настройки предназначены только для оптимизации, простая реализация с fields.foldLeft также будет работать, поскольку дубликаты не повлияют на результат.

Дополнительным преимуществом является то, что SortOrder.Keep больше не нужен, поскольку коллекция, используемая для создания упорядочивания, содержит только обязательные поля (остальные игнорируются) - для того, чтобы вообще не упорядочивать, нужно просто предоставить пустую коллекцию. Это также очень удобно для сопоставления HTTP-запросов или пользовательских команд, которые будут предоставлять только необходимые упорядочения, в эту модель предметной области.

Обратной стороной этого подхода, конечно же, является более подробная модель. Возможно, в любом случае потребуется какое-то перечисление для вышеупомянутого сопоставления запроса, но определение объектов поля больше похоже на шаблон, чем на необходимую меру. Возможно, четвертая итерация могла бы определять макрос компилятора с поддержкой аннотаций для пользовательского порядка, но сомнительно, что какие-либо реальные проекты дойдут до этого момента. Однако текущая настройка позволяет легко добавлять / удалять поля, поскольку код сортировки не затрагивается.

С набором тестов для итерации 3 можно ознакомиться здесь.

Заключение

Хотя полученная реализация может выглядеть похожей на некоторых других языках, я считаю, что Scala предлагает хороший компромисс между безопасностью типов и лаконичностью / удобочитаемостью кода. Надеюсь, эта статья дала вам достаточно информации о том, как можно отсортировать коллекцию в Scala, или предоставила вам идеи, которые вы можете адаптировать в кодовых базах, не имеющих прямого отношения к Ordering. Все образцы кода доступны в этом репозитории GitHub.

Эта статья была вдохновлена ​​сравнением Java / Scala, проведенным Алонсо Дель Арте.

Если у вас есть какие-либо вопросы или комментарии, напишите мне в личку в Twitter или Telegram - отметьте тег «@ a7emenov».