Dziś w Elixir w końcu długo oczekiwany przeze mnie operator pipe potocznie zwany pipleline a w po polsku… potok (nazwa równie dobra jak dwumlask). Jednak zanim przejdziemy do jego omawiania, kilka podstaw.

Wszystko coś zwraca

W Elixir jak już wspomniałem każda operacja coś zwraca – 2+2 zwróci wynik, 2 zwróci 2. Funkcja zwróci ostatnią operację. Itd. Jest to dość istotne i tutaj też będzie znacznie ułatwiało nam pracę w operatorem pipe.

Weźmy dla przykładu poniższy kod:

defmodule TestRet do
    def ret_2, do: 2
    def ret_double(v), do: v*2
    def ret_div(d), do: d/2
    def sey_hello, do: "Hello"
end

Każda funkcja w TestRet zwróci nam odpowiednią wartość, ret_2 zwróci 2 itd.

Kolejkowanie funkcji

Przez to, że wszystko coś zwraca, możemy bezpiecznie kolejkować funkcje. W innych językach też to możemy, to nie jest nic nowego. Tutaj po prostu jakoś świadomość, że wszystko coś zwraca, powoduje iż łatwiej się odnieść do zagnieżdżania wywołań. A to znaczy, że aby 2 pomnożyć przez 2 i potem podzielić możemy wykonać kod:

TestRet.ret_div(TestRet.ret_double(TestRet.ret_2))

Który jest odwrotnym zapisem tego co chcemy.

Albo inny przykład, chcielibyśmy spłaszczyć tablicę [1, [2, [3, [4]], [5]]] i następnie każdy jej element pomnożyć przez dwa?

Enum.map(List.flatten([1, [2, [3, [4]], [5]]]), fn x -> x * 2 end)
Enum.map(List.flatten([1, [2, [3, [4]], [5]]]), &Kernel.*(&1, 2))

Jednak taki zapis jest bardzo, ale to bardzo nie czytelny. Im bardziej będziemy wchodzić w szczegóły lub im więcej będziemy mieli funkcji do wywołania/zagnieżdżenia tym gorzej i trudniej będzie się czytało kod. A Elixir jest przede wszystkim piękny :) Tutaj przychodzi nam z pomocą operator pipe

Operator pipe |>

Operator pipe |> bierze wynik z poprzedniego wyrażenia, i przekazuje go do następnego wyrażenia jako pierwszy argument. Co powoduje, że kod jest znacznie czytelniejszy, nie posiada tymczasowych zmiennych oraz czyta się go tak jak myślimy – od góry do dołu, od lewej do prawej. To znaczy, że zapis spłaszcz tablicę i pomnóż jej elementy przez dwa będzie miał 1-1 odzwierciedlenie w kodzie. Pod sposdem zaś, nasz kod zostanie zamieniony na wersję nieczytelną – czyli wywołanie funkcji przez funkcję. Zapis:

TestRet.ret_div(TestRet.ret_double(TestRet.ret_2))

Można więc przedstawić dosłownie:

TestRet.ret_2 |> TestRet.ret_double |> TestRet.ret_div

Dzięki czemu czytamy to tak jak to napisaliśmy, zwróć dwa i pomnóż je przez dwa a następnie podziel przez dwa. Kod zaczyna być super czytelny do tego jego zapis zaczyna nabierać znaczenia.

Przykład z listą można więc zapisać w ten sposób:

[1, [2, [3, [4]], [5]]]
|> List.flatten
|> Enum.map(fn x -> x * 2 end)

To są może proste przykłady, ale weźmy coś bardziej skomplikowane. Dla przykładu mamy plik tekstowy, zawierający tekst. Możemy dzięki operatorwi pipe bardzo łatwo policzyć ile zajmie nam przeczytanie takiego tekstu:

defmodule ReadTime do
    def read_file(file) do
        File.read(file)
        |> how_long
    end

    def how_long ({:ok, body}) do
        body
        |> String.split(" ")
        |> Enum.count
        |> Kernel./(180)
        |> round
        |> Integer.to_string
        |> Kernel.<>(" minutes")
    end

    def how_long ({:error, reason}) do
       "an error has occurred: "
       |> Kernel.<>(to_string reason)
       |> IO.puts
    end
end

Jak dla mnie jest to bardzo czytelne, wejście jest proste ReadTime.read_file "file_name", i w zależności od tego czy plik udało się przeczytać zostanie zwrócona odpowiednia informacja.

Standardowe problemy w operatorem pipe

Aż się by się chciało móc pomnożyć każdy element tablicy za pomocą:

1..10 |> Kernel.*(2)

Jednak niestety to nie zadziała. A dla tego, że operator |> przekazuje wynik lewego zapytania jako pierwszy parametr prawego zapytania. A więc przekazuje on tablicę elementów do funkcji mnożenia, co raczej się nie uda. By tego dokonać trzeba by było z mapować listę elementów tak jak to robiliśmy wcześniej.

Kolejnym problemem jest brak nawiasów. Dokładnie mówiąc Elixir może założyć pewną rzecz nie koniecznie tak jak my byśmy chcieli. Dla przykładu, jakbyśmy chcieli zamienić liczbę na string i następnie dodać do niej h, to poniższy kod nie zadziała:

Integer.to_string 10 |> Kernel.<>("h")

Gdyż, Elixir potraktuje to jako:

Integer.to_string(10 |> Kernel.<>("h"))

By funkcja zadziałała, musimy dodać sami nawiasy:

Integer.to_string(10) |> Kernel.<>("h")

Lub w ogóle pójście trochę wyżej i:

10 |> Integer.to_string |> Kernel.<>("h")

Podsumowanie

Operator pipe jest naprawdę miłym dodatkiem, który:

  • Daje nam możliwość zapisu kodu tak jak myślimy
  • Daje nam możliwość czytania kodu tak jak jest napisany
  • Upiększa nieczytelny kod zagnieżdżonych funkcji
  • Pozwala na zapis skomplikowanej logiki w przyjazny sposób

Oczywiście nie zawsze jest to taki fajny i ładny jak go piszą. Są miejsca gdzie lepiej zastosować inne podejście ze względu na czytelność całego kodu. To jest ważne. Jak widzicie, że kod zaczyna brzydko wyglądać lub jego czytelność jest utrudniona, to może pora na zmianę, lub rozbicie/pogrupowanie operacji? Dla przykładu, mi się bardzo nie podoba zapis:

def how_long ({:error, reason}) do
    "an error has occurred: "
    |> Kernel.<>(to_string reason)
    |> IO.puts
end

Może można spróbować inaczej to napisać tak by nie było wołania to_string? Na pewno się da :) na przykład (bardziej opisowy ale taki który nie ma tego to_string:

def how_long ({:error, reason}) do
    "an error has occurred: "
    |> contact(reason)
    |> IO.puts
end

defp contact(msg, err) do
    err
    |> to_string
    |> revert(msg)
end

defp revert(err, msg) do
    msg
    |> Kernel.<>(err)
end

Ogólnie, dążymy do perfekcji i uczymy się dalej :)