2. Konstrukcja for
for comprehension to idealna abstrakcja do pisania funkcyjnych programów komunikujących się ze światem.
Ponieważ będziemy używać jej bardzo często, poświęcimy chwilę na przypomnienie sobie zasad jej działania, a
przy okazji zobaczymy, jak Scalaz może pomóc nam pisać czystszy kod.
Ten rozdział nie skupia się na programowaniu czysto funkcyjnym, a techniki w nim opisane znajdą zastosowanie również w zwykłych aplikacjach.
2.1 Wzbogacona składnia
Konstrukcja for w Scali jest prostą regułę przepisania (rewrite rule), zwaną również cukrem składniowym (syntactic sugar),
i nie wnosi żadnych dodatkowych informacji do naszego programu.
Aby zobaczyć, co tak naprawdę robi for, użyjemy funkcji show i reify dostępnych w REPLu. Dzięki nim możemy
wypisać kod w formie, jaką przyjmuje po inferencji typów (type inference).
scala> import scala.reflect.runtime.universe._
scala> val a, b, c = Option(1)
scala> show { reify {
for { i <- a ; j <- b ; k <- c } yield (i + j + k)
} }
res:
$read.a.flatMap(
((i) => $read.b.flatMap(
((j) => $read.c.map(
((k) => i.$plus(j).$plus(k)))))))
Widzimy dużo szumu spowodowanego dodatkowymi wzbogaceniami (np. + jest przepisany jako $plus itp.).
Dla zwiększenia zwięzłości w dalszych przykładach pominiemy wywołania show oraz reify, kiedy linia rozpoczyna się
od reify>. Dodatkowo generowany kod poddamy ręcznemu oczyszczeniu, aby nie rozpraszać czytelnika.
reify> for { i <- a ; j <- b ; k <- c } yield (i + j + k)
a.flatMap {
i => b.flatMap {
j => c.map {
k => i + j + k }}}
Zasadą kciuka jest, że każdy <- (zwany generatorem) jest równoznaczny z zagnieżdżonym wywołaniem flatMap,
z wyjątkiem ostatniego, który jest wywołaniem funkcji map, do której przekazane zostaje ciało bloku yield.
2.1.1 Przypisania
Możemy bezpośrednio przypisywać wartości do zmiennych za pomocą wyrażeń typu ij = i + j (słowo kluczowe val nie jest wymagane).
reify> for {
i <- a
j <- b
ij = i + j
k <- c
} yield (ij + k)
a.flatMap {
i => b.map { j => (j, i + j) }.flatMap {
case (j, ij) => c.map {
k => ij + k }}}
Wywołanie map na b wprowadza zmienną ij, która jest flat-mapowana razem z j, a na końcu
wołane jest ostateczne map wykorzystujące kod z bloku yield.
Niestety nie możemy deklarować przypisań przed użyciem generatora. Funkcjonalność ta została zasugerowana, ale nie została jeszcze zaimplementowana: https://github.com/scala/bug/issues/907
scala> for {
initial = getDefault
i <- a
} yield initial + i
<console>:1: error: '<-' expected but '=' found.
Możemy obejść to ograniczenie poprzez zadeklarowanie zmiennej poza konstrukcją for
scala> val initial = getDefault
scala> for { i <- a } yield initial + i
lub poprzez stworzenie Option z pierwotnej wartości
scala> for {
initial <- Option(getDefault)
i <- a
} yield initial + i
2.1.2 Filtrowanie
Możemy umieścić wyrażenie if za generatorem, aby ograniczyć wartości za pomocą predykatu
reify> for {
i <- a
j <- b
if i > j
k <- c
} yield (i + j + k)
a.flatMap {
i => b.withFilter {
j => i > j }.flatMap {
j => c.map {
k => i + j + k }}}
Starsze wersje Scali używały metody filter, ale ponieważ Traversable.filter tworzy nową kolekcję dla każdego predykatu,
wprowadzono metodę withFilter jako bardziej wydajną alternatywę. Musimy uważać, aby przypadkowo nie wywołać withFilter, podając informację
co do oczekiwanego typu, którą kompilator interpretuje jako pattern matching.
reify> for { i: Int <- a } yield i
a.withFilter {
case i: Int => true
case _ => false
}.map { case i: Int => i }
Podobnie jak w przypadku przypisywania wartości do zmiennych, generatory mogą używać pattern matchingu po swojej lewej stronie. W przeciwieństwie
do przypisań (które rzucają MatchError w przypadku niepowodzenia), generatory są filtrowane i nie rzucają wyjątków
w czasie wykonania. Niestety dzieje się to kosztem podwójnego zaaplikowania wzoru.
2.1.3 For Each
Jeśli nie użyjemy słowa yield, kompilator użyje metody foreach zamiast flatMap, co ma sens
jedynie w obecności efektów ubocznych.
reify> for { i <- a ; j <- b } println(s"$i $j")
a.foreach { i => b.foreach { j => println(s"$i $j") } }
2.1.4 Podsumowanie
Pełen zbiór metod używanych przez konstrukcję for nie ma jednego wspólnego interfejsu, a każde użycie jest
niezależnie kompilowane. Gdyby taki interfejs istniał, wyglądałby mniej więcej tak:
trait ForComprehensible[C[_]] {
def map[A, B](f: A => B): C[B]
def flatMap[A, B](f: A => C[B]): C[B]
def withFilter[A](p: A => Boolean): C[A]
def foreach[A](f: A => Unit): Unit
}
Jeśli kontekst (C[_]) konstrukcji for nie dostarcza swoich własnych metod map i flatMap
to nie wszystko jeszcze stracone. Jeśli dostępna jest niejawna instancja typu scalaz.Bind[C]
to dostarczy ona potrzebne metody map oraz flatMap.
2.2 Obsługa błędów
Jak dotąd patrzyliśmy jedynie na reguły przepisywania, nie natomiast na to, co dzieje się wewnątrz metod map i flatMap.
Zastanówmy się co dzieje się, kiedy kontekst for zadecyduje, że nie może kontynuować działania.
W przykładzie bazującym na typie Option blok yield wywoływany jest jedynie kiedy wartości wszystkich zmiennych
i, j, k są zdefiniowane.
for {
i <- a
j <- b
k <- c
} yield (i + j + k)
Jeśli którakolwiek ze zmiennych a, b, c przyjmie wartość None, konstrukcja for zrobi zwarcie9 i zwróci None, nie mówiąc
nam co poszło nie tak.
Jeśli użyjemy typu Either, wtedy Left powodować będzie zwarcie konstrukcji for z dodatkową informacją, którą niesie w sobie.
Rozwiązanie to jest zdecydowanie lepsze w przypadku raportowania błędów niż użycie typu Option:
scala> val a = Right(1)
scala> val b = Right(2)
scala> val c: Either[String, Int] = Left("sorry, no c")
scala> for { i <- a ; j <- b ; k <- c } yield (i + j + k)
Left(sorry, no c)
Na koniec spójrzmy co stanie się z typem Future, który zawiedzie
scala> import scala.concurrent._
scala> import ExecutionContext.Implicits.global
scala> for {
i <- Future.failed[Int](new Throwable)
j <- Future { println("hello") ; 1 }
} yield (i + j)
scala> Await.result(f, duration.Duration.Inf)
caught java.lang.Throwable
Wartość Future, która wypisuje wiadomość do terminala, nie jest nigdy tworzona, ponieważ,
tak jak w przypadku Option i Either, konstrukcja for zwiera obwód i zakańcza przetwarzanie.
Zwieranie obwodu w przypadku odejścia od oczekiwanej ścieżki przetwarzania jest ważnym i często spotykanym rozwiązaniem.
Pamiętajmy, że konstrukcja for nie jest w stanie obsłużyć uprzątnięcia zasobów (resource cleanup), a więc nie ma możliwości wyrażenia zachowania
podobnego do try/finally. Rozwiązanie to jest dobre, gdyż jasno deklaruje, że to kontekst
(który zazwyczaj jest Monadą, jak zobaczymy później), a nie logika biznesowa, jest odpowiedzialny za obsługę błędów
i uprzątnięcie zasobów.
2.3 Sztuczki
Chociaż łatwo jest przepisać prosty kod sekwencyjny przy pomocy konstrukcji for,
czasami chcielibyśmy zrobić coś, co w praktyce wymaga mentalnych fikołków. Ten rozdział zbiera
niektóre praktyczne przykłady i pokazuje jak sobie z nimi radzić.
2.3.1 Wyjście awaryjne
Powiedzmy, że wywołujemy metodę, która zwraca typ Option. Jeśli wywołanie to się nie powiedzie,
chcielibyśmy użyć innej metody (i tak dalej i tak dalej), np. gdy używamy cache’a.
def getFromRedis(s: String): Option[String]
def getFromSql(s: String): Option[String]
getFromRedis(key) orElse getFromSql(key)
Jeśli chcemy zrobić to samo poprzez asynchroniczną wersję tego samego API
def getFromRedis(s: String): Future[Option[String]]
def getFromSql(s: String): Future[Option[String]]
musimy uważać, aby nie spowodować zbędnych obliczeń, ponieważ
for {
cache <- getFromRedis(key)
sql <- getFromSql(key)
} yield cache orElse sql
uruchomi oba zapytania. Możemy użyć pattern matchingu na pierwszym rezultacie, ale typ się nie zgadza
for {
cache <- getFromRedis(key)
res <- cache match {
case Some(_) => cache !!! wrong type !!!
case None => getFromSql(key)
}
} yield res
Musimy stworzyć Future ze zmiennej cache.
for {
cache <- getFromRedis(key)
res <- cache match {
case Some(_) => Future.successful(cache)
case None => getFromSql(key)
}
} yield res
Future.successful tworzy nową wartość typu Future, podobnie jak konstruktor typu Option lub
List.
2.3.2 Wczesne wyjście
Powiedzmy, że znamy warunek, który pozwala nam szybciej zakończyć obliczenia z poprawną wartością.
Jeśli chcemy zakończyć je szybciej z błędem, standardowym sposobem na zrobienie tego w OOP10 jest rzucenie wyjątku
def getA: Int = ...
val a = getA
require(a > 0, s"$a must be positive")
a * 10
co można zapisać asynchronicznie jako
def getA: Future[Int] = ...
def error(msg: String): Future[Nothing] =
Future.failed(new RuntimeException(msg))
for {
a <- getA
b <- if (a <= 0) error(s"$a must be positive")
else Future.successful(a)
} yield b * 10
Lecz jeśli chcemy zakończyć obliczenia z poprawną wartością, prosty kod synchroniczny:
def getB: Int = ...
val a = getA
if (a <= 0) 0
else a * getB
przekłada się na zagnieżdżone konstrukcje for, gdy tylko nasze zależności staną się asynchroniczne:
def getB: Future[Int] = ...
for {
a <- getA
c <- if (a <= 0) Future.successful(0)
else for { b <- getB } yield a * b
} yield c
2.4 Łączenie kontekstów
Kontekst, którego używamy wewnątrz konstrukcji for, musi być niezmienny: nie możemy mieszać wielu różnych typów jak
na przykład Future i Option.
scala> def option: Option[Int] = ...
scala> def future: Future[Int] = ...
scala> for {
a <- option
b <- future
} yield a * b
<console>:23: error: type mismatch;
found : Future[Int]
required: Option[?]
b <- future
^
Nie ma nic, co pozwoliłoby nam mieszać dowolne dwa konteksty wewnątrz konstrukcji for, ponieważ nie da się zdefiniować znaczenia takiej operacji.
Jednak gdy mamy do czynienia z konkretnymi kontekstami intencja jest zazwyczaj oczywista, tymczasem kompilator nadal nie przyjmuje naszego kodu.
scala> def getA: Future[Option[Int]] = ...
scala> def getB: Future[Option[Int]] = ...
scala> for {
a <- getA
b <- getB
} yield a * b
^
<console>:30: error: value * is not a member of Option[Int]
Chcielibyśmy, aby konstrukcja for zajęła się zewnętrznym kontekstem i pozwoliła nam
skupić się modyfikacji wartości wewnątrz instancji typu Option.
Ukrywaniem zewnętrznego kontekstu zajmują się tzw. transformatory monad (monad transformers),
a Scalaz dostarcza nam implementacje tychże dla typów Option i Either, nazywające się
odpowiednio OptionT oraz EitherT.
Kontekst zewnętrzny może być dowolnym kontekstem, który sam w sobie kompatybilny jest z konstrukcją for,
musi jedynie pozostać niezmienny.
Tworzymy instancję OptionT z każdego wywołania metody, zmieniając tym samym kontekst z Future[Option[_]]
na OptionT[Future, _].
scala> val result = for {
a <- OptionT(getA)
b <- OptionT(getB)
} yield a * b
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
.run pozwala nam wrócić do oryginalnego kontekstu
scala> result.run
res: Future[Option[Int]] = Future(<not completed>)
Transformatory monad pozwalają nam mieszać wywołania funkcji zwracających Future[Option[_]] z
funkcjami zwracającymi po prostu Future poprzez metodę .liftM[OptionT] (pochodzącą ze scalaz):
scala> def getC: Future[Int] = ...
scala> val result = for {
a <- OptionT(getA)
b <- OptionT(getB)
c <- getC.liftM[OptionT]
} yield a * b / c
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
Dodatkowo możemy mieszać wartości typu Option poprzez wywołanie
Future.successful (lub .pure[Future]), a następnie OptionT.
scala> def getD: Option[Int] = ...
scala> val result = for {
a <- OptionT(getA)
b <- OptionT(getB)
c <- getC.liftM[OptionT]
d <- OptionT(getD.pure[Future])
} yield (a * b) / (c * d)
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
Znów zrobił się mały bałagan, ale i tak jest lepiej niż gdybyśmy ręcznie pisali zagnieżdżone
wywołania metod flatMap oraz map. Możemy nieco uprzątnąć za pomocą DSLa który obsłuży
wszystkie wymagane konwersje
def liftFutureOption[A](f: Future[Option[A]]) = OptionT(f)
def liftFuture[A](f: Future[A]) = f.liftM[OptionT]
def liftOption[A](o: Option[A]) = OptionT(o.pure[Future])
def lift[A](a: A) = liftOption(Option(a))
w połączeniu z operatorem |>, który aplikuje funkcje podaną po prawej stronie na argumencie
podanym z lewej strony, możemy wizualnie oddzielić logikę od transformacji.
scala> val result = for {
a <- getA |> liftFutureOption
b <- getB |> liftFutureOption
c <- getC |> liftFuture
d <- getD |> liftOption
e <- 10 |> lift
} yield e * (a * b) / (c * d)
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
To podejście działa również dla Either i innych typów danych, ale w ich przypadku metody pomocnicze są bardziej skomplikowane
i wymagają dodatkowy parametrów. Scalaz dostarcza wiele transformatorów
monad dla typów, które definiuje, więc zawsze
warto sprawdzić, czy ten, którego potrzebujemy jest dostępny.