Polymorfism med protokoll i Elixir

Polymorfism är ett viktigt begrepp i programmering, och nybörjare programmerare brukar lära sig om det under de första månaderna av studien. Polymorfism betyder i grunden att du kan tillämpa en liknande operation på enheter av olika slag. Till exempel kan count / 1-funktionen appliceras både till ett intervall och till en lista:

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

Hur är det mojligt? I Elixir uppnås polymorfism genom att använda en intressant egenskap som kallas ett protokoll, vilket verkar som a kontrakt. För varje datatyp du vill stödja måste detta protokoll implementeras.

Sammantaget är inte detta tillvägagångssätt revolutionärt, som det finns på andra språk (till exempel Ruby, till exempel). Ändå är protokoll verkligen lämpliga, så i denna artikel kommer vi att diskutera hur man definierar, implementerar och arbetar med dem när man utforskar några exempel. Låt oss börja!

Kort introduktion till protokoll

Så som redan nämnts ovan har ett protokoll någon generisk kod och är beroende av den specifika datatypen för att implementera logiken. Detta är rimligt, eftersom olika datatyper kan kräva olika implementeringar. En datatyp kan då avsändande på ett protokoll utan att oroa sig för sina internals.

Elixir har en massa inbyggda protokoll, inklusive uppräkningsbar, Samlings, Inspektera, List.Chars, och String.Chars. Några av dem kommer att diskuteras senare i den här artikeln. Du kan implementera något av dessa protokoll i din anpassade modul och få en massa funktioner gratis. Om du till exempel har implementerat Enumerable får du tillgång till alla funktioner som definieras i Enum-modulen, vilket är ganska coolt.

Om du har kommit från den underbara Ruby-världen full av föremål, klasser, feer och drakar, har du träffat ett mycket liknande koncept av mixins. Om du till exempel behöver göra dina föremål jämförbara, blandar du bara en modul med motsvarande namn i klassen. Sedan bara implementera ett rymdskepp <=> metod och alla instanser av klassen kommer att få alla metoder som > och < gratis. Denna mekanism liknar något protokoll i Elixir. Även om du aldrig har träffat det här konceptet innan, tro mig, det är inte så komplicerat. 

Okej, så första saker först: protokollet måste definieras, så låt oss se hur det går att göra i nästa avsnitt.

Definiera ett protokoll

Att definiera ett protokoll involverar inte någon svart magi, i själva verket är det mycket lik definierande moduler. Använd defprotocol / 2 för att göra det:

defprotocol MyProtocol slutar

Inne i protokollets definition placerar du funktioner, precis som med moduler. Den enda skillnaden är att dessa funktioner inte har någon kropp. Det innebär att protokollet endast definierar ett gränssnitt, en ritning som bör implementeras av alla datatyper som önskar skickas på detta protokoll:

defprotocol MyProtocol gör def my_func (arg) slutet

I det här exemplet behöver en programmerare att implementera my_func / 1 funktion att framgångsrikt utnyttja MyProtocol.

Om protokollet inte är implementerat kommer ett fel att höjas. Låt oss återvända till exemplet med räkna / 1 funktion definierad inuti Enum modul. Om du kör följande kod kommer det att uppstå ett fel:

Enum.count 1 # ** (Protocol.UndefinedError) protokoll Uppräkningsbar inte implementerad för 1 # (elixir) lib / enum.ex: 1: Enumerable.impl_for! / 1 # (elixir) lib / enum.ex: 146: Enumerable. räkna / 1 # (elixir) lib / enum.ex: 467: Enum.count / 1

Det betyder att Heltal implementerar inte uppräkningsbar protokoll (vilken överraskning) och därför kan vi inte räkna heltal. Men protokollet faktiskt kan implementeras, och detta är lätt att uppnå.  

Genomförande av ett protokoll

Protokoll implementeras med hjälp av makroflödet / 3-makroen. Du anger vilket protokoll som ska implementeras och för vilken typ:

defimpl MyProtocol, för: Integer def my_func (arg) gör IO.puts (arg) endänden

Nu kan du göra dina heltal beräknade genom att delvis implementera uppräkningsbar protokoll:

