Metaprogramowanie
Metaprogramowanie to proces tworzenia kodu, którego zadaniem jest generowanie kodu. W Elixirze mamy możliwość rozszerzania języka tak, by dynamicznie generowany kod dostosowywał się do naszych bieżących potrzeb. Najpierw przyjrzymy się, jaka jest wewnętrzna reprezentacja kodu Elixira, następnie zobaczmy, jak można ją modyfikować, by w końcu wykorzystać zdobytą wiedzę do rozszerzania kodu za pomocą makr.
Drobna uwaga: metaprogramowanie jest zawiłe i powinno być stosowane tylko w ostateczności Nadużywane go może doprowadzić do stworzenia zbyt skomplikowanego kodu, który będzie trudny do zrozumienia i debugowania.
Reprezentacja wewnętrzna kodu
Pierwszym krokiem w metaprogramowaniu jest zrozumienie, jak reprezentowana jest składnia programu. W Elixirze drzewo składniowe (ang. Abstract Syntax Tree — AST) jest wewnętrznie reprezentowane w postaci zagnieżdżonych krotek. Każda z nich ma trzy elementy: nazwę funkcji, metadane i argumenty.
Abyśmy mogli zobaczyć tę wewnętrzną strukturę, Elixir udostępnia funkcję quote/2
.
Używając quote/2
możemy zamienić kod Elixira tak, by struktura ta była dla nas zrozumiała:
iex> quote do: 42
42
iex> quote do: "Hello"
"Hello"
iex> quote do: :world
:world
iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
iex> quote do: if value, do: "True", else: "False"
{:if, [context: Elixir, import: Kernel],
[{:value, [], Elixir}, [do: "True", else: "False"]]}
Zauważyłeś, że pierwsze trzy wywołania nie zwróciły krotek? Istnieje pięć elementów języka, które zachowują się w ten sposób:
iex> :atom
:atom
iex> "string"
"string"
iex> 1 # Wszystkie liczby
1
iex> [1, 2] # Listy
[1, 2]
iex> {"hello", :world} # Dwuelementowe krotki
{"hello", :world}
Modyfikacja AST
Wiemy już, jak uzyskać wewnętrzną reprezentację kodu — jak możemy ją natomiast modyfikować? By wstawić do kodu nową wartość lub wyrażenie, użyjemy unquote/1
.
Zostanie ono wyliczone, a następnie wstawione w odpowiednie miejsce AST.
Zobaczmy, jak działa unquote/1
na poniższym przykładzie:
iex> denominator = 2
2
iex> quote do: divide(42, denominator)
{:divide, [], [42, {:denominator, [], Elixir}]}
iex> quote do: divide(42, unquote(denominator))
{:divide, [], [42, 2]}
W pierwszym przykładzie zmienna denominator
jest elementem drzewa AST i została przedstawiona jako krotka opisująca odwołanie do zmiennej.
Gdy użyjemy jednak unquote/1
, to w rezultacie zostanie wyznaczona wartość zmiennej denominator
i to ona zostanie wyświetlona.
Makra
Jeśli rozumiemy już quote/2
i unquote/1
, możemy przyjrzeć się makrom.
Ważną rzeczą do zapamiętania jest to, że makra, tak jak całe metaprogramowanie, powinny być używane oszczędnie.
Najprościej mówiąc, makro to rodzaj funkcji, która zwraca fragment AST, które może zostać wstawione do naszego kodu. Makro zostanie przy tym zamienione na nasz kod, a nie wywołane jak zwykła funkcja. Dysponując makrami mamy wszystkie niezbędne narzędzia, by dynamicznie dodawać kod w naszych aplikacjach.
By zdefiniować makro, użyjemy defmacro/2
, które, jak wiele rzeczy w Elixirze, samo też jest makrem.
W naszym przykładzie zaimplementujemy unless
jako makro.
Pamiętaj, że makro musi zwrócić fragment AST:
defmodule OurMacro do
defmacro unless(expr, do: block) do
quote do
if !unquote(expr), do: unquote(block)
end
end
end
Zaimportujmy więc nasz moduł i pozwólmy makru działać:
iex> require OurMacro
nil
iex> OurMacro.unless true, do: "Hi"
nil
iex> OurMacro.unless false, do: "Hi"
"Hi"
Jako że makra podmieniają kod w naszej aplikacji, to mamy wpływ na to, co i kiedy zostanie skompilowane.
Przykład tego typu zabiegów znajdziemy w module Logger
.
Kiedy logowanie jest wyłączone, żaden kod nie zostanie dopisany i w rezultacie nasza aplikacja nie będzie miała śladu po wywołaniach loggera.
Takie zachowanie wyróżnia Elixira spośród innych języków, w których nadal istnieje narzut związany z wywołaniem funkcji, które ze względu na konfigurację nic nie robią.
By zademonstrować to zachowanie, stwórzmy prosty logger, który będzie można włączyć i wyłączyć:
defmodule Logger do
defmacro log(msg) do
if Application.get_env(:logger, :enabled) do
quote do
IO.puts("Logged message: #{unquote(msg)}")
end
end
end
end
defmodule Example do
require Logger
def test do
Logger.log("This is a log message")
end
end
Gdy logowanie jest włączone funkcja test
będzie wyglądać mniej więcej w ten sposób:
def test do
IO.puts("Logged message: #{"This is a log message"}")
end
Ale jeżeli jest wyłączone to kod będzie następujący:
def test do
end
Debugowanie
Dobrze — wiemy już, jak używać quote/2
, unquote/1
i pisać makra.
Co jednak, jeśli mamy duży kawałek kodu rozłożonego na drzewo składniowe i chcemy go zrozumieć? W tym przypadku możemy użyć funkcji Macro.to_string/2
.
Spójrzmy na tem przykład:
iex> Macro.to_string(quote(do: foo.bar(1, 2, 3)))
"foo.bar(1, 2, 3)"
Kiedy natomiast chcemy zobaczyć kod generowany przez makra, możemy użyć funkcji Macro.expand/2
i Macro.expand_once/2
, które rozwijają makra do tworzonego przez nie kodu na podstawie drzewa składniowego.
Pierwsza z nich może rozwijać makra wiele razy, druga — tylko raz.
Dla przykładu zmodyfikujmy nasze makro unless
z poprzedniej sekcji:
defmodule OurMacro do
defmacro unless(expr, do: block) do
quote do
if !unquote(expr), do: unquote(block)
end
end
end
require OurMacro
quoted =
quote do
OurMacro.unless(true, do: "Hi")
end
iex> quoted |> Macro.expand_once(__ENV__) |> Macro.to_string |> IO.puts
if(!true) do
"Hi"
end
Jeśli uruchomimy ten sam kod z funkcją Macro.expand/2
, wynik może być nieco intrygujący:
iex> quoted |> Macro.expand(__ENV__) |> Macro.to_string |> IO.puts
case(!true) do
x when x in [false, nil] ->
nil
_ ->
"Hi"
end
Być może pamiętasz, jak wspomnieliśmy o tym, że instrukcja if
w Elixirze także jest makrem — tutaj widzimy ją rozwiniętą do instrukcji case
, na której opiera się jej implementacja.
Makra prywatne
Niespotykanym w innych językach rozwiązaniem jest wspierany przez Elixira mechanizm makr prywatnych.
Makro prywatne definiujemy za pomocą defmacrop
i można je wywołać tylko w module, w którym zostało zdefiniowane.
Prywatne makra muszą być zdefiniowane przed kodem, który je wywołuje.
Separacja makr
Sposób, w jaki makro wchodzi w interakcję z kontekstem wywołującego, jest nazywane separacją makr. Domyślnie Elixir odseparowuje makra od kontekstu, by nie powodowały konfliktów:
defmodule Example do
defmacro hygienic do
quote do: val = -1
end
end
iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42
Co, jeżeli chcielibyśmy manipulować zmienną val
w makrze? Możemy oznaczyć naszą zmienną jako nieodseparowaną za pomocą var!/2
.
Dodajmy do przykładu kolejne, nieodseparowane makro, używające var!/2
:
defmodule Example do
defmacro hygienic do
quote do: val = -1
end
defmacro unhygienic do
quote do: var!(val) = -1
end
end
Porównajmy, jaki wpływ mają na nasz kontekst:
iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42
iex> Example.unhygienic
-1
iex> val
-1
Dodając var!/2
możemy manipulować wartością val
bez konieczności przekazywana jej jako parametru.
Używanie nieodseparowanych makr powinno być ograniczone do minimum.
Używając var!/2
zwiększamy szansę na pojawienie się konfliktu nazw i zakresów wśród zmiennych.
Spinanie
Wiemy już, jak użyteczna jest funkcja unquote/1
, ale poza nią istnieje jeszcze inna metoda wstawiania wartości do kodu — spinanie.
Wykorzystując spinanie zmiennych, możemy używać zmiennej wiele razy w jednym makrze bez obawy, że nastąpi zmiana jej wartości.
By tego dokonać, musimy przekazać opcję bind_quoted
do funkcji quote/2
.
By pokazać zalety bind_quote
oraz problem zmiany wartości zmiennej, posłużmy się przykładem.
Stwórzmy proste makro, które będzie dwukrotnie wypisywać przekazaną wartość:
defmodule Example do
defmacro double_puts(expr) do
quote do
IO.puts(unquote(expr))
IO.puts(unquote(expr))
end
end
end
Przetestujmy je, podając aktualny czas. W rezultacie powinniśmy zobaczyć dwie linie:
iex> Example.double_puts(:os.system_time)
1450475941851668000
1450475941851733000
Wartości są różne! Co się stało? Gdy użyliśmy unquote/1
na tym samym wyrażeniu kilka razy, to pomiędzy naszymi wywołaniami wartość wyrażenia zmieniła się, co doprowadziło do nieoczekiwanego zachowania.
Poprawmy kod, używając bind_quoted
, i zobaczmy, co się stanie:
defmodule Example do
defmacro double_puts(expr) do
quote bind_quoted: [expr: expr] do
IO.puts(expr)
IO.puts(expr)
end
end
end
iex> require Example
nil
iex> Example.double_puts(:os.system_time)
1450476083466500000
1450476083466500000
Kod z bind_quoted
zadziałał zgodnie z oczekiwaniami: dwukrotnie pojawiła się taka sama wartość.
Teraz, gdy znamy quote/2
, unquote/1
i defmacro/2
, mamy już wszystkie potrzebne narzędzia, by rozszerzać Elixira wedle naszych potrzeb.
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!