Problem wywołań cebulowych w Scali

7 minut(y)

Problem: wywołania cebulowe

Przypadek pierwszy - języki nieobiektowe

Jeśli mamy dane wejściowe, które chcemy przetworzyć za pomoca kilku funkcji po kolej, np:

third_function(second_function(first_function(data)))

Powstaje nam brzydkie i nieczytelne wywołanie cebulowe.

  • Cebulowe, ponieważ nawiasy układają się jak warstwy w cebuli wokół rdzenia, którym są tu dane wejściowe.
  • Nieczytelne, ponieważ przy czytaniu wzrok podąża od lewej do prawej. W tym wypadku jednak, żeby zrozumieć sens linijki wzrok musi zawrócić i ponownie czytać od prawej do lewej.
  • Brzydkie, ponieważ rozwiązanie to się nie skaluje, jeśli mielibyśmy trzydzieści takich funkcji musielibyśmy w końcu złamać linię lub przepisać kod na taki:
val data1 = first_function(data)
val data2 = second_function(data1)
val data3 = third_function(data2)
// (...)
val data30 = thirty_function(data29)

Dlaczego uważam, że ten kod jest zły? Ponieważ zawiera wiele niepotrzebnego szumu. Szumem jest tutaj tworzenie pomocniczych zmiennych, które często trudno nazwać w sensowny sposób.

Dlatego powinniśmy unikać wywołań cebulowych Chyba, że piszemy w języku Clojure, Racket lub innym Lispie. Wtedy formatujemy kod:

(third_function
  (second_function
    (first_function data)))

i mówimy że wszystko jest w porządku.

Przypadek drugi - języki obiektowe

Gdybyśmy programowali w języku obiektowym moglibyśmy zapisać:

data.first_function().second_function().third_function()

lub bardziej czytelnie:

data
  .first_function()
  .second_function()
  .third_function()

Oczywiście o ile first_function jest metodą obiektu data, second_function jest metodą obiektu zwracanego przez first_function i tak dalej. Jeśli nie, to musimy użyć haków jak implicit classes w języku Scala lub extensions w języku Kotlin.

Jeśli nasz język programowania nie wspiera haków to pozostaje nam kod z tworzeniem wielu pomocniczych zmiennych:

val data1 = first_function(data)
val data2 = second_function(data1)
val data3 = third_function(data2)
// (...)
val data30 = thirty_function(data29)

Prawdopodobna inspiracja - potoki w Bash i Jekyll

W powłoce systemowej Bash przesyłanie danych między jednym poleceniem, a drugim jest realizowane przez potoki, np:

ps -a | sort | uniq | grep -v sh

Dwa lub więcej poleceń można połączyć w jedno polecenie za pomocą operatora pionowej kreski | (ang. pipe).

Jekyll także posiada potoki, ale dla utrudnienia nazywają się filtrami. Precyzyjniej to Jekyll używa języka szablonów Liquid, a Liquid posiada Filtry. Kod :


{{ "scala!" | capitalize | prepend: "Hello " }}

daje na wyjściu:

Hello Scala!

Rozwiązanie - operator potoku

Jednym z możliwych rozwiązań wywołań cebulowych jest operator potoku (ang. pipe operator lub pipeline operator) |>. Pozwala on na zapis:

data |> first_function |> second_function |> third_function

lub czytelniej:

data
  |> first_function
  |> second_function
  |> third_function

Został spopularyzowany przez język Elixir, ale wcześniej był już używany w odmianach języka Meta Language jak OCaml oraz F#. W tym ostatnim istnieje nawet możliwość samodzielnego zdefiniowania operatora potoku za pomocą linii:

let inline (|>) x f = f x

Na fali popularności operator potoku został dodany także do wielu innych języków programowania takich jak Elm, LiveScript, Julia czy Hack.

Część z nich zawiera także drugi podobny operator zwany back pipe operator zapisywany <| lub |>>. Ten drugi zapis prawdopodobnie inspirowany jest językiem Clojure.

Tak, Clojure posiada dużo lukru składniowego, żeby poprawić standardową nieczytelność Lispa dzięki czemu możemy zapisać:

(-> data (first_function) (second_function) (third_function))

lub

(--> data (first_function) (second_function) (third_function))

Po co nam dwa operatory potoku, tj |> i <|?

Otóż |> dodaje argument na początku listy parametrów, a <| - na końcu listy. Czyli jeśli w języku Perl 5 wprowadzonoby oba operatory to moglibyśmy wywołać:

(0, 1, 2, 3) <| grep { $_ != 2} <| map { $_ * 2})

Rozwiązanie w Scali - ScalaPipe i operator drozda

Język Scala nie posiada operatora potoku, ale posiada możliwość definiowania operatorów. W internecie pod hasłem ScalaPipe można znaleść wiele możliwych implementacji. Moja ulubiona to:

package pl.writeonly.re.shared.scalapipe

trait ScalaPipeOps {
  implicit def toPipe[A](a: A): ScalaPipe[A] = ScalaPipe(a)

  class ScalaPipe[A](a: A) {
    def |>[B](f: A => B): B = f(a)
  }

  object ScalaPipe {
    def apply[A](v: A): ScalaPipe[A] = new ScalaPipe(v)
  }

}

object ScalaPipeOps extends ScalaPipeOps

Na szczęście nie musimy sami implementować operatora ScalaPipe, ponieważ istnieje on już w bibliotece Scalaz ale dla utrudnienia nazywa się operator drozda (ang. Thrush combinator lub Thrush combinator). Używając tego operatora z łatwością możemy zapisać:

import scalaz.Scalaz._

data |> firstFunction |> secondFunction |> threeFunction

Niestety Scala to nie Elixir i nie możemy zapisać:

data
  |> firstFunction
  |> secondFunction
  |> threeFunction

Wywołanie zdefiniowanego przez nas operatora nie może znajdować się bezpośrednio na początku linii. Ale może znajdować się na końcu linii:

data |>
  firstFunction |>
  secondFunction |>
  threeFunction

Lub, co wygląda jeszcze gorzej, na początku linii poprzedzony kropką:

data
  .|>(firstFunction)
  .|>(secondFunction)
  .|>(threeFunction)

Jednak w takim wypadku lepiej użyć aliasu into:

data
  .into(firstFunction)
  .into(secondFunction)
  .into(threeFunction)

Podsumowanie

Składnia języka Scala jest elastyczna, ale czasem nie aż tak bardzo jak była by potrzeba. Mimo to, łatwo definiować nowe operatory oraz składnię, która może znacząco skrócić i uprościć kod. Warto jednak wcześnie sprawdzić czy nasz operator nie jest zdefiniowany w istniejącej i popularnej bibliotece.

Zostaw komentarz