en introduktion till typeclasses i Scala

  • Del 1: Det här inlägget
  • del 2: Typsäker serialisering i Scala
  • del 3: typfel hantering med högre typer

Vad är en typklass

ett av de största hindren för att förstå och använda typklasser i din egen kod är själva namnet. I motsats till vad namnet framkallar är en typklass inte en klass från ett objektorienterat språk. Enligt wikipedia:

i datavetenskap är en typklass en typsystemkonstruktion som stöder ad hoc-polymorfism. Detta uppnås genom att lägga till begränsningar för typvariabler i parametriskt polymorfa typer.

vi har lite uppackning att göra:

  • vad är ad hoc-polymorfism? Vad det än är, det är vilken typ klasser kommer att hjälpa oss att uppnå.
  • Vad är en ’parametriskt polymorf’ typ? Vad det än är, så ska vi skapa våra egna typklasser.

typklasser uppträdde först i Haskell och är ett grundläggande sätt att bygga upp abstraktioner i Haskell. De är så viktiga att de introduceras på första sidan I Learn You a Haskell-boken:

en typklass är ett slags gränssnitt som definierar något beteende. Om en typ är en del av en typklass betyder det att den stöder och implementerar det beteende som typklassen beskriver.

Ok, så det verkar som om vi försöker definiera något beteende för våra typer och implementera det beteendet annorlunda beroende på typen. Den sista delen, ”implementera beteendet annorlunda beroende på typen” är ”polymorfismen” bit från tidigare. Så termen börjar vara meningsfull: det är en klass för typer. En klass som implementerar beteendet som utsetts av typklassen sägs vara medlem i den klassen av typer — ’typklass’.

vid denna tidpunkt skulle det vara lämpligt att säga att detta låter som arv. De tillåter oss båda att bygga upp abstraktioner och ge polymorfism.

så varför utforska typklasser alls? I många fall är de ganska kraftfulla och utbyggbara än en arvshierarki. De kan vara mycket enklare och passar mycket bättre när din kodbas redan lutar FP över OO. Även om även i ett starkt OO-projekt kan typklasser fortfarande rädda dagen.

definiera en typeclass

det är tillräckligt introduktion — för resten av artikeln kommer vi att utforska en del mycket bekant funktionalitet, som faktiskt implementeras i Java via arv, och implementera den med en typklass: strängning.

vi är alla bekanta med metoden som finns på alla objekt i Java (och därmed Scala): .toString. Dess syfte är att producera en strängrepresentation av objektet i fråga. Låt oss titta på hur vi skulle använda typklasser för att uppnå detta. Haskells version av toString heter Show , så vi håller fast vid det namnet:

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

det är det: det finns vår Showable typklass. Varför tar det en typparameter A? Om vi minns vår första definition av typklasser läser vi att ” uppnås genom att lägga till begränsningar för typvariabler i parametriskt polymorfa typer.”Showable är vår parametriskt polymorfa typ, och A är vår typvariabel, vilket helt enkelt betyder att det tar en typ (A) som en typparameter. (Observera att vi inte ställde några begränsningar på A, enligt definitionen, men det är okej. Det är något vi gör när vi komponerar typklasser tillsammans eller använder dem, vilket kommer senare.)

implementera en typeclass

nu måste vi tillhandahålla implementeringar av egenskapen Showable för alla typer som vi vill kunna visa. Dessa implementeringar kallas ”instanser” av vår typklass, eftersom de bokstavligen kommer att vara instanser av egenskapen Showable. Dessa kommer oftast att vara anonyma klasser som åsidosätter .show, och eftersom det finns någon pannplatta som är involverad i att definiera dem (kom ihåg ’funktionsobjekt’ i pre-lambda Java-programmering?), definierar vi en hjälpare som heter make. Låt oss börja med en implementering för en Användarnamnstyp:

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å hur skulle vi åberopa detta? Vi kunde direkt ringa metoden från den showable instansen, som så:

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

förbättrad Syntax

vi har lyckats åberopa vår Showable implementering av UserName, men det var inte så trevligt. Vi var tvungna att ha en hänvisning till vår typklassinstans runt (defaultUserNameShowable) och känna till dess namn. Detta är något som vi vill abstrahera bort. Om historien slutade här skulle typklasser i Scala inte vara så trevliga att använda. Lyckligtvis kan vi förbättra ergonomin drastiskt med hjälp av implicits. Vi kommer att förstärka Showable’s följeslagare objekt med en invocation helper:

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

