en introduktion til typeklasser i Scala

  • Del 1: Dette indlæg
  • Del 2: Type-Safe serialisering i Scala
  • Del 3: Typeful fejlhåndtering med højere kinded typer

Hvad er en type klasse

en af de største barrierer for at forstå og bruge type klasser i din egen kode er selve navnet. I modsætning til hvad navnet fremkalder, er en typeklasse ikke en klasse fra et objektorienteret sprog. Ifølge :

i datalogi er en type klasse en type systemkonstruktion, der understøtter ad hoc polymorfisme. Dette opnås ved at tilføje begrænsninger for at skrive variabler i parametralt polymorfe typer.

vi har noget udpakning at gøre:

  • Hvad er ad hoc polymorfisme? Uanset hvad det er, det er, hvad type klasser vil hjælpe os med at opnå.
  • Hvad er en ‘parametralt polymorf’ type? Uanset hvad det er, er det sådan, vi skal oprette vores egne typeklasser.

Type klasser først dukkede op i Haskell, og er en grundlæggende måde man opbygger abstraktioner i Haskell. De er så vigtige, at de introduceres på den første side af Lær dig en Haskell-bog:

en type klasse er en slags grænseflade, der definerer en vis adfærd. Hvis en type er en del af en type klasse, betyder det, at den understøtter og implementerer den adfærd, som type klassen beskriver.

Ok, så det ser ud til, at vi forsøger at definere nogle adfærd for vores typer og implementere denne adfærd forskelligt afhængigt af typen. Den sidste del, “implementere adfærd forskelligt afhængigt af typen” er ‘polymorfisme’ bit fra tidligere. Så udtrykket begynder at give mening: det er en klasse for typer. En klasse, der implementerer den adfærd, der er udpeget af typen klasse siges at være medlem af denne klasse af typer — ‘type klasse’.

på dette tidspunkt ville det være passende at sige, at dette lyder som arv. De giver os begge mulighed for at opbygge abstraktioner og give polymorfisme.

så hvorfor udforske type klasser overhovedet? I mange tilfælde er de ganske lidt mere magtfulde og udvidelige end et arvshierarki. De kan være meget enklere, og passer meget bedre, når din kodebase allerede læner FP over oo. Selvom selv i et stærkt oo-projekt, kan typeklasser stadig redde dagen.

definition af en typeklasse

det er nok introduktion — for resten af artiklen vil vi udforske et stykke meget velkendt funktionalitet, som faktisk implementeres i Java via arv, og implementere det ved hjælp af en type klasse: stringification.

vi er alle bekendt med den metode, der findes på alle objekter i Java (og dermed Scala): .toString. Dens formål er at producere en strengrepræsentation af det pågældende objekt. Lad os se på, hvordan vi ville bruge typeklasser til at opnå dette. Haskells version af toString hedder Show, så vi holder os groft med det navn:

trait Showable {
def show(a: A): String
}

det er det: der er vores Showable type klasse. Hvorfor tager det en type parameter A? Hvis vi husker vores første definition af typeklasser, læser vi, at ” opnået ved at tilføje begrænsninger for at skrive variabler i parametralt polymorfe typer.”Showable er vores parametralt polymorfe type, og A er vores typevariabel, hvilket simpelthen betyder, at det tager en type (A) som en typeparameter. (Bemærk, at vi ikke har lagt nogen begrænsninger på A , som pr. Det er noget, vi gør, når vi komponerer typeklasser sammen eller bruger dem, som kommer senere.)

implementering af en typeclass

nu skal vi levere implementeringer af Showable træk for alle typer, som vi ønsker at kunne vise. Disse implementeringer kaldes” forekomster ” af vores type klasse, da de bogstaveligt talt vil være forekomster af egenskaben Showable. Disse vil oftest være anonyme klasser, der tilsidesætter .show, og da der er noget kedelplade involveret i at definere dem (husk ‘funktionsobjekter’ i pre-lambda Java programmering?), definerer vi en hjælper kaldet make. Lad os starte med en implementering af en Brugernavnetype:

object Showable {
def make(showFn: A => String): Showable = new Showable {
override def show(a: A): String = showFn(a)
}
}final case class UserName(first: String, last: String)implicit val defaultUserNameShowable = Showable.make(userName => s"${userName.first} ${userName.last}")val janeName = UserName("Jane", "Doe")

så hvordan ville vi påberåbe sig dette? Vi kunne direkte kalde metoden fra den synlige instans, som sådan:

defaultUserNameShowable.show(janeName) // "Jane Doe"

forbedret syntaks

vi har formået at påberåbe os vores Showable implementering af UserName, men det var ikke særlig rart. Vi var nødt til at have en henvisning til vores type klasse forekomst omkring (defaultUserNameShowable) og kender dens navn. Dette er noget, vi ønsker abstraheret væk. Hvis historien sluttede her, ville skriveklasser i Scala ikke være så rart at bruge. Heldigvis kan vi forbedre ergonomien drastisk ved hjælp af implicits. Vi vil udvide Showable‘s følgesvend objekt med en invocation helper:

implicit class ShowSyntax(a: A) extends AnyVal {
def show(implicit showable: Showable): String = showable.show(a)
}
...
janeName.show

ved at placere denne “udvidelsesmetode” et eller andet sted i omfang, kan vi simpelthen foregive, at alle objekter har en .show metode.

