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

Meta-програмиране в Elixir част 1


Какво всъщност е мета програмиране? Накратко, това е възможноста да пишем код, който пише код. Тъй като това е една от онези дефиниции, който не успяват много успешно да обяснят за какво точно става въпрос, нека да видим няколко примера за приложения на мета програмирането.

Автоматизирано създаване на функции

Мета програмирането позволява да се автоматизира генерирането на множество функции с подобна или една и съща имплементация. Нека например си представим как би изглеждало генерирането на HTML като код на Elixir:

html do
  head do
    title do
      text "Hello To Our HTML DSL"
    end
  end
  body do
    h1 class: "title" do
      text "Introduction to metaprogramming"
    end
    p do
      text "Metaprogramming with Elixir is really awesome!"
    end
  end
end

Принципно нищо не ни пречи да имплементираме горните функции без мета програмиране, но ще трябва за всеки HTML таг да напишем функция, която да прави едно и също. Много по-лесно би било да имаме списък от всички тагове в един файл и от него да генерираме всички нужни функции. Това може да стане лесно с мета програмиране и няма да ни е нужен никакъв допълнителен инструмент, освен Elixir. В други езици, подобен проблем обикновенно се решава чрез инструменти като Makefiles и допълнителни скриптове.

Дефиниране на DSL-и

Мета програмирането позволява да дефинираме нови “езици”, които решават някакъв специфичен проблем. Нека например видим библиотеката Ecto, която позволява да се пишат SQL заявки като код на Elixir:

from o in Order,
where: o.created_at > ^Timex.shift(DateTime.utc_now(), days: -2)
join: i in OrderItems, on: i.order_id == o.id

Горният код ще генерира SQL от следният вид:

SELECT o.*
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.created_at > '2017-04-28 14:15:34'

Както виждате можем да пишем SQL код директно в Elixir! Забележете, че това е синтактично валиден код, но без да се пренапише няма да може да се компилира. Тук дори и да искаме няма да може да се разминем от мета програмирането за да постигнем горният синтаксис. Това което макроса from прави е да пренапише аргументите, които му се подават към специална структура, която после се използва за да се конструира финалният SQL. Преимуществата на горния подход, са че заявките могат много лесно да се разделят на малки, части които да се комбинират:

def orders(from_date) do
  from o in Order,
  where: o.created_at > ^from_date
  join: i in OrderItems, on: i.order_id == o.id
end

def user_orders(from_date, user_id) do
  from o in orders(from_date),
  where: o.user_id == ^user_id
end

Както виждате във функцията user_orders, където искаме да имаме допълнително филтриране по потребителя направил заявката, можем да преизползваме функцията orders и единствено да добавим филтрирането по потребител.

Използването на DSL език за заявки към базата данни има и други преимущества:

  • Автоматично санитизиране на данните и защита от SQL injections
  • Валидиране на заявките по време на компилация
  • По-лесна поддръжка на различни бази данни

Въведение в Abstract Syntax Tree

Мета програмирането в Elixir става чрез дефинирането на макроси. Това са функции, които приемат като аргументи код и връщат код като резултат, като кода е във формата на Abstract Syntax Tree или AST.

Почти всеки един език за програмиране по един или по друг начин използва AST, но в много случаи потребителите на езика нямат достъп до него. Обикновенно AST се използва като междинно представяне преди кода да бъде компилиран до машинни инструкции. В Elixir обаче, не само че имаме достъп до AST, но и това AST е представено чрез стандартните структури от данни, с които вече се запознахме.

Реално мета програмирането се извършва чрез манипулация на AST-то генерирано по време на компилация и затова е и много важно да запомним, че мета програмирането се извършва само по време на компилация. Това значи, че веднъж компилиран, кода не може да бъде променян, за разлика от Ruby например. Това може да звучи, като сериозно ограничение, но реално се оказва, че е напълно достатъчно в повечето случаи и при всички положения подобрява бързината на езика многократно. Все още не съм установил ситуация, където да ми е трябвала манипулация на кода по време на изпълнение и мисля, че авторите на езика са направили много добър trade-off.

Нека да разгледаме малко примери за това как изглежда AST. За да получим AST на някакъв Elixir код, можем да използваме макроса quote. Например:

iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
iex> quote do: div(10, 2)
{:div, [context: Elixir, import: Kernel], [10, 2]}

Както виждате, AST представлява кортежи от тройки, които имат следният формат:

{<име на функция>, <контекст>, <списък от аргументи>}

Този формат може много да ви напомни на Lisp и ще сте напълно прави. Реално в Lisp, кода се представя по много подобен начин и от там идва и идеята, че в Lisp кода всъщност са просто данни. За разлика от Lisp обаче, в Elixir имаме приятен синтаксис, който се преобразува в AST от компилатора и само ако искаме да правим мета програмиране се налага да работим със странния “префиксен” начин да представяне на код.

Да видим някои по-сложни примери:

iex> quote do: 1 + 2 * 3
{:+, [context: Elixir, import: Kernel],
 [1, {:*, [context: Elixir, import: Kernel], [2, 3]}]}

Ако разпишем горното дърво ще видим, че приоритета на операциите е правилен:

{:+, _, [
  1,
  {:*, _, [2, 3]}
]}

Както виждате умножението е в отделно под-дърво от събирането. Да видим как би изглеждало AST-то на HTML езика, който разгледахме по-рано:

iex> quote do
...> html do
...>   head do
...>     title do
...>       text "Hello To Our HTML DSL"
...>     end
...>   end
...> end
...> end
{:html, [],
 [[do: {:head, [],
    [[do: {:title, [], [[do: {:text, [], ["Hello To Our HTML DSL"]}]]}]]}]]}