genom att placera denna ”förlängningsmetod” någonstans i omfattning kan vi helt enkelt låtsas att alla objekt har en .show – metod.

Vad har detta uppnått

så varför inte bara åsidosätta toStringUserName istället? Tja, låt oss säga UserName bor i ett bibliotek vi är beroende av, och är redan sammanställt när vi konsumerar det. Vi kan inte underklassa det eftersom det är en final – klass. Men med hjälp av typklasser är vi fria att i huvudsak ”fästa” nytt beteende på det på ett ad hoc-sätt. Detta är” ad hoc ” – delen av definitionen ovan: vi kan lägga till denna polymorfism separat från den plats där själva typen definieras.

dessutom, till skillnad från med arv, när vi definierar typklasser i Scala, behöver de inte vara ’sammanhängande’, vilket innebär att vi kan definiera flera implementeringar för Showable och välja mellan dem. För vissa typklasser är det bra, för andra inte så mycket.

i Haskell är typklasser sammanhängande. I Scala 3, enligt Martin Odersky, kommer vi att ha möjlighet att välja, för varje typklass, om det ska vara sammanhängande eller inte.

men för nu, låt oss ge en alternativ betydelse av show för UserName:

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

allt vi behöver göra nu är att vara säker på att endast en av våra Showable instanser är i omfattning åt gången. Och om inte, det är inte en stor sak, vi får en kompileringstid ’tvetydiga implicita värden’ fel:

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 viktig anmärkning är att vi inte har kopplat UserName och Showable. Allt vi har gjort är att definiera en betydelse av Showable för UserName, samtidigt som de håller dem helt oberoende av varandra. Denna frikoppling har enorma underhållsimplikationer, och hjälper till att förhindra trassliga beroendeförhållanden i hela din kod.

Kompileringstidssäkerhet

vad händer om vi försöker ringa .showpå ett objekt som vi inte har angett en betydelse för show? Med andra ord, när det inte finns någon instans av Showable i utrymme för vårt mål? Vi får helt enkelt ett kompilatorfel:

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

vi kan faktiskt anpassa detta felmeddelande när vi definierar vår typklass:

@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.

viktiga Takeaways

  • typklasser tillåter oss att definiera en uppsättning beteenden helt separat från de objekt och typer som kommer att implementera dessa beteenden.
  • typklasser uttrycks i ren Scala med egenskaper som tar typparametrar och impliciter för att göra syntaxen ren.
  • typklasser tillåter oss att utöka eller implementera beteende för typer och objekt vars källa vi inte kan ändra. (De ger den ad hoc-polymorfism som är nödvändig för att lösa uttrycksproblemet).

vad kommer nästa

beviljas, Showable är en trivial typklass, men inte utan dess användningsområden. Vi kan lätt föreställa oss mycket mer användbara, i alla fall:

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 kan vara ett bra alternativ till något som Akka serialisering där vi väntar tills runtime för att se om en serializer är registrerad för vår typ eller inte. Det är mycket säkrare att implicit skicka din serialiseringsimplementering vid kompileringstid!

Convertible kan vara ett bra återanvändbart sätt att konvertera data över olika typer. Det finns ett bibliotek av Twitter med samma mål i åtanke som kallas Bijection.

Functor är en byggsten för några extremt kraftfulla abstraktioner inom ett område med funktionell programmering som kallas ’kategorisk programmering’. Bibliotek som ScalaZ och katter bygger på typklasser som Functor. En viktig uppfattning som vi inte diskuterade den här gången är att typklasser lätt kan komponeras för att bygga upp alltmer komplext beteende från enkla byggstenar som Functor.

dessutom har många viktiga bibliotek i Scala ekosystemet (spela JSON, Slick, et. al) använda typklasser och exponera dem som huvuddrivrutin för deras API i många fall (e.g. spela JSONS Format typklass). Att förstå typklasser själv är ett bra sätt att bli effektivare för att utnyttja vad som finns där ute i Scala-ekosystemet, även om du aldrig skriver något eget.

i framtiden kommer jag att titta på några omedelbart praktiska typklasser som SafeSerializable. För en introduktion till de mer abstrakta kategoriska typklasserna som Functor] och Monad], leta inte längre än cats biblioteksdokumentationen . För en mer djupgående guide till programmering i Scala med hjälp av typklasser, den underscore.io har en fantastisk bok om ämnet: Scala med katter

del 2: Typsäker serialisering i Scala

GIST med kod

Lämna ett svar

Din e-postadress kommer inte publiceras.