German (Deutsch) translation by Katharina Grigorovich-Nevolina (you can also view the original English article)
Polymorphismus ist ein wichtiges Konzept in der Programmierung, und Anfänger lernen es normalerweise in den ersten Monaten des Studiums. Polymorphismus bedeutet im Grunde, dass Sie eine ähnliche Operation auf Entitäten unterschiedlichen Typs anwenden können. Beispielsweise kann die Funktion count/1 sowohl auf einen Bereich als auch auf eine Liste angewendet werden:
Enum.count(1..3) Enum.count([1,2,3])
Wie ist das möglich? In Elixir wird Polymorphismus durch die Verwendung eines interessanten Merkmals erreicht, das als Protokoll bezeichnet wird und wie ein Vertrag wirkt. Für jeden Datentyp, den Sie unterstützen möchten, muss dieses Protokoll implementiert werden.
Alles in allem ist dieser Ansatz nicht revolutionär, wie er in anderen Sprachen (wie zum Beispiel Ruby) zu finden ist. Da Protokolle dennoch sehr praktisch sind, werden wir in diesem Artikel erläutern, wie Sie sie definieren, implementieren und damit arbeiten, während wir einige Beispiele untersuchen. Lass uns anfangen!
Kurze Einführung in Protokolle
Wie oben bereits erwähnt, hat ein Protokoll einen generischen Code und stützt sich auf den spezifischen Datentyp, um die Logik zu implementieren. Dies ist sinnvoll, da unterschiedliche Datentypen möglicherweise unterschiedliche Implementierungen erfordern. Ein Datentyp kann dann auf ein Protokoll dispatchen, ohne sich um dessen Interna zu kümmern.
Elixir verfügt über eine Reihe integrierter Protokolle, darunter Enumerable
, Collectable
, Inspect
, List.Chars
und String.Chars
. Einige davon werden später in diesem Artikel behandelt. Sie können jedes dieser Protokolle in Ihrem benutzerdefinierten Modul implementieren und eine Reihe von Funktionen kostenlos erhalten. Wenn Sie beispielsweise Enumerable implementiert haben, erhalten Sie Zugriff auf alle im Enum-Modul definierten Funktionen, was ziemlich cool ist.
Wenn Sie aus der wundersamen Ruby-Welt voller Objekte, Klassen, Feen und Drachen gekommen sind, haben Sie ein sehr ähnliches Konzept von Mixins kennengelernt. Wenn Sie beispielsweise Ihre Objekte jemals vergleichbar machen müssen, mischen Sie einfach ein Modul mit dem entsprechenden Namen in die Klasse. Dann implementieren Sie einfach eine Raumschiffmethode <=>
und alle Instanzen der Klasse erhalten alle Methoden wie >
und <
kostenlos. Dieser Mechanismus ähnelt den Protokollen in Elixir. Auch wenn Sie dieses Konzept noch nie zuvor kennengelernt haben, glauben Sie mir, es ist nicht so komplex.
Okay, also das Wichtigste zuerst: Das Protokoll muss definiert werden. Lassen Sie uns im nächsten Abschnitt sehen, wie es gemacht werden kann.
Protokoll definieren
Das Definieren eines Protokolls beinhaltet keine schwarze Magie - tatsächlich ist es dem Definieren von Modulen sehr ähnlich. Verwenden Sie dazu defprotocol/2:
defprotocol MyProtocol do end
Innerhalb der Protokolldefinition platzieren Sie Funktionen, genau wie bei Modulen. Der einzige Unterschied ist, dass diese Funktionen keinen Körper haben. Dies bedeutet, dass das Protokoll nur eine Schnittstelle definiert, eine Blaupause, die von allen Datentypen implementiert werden sollte, die dieses Protokoll versenden möchten:
defprotocol MyProtocol do def my_func(arg) end
In diesem Beispiel muss ein Programmierer die Funktion my_func/1
implementieren, um MyProtocol
erfolgreich nutzen zu können.
Wenn das Protokoll nicht implementiert ist, wird ein Fehler ausgelöst. Kehren wir zum Beispiel mit der im Enum
-Modul definierten Funktion count/1
zurück. Das Ausführen des folgenden Codes führt zu einem Fehler:
Enum.count 1 # ** (Protocol.UndefinedError) protocol Enumerable not implemented for 1 # (elixir) lib/enum.ex:1: Enumerable.impl_for!/1 # (elixir) lib/enum.ex:146: Enumerable.count/1 # (elixir) lib/enum.ex:467: Enum.count/1
Dies bedeutet, dass die Integer
das Enumerable
-Protokoll nicht implementiert (was für eine Überraschung) und wir daher keine Ganzzahlen zählen können. Das Protokoll kann jedoch tatsächlich implementiert werden, und dies ist leicht zu erreichen.
Implementierung eines Protokolls
Protokolle werden mit dem Makro defimpl/3 implementiert. Sie geben an, welches Protokoll für welchen Typ implementiert werden soll:
defimpl MyProtocol, for: Integer def my_func(arg) do IO.puts(arg) end end
Jetzt können Sie Ihre Ganzzahlen zählbar machen, indem Sie das Enumerable
-Protokoll teilweise implementieren:
defimpl Enumerable, for: Integer do def count(_arg) do {:ok, 1} # integers always contain one element end end Enum.count(100) |> IO.puts # => 1
Wir werden das Enumerable
-Protokoll später in diesem Artikel genauer diskutieren und auch seine andere Funktion implementieren.
Für den Typ (an das for
übergeben) können Sie einen beliebigen integrierten Typ, Ihren eigenen Alias oder eine Liste von Aliasen angeben:
defimpl MyProtocol, for: [Integer, List] do end
Darüber hinaus können Sie Any
sagen:
defimpl MyProtocol, for: Any def my_func(_) do IO.puts "Not implemented!" end end
Dies verhält sich wie eine Fallback-Implementierung, und es wird kein Fehler ausgelöst, wenn das Protokoll für einen bestimmten Typ nicht implementiert ist. Damit dies funktioniert, setzen Sie das Attribut @fallback_to_any
in Ihrem Protokoll auf true
(andernfalls wird der Fehler weiterhin ausgelöst):
defprotocol MyProtocol do @fallback_to_any true def my_func(arg) end
Sie können das Protokoll jetzt für jeden unterstützten Typ verwenden:
MyProtocol.my_func(5) # simply prints out 5 MyProtocol.my_func("test") # prints "Not implemented!"
Ein Hinweis zu Strukturen
Die Implementierung für ein Protokoll kann innerhalb eines Moduls verschachtelt werden. Wenn dieses Modul eine Struktur definiert, müssen Sie beim Aufruf von defimpl
nicht einmal for
angeben:
defmodule Product do defstruct title: "", price: 0 defimpl MyProtocol do def my_func(%Product{title: title, price: price}) do IO.puts "Title #{title}, price #{price}" end end end
In diesem Beispiel definieren wir eine neue Struktur namens Product
und implementieren unser Demo-Protokoll. Im Inneren passen Sie einfach den Titel und den Preis an und geben dann eine Zeichenfolge aus.
Beachten Sie jedoch, dass eine Implementierung in einem Modul verschachtelt sein muss. Dies bedeutet, dass Sie jedes Modul problemlos erweitern können, ohne auf seinen Quellcode zugreifen zu müssen.
Beispiel: String.Chars-Protokoll
Okay, genug mit abstrakter Theorie: Schauen wir uns einige Beispiele an. Ich bin sicher, dass Sie die IO.puts/2-Funktion ziemlich ausführlich eingesetzt haben, um Debugging-Informationen an die Konsole auszugeben, wenn Sie mit Elixir herumspielen. Sicherlich können wir verschiedene integrierte Typen einfach ausgeben:
IO.puts 5 IO.puts "test" IO.puts :my_atom
Aber was passiert, wenn wir versuchen, unsere im vorherigen Abschnitt erstellte Product
-Struktur auszugeben? Ich werde den entsprechenden Code in das Main
-Modul einfügen, da sonst die Fehlermeldung angezeigt wird, dass die Struktur nicht im selben Bereich definiert ist oder auf sie zugegriffen wird:
defmodule Product do defstruct title: "", price: 0 end defmodule Main do def run do %Product{title: "Test", price: 5} |> IO.puts end end Main.run
Nachdem Sie diesen Code ausgeführt haben, wird eine Fehlermeldung angezeigt:
(Protocol.UndefinedError) protocol String.Chars not implemented for %Product{price: 5, title: "Test"}
Aha! Dies bedeutet, dass die puts
-Funktion auf dem integrierten String.Chars-Protokoll basiert. Solange es nicht für unser Product
implementiert ist, wird der Fehler ausgelöst.
String.Chars
ist für die Konvertierung verschiedener Strukturen in Binärdateien verantwortlich. Die einzige Funktion, die Sie implementieren müssen, ist to_string/1, wie in der Dokumentation angegeben. Warum implementieren wir es jetzt nicht?
defmodule Product do defstruct title: "", price: 0 defimpl String.Chars do def to_string(%Product{title: title, price: price}) do "#{title}, $#{price}" end end end
Wenn dieser Code vorhanden ist, gibt das Programm die folgende Zeichenfolge aus:
Test, $5
Was bedeutet, dass alles gut funktioniert!
Beispiel: Inspect Protocol
Eine weitere sehr häufige Funktion ist IO.inspect/2, um Informationen über ein Konstrukt zu erhalten. Im Kernel
-Modul ist auch eine inspect/2-Funktion definiert, die eine Inspektion gemäß dem integrierten Inspect-Protokoll durchführt.
Unsere Product
-Struktur kann sofort überprüft werden, und Sie erhalten einige kurze Informationen dazu:
%Product{title: "Test", price: 5} |> IO.inspect # or: %Product{title: "Test", price: 5} |> inspect |> IO.puts
Es wird %Product{price: 5, title: "Test"}
zurückgegeben. Aber auch hier können wir das Inspect
-Protokoll problemlos implementieren, bei dem nur die inspect/2-Funktion codiert werden muss:
defmodule Product do defstruct title: "", price: 0 defimpl Inspect do def inspect(%Product{title: title, price: price}, _) do "That's a Product struct. It has a title of #{title} and a price of #{price}. Yay!" end end end
Das zweite Argument, das an diese Funktion übergeben wird, ist die Liste der Optionen, die uns jedoch nicht interessieren.
Beispiel: Aufzählbares Protokoll
Sehen wir uns nun ein etwas komplexeres Beispiel an, während wir über das Enumerable-Protokoll sprechen. Dieses Protokoll wird vom Enum-Modul verwendet, das uns so praktische Funktionen wie each/2 und count/1 bietet (ohne dieses Protokoll müssten Sie sich an die einfache alte Rekursion halten).
Enumerable definiert drei Funktionen, die Sie ausarbeiten müssen, um das Protokoll zu implementieren:
- count/1 gibt die Größe der Aufzählung zurück.
- member?/2 prüft, ob die Aufzählung ein Element enthält.
- reduce/3 wendet eine Funktion auf jedes Element der Aufzählung an.
Wenn Sie alle diese Funktionen eingerichtet haben, erhalten Sie Zugriff auf alle Extras des Enum
-Moduls, was wirklich ein gutes Geschäft ist.
Als Beispiel erstellen wir eine neue Struktur namens Zoo
. Es wird einen Titel und eine Liste von Tieren haben:
defmodule Zoo do defstruct title: "", animals: [] end
Jedes Tier wird auch durch eine Struktur dargestellt:
defmodule Animal do defstruct species: "", name: "", age: 0 end
Lassen Sie uns nun einen neuen Zoo instanziieren:
defmodule Main do def run do my_zoo = %Zoo{ title: "Demo Zoo", animals: [ %Animal{species: "tiger", name: "Tigga", age: 5}, %Animal{species: "horse", name: "Amazing", age: 3}, %Animal{species: "deer", name: "Bambi", age: 2} ] } end end Main.run
Wir haben also einen "Demo Zoo" mit drei Tieren: einem Tiger, einem Pferd und einem Hirsch. Was ich jetzt tun möchte, ist die Unterstützung für die Funktion count/1 hinzuzufügen, die wie folgt verwendet wird:
Enum.count(my_zoo) |> IO.inspect
Lassen Sie uns diese Funktionalität jetzt implementieren!
Implementieren der Zählfunktion
Was meinen wir mit "Zähle meinen Zoo"? Es klingt ein bisschen seltsam, aber wahrscheinlich bedeutet es, alle dort lebenden Tiere zu zählen, sodass die Implementierung der zugrunde liegenden Funktion recht einfach ist:
defmodule Zoo do defstruct title: "", animals: [] defimpl Enumerable do def count(%Zoo{animals: animals}) do {:ok, Enum.count(animals)} end end end
Alles, was wir hier tun, ist, uns auf die Funktion count/1 zu verlassen, während wir eine Liste von Tieren an sie übergeben (da diese Funktion sofort einsatzbereite Listen unterstützt). Eine sehr wichtige Sache zu erwähnen ist, dass die count/1
-Funktion ihr Ergebnis in Form eines Tupels {:ok, result}
zurückgeben muss, wie von den Dokumenten vorgegeben. Wenn Sie nur eine Zahl zurückgeben, wird ein Fehler ** (CaseClauseError) no case clause matching
ausgelöst.
Das wars so ziemlich. Sie können jetzt Enum.count(my_zoo)
im Main.run
sagen und es sollte als Ergebnis 3
zurückgeben. Gut gemacht!
Durchführendes Mitglied? Funktion
Die nächste vom Protokoll definierte Funktion ist das member?/2
. Es sollte ein Tupel {:ok, boolean}
als Ergebnis zurückgeben, das angibt, ob eine Aufzählung (als erstes Argument übergeben) ein Element (das zweite Argument) enthält.
Ich möchte, dass diese neue Funktion sagt, ob ein bestimmtes Tier im Zoo lebt oder nicht. Daher ist die Implementierung auch ziemlich einfach:
defmodule Zoo do defstruct title: "", animals: [] defimpl Enumerable do # ... def member?(%Zoo{title: _, animals: animals}, animal) do {:ok, Enum.member?(animals, animal)} end end end
Beachten Sie erneut, dass die Funktion zwei Argumente akzeptiert: eine Aufzählung und ein Element. Im Inneren verlassen wir uns einfach auf die Funktion member?/2
, um nach einem Tier in der Liste aller Tiere zu suchen.
Also jetzt laufen wir:
Enum.member?(my_zoo, %Animal{species: "tiger", name: "Tigga", age: 5}) |> IO.inspect
Und dies sollte true
sein, da wir tatsächlich ein solches Tier auf der Liste haben!
Implementierung der Reduce-Funktion
Mit der reduce/3
-Funktion wird es etwas komplexer. Es akzeptiert die folgenden Argumente:
- eine Aufzählung, auf die die Funktion angewendet werden kann
- ein Akkumulator zum Speichern des Ergebnisses
- die tatsächlich anzuwendende Reduzierfunktion
Interessant ist, dass der Akkumulator tatsächlich ein Tupel mit zwei Werten enthält: ein Verb und einen Wert: {verb, value}
. Das Verb ist ein Atom und kann einen der folgenden drei Werte haben:
-
:cont
(weiter) -
:halt
(beenden) -
:suspend
(vorübergehend suspendieren)
Der resultierende Wert, der von der Funktion reduce/3
zurückgegeben wird, ist ebenfalls ein Tupel, das den Status und ein Ergebnis enthält. Der Zustand ist auch ein Atom und kann folgende Werte haben:
-
:done
(Verarbeitung ist erledigt, das ist das Endergebnis) -
:halted
(Verarbeitung wurde gestoppt, weil der Akkumulator das Verb:halt
enthielt) -
:suspended
(Verarbeitung wurde ausgesetzt)
Wenn die Verarbeitung angehalten wurde, sollten wir eine Funktion zurückgeben, die den aktuellen Status der Verarbeitung darstellt.
All diese Anforderungen werden durch die Implementierung der Funktion reduce/3
für die Listen (aus den Dokumenten entnommen) gut demonstriert:
def reduce(_, {:halt, acc}, _fun), do: {:halted, acc} def reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(list, &1, fun)} def reduce([], {:cont, acc}, _fun), do: {:done, acc} def reduce([h | t], {:cont, acc}, fun), do: reduce(t, fun.(h, acc), fun)
Wir können diesen Code als Beispiel verwenden und unsere eigene Implementierung für die Zoo
-Struktur codieren:
defmodule Zoo do defstruct title: "", animals: [] defimpl Enumerable do def reduce(_, {:halt, acc}, _fun), do: {:halted, acc} def reduce(%Zoo{animals: animals}, {:suspend, acc}, fun) do {:suspended, acc, &reduce(%Zoo{animals: animals}, &1, fun)} end def reduce(%Zoo{animals: []}, {:cont, acc}, _fun), do: {:done, acc} def reduce(%Zoo{animals: [head | tail]}, {:cont, acc}, fun) do reduce(%Zoo{animals: tail}, fun.(head, acc), fun) end end end
In der letzten Funktionsklausel nehmen wir den Kopf der Liste mit allen Tieren, wenden die Funktion darauf an und führen dann eine reduce
gegen den Schwanz durch. Wenn keine Tiere mehr übrig sind (dritte Klausel), geben wir ein Tupel mit dem Status :done
und dem Endergebnis zurück. Die erste Klausel gibt ein Ergebnis zurück, wenn die Verarbeitung angehalten wurde. Die zweite Klausel gibt eine Funktion zurück, wenn das Verb :suspend
übergeben wurde.
Jetzt können wir zum Beispiel das Gesamtalter aller unserer Tiere einfach berechnen:
Enum.reduce(my_zoo, 0, fn(animal, total_age) -> animal.age + total_age end) |> IO.puts
Grundsätzlich haben wir jetzt Zugriff auf alle Funktionen des Enum
-Moduls. Versuchen wir, join/2 zu verwenden:
Enum.join(my_zoo) |> IO.inspect
Es wird jedoch eine Fehlermeldung angezeigt, dass das String.Chars
-Protokoll für die Animal
-Struktur nicht implementiert ist. Dies geschieht, weil join
versucht, jedes Element in eine Zeichenfolge zu konvertieren, dies jedoch nicht für das Animal
tun kann. Lassen Sie uns daher jetzt auch das String.Chars
-Protokoll implementieren:
defmodule Animal do defstruct species: "", name: "", age: 0 defimpl String.Chars do def to_string(%Animal{species: species, name: name, age: age}) do "#{name} (#{species}), aged #{age}" end end end
Jetzt sollte alles gut funktionieren. Sie können auch versuchen, each/2 auszuführen und einzelne Tiere anzuzeigen:
Enum.each(my_zoo, &(IO.puts(&1)))
Dies funktioniert erneut, da wir zwei Protokolle implementiert haben: Enumerable
(für den Zoo
) und String.Chars
(für das Animal
).
Schlussfolgerung
In diesem Artikel haben wir diskutiert, wie Polymorphismus in Elixir mithilfe von Protokollen implementiert wird. Sie haben gelernt, wie Sie Protokolle definieren und implementieren sowie integrierte Protokolle verwenden: Enumerable
, Inspect
und String.Chars
.
Als Übung können Sie versuchen, unser Zoo
-Modul mit dem Collectable-Protokoll zu versehen, damit die Enum.into/2-Funktion ordnungsgemäß verwendet werden kann. Dieses Protokoll erfordert die Implementierung nur einer Funktion: into/2, die Werte sammelt und das Ergebnis zurückgibt (beachten Sie, dass es auch die Verben :done
, :halt
und :cont
unterstützen muss; der Status sollte nicht gemeldet werden). Teilen Sie Ihre Lösung in den Kommentaren!
Ich hoffe, Ihnen hat das Lesen dieses Artikels gefallen. Wenn Sie noch Fragen haben, zögern Sie nicht, mich zu kontaktieren. Vielen Dank für die Geduld und bis bald!