defimpl Enumerable, för: Integer gör def count (_arg) gör : ok, 1 # heltal innehåller alltid ett elementändänd Enum.count (100) |> IO.puts # => 1

Vi kommer att diskutera uppräkningsbar protokoll i mer detalj senare i artikeln och genomföra dess andra funktion också.

När det gäller typen (skickad till för), kan du ange vilken inbyggd typ som helst, ditt eget alias eller en lista över alias:

defimpl MyProtocol, för: [Integer, List] sluta

 Dessutom kan du säga Några:

defimpl MyProtocol, för: Alla def my_func (_) gör IO.puts "Not implemented!" änden

Detta kommer att fungera som en implementering av fallback, och ett fel kommer inte att höjas om protokollet inte är implementerat för någon typ. För att detta ska fungera, sätt in @fallback_to_any tillskriva Sann inuti ditt protokoll (annars kommer felet fortfarande att höjas):

defprotocol MyProtocol gör @ fallback_to_any true def my_func (arg) slutet

Du kan nu använda protokollet för vilken typ som helst som stöds:

MyProtocol.my_func (5) # skriver enkelt ut 5 MyProtocol.my_func ("test") # utskrifter "Ej implementerat!"

En anteckning om strukturer

Implementeringen av ett protokoll kan nästas inuti en modul. Om den här modulen definierar en struktur behöver du inte ens ange för när du ringer defimpl:

defmodule Produkt defekta titel: "", pris: 0 defimpl MyProtocol gör def my_func (% Produkt title: title, price: price) gör IO.puts "Titel # title, pris # price" ändänden

I det här exemplet definierar vi en ny struktur som heter Produkt och implementera vårt demoprotokoll. Inuti, mönstret-matcha titeln och priset och mata sedan ut en sträng.

Kom ihåg att en implementering måste nästas inuti en modul - det betyder att du enkelt kan förlänga en modul utan att få tillgång till källkoden.

Exempel: String.Chars Protocol

Okej, nog med abstrakt teori: Låt oss ta en titt på några exempel. Jag är säker på att du har använt IO.puts / 2-funktionen ganska omfattande för att utmata felsökningsinformation till konsolen när du spelar med Elixir. Visst kan vi enkelt utföra olika inbyggda typer:

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

Men vad händer om vi försöker producera vår Produkt struct skapad i föregående avsnitt? Jag kommer att placera motsvarande kod inuti Huvudsaklig modulen eftersom annars får du ett fel som säger att strukturen inte är definierad eller åtkomlig inom samma räckvidd:

defmodule Produkt defektera titel: "", pris: 0 slut defodule Main gör def run do% Produkt title: "Test", pris: 5 |> IO.puts slutänden Main.run

Om du har kört den här koden får du ett fel:

 (Protocol.UndefinedError) protokollet String.Chars inte implementerat för% Produkt pris: 5, titel: "Test"

A ha! Det betyder att sätter funktionen bygger på det inbyggda String.Chars-protokollet. Så länge det inte är implementerat för vår Produkt, felet höjas.

String.Chars ansvarar för att konvertera olika strukturer till binärer, och den enda funktionen som du behöver implementera är to_string / 1, enligt dokumentationen. Varför implementerar vi inte det nu?

defmodule Produkt defekta titel: "", pris: 0 defimpl String.Chars gör def to_string (% Produkt title: title, price: price) gör "# title, $ # price" ändänden

Med denna kod på plats kommer programmet att mata ut följande sträng:

Test, $ 5

Det betyder att allt fungerar bra!

Exempel: Inspektera protokoll

En annan mycket vanlig funktion är IO.inspect / 2 för att få information om en konstruktion. Det finns också en inspektion / 2-funktion som definieras inuti Kärna modul-det utför inspektion enligt Inspect-inbyggt protokoll.

Vår Produkt struct kan inspekteras direkt, och du får lite kort information om det:

% Produkt title: "Test", pris: 5 |> IO.inspect # eller:% Produkt title: "Test", pris: 5 |> inspekter |> IO.puts

Det kommer att återvända % Produkt pris: 5, titel: "Test". Men än en gång kan vi enkelt genomföra Inspektera protokoll som endast kräver att inspektionen / 2-funktionen kodas:

