Типы в рантайме: глубже в крольчью нору

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Когда я начинал писать заметку «Типы, где их не ждали», мне казалось, что я осилил принести эрланговские типы в рантайм и теперь могу их использовать в клиентском коде на эликсире. Ха-ха, как же я был наивен.


Все, что предложено по ссылке, будет работать для явных определений типа по месту использования, наподобие use Foo, var: type(). К сожалению, такой подход обречен, если мы хотим определить типы где-нибудь в другом месте: рядом в коде при помощи атрибутов модуля, или, там, в конфиге. Например, для определения структуры мы можем захотеть написать что-то типа такого:


# @fields [foo: 42]
# defstruct @fields

@definition var: atom()
use Foo, @definition

Lighthouse in French Catalonia


Код выше не то, что не обработает тип так, как нам хочется — он не соберется вовсе, потому что @definition var: atom() выбросит исключение ** (CompileError) undefined function atom/0.


Наивный подход


Один из моих самых любимых профессиональных афоризмов — «недели кодирования могут сэкономить вам часы планирования» (обычно приписывается @tsilb, но пользователь был заблокирован супердемократичным твиттером, поэтому я не уверен.) Мне она так нравится, что я повторяю ее как мантру на каждом втором совещании, но, как это всегда бывает с незыблемыми жизненными принципами, — часто не следую провозглашаемому сам.


Итак, я начал с того, что сделал две разных реализации __using__/1: одну, которая принимает список (и ожидает увидеть в нем пары field → type()), и другую — принимающую все, что угодно, ожидая встретить в аргументах либо квотированные типы, либо триплы {Module, :type, [params]}. Я использовал сигил ~q||, который был услужливо имплементирован мной же, в одном из стародавних игрушечных проектов, во времена, когда я учился работать с макросами и AST. Он позволяет вместо quote/1 писать лаконичнее: foo: ~q|atom()|. Там внутри я руками строил список, который потом передавался в первую функцию, принимающую списки. Весь этот код был настоящим кошмаром. Я сомневаюсь, что видел что-то более невнятное за всю свою карьеру, несмотря на то, что я чувствую себя абсолютно комфортно с регулярными выражениями, они мне нравятся, и я их часто использую. Однажды я выиграл спор на воспроизведение регулярного выражения для электронной почты максимально близко к оригиналу, но этот код, всего-то передававший туда-сюда старый добрый простой эрланговский тип оказался в пять раз запутаннее и как-то неаккуратнее, что ли.


Все это выглядело безумием. Мне на подсознательном уровне кажется, что работа с нативными типами erlang во время выполнения — не должна нуждаться в тонне переусложненного избыточного кода, который весь выглядел как заклинание, которое может вызвать духов Кобола. Поэтому я заварил себе кофе покрепче — и начал думать вместо того, чтобы писать код, который никто никогда не сможет понять позже (включая меня самого).


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


Tyyppi


Пару дней спустя, я вышел из дома искупаться — и на пляже вдруг понял, что я столкнулся с типичной XY проблемой. Все, что мне было нужно, — так это просто сделать эрланговские типы — почетным гражданином рантайме. Так родилась библиотека Tyyppi.


В ядре эликсира присутствует незадокументированный модуль Code.Typespec, который существенно облегчил мне жизнь. Я начал с очень простого подхода: с проверки всех возможных термов по всем возможным типам. Я просто загрузил все типы, доступные в моей текущей сессии, и дописывал новые обработчики по мере того, как рекурсивный анализ типов падал глубже по рекурсии. Честно говоря, это было скорее скучно, чем весело. Зато оно привело меня к первой полезной части этой библиотеки — функции Tyyppi.of?/2, которая принимает тип и терм, а возвращает — логическое значение «да»/«нет» в зависимости от того, принадлежит ли терм указанному типу.


iex|tyyppi|1  Tyyppi.of? GenServer.on_start(), {:ok, self()}
#⇒ true
iex|tyyppi|2  Tyyppi.of? GenServer.on_start(), :ok
#⇒ false

Мне нужно было какое-то внутреннее представление для типов, поэтому я решил хранить все в виде структуры с именем Tyyppi.T. Так у Tyyppi.of?/2 появился брат-близнец — Tyyppi.of_type?/2.


iex|tyyppi|3  type = Tyyppi.parse(GenServer.on_start)
iex|tyyppi|4  Tyyppi.of_type? type, {:ok, self()}
#⇒ true

Единственный нюанс, связанный с этим подходом, заключается в том, что мне нужно загрузить и сохранить все типы, доступные в системе, и эта информация не будет доступна в релизах. На данный момент я прекрасно справляюсь с хранением всего этого в обычном файле при помощи :erlang.term_to_binary/1, который связывается с релизом и загружается через обычный специализированный Config.Provider.


Структуры


Теперь я был полностью вооружен, чтобы вернуться к своей первоначальной задаче: создать удобный способ объявления типизированной структуры. Со всем этим багажом на борту, это было легко. Я решил ограничить само объявление структуры явным встроенным литералом, содержащим пары key: type(). Также я реализовал для него Access, с проверкой типов при upserts. Имея все это под рукой, я решил позаимствовать еще пару идей у Ecto.Changeset и добавил перегружаемые функции cast_field/1 и validate/1.


Теперь мы можем объявить структуру, которая разрешала бы апсерты, тогда и только тогда, когда типы всех значений верны, (и когда проходит пользовательская проверка, если была задана).


defmodule MyStruct do
  import Kernel, except: [defstruct: 1]
  import Tyyppi.Struct, only: [defstruct: 1]

  @typedoc "The user type defined before `defstruct/1` declaration"
  @type my_type :: :ok | {:error, term()}

  @defaults foo: :default,
            bar: :erlang.list_to_pid('<0.0.0>'),
            baz: {:error, :reason}
  defstruct foo: atom(), bar: GenServer.on_start(), baz: my_type()

  def cast_foo(atom) when is_atom(atom), do: atom
  def cast_foo(binary) when is_binary(binary),
    do: String.to_atom(binary)

  def validate(%{foo: :default} = my_struct), do: {:ok, my_struct}
  def validate(%{foo: foo} = my_struct), do: {:error, {:foo, foo}
end

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


Весь код библиотеки доступен, как всегда, на гитхабе.




Удачного рантаймтайпинга!

Источник: https://habr.com/ru/post/528814/


Интересные статьи

Интересные статьи

SWAP (своп) — это механизм виртуальной памяти, при котором часть данных из оперативной памяти (ОЗУ) перемещается на хранение на HDD (жёсткий диск), SSD (твёрдотельный накоп...
Сегодня мы публикуем второй материал из цикла, посвящённого использованию Python в Instagram. В прошлый раз речь шла проверке типов серверного кода Instagram. Сервер представляет собой монолит, н...
Вам приходилось сталкиваться с ситуацией, когда сайт или портал Битрикс24 недоступен, потому что на диске неожиданно закончилось место? Да, последний бэкап съел все место на диске в самый неподходящий...
Не так давно коллега ретвитнул отличный пост How to Use Go Interfaces. В нем рассматриваются некоторые ошибки при использовании интерфейсов в Go, а также даются некоторые рекомендации по поводу т...
Если Вы используете в своих проектах инфоблоки 2.0 и таблицы InnoDB, то есть шанс в один прекрасный момент столкнуться с ошибкой MySQL «SQL Error (1118): Row size too large. The maximum row si...