5 stappen voor het maken van je allereerste Type Class in Scala

In dit blogbericht leert u hoe u uw eerste typeklasse implementeert, wat een fundamentele taalfunctie is in het pictogram van de functionele programmeertalen - Haskell.

Foto door Stanley Dai op Unsplash

Type Class is een patroon dat afkomstig is van Haskell en het is de standaard manier om polymorfisme te implementeren. Dit type polymorfisme wordt ad-hoc polymorfisme genoemd. De naam komt van het feit dat we, in tegenstelling tot het bekende subtypische polymorfisme, sommige functionaliteit van de bibliotheek kunnen uitbreiden, zelfs zonder toegang te hebben tot de broncode van de bibliotheek en klasse naar welke functionaliteit we willen uitbreiden.

In dit bericht zul je zien dat het gebruik van typeklassen net zo handig kan zijn als het gebruik van regulier OOP-polymorfisme. De onderstaande inhoud leidt u door alle fasen van de implementatie van het Type Class-patroon om u te helpen beter inzicht te krijgen in de interne aspecten van functionele programmeerbibliotheken.

Uw eerste typeklasse maken

Technisch is Type Class gewoon een geparametriseerde eigenschap met een aantal abstracte methoden die kunnen worden geïmplementeerd in klassen die die eigenschap uitbreiden. Voor zover alles er echt uitziet in het bekende subtypemodel.
Het enige verschil is dat we met behulp van subtypen contract moeten implementeren in klassen die een stuk domeinmodel zijn, in Type Klassen wordt de uitvoering van de eigenschap in een volledig andere klasse geplaatst die per type parameter is gekoppeld aan "domeinklasse".

Als voorbeeld in dit artikel zal ik de Eq Type Class uit Cats-bibliotheek gebruiken.

eigenschap Eq [A] {
  def areEquals (a: A, b: A): Boolean
}

Type Klasse Eq [A] is een contract waarbij kan worden gecontroleerd of twee objecten van type A gelijk zijn op basis van enkele criteria die zijn geïmplementeerd in de methode areEquals.

Het maken van een instantie van onze typeklasse is zo eenvoudig als het instantiëren van een klasse die genoemde eigenschap uitbreidt met slechts één verschil dat onze typeklasse-instantie toegankelijk zal zijn als impliciet object.

def moduloEq (deler: Int): Eq [Int] = nieuw Eq [Int] {
 overschrijven def areEquals (a: Int, b: Int) = a% deler == b% deler
}
impliciete val modulo5Eq: Eq [Int] = moduloEq (5)

Bovenstaand stukje code kan in de volgende vorm een ​​beetje worden verdicht.

def moduloEq: Eq [Int] = (a: Int, b: Int) => a% 5 == b% 5

Maar wacht, hoe kunt u de functie (Int, Int) => Boolean toewijzen aan verwijzing met type Eq [Int] ?! Dit ding is mogelijk dankzij de Java 8-functie genaamd Single Abstract Method-type interface. We kunnen zoiets doen als we maar één abstracte methode hebben.

Type Klasse resolutie

In deze paragraaf zal ik je laten zien hoe je type-instantie instanties kunt gebruiken en hoe je type-klasse Eq [A] magisch aan elkaar kunt koppelen met het overeenkomstige object van type A wanneer dat nodig is.

Hier hebben we de functionaliteit geïmplementeerd van het vergelijken van twee Int-waarden door te controleren of hun modulo-delingswaarden gelijk zijn. Met al dat werk kunnen we onze Type Class gebruiken voor het uitvoeren van een aantal bedrijfslogica, bijv. we willen twee waarden koppelen die modulo gelijk zijn.

def pairEquals [A] (a: A, b: A) (impliciete eq: Eq [A]): ​​Optie [(A, A)] = {
 if (eq.areEquals (a, b)) Sommige ((a, b)) anders Geen
}

We hebben functie paarEquals geparametriseerd om te werken met alle typen die instanties van klasse Eq [A] bieden die beschikbaar zijn in de impliciete scope.

