1. Wprowadzenie

To normalne, że do nowego paradygmatu podchodzimy sceptycznie. Aby zarysować nieco drogę, jaką już przeszliśmy i zmiany, jakie udało nam się zaakceptować w JVMie, zacznijmy od szybkiej powtórki z ostatnich 20 lat tej platformy.

Java 1.2 wprowadziła Collections API, pozwalające nam pisać metody mogące abstrahować nad5 mutowalnymi kolekcjami. Była to zmiana pomocna w pisaniu algorytmów ogólnego przeznaczenia, która stała się podwaliną dla wielu systemów i bibliotek.

Niestety, API to miało jeden problem, zmuszało nas do rzutowania w czasie wykonania (runtime casting):

  public String first(Collection collection) {
    return (String)(collection.get(0));
  }

W odpowiedzi na ten problem deweloperzy definiowali obiekty domenowe, które przyjmowały formę CollcetionOfThing, czyli były kolekcjami konkretnych typów, z ich własnym silnie typowanym interfejsem, a Collection API stało się jedynie szczegółem implementacyjnym.

W 2005 Java 5 wprowadziła typy generyczne (generics), pozwalające nam definiować Collection<Thing>, abstrahując nad konkretną kolekcją oraz typem jej elementów. Typy generyczne po raz kolejny zmieniły sposób, w jaki pisaliśmy kod w Javie.

Autor Javowego kompilatora typów generycznych, Martin Odersky, niedługo później stworzył Scalę, język z silniejszym systemem typów, niemutowalnymi kolekcjami oraz wielokrotnym dziedziczeniem. Wprowadziło to fuzję pomiędzy programowaniem obiektowym (OOP) oraz programowaniem funkcyjnym (FP).

Dla większości programistów FP oznacza używanie niemutowalnych struktur danych tak często jak to możliwe, ale mutowalny stan jest nadal złem koniecznym, które musi być wyizolowane i zarządzane, np. przy użyciu aktorów z Akki lub klas używających synchronized. Ten styl FP skutkuje prostszymi programami, które łatwiej zrównoleglić i rozproszyć, stawiając zdecydowany krok naprzód względem Javy. Jest to jednak niewielka część zalet i korzyści płynących z programowa funkcyjnego, które odkryjemy w tej książce.

Scala wprowadza również typ Future, sprawiając, że pisanie aplikacji asynchronicznych staje się dużo łatwiejsze. Jednak gdy tylko Future pojawi się w typie zwracanym z funkcji, wszystko musi zostać przepisane i dostosowane, wliczając testy, które teraz narażone są na arbitralne timeouty6.

Mamy więc problem podobny to tego z Javy 1.0: brakuje nam możliwości abstrahowania nad wykonaniem programu, tak samo, jak brakowało nam możliwość abstrahowania nad używanymi kolekcjami.

1.1 Abstrahowanie nad wykonaniem

Wyobraźmy sobie, że chcemy komunikować się z użytkownikiem poprzez wiersz poleceń. Możemy czytać (.read), to co użytkownik napisał i pisać (.write) wiadomości, które będzie mógł przeczytać.

  trait TerminalSync {
    def read(): String
    def write(t: String): Unit
  }
  
  trait TerminalAsync {
    def read(): Future[String]
    def write(t: String): Future[Unit]
  }

Jak możemy napisać generyczny kod, który zrobi coś tak prostego, jak powtórzenie (echo) wiadomości wpisanej przez użytkownika w sposób synchroniczny bądź asynchroniczny w zależności od naszego środowiska uruchomieniowego?

Moglibyśmy napisać wersję synchroniczną i owinąć ją typem Future, ale zmusiłoby nas to do zdecydowania, jakiej puli wątków powinniśmy użyć. Alternatywnie moglibyśmy zawołać Await.result na Future i wprowadzić tym samym blokowanie wątku. W obu przypadkach sprowadza się to do napisanie dużej ilości boilerplate’u i utrzymywania dwóch różnych API, które nie są w żaden sposób zunifikowane.

Możemy też rozwiązać ten problem, podobnie jak w Javie 1.2, używając wspólnego interfejsu bazującego na typach wyższego rodzaju (higher kinded types, HKT) dostępnych w Scali.

