Polymorphisme Avec Des Protocoles Dans L'élixir

Le polymorphisme est un concept important dans la programmation et les programmeurs débutants l’apprennent généralement au cours des premiers mois de leurs études. Polymorphisme signifie que vous pouvez appliquer une opération similaire à des entités de types différents. Par exemple, la fonction count / 1 peut être appliquée à la fois à une plage et à une liste:

Enum.count (1… 3) Enum.count ([1,2,3])

Comment est-ce possible? Dans Elixir, le polymorphisme est obtenu en utilisant une fonctionnalité intéressante appelée protocole, qui agit comme un Contrat. Pour chaque type de données que vous souhaitez prendre en charge, ce protocole doit être implémenté..

Dans l’ensemble, cette approche n’est pas révolutionnaire, comme on en trouve dans d’autres langages (comme Ruby, par exemple). Néanmoins, les protocoles sont vraiment pratiques. Dans cet article, nous expliquerons comment les définir, les implémenter et les utiliser tout en explorant quelques exemples. Commençons!

Brève introduction aux protocoles

Ainsi, comme déjà mentionné ci-dessus, un protocole a un code générique et s'appuie sur le type de données spécifique pour implémenter la logique. Cela est raisonnable, car différents types de données peuvent nécessiter des implémentations différentes. Un type de données peut alors envoi sur un protocole sans se soucier de ses internes.

Elixir a un tas de protocoles intégrés, y compris Enumérable, À collectionner, Inspecter, List.Chars, et String.Chars. Certains d'entre eux seront discutés plus tard dans cet article. Vous pouvez implémenter n'importe lequel de ces protocoles dans votre module personnalisé et obtenir un tas de fonctions gratuitement. Par exemple, après avoir implémenté Enumerable, vous aurez accès à toutes les fonctions définies dans le module Enum, ce qui est plutôt cool.

Si vous venez du merveilleux monde Ruby plein d'objets, de classes, de fées et de dragons, vous aurez rencontré un concept très similaire de mixins. Par exemple, si vous avez besoin de rendre vos objets comparables, mélangez simplement un module avec le nom correspondant dans la classe. Il suffit ensuite de mettre en place un vaisseau spatial <=> méthode et toutes les instances de la classe obtiendront toutes les méthodes comme > et < gratuitement. Ce mécanisme est quelque peu similaire aux protocoles dans Elixir. Même si vous n'avez jamais rencontré ce concept auparavant, croyez-moi, ce n'est pas si complexe. 

Bon, alors commençons par le début: le protocole doit être défini, voyons comment cela peut être fait dans la section suivante.

Définir un protocole

Définir un protocole n'implique aucune magie noire. En fait, cela ressemble beaucoup à la définition de modules. Utilisez defprotocol / 2 pour le faire:

defprotocol MyProtocol do end

Dans la définition du protocole, vous placez des fonctions, comme avec des modules. La seule différence est que ces fonctions n'ont pas de corps. Cela signifie que le protocole ne définit qu'une interface, un plan qui devrait être mis en œuvre par tous les types de données souhaitant être acheminés sur ce protocole:

defprotocol MyProtocol do def my_func (arg) end

Dans cet exemple, un programmeur doit implémenter le mon_fonc / 1 fonction pour utiliser avec succès Mon protocole.

Si le protocole n'est pas implémenté, une erreur sera générée. Revenons à l'exemple avec le compte / 1 fonction définie à l'intérieur du Enum module. Si vous exécutez le code suivant, vous obtiendrez une erreur:

Enum.count 1 # ** (Protocol.UndefinedError) protocole Enumerable non implémenté pour 1 # (elixir) lib / enum.ex: 1: Enumerable.impl_for! / 1 # (élixir) lib / enum.ex: 146: Enumerable. count / 1 # (elixir) lib / enum.ex: 467: Enum.count / 1

Cela signifie que le Entier ne met pas en œuvre le Enumérable protocole (quelle surprise) et, par conséquent, nous ne pouvons pas compter les entiers. Mais le protocole fait pouvez être mis en œuvre, et cela est facile à réaliser.  

Mettre en œuvre un protocole

Les protocoles sont implémentés à l'aide de la macro defimpl / 3. Vous spécifiez le protocole à implémenter et pour quel type:

defimpl MyProtocol, pour: Integer def my_func (arg) do IO.puts (arg) end end

Maintenant, vous pouvez rendre vos entiers dénombrables en implémentant partiellement le Enumérable protocole:

