Как стать автором
Обновить

Иммутабельность и диоптрии

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров843

Сегодня мы поговорим о еще одном, незаслуженно игнорируемом джейсоноукладчиками с узким кругозором, мощнейшем инструменте для работы со структурированными данными. О линзах. Удивительнейшим образом, поиск в интернетах по этому ключевому слову — из внятного — отдает только текст Эрика Эллиота с примерами на джаваскрипте. Эрик — умнейший человек и очень сильный популяризатор, но …кхм… «джаваскрипт, сэр».

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

Итак, пусть у нас есть вложенная структура, описывающая набор точек на карте. Что-то типа такого:

pois = [
  %{
    "España" =>
      [%{
        code: "08020",
        data: %{
          city: "Barcelona",
          city_district: "Sant Martí",
          road: "Avinguda del Litoral",
          suburb: "la Vila Olímpica del Poblenou"
        }
      }],
    "Deutschland" => %{…},
    …
  },
  …
] 

Это список, внутри него — мапы, а дальше вглубь кроликовой норы — еще мапы и списки. Представьте себе, что нам необходимо как-то эти данные обрабатывать. Например, вытащить все почтовые коды упомянутых испанских городов, все упомянутые улицы, или типа того. Код обхода уже будет довольно нетривиальным (рекурсия с проверками вложенных типов — это же не то, что вы любите, правда?), но это бы еще и полбеды. Что, если перед нами поставлена задача капитализации всех строк (в примере выше — в suburb нужно озаглавить определённый артикль «la» → «La»)? Всё еще работы на час с перекурами? — А теперь представьте себе, что язык полностью иммутабелен (или кто-то озаботился оконстантить/заморозить все вложенные структуры).

Сие означает, что по месту suburb изменить не получится: нужно изменить 6 (шесть) замороженных объектов, помимо самой строки: внешний список, первая мапа в нём, значение по ключу «España», список, первую мапу в нём, и значение по ключу data. Если не придумать, как это можно сделать без необходимости писать триста строк бойлерплейта, языком не станут пользоваться даже самые яростные адепты (вру, конечно, если техлид скажет — будут, и даже напишут эти триста строк, сам не раз разворачивал на кодревью и клеил пластыри на расшибленные в кашу лбы).

Так вот, линзы. Они придуманы как раз для умной и сложной выборки данных из вложенных структур. Давайте начнем с простого: придумаем, как нам бы хотелось применить капитализацию, упомянутую ранее. Что-то типа такого подойдет, наверное:

update_in(
  pois,
  magically_get_all_string_values(),
  fn s -> String.capitalize(s) end)

Хорошие новости: для этого и придуманы линзы. Плохая новость: всё-таки настолько доступ к отфильтрованным значениям не упростить (на самом деле упростить, я даже в свое время написал библиотеку iteraptor, которая буквально обойдёт всё и вызовет соответствующие колбэки, но я не рекомендую её использовать: встроенный механизм мощнее, и ниже я покажу, чем именно).

Во-первых, списки и мапы — принципиально разные сущности, у списка нет прямого доступа (этим он еще отличается от массива, кстати), а у мапы довольно бессмысленно обходить все значения, но важно иметь доступ по ключу. Давайте введём понятие «путь до элемента» (те, кто работал с xpath из xml — несомненно уловили что-то знакомое). Тогда путь до испанских пригородов будет выглядеть так:

[ALL, "España", ALL, :data, :suburb]

ALL выше означает «все элементы списка», строки и атомы — ключи в соответствующих мапах. Теперь осталось научиться по такому пути не только доставать, но и изменять значения. Ну, или заглянуть в документацию.

Access

Давайте я для начала покажу, как он отработает на структуре pois.

iex|🌢|1 ▶ pois = [
  %{
    "España" =>
      [%{
        code: "08020",
        data: %{
          city: "Barcelona",
          city_district: "Sant Martí",
          road: "Avinguda del Litoral",
          suburb: "la Vila Olímpica del Poblenou"
        }
      }],
    "Deutschland" => %{}
  }
] 

iex|🌢|2 ▶ get_in(pois, [Access.all(), "España", Access.all(), :data, :suburb])
[["la Vila Olímpica del Poblenou"]]
iex|🌢|3 ▶ update_in(pois, [Access.all(), "España", Access.all(), :data, :suburb], &String.capitalize/1)
[
  %{
    "Deutschland" => %{},
    "España" => [
      %{
        code: "08020",
        data: %{
          city: "Barcelona",
          city_district: "Sant Martí",
          road: "Avinguda del Litoral",
          suburb: "La vila olímpica del poblenou"
        }
      }
    ]
  }
]

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

