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.