defimpl Enumerable, pour: Integer ne compte pas (_arg) do : ok, 1 # nombres entiers contiennent toujours une fin d’élément Enum.count (100) |> IO.puts # => 1

Nous allons discuter de la Enumérable protocole plus en détail plus tard dans l'article et mettre en œuvre son autre fonction ainsi.

Quant au type (passé à la pour), vous pouvez spécifier n’importe quel type intégré, votre propre alias ou une liste d’alias:

defimpl MyProtocol, pour: [Integer, List] se termine

 En plus de cela, vous pouvez dire Tout:

defimpl MyProtocol, pour: tout def my_func (_) do IO.puts "non implémenté!" fin fin

Cela agira comme une implémentation de secours et une erreur ne sera pas déclenchée si le protocole n'est pas implémenté pour un type quelconque. Pour que cela fonctionne, définissez le paramètre @ fallback_to_any attribuer à vrai dans votre protocole (sinon l'erreur sera toujours générée):

defprotocol MyProtocol do @fallback_to_any true def my_func (arg) end

Vous pouvez maintenant utiliser le protocole pour tout type pris en charge:

MyProtocol.my_func (5) # imprime simplement 5 MyProtocol.my_func ("test") # imprime "Non implémenté!"

Une note sur les structures

L'implémentation d'un protocole peut être imbriquée dans un module. Si ce module définit une structure, vous n'avez même pas besoin de spécifier pour en appelant défonce:

defmodule Product do defstruct title: "", prix: 0 defimpl MyProtocol do def my_func (% Produit titre: titre, prix: prix) fait IO.puts "Titre # titre, prix # prix" fin fin fin

Dans cet exemple, nous définissons une nouvelle structure appelée Produit et implémenter notre protocole de démonstration. À l'intérieur, faites simplement correspondre le titre et le prix au modèle, puis générez une chaîne.

Rappelez-vous cependant qu'une implémentation doit être imbriquée dans un module. Cela signifie que vous pouvez facilement étendre n'importe quel module sans accéder à son code source..

Exemple: protocole String.Chars

Ok, assez avec la théorie abstraite: regardons quelques exemples. Je suis sûr que vous avez largement utilisé la fonction IO.puts / 2 pour générer des informations de débogage sur la console lors de la lecture avec Elixir. Certes, nous pouvons facilement générer différents types intégrés:

IO.puts 5 IO.puts "test" IO.puts: my_atom

Mais que se passe-t-il si nous essayons de sortir notre Produit struct créé dans la section précédente? Je vais placer le code correspondant à l'intérieur du Principale module car sinon, vous obtiendrez une erreur indiquant que la structure n'est pas définie ni accédée dans la même portée:

defmodule Produit defstruct title: "", prix: 0 fin defmodule Main doit être exécuté correctement% Produit title: "Test", prix: 5 |> IO.puts end end Main.run

Après avoir exécuté ce code, vous obtiendrez une erreur:

 (Protocol.UndefinedError) protocol protocol.Chars non implémenté pour% Product price: 5, title: "Test"

Aha! Cela signifie que le met Cette fonction repose sur le protocole intégré String.Chars. Tant qu'il n'est pas mis en œuvre pour notre Produit, l'erreur est soulevée.

String.Chars est responsable de la conversion de diverses structures en fichiers binaires et la seule fonction à implémenter est to_string / 1, comme indiqué dans la documentation. Pourquoi ne l'appliquons-nous pas maintenant?

defmodule Product do defstruct title: "", prix: 0 defimpl String.Chars do def to_string (% Product titre: titre, prix: prix) fait "# titre, $ # prix" fin fin fin

Ayant ce code en place, le programme affichera la chaîne suivante:

Test, 5 $

Ce qui signifie que tout fonctionne bien!

Exemple: protocole d'inspection

Une autre fonction très courante est IO.inspect / 2 pour obtenir des informations sur une construction. Il existe également une fonction inspect / 2 définie à l'intérieur du Noyau module-it effectue une inspection conformément au protocole intégré Inspect.

Notre Produit struct peut être inspecté immédiatement, et vous obtiendrez de brèves informations à ce sujet:

% Produit titre: "Test", prix: 5 |> IO.inspect # ou:% Produit titre: "Test", prix: 5 |> inspect |> IO.puts

Il reviendra % Produit prix: 5, titre: "test". Mais, encore une fois, nous pouvons facilement mettre en œuvre le Inspecter protocole qui exige que seule la fonction inspect / 2 soit codée:

defmodule Product defstruct title: "", prix: 0 defimpl Inspecter ne def inspecter (% Product titre: titre, prix: prix, _) faire "C’est une structure de produit. Elle porte un titre de # titre et une prix de # prix. Yay! " fin fin fin 

