Aus Linux-Magazin 07/2021

Eine Einführung in Elixir (Teil 1)

© wabeno / 123RF.com

Elixir ist eine funktionale Programmiersprache. Wer noch nie mit einer solchen Sprache zu tun hatte, muss sich völlig neu orientieren. Vorwissen über objektorientierte Sprachen stört dabei nur.

Anfänglich beschäftigt man sich mit Elixir [1] meist wegen der hohen Geschwindigkeit, der einfachen Skalierbarkeit und der möglichen Parallelverarbeitung, die sich nicht nur über alle CPUs eines Servers, sondern auch über alle Nodes eines Clusters erstrecken kann.

Wesentlich für die Skalierbarkeit und Parallelität sind die Immutability (Unveränderlichkeit) und der eingeschränkte Scope von Variablen. Eine Variable zeigt auf eine Adresse im Speicher, die der Anwender in vielen Programmiersprachen einfach überschreiben kann. Das führt dann teilweise zu ungewollten Effekten, etwa weil ein bestimmter Teil des Programms einen Wert geändert hat und ein anderer Teil des Programms das nicht weiß oder berücksichtigt. Das wirkt sich besonders kritisch aus, wenn mehrere CPUs oder Nodes parallel am gleichen Problem arbeiten.

Nicht so in Elixir: Hier bekommt der neue Wert im Moment der Zuweisung eine neue Adresse im Speicher. Funktionen, die vor der Veränderung des Werts mit dieser Variable gearbeitet haben, greifen weiter auf die alte Adresse zu, neue Funktionen nutzen die neue Adresse. Sobald sich eine Funktion beendet, löscht die Garbage Collection alle mit ihr verbundenen Werte.

Und das soll schnell sein? Ja, denn es verursacht mehr Aufwand, existierende Speicherinhalte mit neuen Werten zu ersetzen, als einen neuen Bereich im Speicher mit dem neuen Wert zu füllen und beim Beenden der Funktion einfach alle Verweise per Garbage Collection zu löschen.

Aus diesem Grund kann man ein Elixir-System auch per Hot Deployment im laufenden Betrieb aktualisieren, ohne bestehende Verbindungen oder Prozesse beenden zu müssen. Ebenso wenig muss eine Armada an parallelen Servern oder Diensten für die kurze Zeit des Updates übernehmen.

Aber ist das wirklich alles so einfach? Betrachten wir zum Beispiel den (Pseudo-)Code aus der ersten Zeile von Listing 1. Bei anderen Programmiersprachen benötigt man dabei immer auch den Speicherinhalt des vorherigen Werts – bei einer Anwendung auf mehreren CPUs nicht eben trivial. In Elixir sieht das Ganze allerdings einen Tick anders aus.

Listing 1

Elixir-Syntax

