”en typklass är ett gränssnitt som definierar något beteende. Mer specifikt anger en typklass en massa funktioner, och när vi bestämmer oss för att göra en typ till en instans av en typklass definierar vi vad dessa funktioner betyder för den typen.”
lär dig en Haskell för mycket bra!
källkod
källkoden för alla klasslektioner finns på följande webbadress:
- github.com/alvinj/FPTypeClasses
koden för den här lektionen finns i typenklasser.v1_humanlike paket. En anteckning om källkoden: den innehåller en extra eatHumanFood
– funktion som inte visas i exemplen som följer. Jag inkluderar den funktionen i källkoden så att du kan se hur du definierar flera funktioner i en typklass.
introduktion
boken Advanced Scala with Cats definierar en typklass som en programmeringsteknik som låter dig lägga till nytt beteende för stängda datatyper utan att använda arv och utan att ha tillgång till den ursprungliga källkoden för dessa typer. Strängt taget är detta inte en teknik som är begränsad till funktionell programmering, men eftersom den används så mycket i Cats-biblioteket vill jag visa några exempel på tillvägagångssättet här, liksom motivationen för tekniken.
katter är ett populärt FP-bibliotek för Scala.
Motivation
författarna till Advanced Scala with Cats gör en intressant observation om arv, och jag kommer att erbjuda en liten variation av den punkten.
givet en OOP Pizza
klass så här:
class Pizza(var crustSize: CrustSize, var crustType: CrustType) { val toppings = ArrayBuffer() def addTopping(t: Topping): Unit = { toppings += t } def removeTopping(t: Topping): Unit = { toppings -= t } def removeAllToppings(): Unit = { toppings.clear() }}
om du vill ändra data eller metoder i detta OOP-tillvägagångssätt, vad skulle du normalt göra? Svaret i båda fallen är att du skulle ändra den klassen.
nästa, givet en modulär FP-design med samma kod:
case class Pizza ( crustSize: CrustSize, crustType: CrustType, toppings: Seq)trait PizzaService { def addTopping(p: Pizza, t: Topping): Pizza = ??? def removeTopping(p: Pizza, t: Topping): Pizza = ??? def removeAllToppings(p: Pizza): Pizza = ???}
om du vill ändra data eller metoder i den här koden, vad skulle du normalt göra? Svaret här är lite annorlunda. Om du vill ändra data uppdaterar du klassen case
och om du vill ändra metoderna uppdaterar du koden i PizzaService.
dessa två exempel visar att det finns en skillnad i koden du måste ändra när du vill lägga till nya data eller beteenden i OOP-och FP-mönster.
typklasser ger dig en helt annan inställning. I stället för att uppdatera någon befintlig källkod skapar du typklasser för att implementera det nya beteendet.
i dessa lektioner visar jag typklassexempel så att du kan lära dig mer om tekniken i allmänhet och mer specifikt lära dig om den så att du kan förstå hur Cats library fungerar, eftersom mycket av det implementeras med typklasser.
Scala 2 typklasser har tre komponenter
Låt oss hoppa in i några exempel på hur man skapar och använder typklasser i Scala 2 så att du kan se hur de fungerar.
typklasser består av tre komponenter:
- typklassen, som definieras som ett drag som tar minst en generisk parameter (en generisk ”typ”)
- instanser av typklassen för Typer du vill utöka
- Gränssnittsmetoder som du exponerar för användare av ditt nya API
Data för det första exemplet
för det första exemplet, antar att jag har dessa befintliga datatyper:
sealed trait Animalfinal case class Dog(name: String) extends Animalfinal case class Cat(name: String) extends Animalfinal case class Bird(name: String) extends Animal
Antag nu att du vill lägga till nytt beteende i klassen Dog
. Eftersom hundar är välkända för sin förmåga att tala som människor, vill du lägga till ett nytt speak
beteende till Dog
instanser, men du vill inte lägga till samma beteende för katter eller fåglar.
om du har källkoden för dessa beteenden kan du bara lägga till den nya funktionen där. Men i det här exemplet ska jag visa hur man lägger till beteendet med en typklass.
Steg 1: typklassen
det första steget är att skapa ett drag som använder minst en generisk parameter. I det här fallet, eftersom jag vill lägga till en ”speak” – funktion, som är ett ”mänskligt liknande” beteende, definierar jag mitt drag så här:
trait BehavesLikeHuman { def speak(a: A): Unit}
med den generiska typen A
kan vi tillämpa denna nya funktionalitet på vilken typ vi vill ha. Om du till exempel vill tillämpa den på en Dog
och en Cat
kan du göra det eftersom jag har lämnat typen Generisk.
steg 2: typklassinstanser
det andra steget i processen är att skapa instanser av typklassen för de datatyper du vill förbättra. I mitt fall, eftersom jag bara vill lägga till det här nya beteendet till typen Dog
, skapar jag bara en instans, som jag definierar så här:
object BehavesLikeHumanInstances { // only for `Dog` implicit val dogBehavesLikeHuman = new BehavesLikeHuman { def speak(dog: Dog): Unit = { println(s"I'm a Dog, my name is ${dog.name}") } }}
de viktigaste punkterna om detta steg är:
- jag skapar bara en instans av
BehavesLikeHuman
för typenDog
. - jag skapade inte instanser för
Cat
ellerBird
eftersom jag inte vill att de ska ha detta beteende. - jag implementerar metoden
speak
enligt önskemål för typenDog
. - jag märker instansen som
implicit
så det kan enkelt dras in i koden som jag skriver i nästa steg. - jag sätter in koden i en
object
, främst som ett sätt att hjälpa mig att organisera den. Detta är inte så viktigt i ett litet exempel, men det är till hjälp i större, verkliga applikationer.
steg 3: API (gränssnitt)
i det tredje steget i processen skapar du de funktioner som du vill att konsumenterna av ditt API ska se. Det finns två möjliga tillvägagångssätt i detta steg:
- definiera en funktion i en
object
, precis som” Utils ” – metoden som jag beskrev i domänmodelleringslektionerna - definiera en
implicit
funktion som kan åberopas på enDog
instans
jag hänvisar till dessa tillvägagångssätt som alternativ 3a och 3b, och för konsumenter av dessa tillvägagångssätt kommer deras kod att se ut så här:
BehavesLikeHuman.speak(aDog) //3aaDog.speak //3b
jag ska visa hur man implementerar dessa tillvägagångssätt nästa, men för att vara tydlig behöver du inte implementera båda metoderna. De är två olika alternativ-konkurrenskraftiga tillvägagångssätt.
alternativ 3a: Interface Objects approach
i Advanced Scala with Cats-boken hänvisar författarna till approach 3a som” Interface Objects ” – metoden. Jag hänvisar till detta som ”explicit” tillvägagångssätt eftersom det använder funktioner i objekt, precis som ”Utils” – metoden som jag beskrev i Domänmodelleringslektionerna.
för mitt hundexempel definierar jag bara en speak
– funktion i en object
, så här:
object BehavesLikeHuman { def speak(a: A)(implicit behavesLikeHumanInstance: BehavesLikeHuman): Unit = { behavesLikeHumanInstance.speak(a) }}
eftersom speak
kan tillämpas på vilken typ som helst, behöver jag fortfarande använda en generisk typ för att definiera funktionen. Funktionen förväntar sig också att en instans av BehavesLikeHuman
ska vara i omfattning när funktionen körs, och den instansen dras in i funktionen genom den implicita parametern i den andra parametergruppen.
som konsument använder du denna 3A-metod enligt följande. Importera först dogBehavesLikeHuman
– instansen:
import BehavesLikeHumanInstances.dogBehavesLikeHuman
kom ihåg att den innehåller en speak
– metod som implementeras för en Dog
. Skapa sedan en Dog
– instans:
val rover = Dog("Rover")
slutligen kan du använda funktionen BehavesLikeHuman.speak
till instansen rover
:
BehavesLikeHuman.speak(rover)
det resulterar i denna produktion:
I'm a Dog, my name is Rover
det är sammanfattningen av det fullständiga tillvägagångssättet med alternativ 3a. som en sista punkt märker du att du också manuellt kan skicka dogBehavesLikeHuman
– instansen till den andra parametergruppen:
BehavesLikeHuman.speak(rover)(dogBehavesLikeHuman)
det är inte nödvändigt, eftersom parametern i den andra parametergruppen definieras som en implicit
variabel, men jag ville visa att du också kan skicka typen manuellt, om du föredrar det.
Observera att det slutliga resultatet av detta tillvägagångssätt är att du har en ny funktion som heter speak
som fungerar för typen Dog
. Det här är trevligt, men det verkar också som mycket arbete för att skapa en ”Utils” – funktion som du kan ansöka om en Dog
. Enligt min mening är alternativ 3b där allt detta arbete verkligen lönar sig.
alternativ 3b: Gränssnittssyntaxmetoden
som ett alternativ till alternativ 3a kan du använda ett andra tillvägagångssätt som Advanced Scala with Cats book refererar till som ”Gränssnittssyntax” – metoden. Nycklarna till detta tillvägagångssätt är:
- i slutändan kan du ringa din nya funktion som
dog.speak
- Cats-boken hänvisar till de metoder du skapar som ”förlängningsmetoder”, eftersom de utökar befintliga datatyper med de nya metoderna
- Cats-projektet hänvisar till detta som ”syntax” för typklassen
som en snabb recension, i steg 1 av Cats processen jag skapade ett drag som använder en generisk typ:
trait BehavesLikeHuman { def speak(a: A): Unit}
sedan i steg 2 skapade jag en instans av typklassen för datatypen Dog
:
object BehavesLikeHumanInstances { // only for `Dog` implicit val dogBehavesLikeHuman = new BehavesLikeHuman { def speak(dog: Dog): Unit = { println(s"I'm a Dog, my name is ${dog.name}") } }}
nu i steg 3b skapar jag den nya ”gränssnittssyntaxen” så här:
object BehavesLikeHumanSyntax { implicit class BehavesLikeHumanOps(value: A) { def speak(implicit behavesLikeHumanInstance: BehavesLikeHuman): Unit = { behavesLikeHumanInstance.speak(value) } }}
konsumenterna av detta tillvägagångssätt kommer att skriva sin kod enligt följande. Först importerar de dogBehavesLikeHuman
– instansen som tidigare:
import BehavesLikeHumanInstances.dogBehavesLikeHuman
sedan importerar de klassen implicit
inifrån objektet BehavesLikeHumanSyntax
:
import BehavesLikeHumanSyntax.BehavesLikeHumanOps
därefter skapar de en Dog
– instans som vanligt:
val rover = Dog("Rover")
slutligen är den stora skillnaden mellan alternativ 3a och 3b att med alternativ 3b kan de åberopa dina metoder direkt på rover
– instansen, så här:
rover.speak
detta visar fördelen med allt arbete som leder fram till denna punkt: Dog
– instansen har en ny speak
– metod. Om du inte hade tillgång till den ursprungliga Dog
källkoden skulle det vara en stor vinst. Mer allmänt visar det en trestegsprocess som du kan använda för att lägga till ny funktionalitet i alla ”stängda” klasser.
Obs: som jag nämnde tidigare har källkoden för den här lektionen en extra
eatHumanFood
– funktion som visar hur man definierar flera funktioner i en typklass.
viktiga punkter
som visat består en typklass av tre komponenter:
- typklassen själv, som definieras som ett drag som tar minst en generisk parameter
- instanser av typklassen för de datatyper du vill utöka
- Gränssnittsmetoder som du exponerar för användare av ditt nya API
fördelarna med att använda en typklass är:
- det ger ett tillvägagångssätt som låter dig lägga till nytt beteende i befintliga klasser utan att använda traditionell arv, särskilt om du inte kan (eller inte vill) ändra den befintliga källkoden för befintliga datatyper