Тъй като нашият DSL е валиден Elixir код, то той може да бъде компилиран до AST. Респективно ние можем да модифицираме това AST към друго валидно AST, което вече компилатора ще преобразува във BEAM byte code. Нека да видим как става това модифициране на AST-то.

Въведение в макросите

Макросите са функции, които приемат като аргумент AST и връщат AST. Нека да разгледаме един най-прост пример: да дефинираме макрос, който обръща плюс с минус и умножение с деление.

defmodule MathChaosMonkey do
  defmacro swap_ops(do: {:+, context, arguments}) do
    {:-, context, arguments}
  end

  defmacro swap_ops(do: {:-, context, arguments}) do
    {:+, context, arguments}
  end

  defmacro swap_ops(do: {:/, context, arguments}) do
    {:*, context, arguments}
  end

  defmacro swap_ops(do: {:*, context, arguments}) do
    {:/, context, arguments}
  end
end

Нека сега да тестваме нашия макрос:

iex> MathChaosMonkey.swap_ops do
...> 1 + 2
...> end
-1
iex> MathChaosMonkey.swap_ops do
...> 10 * 2 + 1
...> end
19
iex> MathChaosMonkey.swap_ops do
...> 10 * 2
...> end
5.0

Както виждаме успяхме да сътворим пълна бъркотия в аритметичните операции. Нещо, което е важно да се отбележи е че за разлика от езици като Ruby, макросите имат много ясно поле на действие, т.е. няма как да направим глобална промяна във runtime-а. Всички макроси имат ефект единствено в модулите, които са require-нати и използвани.

Примерът по-горе е доста опростен и напълно безполезен в реални условия. За да можем да дефинираме използваеми макроси по лесен начин, има помощни функции, които ни позволяват да работим без да слизаме на ниво AST структурата. Това е функцията unquote, която взема AST структура и я интерпретира в текущия контекст. Нека да разгледаме един пример:

iex> value = 12
12
iex> quote do
...> 1 + 2 * value
...> end
{:+, [context: Elixir, import: Kernel],
 [1, {:*, [context: Elixir, import: Kernel], [2, {:value, [], Elixir}]}]}
iex> quote do
...> 1 + 2 * unquote(value)
...> end
{:+, [context: Elixir, import: Kernel],
 [1, {:*, [context: Elixir, import: Kernel], [2, 12]}]}

Нека да разгледаме друг пример, които ще дефинира функции за умножение на числа по някаква стойност:

defmodule Multiplier do
  defmacro of(value) do
    quote do
      def unquote(:"multiplier_#{value}")(expr) do
        expr * unquote(value)
      end
    end
  end
end

defmodule Math do
  require Multiplier

  Multiplier.of(5)
end

Math.multiplier_5(2) # => 10

Както виждате успяхе да напишем макрос, който да генерира функция, която има поведение зависещо от аргументите, които подадохме на макроса.

Дефиниция на while

Нека да разгледаме един по-интересен пример: да дефинираме while цикъл. Това ще рече, да подкараме следният код в Elixir:

defmodule Fib do
  def async_fib(n) do
    spawn(fn -> fib(n) end)
  end

  def sync_fib(n) do
    pid = async_fib(n)
    while(Process.alive?(pid)) do
      IO.puts "Waiting..."
      sleep(1)
    end
    IO.puts "Done!"
  end

  defp fib(0), do: 0
  defp fib(1), do: 1
  defp fib(n), do: fib(n-1) + fib(n-2)
end
defmodule Loops do
  defmacro while(expression, do: block) do
    quote do
      try do
        for _ <- Stream.cycle([:ok]) do
          if unquote(expression) do
            unquote(block)
          else
            throw :break
          end
        end
      catch
        :break -> :ok
      end
    end
  end
end

defmodule Fib do
  require Loops

  def async_fib(n) do
    spawn(fn -> fib(n) end)
  end

  def sync_fib(n) do
    pid = async_fib(n)
    Loops.while(Process.alive?(pid)) do
      IO.puts "Waiting..."
      Process.sleep(1000)
    end
    IO.puts "Done!"
  end

  defp fib(0), do: 0
  defp fib(1), do: 1
  defp fib(n), do: fib(n-1) + fib(n-2)
end

iex> Fib.sync_fib(40)
Waiting...
Waiting...
Waiting...
Waiting...
Waiting...
Waiting...
Waiting...
Done!
:ok

Както виждате успяхме да създадем while конструкция, която е подобна на циклите в другите езици. Добре е да се отбележи, че следният код няма да работи:

defmodule Bottles do
  def sing(n) do
    i = 0
    while(i < n) do
      IO.puts "#{n} bottles hanging on the wall"
      IO.puts "If one bottle crashes on the floor, there will be..."
      i = i - 1 # Won't change the binding in the condition of the loop
    end
    IO.puts "No bottles hanging on the wall"
  end
end

Тъй като променливите има строго дефиниран scope и данните са неизменими, в горния пример i = i - 1 няма да промени условието i < 10, тъй като unquote ще вземе стойността на i преди while макроса и ако променим тази стойност вътре в болка, то тя няма да се отрази при втория цикъл. За да илюстрираме по-ясно това нека да пренапишем горния макрос с рекурсия:

defmodule Loops do
  defmacro while(expr, do: block) do
    quote do
      Loops.run_loop(fn -> unquote(expr) end, fn -> unquote(block) end)
    end
  end

  def run_loop(expr_body, loop_body) do
    case expr_body.() do
      true ->
        loop_body.()
        run_loop(expr_body, loop_body)
      _ ->
        :ok
    end
  end
end

В горната имплементация си разделяме цикъла на условие и на тяло и ги “обвиваме” във функции, за да можем да ги изпълняваме когато искаме. Всяка от тази функции си има локален binding на променливите с нея и този binding не може да бъде променян от външните функции.