Fork me on GitHub

Metaprogrammierung

Metaprogrammierung ist der Vorhang Code zu benutzen, um Code zu schreiben. In Elixir gibt uns das die Möglichkeit die Sprache zu erweitern, so dass sie unseren Anforderungen eher entspricht und dynamisch den Code zu verändern. Wir starten mit einem Blick darauf, wie Elixir unter der Haube repräsentiert wird; dann wie man es verändert und schlussendlich können wir dieses Wissen dazu nutzen, um es zu erweitern.

Um eine Warnung auszusprechen: Metaprogrammierung ist kniffelig und sollte nur falls wirklich notwendig eingesetzt werden. Zuviel benutzt wird sie unweigerlich zu komplexem Code führen, der schwierig zu verstehen und debuggen ist.

Inhaltsverzeichnis

Quote

Der erste Schritt für Metaprogrammierung ist zu verstehen, wie Ausdrücke repräsentiert werden. In Elixir besteht der abstract syntax tree (AST), die interne Repräsentation unseres Codes, aus Tupeln. Diese Tupel beinhalten drei Teile: Funktionsname, Metadaten und Funktionsargumente.

Um diese internen Strukturen zu betrachen, bietet uns Elixir die Funktion quote/2. Mit quote/2 können wir Elixir Code in die darunterliegende Repräsentation verwandeln:

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"]]}

Ist dir aufgefallen, dass die ersten drei Aufrufe keine Tupel zurück gegeben haben? Es gibt 5 Literale, die sich selbst zurück geben, falls sie gequoted werden:

iex> :atom
:atom
iex> "string"
"string"
iex> 1 # All numbers
1
iex> [1, 2] # Lists
[1, 2]
iex> {"hello", :world} # 2 element tuples
{"hello", :world}

Unquote

Nun, da wir jetzt die interne Struktur unseres Codes erhalten können, wie verändern wir sie? Um neuen Code oder auch Werte zu injecten benutzen wir unquote/1. Wenn wir einen Ausdruck unquoten, wird er ausgewertet und in den AST injected. Um unquote/1 zu demonstrieren, lass uns ein paar Beispiele anschauen:

iex> denominator = 2
2
iex> quote do: divide(42, denominator)
{:divide, [], [42, {:denominator, [], Elixir}]}
iex> quote do: divide(42, unquote(denominator))
{:divide, [], [42, 2]}

Im ersten Beispiel wird unsere Variable denominator gequoted, so dass der resultierende AST ein Tupel beinhaltet, um auf die Variable zugreifen zu können. Im unquote/1-Beispiel enthält der resultierende Code dagegen den Wert von denominator.

Makros

Wenn wir erst mal quote/2 und unquote/1 verstanden haben, sind wir bereit dazu in Makros abzutauchen. Es ist wichtig sich zu merken, dass Makros wie jede Metaprogrammierung nur spärlich eingesetzt werden sollte.

In ihrer einfachsten Form sind Makros nur besondere Funktionen, die entworfen wurden, um einen gequoteten Ausdruck in unseren Anwendungscode zu injecten. Stell dir vor, dass das Makro mit dem gequoteten Ausdruck ersetzt wird, anstatt wie eine Funktion aufgerufen zu werden. Mit Makros haben wir alles an der Hand, um Elixir zu erweitern und unserer Anwendung dynamisch Code hinzuzufügen.

Wir beginnen mit defmacro/2 ein Makro zu definieren, welches, wie vieles in Elixir, selbst ein Makro ist (lass das erst mal ins Bewusstein dringen). Als Beispiel werden wir unless als Makro implementieren. Erinner dich daran, dass ein Makro einen gequoteten Ausdruck zurückgeben muss:

defmodule OurMacro do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

Lass uns unser Modul requiren und unser Makro benutzen:

iex> require OurMacro
nil
iex> OurMacro.unless true, do: "Hi"
nil
iex> OurMacro.unless false, do: "Hi"
"Hi"

Da Makros Code in unserer Anwendung ersetzen, können wir kontrollieren, was und wann etwas kompiliert wird. Ein Beispiel dafür kann im Logger-Modul gefunden werden. Wenn logging deaktiviert ist, wird kein Code injected und der so resultierende Code beinhaltet keine Referenzen oder Funktionsaufrufe auf logging. Das ist unterschiedlich zu anderen Sprachen, in denen das Overhead durch einen Funktionsaufruf darstellt, selbst wenn die Implementierung NOP ist.

Um dies zu demonstrieren, lass uns einen einfachen Logger erstellen, der entweder aktiviert oder deaktivert werden kann.

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

Wenn logging aktiviert ist, würde unsere test-Funktion im Code etwa so aussehen:

def test do
  IO.puts("Logged message: #{"This is a log message"}")
end

Wenn wir logging deaktivieren, sieht der resultierende Code so aus:

def test do
end

Debugging

Okay, jetzt wissen wir, wie man quote/2 und unquote/1 benutzt und Makros schreibt. Aber was, wenn du einen großen Haufen gequoteten Code hast und diesen verstehen möchtest? In diesem Fall kannst du Macro.to_string/2 benutzen. Sieht dir dieses Beispiel an:

iex> Macro.to_string(quote(do: foo.bar(1, 2, 3)))
"foo.bar(1, 2, 3)"

Wenn du den Code anschauen möchtest, der durch die Makros erzeugt wird, kannst du sie mit Macro.expand/2 und Macro.expand_once/2 kombinieren. Diese Funktionen dehnen Makros in ihren gequoteten Code aus. Die erste Funktion dehnt sie eventuell mehrere Male aus, während die letztere dies nur einmal macht. Lass uns zum Beispiel unser unless-Beispiel aus der vorherigen Sektion anschauen:

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

Falls wir den selben Code mit Macro.expand/2 aufrufen, ist es faszinierend:

iex> quoted |> Macro.expand(__ENV__) |> Macro.to_string |> IO.puts
case(!true) do
  x when x in [false, nil] ->
    nil
  _ ->
    "Hi"
end

Du kannst dich vielleicht daran erinnern, dass wir erwähnt hatten, wie if ein Makro in Elixir ist. Hier sieht du es ausgedehnt, in die darunter liegende case-Aussage.

Private Makros

Obwohl sie nicht so häufig sind, unterstützt Elixir private Makros. Ein privates Makro wird mit defmacrop definiert und kann nur innerhalb des Moduls aufgerufen werden, in dem es definiert wurde. Private Makros müssen definiert werden, bevor Code sie aufrufen kann.

Makrohygiene

In welcher Weise Makros mit dem Kontext des Aufrufers interagieren, nachdem sie ausgedehnt wurden, ist als Makrohygiene bekannt. Standardmäßig sind Makros in Elixir hygienisch und erzeugen keine Konflikte in unserem Kontext:

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

Was, wenn wir den Wert von val manipulieren möchten? Um eine Variable as unhygienic zu markieren, können wir var!/2 benutzen. Lass uns das obige Beispiel aktualisieren und ein anderes Makro mit var!/2 benutzen:

defmodule Example do
  defmacro hygienic do
    quote do: val = -1
  end

  defmacro unhygienic do
    quote do: var!(val) = -1
  end
end

Lass uns vergleichen, wie sie mit unserem Kontext umgehen:

iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42
iex> Example.unhygienic
-1
iex> val
-1

Indem wir var!/2 in unser Makro inkludiert haben, können wir den Wert von val verändern, ohne ihn an das andere Makro zu übergeben. Der Gebrauch von unhygienischen Makros sollte auf ein Minimum reduziert werden. Durch das Inkludieren von var!/2 erhöhen wir das Risiko eines Auflösungskonflikts einer Variablen.

Bindung

Wir haben bereits die Nützlichkeit von unquote/1 besprochen, aber es gibt noch einen weiteren Weg Werte in unseren Code zu injecten: Bindung. Durch Variablenbindung ist es uns möglich mehrere Variablen in unser Makro zu inkludieren und sicherzustellen, dass sie nur einmal unquoted werden, um so versehentliche Neuevaluierung zu umgehen. Um gebundene Variablen zu benutzen, müssen wir der Option bind_quoted in quote/2 eine Liste an Keywords übergeben.

Um den Vorteil von bind_quote zu sehen und das Problem mit Neuevaluierung zu demonstrieren lass uns ein Beispiel erstellen. Wir starten mit der Erstellung eines Makros, das einen Ausdruck zweimal ausgeben soll:

defmodule Example do
  defmacro double_puts(expr) do
    quote do
      IO.puts unquote(expr)
      IO.puts unquote(expr)
    end
  end
end

Wir testen unser neu erstelltes Makro, indem wir ihm die aktuelle Systemzeit übergeben. Wir erwarten, diese zweimal ausgegeben zu sehen:

iex> Example.double_puts(:os.system_time)
1450475941851668000
1450475941851733000

Die Zeiten sind unterschiedlich! Was ist passiert? unquote/1 mehrmals auf dem gleichen Ausdruck zu benutzen resultiert in Neuevaluierung, was unerwartete Konsequenzen bergen kann. Lass uns unser Beispiel so aktualisieren, dass es bind_quoted benutzt und dann nochmal sehen, was wir bekommen:

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

Mit bind_quoted bekommen wir das Ergebnis, das wir erwartet haben: Dieselbe Zeit zweimal ausgegeben.

Nun, da wir quote/2, unquote/1 und defmacro/2 behandelt haben, haben wir all die notwendigen Werkzeuge, um Elixir auf unsere Bedürfnisse anzupassen.


Share This Page