Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Когда я начинал писать заметку «Типы, где их не ждали», мне казалось, что я осилил принести эрланговские типы в рантайм и теперь могу их использовать в клиентском коде на эликсире. Ха-ха, как же я был наивен.
Все, что предложено по ссылке, будет работать для явных определений типа по месту использования, наподобие 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()}
#⇒ 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, особенно при работе с внешними источниками.
Весь код библиотеки доступен, как всегда, на гитхабе.
Удачного рантаймтайпинга!