Hvad har dette opnået

så hvorfor ikke bare tilsidesætte toStringUserName i stedet? Nå, lad os sige UserName bor i et bibliotek, Vi er afhængige af, og er allerede udarbejdet af den tid, vi bruger det. Vi kan ikke underklasse det, da det er en final klasse. Men ved hjælp af typeklasser er vi fri til i det væsentlige at” vedhæfte ” ny adfærd til det på ad hoc-måde. Dette er” ad hoc ” stykke af definitionen ovenfor: vi kan tilføje denne polymorfisme separat fra det sted, hvor selve typen er defineret.

derudover, i modsætning til arv, når vi definerer typeklasser i Scala, er de ikke forpligtet til at være ‘sammenhængende’, hvilket betyder, at vi kan definere flere implementeringer for Showable og vælge mellem dem. For nogle typer klasser er dette en god ting, for andre ikke så meget.

i Haskell er typeklasser sammenhængende. I Scala 3 vil vi ifølge Martin Odersky have evnen til at vælge for hver type klasse, om den skal være sammenhængende eller ej.

men for nu, lad os give en alternativ betydning af show for UserName:

implicit val secretUserNameShowable = Showable.make(_ => "<secret person>")

alt, hvad vi skal gøre nu, er at være sikker på, at kun en af vores Showable tilfælde er i omfang ad gangen. Og hvis ikke, er det ikke en big deal, vi får en kompileringstid ‘tvetydige implicitte værdier’ fejl:

janeName.show
> ambiguous implicit values:
both value secretUserNameShowable in object typeclasses of type => Showable
and value defaultUserNameShowable in object typeclasses of type => Showable
match expected type Showable

en vigtig note er, at vi ikke har koblet UserName og Showable. Alt, hvad vi har gjort, er at definere en betydning af Showable for UserName, mens vi holder dem helt uafhængige af hinanden. Denne afkobling har enorme implikationer for vedligeholdelse, og hjælper med at forhindre sammenfiltrede afhængighedsforhold i hele din kode.

Compile-time sikkerhed

Hvad sker der, hvis vi forsøger at kalde .showpå et objekt, som vi ikke har givet en betydning af show? Med andre ord, når der ikke er nogen forekomst af Showable i omfang for vores mål? Vi får simpelthen en compiler fejl:

java.util.UUID.randomUUID().show
> could not find implicit value for parameter showable: Showable

vi kan faktisk tilpasse denne fejlmeddelelse, når vi definerer vores type klasse:

@scala.annotation.implicitNotFound("No Showable in scope for A = ${A}. Try importing or defining one.")
trait Showable {
...
}
...
janeName.show
> No Showable in scope for A = java.util.UUID. Try importing or defining one.

nøgle grillbarer

  • Typeklasser giver os mulighed for at definere et sæt adfærd helt adskilt fra de objekter og typer, der vil implementere denne adfærd.
  • Typeklasser udtrykkes i ren Scala med træk, der tager typeparametre, og implicerer for at gøre syntaksen ren.
  • Typeklasser giver os mulighed for at udvide eller implementere adfærd for typer og objekter, hvis kilde vi ikke kan ændre. (De giver den ad hoc-polymorfisme, der er nødvendig for at løse ekspressionsproblemet).

Hvad kommer næste

indrømmet, Showable er en triviel type klasse, men ikke uden dens anvendelser. Vi kan let forestille os langt mere nyttige, imidlertid:

trait SafeSerializable {
def serialize(a: A): Array
def deserialize(bytes: Array): A
}
trait Convertible {
def to(a: A): B
def from(b: B): A
}
trait Functor] {
def map(a: F, f: A => B): F
}

SafeSerializable kunne være et godt alternativ til noget som Akka serialisering, hvor vi venter til runtime for at se, om en serialiserer er registreret for vores type. Det er meget sikrere at implicit videregive din Serialiseringsimplementering på kompileringstidspunktet!

Convertible kunne være en god genanvendelig måde at konvertere data på tværs af typer. Der er et bibliotek af kvidre med det samme mål i tankerne kaldet Bijektion.

Functor er en byggesten for nogle ekstremt kraftige abstraktioner i et område med funktionel programmering kaldet ‘kategorisk programmering’. Biblioteker som Scalas og katte er bygget på typeklasser som Functor. En vigtig forestilling, som vi ikke diskuterede denne gang, er, at typeklasser let kan sammensættes for at opbygge stadig mere kompleks adfærd fra enkle byggesten som Functor.

derudover er mange vigtige biblioteker i Scala-økosystemet (Play JSON, Slick, et. al) gøre brug af typen klasser og udsætte dem som den vigtigste drivkraft for deres API i mange tilfælde (e.G. spil JSONS Format type klasse). At forstå typeklasser selv er en fantastisk måde at blive mere effektiv til at gøre brug af det, der er derude i Scala-økosystemet, selvom du aldrig skriver noget af dit eget.

i fremtiden vil jeg se på nogle umiddelbart praktiske type klasser som SafeSerializable. For en introduktion til de mere abstrakte kategoriske typeklasser som Functor] og Monad], skal du ikke lede længere end cats biblioteksdokumentationen . For en mere dybdegående guide til programmering i Scala ved hjælp af typeklasser, den underscore.io har en fantastisk bog om emnet: Scala med katte

Del 2: Type-sikker serialisering i Scala

GIST med kode

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.