Wanneer de compiler geen instantie vindt die overeenkomt met de bovenstaande verklaring, geeft dit een compilatiefoutwaarschuwing over het ontbreken van de juiste instantie in de impliciete scope.
  1. Compiler zal het type verstrekte parameters afleiden door argumenten toe te passen op onze functie en deze toewijzen aan alias A.
  2. Voorafgaand argument eq: Eq [A] met impliciet trefwoord zal suggestie activeren om te zoeken naar een object van het type Eq [A] met impliciet bereik.

Dankzij implicits en getypte parameters kan de compiler de klasse verbinden met de bijbehorende instantie van de typeklasse.

Alle instanties en functies zijn gedefinieerd, laten we controleren of onze code geldige resultaten oplevert

pairEquals (2,7)
res0: Optie [(Int, Int)] = Sommige ((2,7))
pairEquals (2,3)
res0: Optie [(Int, Int)] = Geen

Zoals u ziet, hebben we de verwachte resultaten ontvangen, dus onze typeklasse presteert goed. Maar deze ziet er een beetje rommelig uit, met behoorlijk wat boilerplate. Dankzij de magie van de syntaxis van Scala kunnen we veel boilerplate laten verdwijnen.

Contextgrenzen

Het eerste wat ik wil verbeteren in onze code is om zich te ontdoen van de tweede lijst met argumenten (die met impliciet trefwoord). We gaan die functie niet direct voorbij, dus laat impliciet weer impliciet zijn. In Scala kunnen impliciete argumenten met typeparameters worden vervangen door taalconstructie genaamd Context Bound.

Context Bound is een verklaring in de lijst met typeparameters met syntaxis A: Eq zegt dat elk type dat wordt gebruikt als argument van de functie pairEquals een impliciete waarde van het type Eq [A] in het impliciete bereik moet hebben.

def pairEquals [A: Eq] (a: A, b: A): Optie [(A, A)] = {
 if (impliciet [Eq [A]]. areEquals (a, b)) Sommige ((a, b)) anders Geen
}

Zoals je hebt gemerkt, hebben we geen verwijzing gevonden die naar impliciete waarde verwijst. Om dit probleem op te lossen, gebruiken we impliciet functie [F [_]] die de gevonden impliciete waarde haalt door op te geven naar welk type we verwijzen.

Dit is wat Scala-taal ons biedt om het allemaal beknopter te maken. Het ziet er echter nog steeds niet goed genoeg voor mij uit. Context Bound is een heel gave syntactische suiker, maar dit lijkt impliciet onze code te vervuilen. Ik zal een leuke truc maken om dit probleem op te lossen en onze implementatievereisten te verminderen.

Wat we kunnen doen, is een geparametriseerde toepassingsfunctie in een begeleidend object van onze typeklasse bieden.

object Eq {
 def apply [A] (impliciete eq: Eq [A]): ​​Eq [A] = eq
}

Dit heel eenvoudige ding stelt ons in staat om impliciet van de hand te doen en onze instantie uit limbo te halen om te gebruiken in domeinlogica zonder boilerplate.

def pairEquals [A: Eq] (a: A, b: A): Optie [(A, A)] = {
 if (Eq [A] .areEquals (a, b)) Sommige ((a, b)) anders Geen
}

Impliciete conversies - aka. Syntax module

Het volgende wat ik op mijn werkbank wil krijgen is Eq [A] .areEquals (a, b). Deze syntaxis ziet er zeer uitgebreid uit, omdat we expliciet verwijzen naar de instantie van de type-klasse die impliciet moet zijn, toch? Het tweede is dat onze instantie van de type-klasse hier werkt als Service (in DDD-betekenis) in plaats van een echte A-klasse-extensie. Gelukkig kan die ook worden opgelost met behulp van een ander nuttig gebruik van impliciet trefwoord.

Wat we hier gaan doen, is de zogenaamde syntaxis of (ops zoals in sommige FP-bibliotheken) module met behulp van impliciete conversies waarmee we de API van een bepaalde klasse kunnen uitbreiden zonder de broncode te wijzigen.

impliciete klasse EqSyntax [A: Eq] (a: A) {
 def === (b: A): Boolean = Eq [A] .areEquals (a, b)
}