for i in range(1,10): print(i," zum Quadrat ist: ",i*i)
for i <- 1..10, do: IO.puts("#{i} zum Quadrat ist: #{i * i})

In der zweiten Zeile dient »for« als syntaktischer Zucker, um Elemente aus einem Enumerable, der Range »1..10«, zu erzeugen. Das »for« ist aber auch eine Funktion, die eine CPU ausführt. Diese Funktion ruft wiederum eine andere Funktion mit dem jeweiligen Wert als Parameter auf. Dabei spielt es keine Rolle, ob die zweite Funktion auf demselben Rechner oder auf einem anderen Node ausgeführt wird: Sie hat ja als Parameter alle notwendigen Informationen im Gepäck.

Elixir wurde vom langjährigen Ruby-Programmierer José Valim erfunden. Ihm gefielen die Möglichkeiten von Erlang, er konnte sich aber nicht mit der oft umständlichen Programmiersprache anfreunden. Deshalb setzt Elixir auf die Erlang VM auf – man kann sogar in Elixir Erlang-Code aufrufen. Viele syntaktische Strukturen erinnern aber an Ruby. Das ist zwar Absicht, führt jedoch Ruby-Kenner oft auch in die Irre: Elixir lebt in einer ganz anderen Paradigmenwelt als Ruby.

Im vorliegenden ersten Teil einer kleinen Artikelserie führen wir Sie zunächst in die Grundlagen von Elixir ein, ohne dabei unnötig in die Tiefe zu gehen. Anschließend können Sie schon einfache Programme schreiben. In einem zweiten Teil erstellen wir dann mithilfe des Phoenix-Frameworks eine Webapplikation.

Ob und in welcher Version Elixir auf dem Rechner liegt, prüfen Sie mit dem Kommando »elixir -v«. Für diese Artikelserie gehen wir von Version 1.11.4 oder höher aus. Fehlt es oder liegt nur in einer älteren Version vor, installieren Sie mit Asdf [2] und dem Elixir-Plugin [3] ein aktuelles Release und können später zwischen verschiedenen Versionen hin und her schalten.

Iex

Zu jeder Elixir-Installation gehört die interaktive Shell Iex, in der sich wunderbar lernen, experimentieren und Code debuggen lässt. Nur das Verlassen der Shell durch ein zweimaliges [Strg]+[C] ist etwas gewöhnungsbedürftig.

Starten Sie »iex« im Terminal und führen Sie das Kommando »IO.puts(“Hallo Welt”)« aus. Damit haben Sie zum ersten Mal eine Elixir-Funktion aufgerufen: »puts()«. Mehrere Funktionen lassen sich in einem Modul zusammenfassen, in diesem Fall heißt das Modul »IO«. Normalerweise müssen Sie Module vor der Benutzung importieren, bei Kernmodulen wie »IO« entfällt das aber.

Verwirrenderweise kann ein Modul gleich mehrere Funktionen identischen Namens, aber mit einer unterschiedlichen Anzahl von Argumenten enthalten. Die Anzahl der Argumente nennt man Arity. Diese Zahl dient im allgemeinen Sprachgebrauch in der Elixir-Community dazu, die einzelnen Funktionen auseinanderzuhalten: Funktionsname/Arity. Bei Funktionen mit einer Arity von 1 darf man beim Aufruf auf die Klammern verzichten.

Das Ganze wird noch einen Tick komplexer, baut man Guards ein: Sie grenzen mehrere Funktionen mit derselben Arity voneinander ab. Ein Guard kann dann zum Beispiel überprüfen, ob es sich bei einem Parameter um eine Ganzzahl handelt und ob diese größer als 100 ist. Aus Gründen der Performance lassen sich aber keine selbst programmierten Funktionen als Guards nutzen. Es gibt eine festgelegte Anzahl von Guards [4], die alle sehr schnell agieren. Das Schlüsselwort »when« definiert einen Guard, ein Beispiel für den Einsatz liefert Listing 6.

Funktionsnamen, Arity und Guards dienen zusammen dem Pattern Matching, in dieser Reihenfolge wird ein Funktionsaufruf mit dem Pattern verglichen. Der erste Treffer gewinnt. Das Schlüsselwort »def« kennzeichnet Funktionsdefinitionen. Funktionsnamen schreibt man in Snakecase (name_der_funktion) mit kleinem Anfangsbuchstaben, Modulnamen (eingeleitet durch »defmodule«) in Camelcase (NameDerFunktion) mit großen Anfangsbuchstaben.

Soll sich eine Funktion nur innerhalb eines Moduls aufrufen lassen, definiert man sie mit »defp« als privat. Nach dem Funktionsnamen und den Argumenten steht dann innerhalb eines »do … end« der Code der Funktion. Für ganz kurze Funktionen gibt es noch die 1-Zeilen-Syntax: »`def inc(x), do: x + 1`«.

Variablen-Scope

Wie in den meisten Programmiersprachen haben Variablen in Elixir einen bestimmten Scope, man kann also nicht im gesamten Programm auf sie zugreifen. Die Scope-Hierarchie ähnelt dabei einem Baum. Der Baumstamm umfasst den Top-Level-Scope, die Module fungieren als Zweige mit eigenen Modul-Scopes. Funktionen bilden Unterzweige mit wiederum eigenem Scope, und am Schluss steht quasi als Blatt der Comprehension-Scope innerhalb einer For-Anweisung.

Von den Blättern aus lässt sich alles darüber lesen, und man kann identische Variablennamen intern neu benutzen, ohne damit darüberliegende Variablen zu verändern. Diese Art des Scopes nennt sich Lexical Scope. Er vermeidet Abhängigkeiten von anderen Codeteilen, und das hilft wiederum dem Programmierer ohne Spezialkenntnisse, mit Elixir Mehrkern-CPUs und ganze Cluster auszureizen.

Zahlen und Strings

Die Typen Integer und Float lassen sich intuitiv mit den üblichen Basisoperatoren »+«, »-«, »*« und »/« benutzen. Mit der Funktion »i/1« lässt sich herausfinden, welchen Typ eine Zahl/Variable hat, falls man einmal den Überblick verliert. Variablennamen schreibt man in Snakecase mit kleinem Anfangsbuchstaben. Ein Variablenname mit einem großen Anfangsbuchstaben führt zu einem Fehler.

Das Modul »Area« aus Listing 2 zeigt das in der Praxis. Zum Experimentieren geben Sie den Code entweder Zeile für Zeile in Iex ein oder speichern ihn in einer Datei, die Sie danach per »iex Datei.ex« ausführen. Iex berechnet dann mit »Area.rectangle(10)« die Fläche eines Quadrats und mit »Area.rectangle(10,20)« die Fläche eines Rechtecks. Die Funktion »rectangle/1« ruft die Funktion »rectangle/2« zum Berechnen der Fläche auf; stattdessen könnte man auch in »rectangle/1« den Term »a * a« berechnen.

Listing 2

area.ex

defmodule Area do
  def rectangle(a) do
    rectangle(a, a)
  end
  def rectangle(a, b) do
    a * b
  end
  def circle(radius) do
    pi() * radius * radius
  end
  defp pi do
    3.14
  end
end

Zum Programmieren braucht man nicht nur Zahlen, sondern auch Zeichenketten (Strings). In Elixir handelt es sich dabei um UTF-8-kodierte, von doppelten Anführungszeichen begrenzte Binaries. Der Operator »<>« verkettet zwei Strings. Oft werden innerhalb der doppelten Anführungszeichen auch andere Variablen mit der »#{}«-Syntax interpoliert: Ist eine Variable »name« definiert, lässt sich der Code »IO.puts(“Hallo #{name}!”)« ausführen.

Der Pipe-Operator

Mit den Funktionen »String.reverse/1« und »String.upcase/1« lässt sich ein String umdrehen und in Großbuchstaben umwandeln. Dazu könnte man die beiden Funktionen ineinander verschachteln (Listing 3, erste Zeile). Das wird bei mehreren Funktionsaufrufen aber schnell unübersichtlich. Deswegen gibt es als syntaktischen Zucker den Pipe-Operator »|>«, der das Ergebnis vor dem »|>« als erstes Argument in die nachfolgende Funktion übergibt. Das lässt sich dann deutlich einfacher lesen (Zeile 2 bis 4).

Listing 3

Pipe-Operator

String.upcase(String.reverse("Hallo"))
"Hallo"
|>
 String.reverse
|> String.upcase

Atoms

Als Atom bezeichnet man einen konstanten Wert, der gleichzeitig als Name des Atoms dient – in Ruby nennt man das Symbol. Dem Namen eines Atoms steht ein Doppelpunkt voran, etwa »:apple«. Bei den Atoms »true«, »false« und »nil« darf man diesen Doppelpunkt weglassen.

Liste, Map und Tuple

Um mehrere Elemente zusammen in ein anderes zu packen, verwendet man Listen, Maps oder Tuples als Gegenstücke zu den Arrays in anderen Sprachen.

Bei Listen handelt es sich um im Speicher miteinander verlinkte Elemente, von denen jedes seinen eigenen Wert und den Link zum nächsten Element enthält. Das erlaubt, einer Liste sehr schnell neue Elemente hinzuzufügen, aber die Dauer für das Auslesen einzelner Werte steigt linear mit der zur Länge der Liste.

Eine Liste darf beliebige Elemente enthalten und steht in eckigen Klammern. So sind »[1, 2, 3, 4]« und »[“asdf”, 1, 34.5]« beides Listen. Mit den Operatoren »++« und »–« kann man Listen konkatenieren oder voneinander abziehen. Die Funktion »length/1« gibt die Länge einer Liste als Integer aus. Das Modul »List« [5] stellt praktische Funktionen zur Verwendung von Listen bereit.

Mit Maps lassen sich Key-Value-Datenstrukturen speichern. Die Syntax »%{}« schließt eine Map von außen ein. Die einzelnen Key-Value-Paare definiert man dann entweder in der Form »:a => 1« oder mit »a: 1«. Der Key ist immer ein Atom; auf die einzelnen Elemente einer Map kann man direkt zugreifen (Listing 4). Das Modul »Map« [6] beinhaltet den üblichen Blumenstrauß an Funktionen, mit denen sich Maps nutzen lassen. So gibt etwa »Map.keys/1« alle Keys einer Map als Liste aus.

Listing 4

Maps

iex> map = %{a: 23, foo: "bar"}
iex> map[:a]
23
iex> map[:foo]
"bar"
iex> map[:blue]
nil

Alle Elemente eines Tuples liegen zusammenhängend im Speicher und lassen sich dadurch sehr schnell und immer in derselben Geschwindigkeit lesen. Das Verändern einzelner Elemente dauert allerdings viel länger als bei Listen. Ein Tuple wird von geschweiften Klammern eingegrenzt und kann beliebige Elemente enthalten: »{1, 2, 3}«, »{“asdf”, 1, 3.14}’«. Auch hier gibt es ein Modul »Tuple« [7] mit einigen wenigen praktischen Funktionen.

Listen, Maps und Tuples dürfen neben Integern und Strings auch Tuples, Maps und Listen enthalten und diese beliebig miteinander verknüpfen. So definiert »[{1,2}, {4,8}, {16,32}]« beispielsweise eine Liste mit drei Tuples.

Pattern Matching

Pattern Matching kennt man aus vielen Programmiersprachen. Meistens wird dabei ein String mit einem bestimmten Muster (Pattern) verglichen. Das kann Elixir zwar auch, aber Pattern Matching kommt hier hauptsächlich auf einer ganz anderen Ebene zum Einsatz.

Schon bei der Zuweisung einer Variablen handelt es sich um Pattern Matching. »x = 23« bedeutet, dass Elixir die linke Seite des Operators »=« mit der rechten Seite matcht. Das Ganze funktioniert auch mit Tuples »« und anderen Typen, wie die erste Zeile von Listing 5 demonstriert.

Listing 5

Pattern Matching

{a, b} = {23, 3}
{a, _b} = {23, 3}
[1, 2, z] = [1, 2, 3]
{^a, b} = {23, 3}
[head | tail] = [1, 2, 3]
def swap({a, b}), do: {b, a}
def swap([a, b]), do: [b, a]

Während man in diesem Beispiel nur die »23« für die Variable »a« benötigt, setzt man vor »b« ein Wildcard-Underscore oder nur ein Underscore (zweite Zeile). Andernfalls wirft Elixir später eine Warnung, dass die Variable »b« nirgendwo benutzt wird.

Das Ganze lässt sich noch weiter eingrenzen. Das Beispiel aus Zeile 3 setzt die Variable »z« auf den Wert 3. Stünde aber auf der rechten Seite »[1, 2, 4]«, würde das Pattern nicht matchen und damit »z« auch keinen Wert enthalten. Pattern Matching wird also für die Programmierlogik eingesetzt.

Will man auch auf der linken Seite des »=«-Operators eine schon vorher festgelegte Variable benutzen, so benötigt man dafür den Pin-Operator »^«. Der Ausdruck aus Zeile 4 setzt »b« auf 3, sofern »a« den Wert 23 hat.

Bei Listen gibt es ein Head-und-Tail-Matching, das der Operator »|« realisiert. Head matcht dabei immer das erste Element von links, Tail alle restlichen Elemente als Liste. Der Code aus Zeile 5 würde »head« auf den Wert 1 setzen und »tail« auf »[2, 3]«. Die daraus resultierenden Variablen werden oft »head« und »tail« oder nur »h« und »t« genannt – das ist aber kein Muss.

Pattern Matching wird auch bei den Argumenten von Funktionen benutzt. Die Funktion in der Zeile 6 tauscht die zwei Elemente eines Zweier-Tuples aus und wird entsprechend nur ausgeführt, wenn man als Argument ein Zweier-Tuple übergibt. Will man das Gleiche auch für eine Liste zur Verfügung stellen, muss man eine zusätzliche Funktion in das Modul einfügen (Zeile 7).

Ein weiteres typische Einsatzgebiet ist ein Case-Entscheidungsbaum. Im Gegensatz zu anderen Programmiersprachen kommt in Elixir traditionell selten ein If-Else-Konstrukt zum Einsatz.

Alle geschilderten Möglichkeiten des Pattern Matchings demonstriert das Modul »TextHelper« aus Listing 6. Von der Kommandozeile importieren Sie es »iex text_helper.ex« und setzen dann die beiden in Listing 7 gezeigten Aufrufe ab. Als Ergebnis erhalten Sie eine der Uhrzeit angepasste Begrüßung.

Listing 6

text_helper.ex

defmodule TextHelper do
  def greeting(%{first_name: first_name, last_name: last_name}) do
    {first_name, last_name}
    |> full_name
    |> greeting
  end
  def greeting(name) do
    "#{salutation()} #{name}"
    |> String.trim()
  end
  defp salutation do
    {:ok, now} = DateTime.now("Etc/UTC")
    case now.hour do
      x when x in 1..10 -> "Guten Morgen"
      x when x in 11..17 -> "Hallo"
      _ -> "Guten Abend"
    end
  end
  defp full_name({first_name, last_name}) do
    case {first_name, last_name} do
      {"", ""} -> ""
      {"", last_name} -> last_name
      {first_name, ""} -> first_name
      {first_name, last_name} -> "#{first_name} #{last_name}"
      _ -> ""
    end
  end
end

Listing 7

TextHelper aufrufen

iex> TextHelper.greeting("Bob")
iex> TextHelper.greeting(%{first_name: "Bob", last_name: "Smith"}

Rekursion

Durch die Immutability von Variablen fehlen Elixir die von der objektorientierten Programmierung bekannten Loops; Rekursionen bieten eine Alternative. Wer in seinem bisherigen Programmiererleben vor Rekursionen zurückschreckte, der muss sich jetzt seinen Ängsten stellen. Der wichtigste Tipp dafür: Beginnen Sie immer mit dem einfachsten Fall und arbeiten Sie dann den nächstkomplexeren Fall ab.

Im Modul »Example« aus Listing 8 befinden sich zwei Rekursionen. Die erste realisiert einen Countdown, der einen positiven Integer auf 0 herunterzählt. Dabei fangen wir mit dem einfachsten Fall an. Rufen Sie »Example.countdown(0)« auf (Zeile 3), gibt die Funktion eine 0 aus. Das nächstschwierigere Problem ist die 1, bei der die Funktion erst die 1 und dann die davor liegende Zahl »(n – 1)« ausgibt (Zeile 5). Das wäre in diesem Fall ein Aufruf von »Example.countdown(0)«, und diese Funktion haben Sie ja schon definiert.

Listing 8

recursion.ex

defmodule Example do
  def countdown(0) do
    IO.puts(0)
  end
  def countdown(n) when is_integer(n) and n > 0 do
    IO.puts(n)
    countdown(n - 1)
  end
  def sum([]) do
    0
  end
  def sum([head | tail]) do
    head + sum(tail)
  end
end

Damit wäre dann auch schon das Problem gelöst, denn bei höheren Zahlen können Sie immer rekursiv die Funktion »Example.countdown/1« aufrufen. Allerdings müssen Sie darauf achten, als Argument eine Ganzzahl größer 0 zu übergeben: Sonst würde bei Aufruf der Funktion mit einer negativen Zahl das Programm in die Rekursionshölle geschickt. Solche Bedingungen realisiert man mit Guards.

Die zweite Rekursion berechnet mit der Funktion »Example.sum/1« die Summe aller Elemente in einer Liste (Zeile 9). Dabei starten Sie wieder mit dem einfachsten Fall einer leeren Liste »[]«, deren Summe null ist. Es folgt der nächstschwierigere Fall mit einem Element in der Liste – »Example.sum([23])«.

Dazu zerlegen Sie die Liste in ein Head-Element und eine Tail-Liste der nachfolgenden Elemente. In diesem Fall wäre »tail« eine leere Liste, die Sie bereits in der davor liegenden Funktion behandeln. Die Summe ist dann »tail« plus 0. Bei mehreren Elementen in der Liste greift immer wieder derselbe Mechanismus.

Um es vorwegzunehmen: Die hier nachprogrammierte »sum«-Funktion stellt Elixir bereits mit »Enum.sum/1« zur Verfügung [8].

Higher-Order Functions

Funktionen können Sie wie Variablen als Argumente für andere Funktionen benutzen. Die Funktionen, die eine andere Funktion als Argument aufrufen, nennt man Higher-Order-Funktionen. Das setzt allerdings eine bestimmte Art von Funktion voraus, die Anonymous Function. Eine solche definiert man ohne eigenen Funktionsnamen mit dem Schlüsselwort »fn«. Manchmal dient auch der Operator »&« als Capture-Operator zur Definition einer anonymen Funktion.

Möchten Sie in der Liste [1,2] alle Elemente mit der Zahl 3 multiplizieren, erreichen Sie das mit dem Aufruf aus der ersten Zeile von Listing 9. Mit dem Capture-Operator geht das noch kürzer (Zeile 2). Mit »Enum.filter/2« lassen sich einzelne Elemente aus einer Liste herausfiltern. Eine Liste aller geraden Zahlen liefert der Aufruf aus Zeile 3.

Listing 9

Higher-Order Functions

Enum.map([1,2], fn x -> 3 * x end)
Enum.map([1,2], &(&1 * 3))
Enum.filter([1,2,3], &(rem(&1, 2) == 0))

Gib mir einen Hammer!

Meine erste Programmiersprache war, wie in den 80er Jahren üblich, Basic. Danach folgten im Wesentlichen Turbo Pascal, Perl, PHP und Ruby; heute bin ich bei Elixir angekommen. Dazwischen lagen einige längst wieder vergessene Exoten und auch die klassischen Programmiersprachen C und C++, zu denen es mich aber wenig hinzog. Nämliches gilt für das Extrem Assembler. Zu Sinclair-ZX81-Zeiten hätte ich es gut gebrauchen können, aber als Schüler und lange vor dem Internet fehlte mir der Zugang zu entsprechenden Büchern. Danach brauchte ich das Maximum an Performance nicht mehr, da die CPUs immer schneller wurden.

Der Umstieg von einer auf eine andere Programmiersprache fiel mir manchmal leicht (beispielsweise auf Perl) und manchmal schwer (etwa auf Elixir). Mit allen Programmiersprachen konnte ich alle meine Probleme und die meiner Kunden lösen. In den Zeiten vor Stackoverflow.com musste ich dafür oft nur ein wenig länger nachdenken.

Mir scheint es müßig, die Vor- und Nachteile verschiedener Programmierparadigmen gegeneinander aufzuwiegen. Hätte es nach Basic keine weitere Programmiersprache mehr gegeben, würden wir heute alle mit Basic und ein paar ganz Ausgefuchste mit Assembler programmieren. Am Ende des Tages braucht der Programmierer einen Hammer. Die Kunst des Programmierens besteht dann darin, aus jedem Problem einen Nagel für diesen Hammer zu machen.

Für das Zeitalter der Multiprozessor-Server erweist sich Elixir als ein wunderbarer Hammer. Anfangs kommt es schon einmal vor, dass einem dieses Werkzeug beim Üben schmerzhaft auf den Zeh fällt. Am Ende des Lernprozesses tauchen dann vor dem geistigen Auge aber überall Nägel auf – das Lernen lohnt sich. (jcb/jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 5 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben