Meta-програмиране в Elixir част 2
Последно се запознахме с AST-то на Elixir, видяхме как прилича на LISP и как да боравим с него. Научихме се да използваме quote
и unquote
. Обърнахме операциите плюс и умножение с минус и деление. Накрая си написахме наша версия на while
цикъл.
Какво ще правим във втората част за метапрограмиране?
Днес ще се запознаем с това какво са модулни атрибути, ще разгледаме различни compile-time hooks, ще се научим да използваме bind_quoted
и __using__
. Ще поговорим за това как да пишем чисти
макроси и ще разгледаме var!
. В процеса на работа, ще научим повече за това как е написан Еликсир и колко лесно можем сами да си разширяваме езика, като сами напишем unless
макроса и разгледаме други примери в ядрото на Еликсир. Накрая ще изобретим собствена версия на библиотека за тестове, в процеса на което ще се сблъскаме с проблеми около писането на макроси.
Макросa unless
.
Нека започнем с нещо лесно, а именно unless
макроса. На кратко unless
изпълнява даден блок, когато дадено условие е лъжа(обратното на if
). Да видим набързо AST-то на един if
.
iex(1)> quote do
...(1)> if true do
...(1)> "Hello"
...(1)> else
...(1)> "Goodbye"
...(1)> end
...(1)> end
{:if, [context: Elixir, import: Kernel], [true, [do: "Hello", else: "Goodbye"]]}
От миналия път знаем, че “…макросите са функции, които приемат като аргумент AST и връщат AST…”. Тогава като гледаме AST-то на if
-а, трябва просто да обърнем стойността на условието, примерно използвайки !
оператора и да върнем почти същото AST. Нека да видим това как ще стане. Започваме като дефинираме макроса с defmacro
, името и блока - като аргументи, които приема. Използваме quote
и unquote
- съответно да върнем AST и вземем стойността на условието и блока.
defmodule Conditions do
defmacro fmi_unless(condition, do: block) do
quote do
if unquote(!condition), do: unquote(block)
end
end
end
Нека видим как може да ползваме нашето макро в iex
:
iex(3)> fmi_unless false, do: IO.puts "Hi"
Hi
:ok
iex(4)> fmi_unless true, do: IO.puts "Hi"
nil
iex(5)> ast = quote do
...(5)> fmi_unless true, do: "Hello"
...(5)> end
{:fmi_unless, [context: Elixir, import: Conditions], [true, [do: "Hello"]]}
iex(6)> Macro.expand_once ast, __ENV__
{:if, [context: Conditions, import: Kernel], [false, [do: "Hello"]]}
Какво виждаме, като веднъж разгънем макроса(като използвахме Macro.expand_once
), то се е генерирало до нормален if
. Всъщност може да считаме, че когато Еликсир види макро, почва да го разгъва рекурсивно, докато повече не може. Може да разгледате Macro
и Code
модулите за повече информация.
Обаче тази имплементация е малко проста и ни оставя да желаем повече, ще работи ли, ако добавим else
, или какво става, ако потребител добави друга клауза освен do
и else
? Може да се опитате да се погрижите за тези случаи сами :).
Една от причините да харесвам толкова много Еликсир, е че лесно можем да видим как са се погрижили за тези неща - просто като отворим кода на Еликсир написан на Еликсир. Ако се предавате за случаите на unless
-а, може да видите как е реализирано тук.
Не ни вярвате, че if–овете са истина за всичко, освен nil/false
? Вече няма нужда да ни се доверявате, може сами да разгледате как са реализирани всички яки работи. И ето малко примери:
Всъщност, може сами да забележите как &&
е направен мързеливо да връща false
ако още първата част не се оценява до истина.
Набързо за bind_quoted
bind_quoted
е една от опциите, които можем да подаваме на quotе
.
Често в макросите използваме unquote
, за да оценим някаква променлива в подадения ни контекст:
defmodule Hello
defmacro say(name)
quote do
"Здравей #{unquote(name)}, как е?"
end
end
end
Всъщност това може да го пренапишем така:
defmodule Hello
defmacro say(name)
quote bind_quoted: [name: name] do
"Здравей #{name}, как е?"
end
end
end
И като разцъкаме в iex
:
iex(1)> Hello.say("Ники")
"Здравей Ники, как е?"
iex(2)> name
** (CompileError) iex:4: undefined function name/0
Тук забелязваме нещо важно - name
, което беше дефинирано вътре в макроса не съществува извън него. Това е част от чистотата на макросите, за която ще си говорим.
Можем да видим пълен списък с опции, които приема quote
тук
Чистота на макросите.
Като пишем макроси в Еликсир не генерираме само код, ние го инжектираме в контекста подаден ни от извикващата функция. Контекстът държи локалния binding, вмъкнатите модули и псевдоними. Един вид контекстът е света, който виждаме от макроса - за това е толкова важен.
Нека видим как Еликсир ни предпазва от това да “замърсяваме” средата в която сме, като се опитаме да достъпим външна променлива.
iex(1)> ast = quote do
...(1)> if a == 42 do
...(1)> "The answer is?"
...(1)> else
...(1)> "Mehhh"
...(1)> end
...(1)> end
iex(2)> Code.eval_quoted ast, a: 42
warning: variable "a" does not exist and is being expanded to "a()", please use parentheses to remove the ambiguity or chang
e the variable name
nofile:1
** (CompileError) nofile:1: undefined function a/0
(stdlib) lists.erl:1354: :lists.mapfoldl/3
(elixir) expanding macro: Kernel.if/2
nofile:1: (file)
Въпреки, че инжектирахме променливата a
в локалния binding чрез Code.eval_quoted
, Еликсир не ни позволява неявно да предефинираме локалния binding на променливи в контекста на извикващия. Как може да накараме този пример да работи?
var!
Като използваме var!
макроса, можем явно да предефинираме локалния binding в контекста - подаден ни в макроса. По този начин казваме на Еликсир: “Знам какво правя, не се притеснявай, тези външни неща ще ги използвам.” Нека накараме предишния пример да проработи:
iex(1)> ast = quote do
...(1)> if var!(a) == 42 do
...(1)> "The answer is?"
...(1)> else
...(1)> "Mehhh"
...(1)> end
...(1)> end
{:if, [context: Elixir, import: Kernel],
[{:==, [context: Elixir, import: Kernel],
[{:var!, [context: Elixir, import: Kernel], [{:a, [], Elixir}]}, 42]},
[do: "The answer is?", else: "Mehhh"]]}
iex(2)> Code.eval_quoted ast, a: 42
{"The answer is?", [a: 42]}
iex(3)> Code.eval_quoted ast, a: 1
{"Mehhh", [a: 1]}
Добре, тук само използваxме променливата в условието на if
-а, но не я променихме по какъвто и да е начин, нека разгледаме по-опасен пример:
iex(1)> defmodule Dangerous do
...(1)> defmacro rename(new_name) do
...(1)> quote do
...(1)> var!(name) = unquote(new_name)
...(1)> end
...(1)> end
...(1)> end
{:module, Dangerous, .....
iex(2)> require Dangerous
Dangerous
iex(3)> name = "Слави"
"Слави"
iex(4)> Dangerous.rename("Вало")
"Вало"
iex(5)> name
"Вало"
Наша собствена библиотека за тестове.
Добре, вече знаем как да използваме quote/unquote/bind_quoted
, var!
. Ще се опитаме да си напишем собствена библиотека за тестове с малък приятен DSL заимстван от exunit
. За целта ще се опитаме да предоставим следните неща:
- удобен начин хората да използват библиотеката ни:
defmodule TestUsers do
use Specs
end
- искаме лесно да може да проверяваш стойности и да ги сравняваш, за целта ще имаме модул Assertion, който ще предоставя тази функционалност.
assert value
assert value == 4
assert value <= 5
- начин да създаваме отделни тестове с кратко описание, което ще изглежда нещо от сорта на:
spec "кратко описание", do: ...block of testing code...
- И разбира се, начин да пускаме тестовете.
using
Добре, нека започнем първо с __using__
- макро, което ни дава да дефинираме callback, когато някой ни използва модула. На кратко, ако имаме:
defmodule UserTest do
use Assertion, option: "Hello"
end
Това ще се компилира до:
defmodule UserTest do
require Assertion
Assertion.__using__(option: "Hello")
end
Това ни позволява лесно да вкараме методите и макросите за тестове, които ще са нужни за потребителите на нашата библиотека.
Как ще го постигнем това? Като предефинираме макроса __using__
. Просто искаме да import
-нем нашия модул, можем да използваме __MODULE__
.
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
end
end
Така ще можем да импортираме всички функции от модулите ни, когато човек иска да се възползва от тях. Потребителите на нашата библиотека, биха писали само use Assertion
.
Assertion
Как ще позволяваме на хората да проверяват различни твърдения? Нека видим в други езици как е постигнато това:
assert value
assert_equal value, 4
assert_operator value, :<, 5
Нещо яко - за разлика от други езици, с помощта на pattern-matching ще пишем само:
assert value
assert value == 4
assert value < 5
Казах ли, че можем да pattern match-ваме по AST-то, точно както pattern match-ваме аргументите в обикновени функции? Ооооо да. Нека видим колко лесно можем да построим модула за твърдения.
defmodule Assertion do
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
end
end
defmacro assert({operator, _context, [lhs, rhs]}) do
quote bind_quoted: [operator: operator, lhs: lhs, rhs: rhs] do
do_assert(operator, lhs, rhs)
end
end
defmacro assert(value) do
quote bind_quoted: [value: value] do
do_assert(value)
end
end
def do_assert(:<, left, right) when left < right, do: :ok
def do_assert(:<, left, right) do
{:error, "Expected left side #{left} to be smaller than right side #{right}"}
end
def do_assert(:==, left, right) when left == right, do: :ok
def do_assert(:==, left, right) do
{:error, "Expected the left side #{left} to be equal to the right side #{right}"}
end
def do_assert(operator, _left, _right) do
{:error, "Could not recognize operator: #{operator}"}
end
def do_assert(value) when value in [false, nil] do
{:error, "Expected #{value} to be truthy."}
end
def do_assert(_value), do: :ok
end
Какво направихме? Съпоставяме получените оператори и изпълняваме функцията do_assert
, която предефинираме за различните оператори. Можем просто да добавим повече клаузи към do_assert
, ако искаме да добавим още оператори, разбира се трябва да внимаваме да не изпуснем някой случай.
В момента импортваме всичко дефинирано вътре в Assertion
, как можем да го избегнем това? Това е оставено като упражнение за читателя.
Добре, вече може да проверяваме твърдения, сега трябва да дефинираме начин да пишем тестовете. Накратко - искаме да дефинираме някакво макро spec
, което ще приема низ(описанието на теста) и блок, който да изпълним след това. Когато потребителят използва spec
ще дефинираме функция, с името на описанието и ще си записваме някъде всички spec
-ове, които хората са си дефинирали. Когато потребител иска да стартира тестовете, ще минаваме през всички записи и ще ги изпълняваме. За целта да ги записваме ще използваме модулни атрибути.
Модулни атрибути
За какво и как се използват модулни атрибути?
- Пазене на временна информация по време на компилация.
- Да държат информация за модула, която ще бъде използвана от потребителя или виртуалната машина.
- Или можем да ги използваме като константи.
Бележим модулните атрибути с префикс @
.
Как ще пазим всички написани тестове? Ще инициализираме модулен атрибут, който ще е списък, в който ще записваме всеки използван тест. За да не презаписваме предишния тест, ще добавяме в началото всяка двойка от име на тест и описание.
defmodule Specs do
defmacro __using__(_options) do
quote do
# Добавяме модула за тестване на твърдения
use Assertion
# Инициализираме празен списък като модулен атрибут.
@specs []
import unquote(__MODULE__)
end
end
defmacro spec(description, do: spec_block) do
# def иска атом като първи аргумент
func_name = String.to_atom(description)
quote do
# Добавяме в началото всеки тест.
@specs [{unquote(func_name), unquote(description)} | @specs]
# spec просто ще дефинира нормална функция
def unquote(func_name)(), do: unquote(spec_block)
end
end
end
Добре, нека да видим дали ни се създават и записват успешно тестовете:
iex(1)> defmodule ExampleTests do
...(1)> use Specs
...(1)>
...(1)> spec "Test success" do
...(1)> assert 1 == 1
...(1)> end
...(1)>
...(1)> spec "Test failure" do
...(1)> assert 1 == 2
...(1)> end
...(1)>
...(1)> def specs do
...(1)> @specs
...(1)> end
...(1)> end
{:module, ExampleTests ...
iex(2)> ExampleTests.specs
["Test failure": "Test failure", "Test success": "Test success"]
iex(3)> apply(ExampleTests, :"Test failure", [])
{:error, "Expected the left side 1 to be equal to the right side 2"}
iex(4)> apply(ExampleTests, :"Test success", [])
:ok
Добре, дефинирахме и записахме успешно тестовете. Използвахме apply/3
- така извикахме произволна функцията само по нейното име и модул. Всичко върви прекрасно, само че трябваше ръчно да извикаме всеки тест с apply
, което не беше много прекрасно. Вместо това ще се опитаме да дефинираме функция run
, вътре в модула на потребителя, която да извика всички тестове за нас.
Тоест ще искаме да пишем само веднъж ExampleTests.run
и това да изпълнява всички тестове.
SpecRunner
Нека реализираме последния нужен модул, който автоматично ще дефинира функцията run
в ExampleTests. За жалост не може просто да си отворим модула ExampleTests
и да му добавим функцията run
, нито пък можем да се отървем само с импортиране на модула ни. Защо не можем? Ако дефинираме run
и просто го импортиме - няма да знаем кога точно е вмъкна тази функция по време на компилация. С други думи може не всички тестове в @specs
да са акумулирани.
before_compile
За целта ще използваме __before_compile__
. __before_compile__
ни осигурява, че кодът ще се изпълни точно преди модула да бъде компилиран, иначе казано - всички тестове вече ще са били дефиниране и ще ги има в модулния атрибут @specs
.
defmodule SpecRunner do
defmacro __using__(_options) do
quote do
# извикай SpecRunner.__before_compile__(env) преди да се компилира дадения модул.
@before_compile unquote(__MODULE__)
end
end
# Тук е идеалното място да вкараме нашата функция `run` в ExampleTests
defmacro __before_compile__(_env) do
quote do
def run do
@specs
|> Enum.each(fn {spec_func, spec_desc} ->
IO.puts "Running spec: #{spec_desc}"
case apply(__MODULE__, spec_func, []) do
# Връщаме точка когато сме окей.
:ok -> IO.puts "."
# Прихващаме грешки.
{:error, reason} -> IO.puts "Failure in #{spec_desc}: #{reason}"
end
IO.puts ""
end)
end
end
end
end
и не забравяме да използваме SpecRunner
в Specs
, като променим __using__
така:
defmodule Specs do
defmacro __using__(_options) do
quote do
use Assertion
use SpecRunner
@specs []
import unquote(__MODULE__)
end
end
# ... spec ...
end
И вече можем да се радваме на крайния резултат:
iex(7)> defmodule ExampleTests do
...(7)> use Specs
...(7)>
...(7)> spec "Test success" do
...(7)> assert 1 == 1
...(7)> end
...(7)>
...(7)> spec "Test failure" do
...(7)> assert 1 == 2
...(7)> end
...(7)> end
{:module, ExampleTests, ...
iex(8)> ExampleTests.run
Running spec: Test failure
Failure in Test failure: Expected the left side 1 to be equal to the right side 2
Running spec: Test success
.
:ok