defmodule Produkt defekta titel: "", pris: 0 defimpl Inspektera inspektera inte (% Produkt titel: titel, pris: pris, _) gör "Det är en produktstruktur. Den har en titel på # title och priset på # pris. Yay! " ändänden 

Det andra argumentet som passerat till denna funktion är listan över alternativ, men vi är inte intresserade av dem.

Exempel: uppräkningsprotokoll

Låt oss nu se ett lite mer komplext exempel när vi talar om det uppräknade protokollet. Detta protokoll används av Enum-modulen, vilket ger oss så bekväma funktioner som varje / 2 och count / 1 (utan det skulle du behöva hålla fast vid vanlig gammal rekursion).

Räknare definierar tre funktioner som du måste klä ut för att kunna implementera protokollet:

  • count / 1 returnerar talans storlek.
  • medlem? / 2 kontrollerar om räknaren innehåller ett element.
  • minska / 3 tillämpar en funktion för varje element i talbarheten.

Med alla dessa funktioner på plats får du tillgång till alla godis som tillhandahålls av Enum modul, vilket är en riktigt bra affär.

Låt oss till exempel skapa en ny struktur som heter Zoo. Det kommer att ha en titel och en lista över djur:

defmodule Zoo gör defekt titel: "", djur: [] slutet

Varje djur kommer också att representeras av en struktur:

defmodule Djur gör defekt arter: "", namn: "", ålder: 0 slut

Låt oss nu inställa en ny zoo:

defodule Main gör def run do my_zoo =% Zoo title: "Demo Zoo", djur: [% Animal species: "tiger", namn: "Tigga", ålder: 5,% Animal species: "horse" namn: "Amazing", ålder: 3,% Animal (art: "hjort", namn: "Bambi", ålder: 2] slutänden Main.run

Så vi har en "Demo Zoo" med tre djur: en tiger, en häst och en hjort. Vad jag skulle vilja göra nu är att lägga till stöd för count / 1-funktionen, som kommer att användas så här:

Enum.count (my_zoo) |> IO.inspect

Låt oss genomföra denna funktion nu!

Implementering av graffunktionen

Vad menar vi när du säger "räkna min zoo"? Det låter lite konstigt, men antagligen betyder det att man räknar alla djur som bor där, så implementeringen av den underliggande funktionen blir ganska enkel:

defmodule Zoo gör defekt titel: "", djur: [] defimpl Uppräknare gör inte räkna (% Zoo animals: animals) gör : ok, Enum.count (djur) ändänden

Allt vi gör här är beroende av count / 1-funktionen medan du skickar en lista över djur till den (eftersom den här funktionen stöder listor ur rutan). En mycket viktig sak att nämna är att räkna / 1 funktionen måste returnera sitt resultat i form av en tupel : ok, resultat som dikteras av docs. Om du bara returnerar ett nummer, ett fel  ** (CaseClauseError) ingen fallklausul matchande kommer att höjas.

Det är ganska mycket det. Du kan nu säga Enum.count (my_zoo) inuti Main.run, och det borde återvända 3 som ett resultat. Bra jobbat!

Genomförande medlem? Fungera

Nästa funktion som protokollet definierar är medlem? / 2. Det borde återvända en tupel : ok, booleska som ett resultat som säger om en talbar (passerad som första argumentet) innehåller ett element (det andra argumentet).

Jag vill ha den här nya funktionen att säga om ett visst djur bor i djurparken eller inte. Därför är genomförandet ganska enkelt också:

defampl Zoo gör defekt titel: "", djur: [] defimpl Enumerable gör # ... def medlem? (% Zoo titel: _, djur: djur, djur) gör : ok, Enum.member?  ändänden

Återigen, notera att funktionen accepterar två argument: en talbar och ett element. Inuti bygger vi helt enkelt på medlem? / 2 funktion för att söka ett djur på listan över alla djur.

Så nu kör vi:

Enum.member? (My_zoo,% Animal species: "tiger", namn: "Tigga", ålder: 5) |> IO.inspect

Och detta borde återvända Sann som vi verkligen har ett sådant djur på listan!

Genomföra reduktionsfunktionen

Sakerna blir lite mer komplexa med minska / 3 fungera. Den accepterar följande argument:

  • en räknare att tillämpa funktionen på
  • en ackumulator för att lagra resultatet
  • Den aktuella reduktionsfunktionen gäller

Vad som är intressant är att ackumulatorn faktiskt innehåller en tupel med två värden: a verb och ett värde: verb, värde. Verbetet är en atom och kan ha ett av följande tre värden:

  • : forts (Fortsätta)
  • :stanna (avsluta)
  • :suspendera (tillfälligt upphäva)

Det resulterande värdet returneras av minska / 3 funktion är också en tupel som innehåller staten och ett resultat. Staten är också en atom och kan ha följande värden: 

  • :Gjort (bearbetningen är klar, det är det slutliga resultatet)
  • : stoppas (bearbetningen avbröts eftersom ackumulatorn innehöll :stanna verb)
  • :suspenderad (bearbetningen avbröts)

Om behandlingen avbröts, ska vi returnera en funktion som representerar det aktuella tillståndet för behandlingen.

Alla dessa krav är fint demonstrerade genom genomförandet av minska / 3 funktion för listorna (taget från dokumenten):

def reducera (_, : halt, acc, _fun), gör: : stoppad, acc def reducera (lista, : suspend, acc & 1, kul) def reducera ([], : cont, acc, _fun), gör: : done, acc def minska ([h | t], : cont, acc minska (t, kul. (h, acc), kul)

Vi kan använda denna kod som ett exempel och koda vår egen implementering för Zoo struct:

defmodule Zoo defray title: "", djur: [] defimpl Enumerable reducera inte (_, : halt, acc, _fun), gör: : stoppad, acc def reducera (% Zoo animals: animals : suspend, acc, kul) gör : suspenderad, acc, & reducera (% Zoo djur: djur, & 1, kul) avsluta def minska (% Zoo animals: [], : cont, acc , _fun), gör: : done, acc def reducera (% Zoo djur: [head | tail], : cont, acc, roligt) minska (% Zoo animals: tail, fun. huvud, acc), kul) ändänden

I den sista funktionsklausulen tar vi huvudet på listan som innehåller alla djur, tillämpar funktionen på den och utför sedan minska mot svansen. När det inte finns några fler djur kvar (tredje klausulen), återvänder vi en tupel med tillståndet av :Gjort och slutresultatet. Den första klausulen returnerar ett resultat om behandlingen stannades. Den andra klausulen returnerar en funktion om :suspendera verb har gått.

Nu kan vi till exempel enkelt beräkna den totala åldern hos alla våra djur:

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

I grund och botten har vi nu tillgång till alla funktioner som tillhandahålls av Enum modul. Låt oss försöka utnyttja med / 2:

Enum.join (my_zoo) |> IO.inspect

Däremot får du ett fel som säger att String.Chars protokollet är inte implementerat för Djur struct. Detta händer eftersom Ansluta sig försöker konvertera varje element till en sträng, men kan inte göra det för Djur. Därför låt oss också genomföra String.Chars protokoll nu:

defmodule Djur gör defekt arter: "", namn: "", ålder: 0 defimpl String.Chars do def to_string (% Animal art: art, namn: namn, ålder: ålder) gör "# name art), i åldern # ålder "slutet änden

Nu ska allt fungera bra. Du kan också försöka springa varje / 2 och visa enskilda djur:

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

Återigen fungerar detta eftersom vi har implementerat två protokoll: uppräkningsbar (för Zoo) och String.Chars (för Djur).

Slutsats

I denna artikel har vi diskuterat hur polymorfism implementeras i Elixir med protokoll. Du har lärt dig hur du definierar och implementerar protokoll, samt använder inbyggda protokoll: uppräkningsbar, Inspektera, och String.Chars.

Som en övning kan du försöka stärka vår Zoo modul med Collectable protokollet så att Enum.into / 2-funktionen kan användas korrekt. Detta protokoll kräver implementering av endast en funktion: in / 2, som samlar värden och returnerar resultatet (notera att det också måste stödja :Gjort, :stanna och : forts verb; staten bör inte rapporteras). Dela din lösning i kommentarerna!

Jag hoppas att du har haft det bra att läsa den här artikeln. Om du har några frågor kvar, tveka inte att kontakta mig. Tack för tålamod, och vi ses snart!