Chcielibyśmy zdefiniować Terminal dla dowolnego konstruktora typu C[_]. Poprzez zdefiniowanie Now jako równoznacznego ze swoim parametrem (analogicznie do Id), możemy zaimplementować ten wspólny interfejs dla terminali synchronicznych i asynchronicznych:

  trait Terminal[C[_]] {
    def read: C[String]
    def write(t: String): C[Unit]
  }
  
  type Now[X] = X
  
  object TerminalSync extends Terminal[Now] {
    def read: String = ???
    def write(t: String): Unit = ???
  }
  
  object TerminalAsync extends Terminal[Future] {
    def read: Future[String] = ???
    def write(t: String): Future[Unit] = ???
  }

Niestety nie wiemy nic o C i nic nie jesteśmy w stanie zrobić z C[String]. Potrzebujemy środowiska wykonania (execution environment), które pozwoli nam zawołać metodę zwracającą C[T] a później zrobić coś z T, np. zawołać kolejną metodę z interfejsu Terminal. Potrzebujemy również możliwości owinięcia prostej wartości typem C[_]. Poniższa sygnatura dobrze spełnia nasze wymagania:

  trait Execution[C[_]] {
    def chain[A, B](c: C[A])(f: A => C[B]): C[B]
    def create[B](b: B): C[B]
  }

pozwalając nam na napisanie:

  def echo[C[_]](t: Terminal[C], e: Execution[C]): C[String] =
    e.chain(t.read) { in: String =>
      e.chain(t.write(in)) { _: Unit =>
        e.create(in)
      }
    }

Możemy teraz współdzielić implementację echo pomiędzy synchroniczną i asynchroniczną wersją naszego programu. Możemy napisać sztuczną (mock) implementację Terminal[Now] i użyć jej w naszych testach beż zagrożenia ze strony timeoutów.

Implementacje Execution[Now] oraz Execution[Future] mogą być reużywane przez generyczne metody takie jak echo.

Ale kod implementujący echo jest okropny!

Używając mechanizmu implicit class w Scali, możemy dodać metody do C. Nazwijmy te metody flatMap i map z powodów, które staną się jasne już niedługo. Każda z metod przyjmuje implicit Execution[C], ale oprócz tego są to dokładnie takie same flatMap i map, jakie znamy z typów Seq, Option czy Future.

  object Execution {
    implicit class Ops[A, C[_]](c: C[A]) {
      def flatMap[B](f: A => C[B])(implicit e: Execution[C]): C[B] =
            e.chain(c)(f)
      def map[B](f: A => B)(implicit e: Execution[C]): C[B] =
            e.chain(c)(f andThen e.create)
    }
  }
  
  def echo[C[_]](implicit t: Terminal[C], e: Execution[C]): C[String] =
    t.read.flatMap { in: String =>
      t.write(in).map { _: Unit =>
        in
      }
    }

Możemy teraz zdradzić, dlaczego użyliśmy flatMap jako nazwy metody: pozwala nam to używać for comprehension, czyli lepszej składni dla zagnieżdżonych wywołań flatMap i map.

  def echo[C[_]](implicit t: Terminal[C], e: Execution[C]): C[String] =
    for {
      in <- t.read
       _ <- t.write(in)
    } yield in

Nasze Execution ma taką samą sygnaturę jak trait w Scalaz zwany Monad, z ta różnicą, że chain to bind and create to pure. Mówimy, że C jest monadyczne (monadic), gdy dostępna jest niejawna (implicit) instancja Monad[C]. Dodatkowo Scalaz definiuje również alias typu Id.

Podsumowując: jeśli piszemy metody operujące na typach monadycznych, wówczas możemy pisać sekwencyjny kod, który abstrahuje nad swoim środowiskiem wykonania. Pokazaliśmy jak zrobić to dla synchronicznego i asynchronicznego wykonania programu, ale tej samej techniki możemy użyć dla innych kontekstów, np. statycznie deklarowanych błędów (gdzie C[_] stanie się Either[Error, _]), zarządzania dostępem do ulotnego stanu aplikacji (volatile state), wykonywania operacji wejścia/wyjścia albo audytowalnej sesji.