Еще раз: элементом пути может быть собственноручно написанная функция, коль скоро она возвращает функцию, которая получит на вход структуру и вернет её «срез». Все готовые функции доступа (типа Access.all/0 — тоже, разумеется, работают именно по такому принципу). Вот, например, реализация функции Access.key/2 которая позволяет вернуть значение по умолчанию, в отличие от просто доступа по ключу:

get_in(%{}, [:foo]) #⇒ nil
get_in(%{}, [Access.key(:foo, 42)]) #⇒ 42

Если приглядеться к реализации (ссылка выше), можно заметить, что у неё по сути две ветки: одна для получения значения, и одна для изменения. Это связано как раз с иммутабельностью языка: для обычного доступа не требуется тащить за собой измененную структуру, а для «мутации» — требуется. Таким образом, на каждом шаге рекурсии (вглубь по пути), — мы получим и сохраним измененное значение: именно так достигается магия «глубокой мутации», которая на самом деле никакая, конечно, не мутация, а банальная рекурсия с аккумулятором.

По умолчанию, Access не реализован для структур (если нужен тупой доступ, как в мапе с привилегиями, — можно использовать use Estructura, access: true, но я бы посоветовал разобраться и реализовывать самостоятельно, там не так много бойлерплейта).

Часть вторая, или хак-ю

Я абсолютно убежден, что каждый, кто приноровится к использованию Access вместо ручной дрессуры вложенных структур — никогда уже от него не откажется. Я даже на SO пытался запихивать его в каждую щель, с переменным успехом, правда.

Когда пытливый разработчик осваивает новый инструмент, ему в голову обычно приходит крамольная мысль: ок, а можно ли тут как-то злоупотребить? Ответ — да, конечно, куда без этого. access_fun/2 — это ведь просто функция, возвращающая функцию. Пока мы не нарушаем контракт, в потрохах мы вольны делать всё, что нам заблагорассудится.

Иными словами, когда мы реализуем свою access_fun/2, мы сможем её передать в качестве элемента пути в Kernel.×××_in/{2,3} — и она будет вызвана в нужное время в нужном месте. Таким образом мы можем превратить любую структуру, для которой реализован Access, в гранату в наших руках. Я воспользовался этим, когда мне потребовался супер-быстрый HTTP-сервер для наших микросервисов. Втаскивать целый Phoenix ради этого — расточительно, да и из пятисот семьдесяти лезвий этого швейцарского ножа мне требовалось два-три, поэтому я изобрел велосипед под названием camarero.

Как настоящий официант, он заточен (оптимизирован) под раздачу контента больше, чем под обновления, лишен всяких лишних антикрыльев наподобие авторизации, а его реализация — полностью статическая (роутинг компилируется в исполняемый код). Мне удалось добиться ответа в пределах 50÷100μs при доставании значения из мапы с миллионом ключей из 24 тредов и 1000 установленных соединений (пользуясь случаем, прорекламирую wrk — мой любимый инструмент для бенчмаркинга HTTP). Для наших целей этого оказалось достаточно.

Внутреннее устройство каждого хендлера этой библиотеки — крайне простое — за каждым эндпоинтом скрывается контейнер, имплементирующий Access. Таким образом, из коробки мы получаем стандартный (неполный, но достаточный для наших целей) набор методов getpostputdelete. Подробнее про саму библиотеку (вдруг кому пригодится) — можно почитать по ссылке выше, а я вернусь к неконвенционному использованию Access’а.

Спустя где-то полгода после внедрения этой библиотеки, ко мне в кабинет заглянул коллега с вопросительным требованием в молящих глазах кота-в-сапогах из Шрека. Нам, говорит, на некоторых эндпоинтах — надо читать значения напрямую из базы, никакой кэш не построить. Я рассказал ему всё то, что рассказал теперь и вам, а потом предложил самому попробовать объявить фейк-структуру, и реализовать для неё Access, который будет ходить в базу. Примерное решение скрывается за спойлером, я настоятельно рекомендую подумать самостоятельно, прежде чем смотреть на мой код.

Я попробовал сам, хочу сравнить результаты
Да, я наврал, ни хрена я не пробовал, покажи уже код, зануда
defmodule S do
  defstruct [:value]

  @behaviour Access
  @impl Access
  def fetch(%S{}, :value), do: {:ok, 42}
  def fetch(%S{}, _), do: :error
end

get_in(%S{}, [:value]) #⇒ 42

Вместо 42 — можно ходить в базу, плясать гопак, и слать письма на деревню дедушке. Можно принимать в качестве ключа не только :value, но любой терм, хоть туплу, defstruct [:value] нужен только для того, чтобы компилятор не почуял подвох.

Вот, в общем-то, и всё.

Удачного доступа!

Теги:
Хабы:
+6
Комментарии7

Публикации

Ближайшие события