Когда я начинал писать заметку Типы, где их не ждали, мне казалось, что я осилил принести эрланговские типы в рантайм и теперь могу их использовать в клиентском коде на эликсире. Ха-ха, как же я был наивен.
Все, что предложено по ссылке, будет работать для явных
определений типа по месту использования, наподобие use Foo,
var: type()
. К сожалению, такой подход обречен, если мы
хотим определить типы где-нибудь в другом месте: рядом в коде при
помощи атрибутов модуля, или, там, в конфиге. Например, для
определения структуры мы можем захотеть написать что-то типа
такого:
# @fields [foo: 42]# defstruct @fields@definition var: atom()use Foo, @definition
Код выше не то, что не обработает тип так, как нам хочетсяон не
соберется вовсе, потому что @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()}# trueiex|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, особенно при работе с внешними источниками.
Весь код библиотеки доступен, как всегда, на гитхабе.
Удачного рантаймтайпинга!