Введение в оптику в Scala
На протяжении всего этого блога я неоднократно упоминал о преимуществах сильной системы типов. Я рассказывал об уточнении типов (type refinement) для проверки значений, о рассчитанном на новичков и продвинутом подходе к выведению классов типов (type-class derivation) или о типобезопасном подходе к обмену сообщениями с помощью pass4s.
Однако этим наши возможности не исчерпываются, поэтому сегодня я хочу рассказать вам еще об одной теме, о которой новички в таких языках, как Scala или Haskell, могут не знать.
В этом посте я расскажу об оптике.
Знакомство с оптикой
Оптика — это собирательное понятие, объединяющее доступ к неизменяемым данным и их преобразованиям. Мы будем исследовать мир оптики с помощью библиотеки Monocle. Но прежде чем приступить к работе, давайте подготовим плацдарм и рассмотрим задачи, которые мы можем решить.
Предметная область
Представьте, что мы моделируем данные для супермаркета. Товары с id
, name
и price
, хранящиеся на полках, сгруппированные в витрины и так далее. Наша цель — смоделировать всю эту сложную структуру.
Начнем с самого простого — с продукта:
case class Product(id: String, name: String, price: Double)
Продукты хранятся на полке:
case class Shelf(id: String, product: Product)
Полки позволяют выстраивать стенд с товарами:
case class Display(id: String, kind: "Ambient" | "Chilled", shelves: List[Shelf])
Стенды с товарами формируют проход:
case class Alley(id: String, displays: List[Display])
Магазин — это просто множество проходов:
case class Shop(alleys: List[Alley])
Задача
Наша задача выглядит довольно просто — мы хотим применить скидку в 10% на ассортимент во всем магазине. Весь магазин остается прежним, меняются только цены. Уже представили, как это сделать по старинке? Скорее всего с помощью кучи map и copy. Попробуйте сделать это сами в качестве упражнения!
В качестве тестовых данных предположим, что наш магазин выглядит следующим образом:
val shop = Shop(
alleys = List(
Alley(
id = "1",
displays = List(
Display("1", "Ambient", List(Shelf("1", water), Shelf("2", milk))),
Display("2", "Chilled", List(Shelf("3", cheese), Shelf("4", ham)))
)
)
)
)
Решаем эту задачу с помощью оптики
Давайте начнем с самого простого и ограничимся каким-нибудь подмножеством товаров, например, одной полкой:
val shelf = Shelf("1", water)
В нашем решении мы будем использовать scala-cli. Начнем с установки зависимостей и включения синтаксиса из Monocle.
//> using scala "3.3.0"
//> using dep "dev.optics::monocle-core:3.2.0"
//> using dep "dev.optics::monocle-macro:3.2.0"
import monocle.syntax.all._
Благодаря импорту этого синтаксиса все case class‘ы теперь дополнены оптическими манипуляторами. С их помощью применить скидку сразу ко всей полке становится проще простого:
shelf
.focus(_.product.price)
.modify(_ * 0.9)
Макрос focus указывает, как найти поле price. Затем мы применяем скидку, вызывая modify
.
Теперь, когда мы знаем основы, давайте применим скидку сразу ко всему ассортименту магазина, как мы хотели с самого начала:
val discounted =
shop
.focus(_.alleys)
.each
.refocus(_.displays)
.each
.refocus(_.shelves)
.each
.refocus(_.product.price)
.modify(_ * 0.9)
Как видите, мы просто повторяем focus
и each
. Они означают, что мы фокусируемся на поле типа List
, а затем даем указания оптике применить предстоящие комбинаторы к каждому из элементов. Это напоминает работу map
или forEach
. Затем, когда мы наконец добрались до полки, нам остается только применить логику, которую мы уже обсудили раньше.
Заключение
На этом введение закончено! Функциональность Monocle гораздо шире, и мы только скользнули по поверхности. Существует еще целое множество других полезных примененийю. Лично я нахожу оптику чрезвычайно полезной для манипулирования данными в интеграционных тестах, когда нужно увидеть, как большие структуры данных изменяются целыми программными компонентами.
Хотите узнать больше? Ознакомьтесь с документацией Monocle https://www.optics.dev/Monocle/.
Хотите увидеть полный пример кода? Он находится в gist https://gist.github.com/majk-p/dfdcf08bdfc3986c3dcd94cc02fa4f52 — вы всегда можете запустить его с помощью scala-cli.
Перевод подготовлен в рамках старта нового потока на курс «Scala-разработчик».