Le deuxième argument passé à cette fonction est la liste des options, mais elles ne nous intéressent pas.

Exemple: protocole énumérable

Voyons maintenant un exemple un peu plus complexe en parlant du protocole Enumerable. Ce protocole est utilisé par le module Enum, qui nous présente des fonctions pratiques telles que each / 2 et count / 1 (sans ce dernier, vous devrez vous en tenir à la récursion ancienne)..

Enumerable définit trois fonctions que vous devez étoffer pour mettre en oeuvre le protocole:

  • count / 1 retourne la taille de l'énumérable.
  • member? / 2 vérifie si l'énumérable contient un élément.
  • réduire / 3 applique une fonction à chaque élément de l'énumérable.

Ayant toutes ces fonctions en place, vous aurez accès à tous les goodies fournis par le Enum module, qui est une très bonne affaire.

Par exemple, créons une nouvelle structure appelée zoo. Il aura un titre et une liste d'animaux:

defmodule Zoo définit le titre: "", animaux: [] end

Chaque animal sera également représenté par une structure:

defmodule Animal définit l’espèce: "", nom: "", âge: 0 fin

Instancions maintenant un nouveau zoo:

defmodule Main do def run do my_zoo =% Zoo titre: "Zoo de démonstration", animaux: [% Animal espèce: "tigre", nom: "Tigga", âge: 5,% Animal espèce: "cheval", nom: "Amazing", age: 3,% Animal espèce: "cerf", nom: "Bambi", age: 2] end end Main.run

Nous avons donc un "Zoo de démonstration" avec trois animaux: un tigre, un cheval et un cerf. Ce que j'aimerais faire maintenant, c'est ajouter un support pour la fonction count / 1, qui sera utilisé comme ceci:

Enum.count (my_zoo) |> IO.inspect

Implémentons cette fonctionnalité maintenant!

Implémentation de la fonction Count

Que voulons-nous dire en disant "Comptez mon zoo"? Cela semble un peu étrange, mais cela implique probablement de compter tous les animaux qui y vivent. La mise en œuvre de la fonction sous-jacente sera donc très simple:

defmodule Zoo ne définit pas le titre: "", animaux: [] defimpl Énumérable compte de def (% Zoo animaux: animaux) do : ok, Enum.count (animaux) end end end

Tout ce que nous faisons ici est de compter sur la fonction count / 1 en lui transmettant une liste d’animaux (car cette fonction prend en charge les listes prêtes à l’emploi). Une chose très importante à mentionner est que le compte / 1 la fonction doit renvoyer son résultat sous la forme d'un tuple : ok, résultat comme dicté par les docs. Si vous ne retournez qu'un nombre, une erreur  ** (CaseClauseError) aucune correspondance de clause case sera soulevé.

C'est à peu près tout. Vous pouvez maintenant dire Enum.count (my_zoo) à l'intérieur de Main.run, et il devrait revenir 3 Par conséquent. Bon travail!

Membre d'exécution? Une fonction

La fonction suivante définie par le protocole est la suivante: membre? / 2. Il devrait retourner un tuple : ok, booléen en tant que résultat indiquant si un énumérable (passé comme premier argument) contient un élément (le second argument).

Je veux que cette nouvelle fonction indique si un animal particulier vit ou non dans le zoo. Par conséquent, la mise en œuvre est assez simple également:

defmodule Zoo définit le titre: "", animals: [] defimpl Enumerable # # def: membre? (% Zoo title: _, animaux: animaux, animal) : ok, Enum.member? (animaux, animal)  end end end

Encore une fois, notez que la fonction accepte deux arguments: un énumérable et un élément. A l'intérieur, nous nous appuyons simplement sur membre? / 2 fonction de recherche d'un animal dans la liste de tous les animaux.

Alors maintenant nous courons:

Enum.member? (My_zoo,% Animal espèce: "tiger", nom: "Tigga", age: 5) |> IO.inspect

Et cela devrait revenir vrai comme nous avons effectivement un tel animal dans la liste!

Implémentation de la fonction de réduction

Les choses deviennent un peu plus complexes avec le réduire / 3 une fonction. Il accepte les arguments suivants:

  • un énumérable pour appliquer la fonction à
  • un accumulateur pour stocker le résultat
  • la fonction de réducteur à appliquer

