7 days of WordPress plugins, themes & templates - for free!* Unlimited asset downloads! Start 7-Day Free Trial
Advertisement
  1. Code
  2. Elixir

Polymorphismus mit Protokollen in Elixir

Read Time: 12 mins

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:

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:

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:

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:

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:

Jetzt können Sie Ihre Ganzzahlen zählbar machen, indem Sie das Enumerable-Protokoll teilweise implementieren:

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:

Darüber hinaus können Sie Any sagen:

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):

Sie können das Protokoll jetzt für jeden unterstützten Typ verwenden:

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:

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:

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:

Nachdem Sie diesen Code ausgeführt haben, wird eine Fehlermeldung angezeigt:

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?

Wenn dieser Code vorhanden ist, gibt das Programm die folgende Zeichenfolge aus:

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:

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:

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:

Jedes Tier wird auch durch eine Struktur dargestellt:

Lassen Sie uns nun einen neuen Zoo instanziieren:

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:

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:

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:

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:

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:

Wir können diesen Code als Beispiel verwenden und unsere eigene Implementierung für die Zoo-Struktur codieren:

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:

Grundsätzlich haben wir jetzt Zugriff auf alle Funktionen des Enum-Moduls. Versuchen wir, join/2 zu verwenden:

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:

Jetzt sollte alles gut funktionieren. Sie können auch versuchen, each/2 auszuführen und einzelne Tiere anzuzeigen:

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!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Scroll to top
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.