Problem wywołań cebulowych w Haskellu
Przez Problem wywołań cebulowych
rozumiem sytuację zagnieżdżonego wywoływania różnego rodzaju funkcji jak:
someFunction someData = thirdFunction (secondFunction (firstFunction someData))
Duża ilość wywołań powoduje dużą ilość nawiasów na końcu:
f x = f9 (f8 (f7 (f6 (f5 (f4 (f3 (f2 (f1 (f0 x)))))))))
co jest mało czytelne.
W Haskellu można zapisać to trochę czytelniej za pomocą klauzuli where
:
someFunction someData = thirdFunction secondDate where
secondData = secondFunction firstData
firstData = firstFunction someData
Niestety klauzula where
może powodować dużą rozwlekłość kodu:
f x = f9 x8 where
x8 = f8 x7
x7 = f7 x6
x6 = f6 x5
x5 = f5 x4
x4 = f4 x3
x3 = f3 x2
x2 = f2 x1
x1 = f1 x
Dodatkowo tworzymy dużą ilość pośrednich zmiennych, które tylko zaciemniają kod.
W językach obiektowych (lub wspierających notację obiektową jak Rust) mamy szansę na lepszy zapis, jeśli firstFunction
jest metodą obiektu firstData
, secondFunction
jest metodą obiektu secondData
, a thirdFunction
jest metodą obiektu thirdData
:
def someFunction(someDate: SomeDate): SomeResult = someData
.firstFunction()
.secondFunction()
.thirdFunction()
Niestety nie zawsze mamy takie szczęście i wtedy kod wygląda mniej więcej tak:
def someFunction(someData: SomeDate): SomeResult = {
val firstData = firstFunction(someData)
val secondDate = secondFunction(firstData)
third_function(secondDate)
}
Lub w krótszej formie bez zmiennych pośrednich tak:
def someFunction(someData: SomeDate): SomeResult = third_function(secondFunction(firstData(someData)))
W Scali można jeszcze próbować dodać metodę do obiektu za pomocą konwersji implicit
, jednak może wymagać to dużej ilości kodu, a zysk jest raczej mały. Chociaż czasem jest to używane w bibliotekach jak Scalaz czy Zio.
Na szczęście można rozwiązać to za pomocą operatora potoku, zwanego też w Scali operatorem drozda:
def someFunction(someData: SomeDate): someData |> firstFunction |> secondFunction |> thirdFunction
Pisałem o tym w artykule Problem wywołań cebulowych w Scali.
Operatory aplikacji (i kombinacji)
Haskell także posiada swoje operatory potoku. Jednak dla utrudnienia nazywają się one operatorami aplikacji. Znajdują się one w module Data.Function.
Podobnie jak w przypadku języków Scala czy OCaml, w Haskell operatory te nie są częścią składni języka, tylko zdefiniowane są tak jak funkcje. Dzięki temu, że te języki programowania mają elastyczną składnię można definiować własne operatory.
W dalszej części artykułu będę poszukiwać czytelniejszej formy dla funkcji:
f x = f3 (f2 (f1 x))
Operator dolara (ang. Dollar operator)
Pierwszym operatorem aplikacji jest operator dolara ($)
. Operator ten robi “apply forward” zwane też “pipe into”. Definicja:
($) :: (a -> b) -> (a -> b)
Użycie operatora dolara wygląda następująco:
f d = f3 $ f2 $ f1 d
Jest to odpowiednik z innych języków programowania:
f d = f3 <| f2 <| f1 <| d
Powyższy zapis jest czytelniejszy niż zapis nawiasowy, jednak dalej wymaga czytania od prawej do lewej, co jest nienaturalne.
Operator et (ang. Ampersand operator)
Drugim operatorem aplikacji jest operator et (&)
. Operator ten robi “apply backward” zwane też “pipe from”. Definicja:
a -> (a -> b) -> b
Użycie operatora et wygląda następująco:
f d = d & f1 & f2 & f3
Jest to odpowiednik z innych języków programowania:
f d = d |> f1 |> f2 |> f3
Jest to czytelniejsza postać dla użytkowników języków obiektowych oraz języka Rust, jednak w Haskellu rzadko używana.
Operator kropki (ang. Dot operator)
Operator kropki (.)
służy do składania funkcji (ang. Function composition). Jest to operator kompozycji. Definicja:
(.) :: (b -> c) -> (a -> b) -> a -> c
I użycie w Haskellu jakiego moglibyśmy się spodziewać:
f d = (f3 . f2 . f1) d
Jednak nie jest to to, czego można by spodziewać się po programistach Haskella. Haskell pozwala na PointFree Style, czyli możliwość niezapisywania argumentów:
f = f3 . f2 . f1
Styl PointFree bywa też nazywany stylem Pointless, ponieważ jest oskarżany o zaciemnianie kodu.
Pakiet Flow
Pakiet Flow pozwala używać operatorów znanych z innych języków programowania, takich jak Scala, OCaml czy Elixir. Definiuje on dwa operatory aplikacji oraz dwa operatory kompozycji, które w uproszczeniu można wyjaśnić jako:
(<|) = ($) -- "apply forward" or "pipe into"
(|>) = (&) -- "apply backward" or "pipe from"
(<.) = (.) -- "compose backward" or "but first"
(.>) = flip (.) -- "compose forward" or "and then"
Dzięki tym operatorom możemy zdefiniować złożenie funkcji na cztery różne czytelne sposoby:
f x = f3 (f2 (f1 x)) -- normalny zapis
f x = f3 <| f2 <| f1 <| x -- apply forward -- jak w Elixirze
f x = x |> f1 |> f2 |> f3 -- apply backward
f = f3 <. f2 <. f1 -- compose backward -- bardziej po Haskelowemu
f = f1 .> f2 .> f3 -- compose forward
Podsumowanie
Haskell posiada wiele operatorów pozwalających na redukcję nawiasów podczas wywoływania funkcji. Samodzielnie (lub w zespole) należy ustalić, który ze styli jest najbardziej czytelny dla nas.