Cu toate că pare un concept foarte complicat, Monad Transformer se bazează pe concepte de bază: în mod specific, functorul [7] și monada [8]. Aceste concepte sunt esențiale pentru orice programator funcțional. Având cunoștințele necesare, nu este dificil de înțeles ce este un Monad Transformer și în ce situații s-ar putea folosi.
Folosim monade pentru a controla efectele într-un program pur funcțional. Cu aceste monade, putem să înlănțuim mai multe funcții care produc un anumit tip de efect, de exemplu IO
într-un mod riguros și type-safe.
Un program în general produce mai multe efecte, precum comunicarea cu un proces extern, erori și modificarea unei stări interne. Toate aceste efecte sunt suprapuse și înlănțuirea lor cu alte procese poate fi foarte complexă și greu de înțeles. Scopul Monad Transformerilor este stivuirea monadelor în cazuri în care producem mai multe efecte diferite. Astfel, înlănțuirea lor se simplifică.
Această structură poate fi întâlnită uneori în cod: ceva.map(_.map(f))
. De exemplu dacă avem un Option care conține o listă de numere și vrem să incrementăm toate numerele:
numberListOption.map(_.map(_ + 1))
În exemplul de sus avem doi functori imbricați. Ar fi o soluție mai elegantă să avem un map special, care s-ar comporta ca două mapuri din exemplul de mai sus. Din fericire, putem să compunem functorii folosind funcția compose:
def compose[F[_]: Functor,
G[_]: Functor]:
Functor[F[G[_]]]
val optionList = Functor[Option].
compose(Functor[List])
val data: Option[List[Int]] =
Some(List(1, 2, 3))
optionList.map(data)(_ + 1)
// == Some(List(2, 3, 4))
După aplicare, obținem un singur functor, și codul de mai sus devine: ceva.map(f)
, având aceiași funcționalitate ca exemplul original.
Doi functori se pot compune, însă acest lucru nu este posibil la monade [5]. Totuși există o cale prin care putem combina mai multe tipuri de monade, dacă creăm o structură nouă. Acea structură este desigur Monad Transformer.
Din moment ce nu se poate generaliza combinarea monadelor, nu există o variantă de monad transformer general, ci e nevoie să fie implementat câte o versiune de monad transformer pentru fiecare monadă pe care o vrem să folosim. Din fericire, există implementări pentru toate monadele definite în cats [4], și le putem combina fără probleme.
Un monad transformer este o monadă care conține o monadă. Zicem că modifică o monadă, sau adaugă un efect la o monadă. De exemplu, aceasta este definiția monad transformerului EitherT
din librăria cats:
final case class EitherT[F[_], A, B]
(value: F[Either[A, B]])
Unde F[_]
este o monadă. De regulă, F
reprezintă ori alt Monad Transformer, ori monada Id
sau IO
.
Dacă alegem ca F
să fie Id
, EitherT[Id, A, B]
este echivalent cu Either[A, B]
prin definiția tipului Id
din cats: type Id[A] = A
.
De obicei IO
este monada de bază, care este extinsă cu monad transformere.
EitherT[IO, Error, A]
înseamnă că avem un efect care poate produce erori. Putem extinde mai mult, dacă de exemplu avem și un efect de colectarea logurilor ca o listă de stringuri:
WriterT[EitherT[IO, Error, ?], List[String], A]
Această reprezentare cu semnul întrebării în Scala este posibilă doar folosind pluginul Kind Projector [9].
Primul parametru din WriterT
trebuie să fie F[_]
, așa că va trebui să aplicăm parțial constructorul lui EitherT
. Un alt fel de a exprima acest lucru ar fi astfel:
type G[A] = EitherT[IO, Error, A] WriterT[G, List[String], A]
S-ar putea considera, practic vorbind, că un monad transformer este doar un wrapper peste tipurile de genul F[G[A]]
, unde F
și G
sunt monade, în primul exemplu F[Either[A, B]]
devine EitherT[F, A, B]
. Din convenție, un monad transformer se termină cu litera mare T. Așa se diferențiază varianta monad transformer a unei monade. De exemplu, Either
și EitherT
, State
și StateT
etc.
Nu există variantă de monad transformer pentru IO
. De aceea, el este de obicei monada de bază.
Dacă programăm, probabil codul nostru produce efecte. Un mod de a controla efectele este folosirea monadelor. De exemplu, monadele IO
, Option
, Either
etc. . Putem să avem mai multe funcții care produc efecte, și putem să le conectăm folosind monade având siguranța respectării tipurilor.
Însă codul poate produce mai multe efecte deodată. De exemplu, unele computații pot comunica cu procese externe și pot returna valori, dar și erori. Așa un proces ar avea o semnătură precum IO[Either[Error, Value]]
.
Să presupunem că avem două funcții:
def getUserByName(name: String): IO[User]
def getEmployerOfUser(user: User): IO[Employer]
Pentru a conecta cele două funcții, am putea folosi flatMap
:
getUserByName(name).flatMap(getEmployerOfUser)
Sau am putea folosi:
for {
user ← getUserByName(name)
employer ← getEmployerOfUser(user)
} yield employer
Logica este simplă și ușor de urmărit.
Dar dacă putem avea și erori? Semnăturile funcțiilor de mai sus se vor schimba:
def getUserByName(name: String):
IO[Either[Error, User]]
def getEmployerOfUser(user: User):
IO[Either[Error, Employer]]
Combinația lor folosind flatMap:
getUserByName(name).flatMap {
case e@Left(_) ⇒ IO.pure(e)
case Right(a) ⇒ getEmployerOfUser(a)
}
și folosind:
for {
userEither ← getUserByName(name)
employer ← userEither match {
case e@Left(_) ⇒ IO.pure(e)
case Right(a) ⇒ getEmployerOfUser(a)
}
} yield employer
Logica de înlănțuire devine mai complexă. Codul se complică, mai ales dacă mai avem un efect sau dacă avem mai multe funcții. Totodată este mai greu de văzut logica de business. Ceea ce este un mare dezavantaj, mai ales pentru programatorii care folosesc limbaje funcționale și se mândresc de abilitatea de a crea limbaje specifice de domeniu (DSL).
O soluție care rezolvă complexitatea înlănțuirii în exemplul nostru ar fi folosirea Monad Transformerelor. Vom folosi EitherT[IO, Error, A]
. Pentru a face codul un pic mai lizibil, vom folosi un type alias:
type Result[A] = EitherT[IO, Error, A]
Astfel funcțiile noastre se transformă în:
def getUserByName(name: String): Result[User]
def getEmployerOfUser(user: User): Result[Employer]
Și putem să le înlănțuim ca înainte:
getUserByName(name).flatMap(getEmployerOfUser)
sau
for {
user ← getUserByName(name)
employer ← getEmployerOfUser(user)
} yield employer
Adevărul este că Monad Transformer poate fi o unealtă folositoare, dar vine cu costuri.
Un cost ar fi performanța. Creăm și distrugem obiecte la fiecare operație. Acest lucru înseamnă inevitabil că avem un cost de memorie dar și de calcul.
Un alt dezavantaj este că un monad transformer poate complica înlănțuirea funcțiilor în cazuri în care funcțiile nu au toate același tipuri de efecte. Să considerăm exemplul cu cele două funcții de mai sus: getUserByName
, getEmployerOfUser
. În exemplul de sus, am presupus implicit că avem un nume cu care apelăm getUserByName. Dar, dacă citim numele de la tastatură sau primim un json cu nume, vom avea o funcție getName
:
def getName: IO[String]
Problema este că rezultatul funcției getName este un String, nu un Either. De aceea, nu putem pur și simplu să îl legăm de celelalte funcții. Direct legat de acest aspect, există metoda liftF. Lift ne permite să transformăm orice functor în monad transformerul nostru. Mai jos avem exemplul pentru EitherT
:
def liftF[F[_]: Functor, A, B](fb: F[B]): EitherT[F, A, B]
Astfel, dacă includem getName, for comprehension devine:
for {
name ← EitherT.liftF(getName)
user ← getUserByName(name)
employer ← getEmployerOfUser(user)
} yield employer
Structura aceasta nu pare foarte complicată, dar se adaugă complexitate și se alterează performanța. Acest aspect s-ar vedea mai ales în situația în care avem mai multe funcții care au doar efect IO, și unele care au și eroare. Dacă vrem să folosim monad transformere atunci, va trebui să liftăm toate acele funcții. Acest lucru nu ar fi elegant, dar ce e mai important este că ajungem înapoi la complexitatea înlănțuirii pe care am vrut să o evităm de la început.
Monad transformer poate fi o unealtă foarte folositoare pentru orice programator funcțional, cât timp acel programator știe când e bine să fie folosit, și când nu e bine. Se folosește cel mai eficient în zone localizate de cod, avantajând clar lizibilitatea codului. Dar se evită în cazuri mai complexe.
Nu se recomandă folosirea lor în situația în care este prioritară performanța, la fel ca atunci când avem un număr de efecte mai mare de doi sau trei, pentru că există riscul apariției de funcții care produc doar un singur sau două efecte. Acest lucru înseamnă că vor apăra o mulțime de liftFuri în cod.
Monad transformer este un concept foarte puternic și folositor, în contextul în care avem funcții similare care produc același efecte, iar claritatea codului este mai importantă decât viteza execuției.