1.2 Programowanie czysto funkcyjne7

Programowanie Funkcyjne to akt tworzenia programów przy użyciu czystych funkcji (pure functions). Czyste funkcje mają trzy właściwości:

  • Totalność: zwracają wartość dla każdego możliwego argumentu (total)
  • Deterministyczność: za każdym razem zwracają tę samą wartość dla tego samego argumentu (deterministic)
  • Niewinność: brak (bezpośrednich) interakcji ze światem lub wewnętrznym stanem programu (inculpable)

Razem te właściwości dają nam bezprecedensową zdolność do rozumowania o naszym kodzie. Na przykład, walidacja wejścia jest łatwiejsza do wyizolowania z totalnością, caching jest możliwy, gdy funkcje są deterministyczne, a interagowanie ze światem jest łatwiejsze do kontrolowania i testowania, gdy funkcje są niewinne.

Rzeczy, które łamią te właściwości, nazywamy efektami ubocznymi (side effects): bezpośredni dostęp lub zmiana mutowalnego stanu aplikacji (np. var wewnątrz klasy), komunikowanie się z zewnętrznymi zasobami (np. plikami lub siecią) lub rzucanie i łapanie wyjątków.

Piszemy czyste funkcje przez unikanie wyjątków i komunikowanie się ze światem jedynie poprzez bezpieczny kontekst wywołania F[_].

W poprzedniej sekcji abstrahowaliśmy nad wykonaniem programu i definiowaliśmy echo[Id] oraz echo[Future]. Możemy oczekiwać, że wywołanie jakiegokolwiek echo nie spowoduje żadnych efektów ubocznych, ponieważ jest to czysta funkcja. Jednak jeśli używamy Future lub Id jako kontekstu wykonania, nasza aplikacja zacznie nasłuchiwać na standardowym strumieniu wejścia (stdin):

  val futureEcho: Future[String] = echo[Future]

Zaburzyliśmy czystość i tym samym nie piszemy już kodu funkcyjnego: futureEcho jest rezultatem wykonania echo jeden raz. Future łączy definicję programu z jego interpretacją (uruchomieniem). Tym samym, trudnym staje się rozumowanie o aplikacjach zbudowanych przy użyciu Future.

Możemy zdefiniować prosty i bezpieczny kontekst wykonania F[_]

  final class IO[A](val interpret: () => A) {
    def map[B](f: A => B): IO[B] = IO(f(interpret()))
    def flatMap[B](f: A => IO[B]): IO[B] = IO(f(interpret()).interpret())
  }
  object IO {
    def apply[A](a: =>A): IO[A] = new IO(() => a)
  }

który leniwie wykonuje thunk8. IO jest zwyczajną strukturą danych, która zawiera kawałek (potencjalnie) nieczystego kodu, ale go nie wykonuje. Możemy zaimplementować Terminal[IO]

  object TerminalIO extends Terminal[IO] {
    def read: IO[String]           = IO { io.StdIn.readLine }
    def write(t: String): IO[Unit] = IO { println(t) }
  }

i wywołać echo[IO] aby dostać z powrotem wartość

  val delayed: IO[String] = echo[IO]

Taka wartość delayed może być reużywana, gdyż jest to tylko definicja pracy, która musi zostać wykonana. Możemy przemapować String i tworzyć kolejne programy, podobnie jak mapowalibyśmy Future. IO pozwala nam zachować szczerość co do tego, że zależymy od pewnych interakcji ze światem zewnętrznym, ale nie pozbawia nas dostępu do wyniku tej interakcji.

Nieczysty kod wewnątrz IO jest ewaluowany, kiedy wywołamy .interpret() na zwróconej wartości. Wywołanie to jest oczywiście również nieczystą akcją

  delayed.interpret()

Aplikacja złożona z programów opartych o IO jest interpretowana jedynie raz, wewnątrz metody main, która jest również zwana końcem świata (the end of the world).

W następnych rozdziałach rozszerzymy wprowadzone tutaj koncepcje i pokażemy jak pisać utrzymywalne czyste funkcje, które rozwiązują stawiane przed nimi problemy.