Ce qui est intéressant, c’est que l’accumulateur contient en fait un tuple avec deux valeurs: a verbe et une valeur: verbe, valeur. Le verbe est un atome et peut avoir l'une des trois valeurs suivantes:

  • : cont (continuer)
  • :arrêt (mettre fin)
  • :suspendre (suspendre temporairement)

La valeur résultante renvoyée par le réduire / 3 fonction est aussi un tuple contenant l’état et un résultat. L'état est aussi un atome et peut avoir les valeurs suivantes: 

  • :terminé (le traitement est terminé, c'est le résultat final)
  • : arrêté (le traitement a été arrêté car l’accumulateur contenait la :arrêt verbe)
  • :suspendu (le traitement a été suspendu)

Si le traitement était suspendu, nous devrions renvoyer une fonction représentant l'état actuel du traitement..

Toutes ces exigences sont bien démontrées par la mise en œuvre de la réduire / 3 fonction pour les listes (tirées de la documentation):

def réduire (_, : arrêt, acc, _fun), faire: : arrêté, acc def réduire (liste, : suspendre, acc, amusant), faire: : suspendu, acc, et réduire (liste, & 1, fun) def réduire ([], : cont, acc, _fun), faire: : done, acc def réduire ([h | t], : cont, acc, amusant), faire: réduire (t, amusant. (h, acc), amusant)

Nous pouvons utiliser ce code comme exemple et coder notre propre implémentation pour le zoo struct:

defmodule Zoo ne définit pas le titre: "", animaux: [] defimpl Enumérable ne réduit pas (_, : arrêt, acc, _fun), fait: : arrêté, réduit (% Zoo animaux: animaux, : suspendre, acc, amusant) faire : suspendu, acc, & réduire (% Zoo animaux: animaux, & 1, amusant) fin def réduire (% Zoo animaux: [], : cont, acc , _fun), do: : done, acc def réduire (% Zoo animaux: [tête | queue], : cont, acc, amusant) réduit (% Zoo animaux: queue, amusant. ( tête, acc), amusant) fin fin fin

Dans la dernière clause de fonction, nous prenons la tête de la liste contenant tous les animaux, lui appliquons la fonction, puis effectuons réduire contre la queue. Quand il ne reste plus d’animaux (la troisième clause), nous retournons un tuple avec l’état de :terminé et le résultat final. La première clause renvoie un résultat si le traitement a été arrêté. La seconde clause renvoie une fonction si le :suspendre le verbe a été passé.

Maintenant, par exemple, nous pouvons calculer facilement l’âge total de tous nos animaux:

Enum.reduce (my_zoo, 0, fn (animal, total_age) -> animal.age + total_age end) |> IO.puts

En gros, nous avons maintenant accès à toutes les fonctions fournies par le Enum module. Essayons d'utiliser join / 2:

Enum.join (my_zoo) |> IO.inspect

Cependant, vous obtiendrez une erreur en disant que le String.Chars le protocole n'est pas implémenté pour la Animal struct. Cela se passe parce que joindre tente de convertir chaque élément en chaîne, mais ne peut pas le faire pour le Animal. Par conséquent, implémentons également le String.Chars protocole maintenant:

defmodule Les animaux ne définissent pas les espèces: "", nom: "", âge: 0. defimpl String.Chars do def to_string (% Animal espèce: espèce, nom: nom, âge: âge) faire "# nom (#  espèce), âgé # age "fin fin fin

Maintenant, tout devrait bien fonctionner. De plus, vous pouvez essayer d’exécuter chaque / 2 et d’afficher des animaux individuels:

Enum.each (my_zoo, & (IO.puts (& 1)))

Encore une fois, cela fonctionne car nous avons implémenté deux protocoles: Enumérable (pour le zoo) et String.Chars (pour le Animal).

Conclusion

Dans cet article, nous avons expliqué comment le polymorphisme est implémenté dans Elixir à l'aide de protocoles. Vous avez appris à définir et à mettre en œuvre des protocoles, ainsi qu'à utiliser des protocoles intégrés: Enumérable, Inspecter, et String.Chars.

En tant qu’exercice, vous pouvez essayer de responsabiliser nos zoo module avec le protocole Collectable afin que la fonction Enum.into / 2 puisse être utilisée correctement. Ce protocole nécessite la mise en œuvre d’une seule fonction: into / 2, qui collecte les valeurs et renvoie le résultat (notez qu’il doit également prendre en charge la :terminé, :arrêt et : cont verbes; l'état ne devrait pas être signalé). Partagez votre solution dans les commentaires!

J'espère que vous avez apprécié la lecture de cet article. Si vous avez des questions, n'hésitez pas à me contacter. Merci pour votre patience et à bientôt!