Marcin Rzeźnicki
Scala Animal
mrzeznicki@virtuslab.com
Logika biznesowa napisana w "Scavie" - Java z trochę innymi słowami kluczowymi. Zazwyczaj:
Logika utopiona w kodzie - niewidoczny flow (nawet jeżeli kod nie jest fatalny)
Dostałem taki komentarz:
I know this is a bit of a religious thing in FP, but personally I don't think there is anything wrong with throwing exceptions. Of all the problems I have encountered in the years of programming, having to deal with exceptions and throwing exceptions was never a core problem. Throwing and catching exceptions is well defined even if it escapes explicit/compile-time types (so is pattern matching).
Realistyczny przykład takiego stylu (to kiedyś był prawdziwy kod produkcyjny)
Jeśli chcemy używać FP, tego typu API nie ma sensu
Chcemy nie tylko wyeliminować problemy - ale również zmienić kod na czysto funkcyjny
Wszystkie metody napisane są wg tego samego wzorca: pobierz dane z bazy, sprawdź poprawność, zmodyfikuj encję, zapisz do bazy.
Czy kompozycja funkcji będzie w sam raz? Każda metoda może być przepisana jako: getFromDb andThen verify andThen copy andThen save.
Pozostaje problem z unit oraz wyjątki - potrzeba potężniejszej abstrakcji
Wikipedia:
Arrows are a type class used in programming to describe computations in a pure and declarative fashion. [Arrows] provide a referentially transparent way of expressing relationships between logical steps in a computation. Unlike monads, arrows don’t limit steps to having one and only one input.
Haskell wiki:
Arrow a b c represents a process that takes as input something of type b and outputs something of type c.
W praktyce: można rozumieć strzałki jako typ rozszerzający funkcję (=>) o dodatkowe kombinatory (poza compose i andThen)
Komponowanie strzałek jest bardziej ekspresywne i może zawierać bogatą semantykę np. komponowanie obliczeń monadycznych
final class ArrowOps[=>:[_, _], A, B](val self: A =>: B)(implicit val arr: Arrow[=>:]) {
def >>>[C](fbc: B =>: C): A =>: C = arr.compose(fbc, self)
def <<<[C](fca: C =>: A): C =>: B = arr.compose(self, fca)
def ***[C, D](g: C =>: D): (A, C) =>: (B, D) = arr.merge(self, g)
def &&&[C](fac: A =>: C): A =>: (B, C) = arr.split(self, fac)
}
Part of my problem with Arrows is the names most people give the functions. andThen and orElse I understand; their names reflect their meaning in some way. *** and &&& not so much.
Interesting read, except for accumulating =>:, >>>, <<<, ***, &&&, >>=, >=>, >==>, <=<, <==<. Looks like ancient sbt to me ;)
Yeah, well, there are two camps of thought on symbolic operator. It's love or hate, nothing in between :-) I tend to like them because they're concise, but I totally understand people who find them cryptic. At least one could argue that things like '***' have somewhat established meaning and that helps to avoid cognitive overload, don't they?
I have no objection to both being available.
implicit object FunctionArrow extends Arrow[Function1] {
override def id[A]: A => A = identity[A]
override def arr[A, B](f: (A) => B): A => B = f
override def compose[A, B, C](fbc: B => C, fab: A => B): A => C =
fbc compose fab
override def first[A, B, C](f: A => B): ((A, C)) => (B, C) = prod =>
(f(prod._1), prod._2)
override def second[A, B, C](f: A => B): ((C, A)) => (C, B) = prod =>
(prod._1, f(prod._2))
override def merge[A, B, C, D](f: (A) => B, g: (C) => D): ((A, C)) => (B, D) = {
case (x, y) => (f(x), g(y))
}
}
private def productionLotArrow[Env](verify: (ProductionLot, Env) => Unit,
copy: (ProductionLot, Env) => ProductionLot):
(Long, Env) => Long = {
val verifyProductionLotNotDoneF: ((ProductionLot, Env)) => Unit = {
case (pl, _) => verifyProductionLotNotDone(pl)
}
Function.untupled(
(productionLotsRepository.findExistingById _ *** identity[Env])
>>> ((verify.tupled &&& verifyProductionLotNotDoneF)
&&& (copy.tupled >>> productionLotsRepository.save))
>>> (_._2)
)
}
private def verifyProductionLotNotDone(productionLot: ProductionLot): Unit =
require(productionLot.status != ProductionLotStatus.Done,
"Attempt to operate on finished production lot")
Kolejny komentarz:
And so it happens that the original half-functional code looks the most readable and easy to grasp version. And yeah, I can copy and paste that to insert another verification…
Obsługa błędów staje się częścią control flow.
Umożliwia bycie type-safe, pure etc. mimo przykrej konieczności obsługiwania błędów.
Przepiszmy wszystkie miejsca gdzie chcemy "rzucić" wyjątek z użyciem Either.
Możemy stworzyć ADT do reprezentowania różnych przypadków
Strzałka się nie kompiluje :-(
Nie ma tego jak naprawić, bo kompozycja funkcji zwracających Either jest średnio wykonalna (każda składowa funkcja musiałaby umieć "odpakować" rezultat z Either - masa boilerplate'u).
Monads do not compose
Abstrakcja strzałki jest w stanie wyrazić wiele "typów" obliczeń. Nie ma żadnych ograniczeń co do typów na jakich operujemy - musimy umieć tylko wyrazić obliczenia na tych typów za pomocą kombinatorów first/second oraz compose
Funkcje A => B są ewidentnie niewystarczające w obliczu monady, ale istnieją inne abstrakcje, w których monadyczne obliczenie jest łatwo wyrażalne.
Są one znane jako ...
Mówiąc językiem Scali:
Strzałki Kleisliego są implementacją klasy typów (typeclass) strzałek w oparciu o funkcje A => M[B], gdzie M jest instancją monady
trait Monad[M[_]] {
def point[A](a: => A): M[A]
def bind[A, B](ma: M[A])(f: A => M[B]): M[B]
def fmap[A, B](ma: M[A])(f: A => B): M[B]
}
final class MonadOps[M[_], A](val self: M[A])(implicit val monad: Monad[M]) {
def >>=[B](f: A => M[B]) = monad.bind(self)(f)
}
object Monad extends MonadInstances {
implicit def ToMonadOps[M[_], A](v: M[A])(implicit m: Monad[M]): MonadOps[M, A] =
new MonadOps(v)
}
Either w scala.util nie jest monadą:
Wprawdzie scala.util.Either nie jest monadą, ale bardzo łatwo można to naprawić odpowiednio implementując instancję klasy typów (albo używamy scalaz). Oba problemy załatwiamy za jednym posunięciem:
implicit def eitherMonad[L] = new Monad[({ type λ[β] = Either[L, β] })#λ] {
override def point[A](a: => A): Either[L, A] = Right(a)
override def bind[A, B](ma: Either[L, A])(f: (A) => Either[L, B]): Either[L, B] =
ma.right.flatMap(f)
override def fmap[A, B](ma: Either[L, A])(f: (A) => B): Either[L, B] =
ma.right.map(f)
}
Type lambdy
- Type lambdas are cool and all, but not a single line of the compiler was ever written with them in mind. They’re just not going to work right: the relevant code is not robust.
implicit def ToArrowOpsFromKleisliLike[G[_], F[G[_], _, _], A, B](v: F[G, A, B])(implicit
arr: Arrow[({ type λ[α, β] = F[G, α, β] })#λ]) =
new ArrowOps[({ type λ[α, β] = F[G, α, β] })#λ, A, B](v)
Składnię można naprawić, dzięki projektowi kind-projector. Polecany plugin do kompilatora, dla lubiących "zabawę z typami":
- implicit def eitherMonad[L] = new Monad[({ type λ[β] = Either[L, β] })#λ] {
+ implicit def eitherMonad[L] = new Monad[Either[L, ?]] {
type E[R] = Either[Error, R]
private def productionLotArrow[Env](verify: (ProductionLot, Env) => E[ProductionLot],
copy: (ProductionLot, Env) => ProductionLot):
Env => Long => E[Long] = {
val verifyProductionLotNotDoneF: (ProductionLot) => E[ProductionLot] =
verifyProductionLotNotDone
(env: Env) => (
Kleisli[E, Long, ProductionLot]{ productionLotsRepository.findExistingById }
>>> Kleisli { verify(_, env) }
>>> Kleisli { verifyProductionLotNotDoneF }
).map(copy(_, env)) >==> productionLotsRepository.save _
}
private def productionLotArrow[Env](verify: (ProductionLot, Env) => Either[Error, ProductionLot],
copy: (ProductionLot, Env) => ProductionLot):
Env => Long => Either[Error, Long] = {
type Track[T] = Either[Error, T]
def track[A, B](f: A => Track[B]) = Kleisli(f)
val getFromDb = track { productionLotsRepository.findExistingById }
val validate = (env: Env) => track { verify(_: ProductionLot, env) }
>>> track { verifyProductionLotNotDone }
val save = track { productionLotsRepository.save }
(env: Env) => (
getFromDb
>>> validate(env)
).map(copy(_, env)) >>> save
}
SI-2712 - bug, którego numer pamiętam:
czemu musimy używać aliasu Kleisli[Track, A, B]?
[error] — because —
[error] argument expression’s type is not compatible with formal parameter type;
[error] found : org.virtuslab.blog.kleisli.ProductionLot =>
Either[org.virtuslab.blog.kleisli.Error,org.virtuslab.blog.kleisli.ProductionLot]
[error] required: ?A => ?M[?B]
SI-2712 - bug, którego numer pamiętam:
to czemu nie napisaliśmy np.
Kleisli[Either[Error, ?], Long, ProductionLot]?
[info] — because —
[info] argument expression’s type is not compatible with formal parameter type;
[info] found :
org.virtuslab.blog.kleisli.Kleisli[[R]scala.util.Either[org.virtuslab.blog.kleisli.Error,R],Long,org.virtuslab.blog. kleisli.ProductionLot]
[info] required: ?F[?G, ?A, ?B]
SI-2712 - bug, którego numer pamiętam:
ale przecież istnieje unifikacja!
F[_, _, _] / Kleisli,
G[_] / [R]scala.util.Either[org.virtuslab.blog.kleisli.Error,R],
A / Long,
B/ ProductionLot
Problematyczna operacja z funkcjonalnego punktu widzenia:
Strzałka wyboru: ekwiwalent if w for-comprehension.
Dodatkowe kombinatory:
Przykładowa implementacja strzałki wyboru dla funkcji
implicit object FunctionArrow extends Arrow[Function1] with Choice[Function1] {
// ...
override def left[A, B, C](f: (A) => B): (Either[A, C]) => Either[B, C] =
_.left.map(f)
override def right[A, B, C](f: (A) => B): (Either[C, A]) => Either[C, B] =
_.right.map(f)
override def multiplex[A, B, C, D](f: (A) => B, g: (C) => D):
(Either[A, C]) => Either[B, D] = {
case Left(a) => Left(f(a))
case Right(c) => Right(g(c))
}
override def fanin[A, B, C](f: (A) => C, g: (B) => C): (Either[A, B]) => C =
_.fold(f, g)
}
private val logger = Logger.getLogger(this.getClass.getName)
private def productionLotArrow[Env](verify: (ProductionLot, Env) => Either[Error, ProductionLot],
copy: (ProductionLot, Env) => ProductionLot,
log: (ProductionLot, Env) => Unit):
Env => Long => Either[Error, Long] = {
type Track[T] = Either[Error, T]
def track[A, B](f: A => Track[B]) = Kleisli[Track, A, B](f)
val getFromDb = track { productionLotsRepository.findExistingById }
val validate = (env: Env) => track { verify(_: ProductionLot, env) }
>>> track { verifyProductionLotNotDone }
val save = track { productionLotsRepository.save }
val logError: Error => Unit = error =>
logger.warning(s"Cannot perform operation on production lot: $error")
val logSuccess: Long => Unit = id =>
logger.fine(s"Production lot $id updated")
(env: Env) =>
((
getFromDb -| (log(_, env))
>>> validate(env)).map(copy(_, env))
>>> save)
.run -| (logError ||| logSuccess)
}