Deze code vertelt compiler om klasse A met instantie van type klasse Eq [A] te converteren naar klasse EqSyntax met één functie ===. Al deze dingen maken de indruk dat we functie === hebben toegevoegd aan klasse A zonder broncodewijziging.

We hebben niet alleen de verwijzing naar het type instantie van het type verborgen, maar bieden ook meer syntaxis van de klasse, waardoor de indruk wordt gewekt dat methode === wordt geïmplementeerd in klasse A, zelfs als we niets weten over deze klasse. Twee vogels gedood met één steen.

Nu mogen we methode === toepassen op type A wanneer we de EqSyntax-klasse hebben. Nu zal onze implementatie van pairEquals een beetje veranderen, en zal als volgt zijn.

def pairEquals [A: Eq] (a: A, b: A): Optie [(A, A)] = {
 if (a === b) Sommige ((a, b)) anders Geen
}

Zoals ik beloofde, zijn we tot een implementatie gekomen waarbij het enige zichtbare verschil met OOP-implementatie Context Bound annotation na parameter A type is. Alle technische aspecten van typeklasse zijn gescheiden van onze domeinlogica. Dat betekent dat je veel meer coole dingen kunt bereiken (die ik in het afzonderlijke artikel zal vermelden wat binnenkort zal worden gepubliceerd) zonder je code te beschadigen.

Impliciet bereik

Zoals u ziet, zijn de klassen in Scala strikt afhankelijk van het gebruik van de impliciete functie, dus is het essentieel om te begrijpen hoe u met impliciete scope werkt.

Impliciet bereik is een bereik waarin de compiler naar impliciete instanties zoekt. Er zijn veel keuzes, dus er was een behoefte om een ​​volgorde te definiëren waarin naar instanties wordt gezocht. De volgorde is als volgt:

1. Lokale en geërfde instanties
2. Geïmporteerde instanties
3. Definities van het bijbehorende object van de typeklasse of de parameters

Het is zo belangrijk omdat wanneer de compiler meerdere instanties vindt of helemaal geen instanties, dit een foutmelding geeft. Voor mij is de meest handige manier om instanties van typeklassen te krijgen door ze in het bijbehorende object van de typeklasse zelf te plaatsen. Hierdoor hoeven we ons niet bezig te houden met het importeren of implementeren van in-place instanties waardoor we locatieproblemen kunnen vergeten. Alles wordt op magische wijze geleverd door de compiler.

Laten we dus punt 3 bespreken met behulp van een voorbeeld van een bekende functie uit de standaardbibliotheek van Scala, gesorteerd op functionaliteit die is gebaseerd op impliciet aangeboden vergelijkers.

gesorteerd [B>: A] (impliciet ord: math.Ordering [B]): Lijst [A]

Type klasse instantie wordt gezocht in:
 * Metgezelobject bestellen
 * Lijst begeleidend object
 * B begeleidend object (dat ook een begeleidend object kan zijn vanwege het bestaan ​​van een ondergrensdefinitie)

schijn

Die dingen helpen veel bij het gebruik van het typeklassepatroon, maar dit is herhaalbaar werk dat in elk project moet worden gedaan. Deze aanwijzingen zijn een duidelijk teken dat het proces kan worden geëxtraheerd naar de bibliotheek. Er is een uitstekende macrogebaseerde bibliotheek genaamd Simulacrum, die alle dingen die nodig zijn voor het genereren van syntaxmodule (ops in Simulacrum genoemd) enz. Handmatig met de hand afhandelt.

De enige wijziging die we moeten aanbrengen, is de annotatie @typeclass, het kenmerk voor macro's om onze syntaxismodule uit te breiden.

simulacrum importeren._
@typeclass eigenschap Eq [A] {
 @op ("===") def areEquals (a: A, b: A): Boolean
}

De andere delen van onze implementatie vereisen geen wijzigingen. Dat is alles. Nu weet je hoe je het typeklassepatroon zelf in Scala kunt implementeren en ik hoop dat je je bewust bent geworden van hoe bibliotheken als Simulacrum werken.

Bedankt voor het lezen, ik waardeer alle feedback van je en ik kijk ernaar uit om je in de toekomst te ontmoeten met een ander gepubliceerd artikel.