Do you want to pick up from where you left of?
Take me there

Funkcje

W Elixirze i w wielu innych językach funkcyjnych, funkcje są konceptem absolutnie podstawowym. W tej lekcji poznamy rodzaje funkcji, różnice pomiędzy nimi oraz ich zastosowania.

Funkcje anonimowe

Jak sama nazwa wskazuje, funkcje anonimowe nie mają nazw. W lekcji Enum zobaczyliśmy, że funkcje często są przekazywane do innych funkcji jako parametry. Jeżeli chcemy zdefiniować funkcję anonimową w Elixirze, musimy użyć słów kluczowych fn i end. Funkcja taka może posiadać wiele parametrów, które są oddzielone od jej ciała za pomocą symbolu ->.

Przyjrzyjmy się prostemu przykładowi:

iex> sum = fn (a, b) -> a + b end
iex> sum.(2, 3)
5

Znak & jako skrót

Funkcje anonimowe są tak często wykorzystywane, że istnieje skrócony sposób ich zapisu:

iex> sum = &(&1 + &2)
iex> sum.(2, 3)
5

Jak można się domyślić, w skróconej formie zapisu argumenty funkcji są dostępne jako &1,&2, &3, itd.

Dopasowanie wzorców

Dopasowanie wzorców w Elixirze nie jest ograniczone tylko do zmiennych – jak przekonamy się w tej sekcji, może ono zostać wykorzystane do dopasowania funkcji na podstawie listy ich parametrów.

Elixir używa dopasowania wzorców, by odnaleźć pierwszy pasujący zestaw parametrów i wykonać połączony z nim kod:

iex> handle_result = fn
...>   {:ok, result} -> IO.puts "Obsługa wyniku..."
...>   {:ok, _} -> IO.puts "To nie zostanie nigdy wykonane, gdyż poprzedni wzorzec zawsze będzie dopasowany jako pierwszy."
...>   {:error} -> IO.puts "Wystąpił błąd!"
...> end

iex> some_result = 1
1
iex> handle_result.({:ok, some_result})
Handling result...
:ok
iex> handle_result.({:error})
An error has occurred!

Funkcje nazwane

Możemy zdefiniować funkcję i nadać jej nazwę, by móc się do niej później odwołać. Robimy to w ramach modułu, wykorzystując słowo kluczowe def. O modułach będziemy jeszcze mówić w kolejnych lekcjach, teraz skupimy się na samych funkcjach.

Funkcje zdefiniowane w module są też domyślnie dostępne w innych modułach. Jest to szczególnie użyteczny element konstrukcyjny w Elixirze:

defmodule Greeter do
  def hello(name) do
    "Hello, " <> name
  end
end

iex> Greeter.hello("Sean")
"Hello, Sean"

Możemy też zapisać funkcję w jednej linijce, wykorzystując wyrażenie do::

defmodule Greeter do
  def hello(name), do: "Hello, " <> name
end

Wykorzystując naszą wiedzę o dopasowaniu wzorców, stwórzmy funkcję rekurencyjną:

defmodule Length do
  def of([]), do: 0
  def of([_ | tail]), do: 1 + of(tail)
end

iex> Length.of []
0
iex> Length.of [1, 2, 3]
3

Nazywanie i arność funkcji

Jak już wspominaliśmy wcześniej, pełna nazwa funkcji jest kombinacją jej nazwy i arności (liczby argumentów). Można to rozumieć w następujący sposób:

defmodule Greeter2 do
  def hello(), do: "Hello, anonymous person!"   # hello/0
  def hello(name), do: "Hello, " <> name        # hello/1
  def hello(name1, name2), do: "Hello, #{name1} and #{name2}"
                                                # hello/2
end

iex> Greeter2.hello()
"Hello, anonymous person!"
iex> Greeter2.hello("Fred")
"Hello, Fred"
iex> Greeter2.hello("Fred", "Jane")
"Hello, Fred and Jane"

Wypisaliśmy pełne nazwy funkcji w komentarzach powyżej. Pierwsza z nich nie przyjmuje żadnego argumentu, zatem jest nazwana hello/0; druga przyjmuje jeden argument, zatem nazwa to hello/1 i tak dalej. Nie należy mylić tego z przeciążaniem funkcji w innych językach, każda z tych funkcji jest niezależna od innych. (Dopasowanie wzorców, o którym przed chwilą mówiliśmy, zostanie zastosowane jedynie wtedy, gdy mamy wiele definicji funkcji o takich samych nazwach i liczbie argumentów).

Funkcje i dopasowanie wzorców

Za kulisami funkcje dopasowują wzorce do argumentów, z którymi są wywoływane.

Powiedzmy, że potrzebujemy funkcji, która akceptuje mapę jako argument, ale interesuje nas użycie jedynie konkretnego klucza. Możemy wykorzystać dopasowanie wzorców do sprawdzenia, czy dany klucz występuje w mapie przekazanej jako argument, jak pokazano w poniższym przykładzie:

defmodule Greeter1 do
  def hello(%{name: person_name}) do
    IO.puts "Hello, " <> person_name
  end
end

Powiedzmy teraz, że mamy mapę opisującą osobę o imieniu Fred:

iex> fred = %{
...> name: "Fred",
...> age: "95",
...> favorite_color: "Taupe"
...> }

Wyniki, które uzyskamy wywołując funkcję Greeter1.hello/1 z mapą fred jako argumentem, są następujące:

# wywołanie z całą mapą jako argumentem
...> Greeter1.hello(fred)
"Hello, Fred"

Co dzieje się w sytuacji, gdy mapa nie zawiera klucza :name?

# wywołanie bez klucza, którego potrzebujemy, zwraca błąd
...> Greeter1.hello(%{age: "95", favorite_color: "Taupe"})
** (FunctionClauseError) no function clause matching in Greeter1.hello/1

    The following arguments were given to Greeter1.hello/1:

        # 1
        %{age: "95", favorite_color: "Taupe"}

    iex:12: Greeter1.hello/1

Przyczyną takiego zachowania jest to, że Elixir dopasowuje argumenty, z którymi funkcja jest wywoływana, do arności funkcji zgodnie z jej definicją.

Zastanówmy się jak wyglądają dane, kiedy docierają do funkcji Greeter1.hello/1:

# incoming map
iex> fred = %{
...> name: "Fred",
...> age: "95",
...> favorite_color: "Taupe"
...> }

Greeter1.hello/1 oczekuje argumentu takiego jak ten:

%{name: person_name}

W Greeter1.hello/1, przekazywana przez nas mapa (fred) jest porównywana z naszym argumentem (%{name: person_name}):

%{name: person_name} = %{name: "Fred", age: "95", favorite_color: "Taupe"}

Porównanie wykazuje, że w przekazanej mapie występuje klucz odpowiadający name. Mamy dopasowanie! Zatem w wyniku pomyślnego dopasowania, wartość odpowiadająca kluczowi :name po prawej stronie (tj. w mapie fred) jest wiązana ze zmienną po lewej (person_name).

Co jednak, gdybyśmy dalej chcieli dopasować imię Freda do person_name ORAZ zachować całą mapę? Powiedzmy, że chcemy użyć polecenia IO.inspect(fred) po tym, jak przywitamy się z Fredem. W tym momencie, ponieważ dopasowaliśmy jedynie klucz :name z naszej mapy, a więc jedynie ta wartość przypisana jest do jakiejkolwiek zmiennej, funkcja nie ma żadnych innych informacji na temat Freda.

Aby zachować te informacje, musimy przypisać całą mapę do oddzielnej zmiennej, której będziemy mogli użyć.

Zacznijmy z nową funkcją:

defmodule Greeter2 do
  def hello(%{name: person_name} = person) do
    IO.puts "Hello, " <> person_name
    IO.inspect person
  end
end

Pamiętajmy, że Elixir dopasuje wzorzec do argumentu, gdy taki się pojawi. W tym przypadku każda strona będzie dopasowywana do przekazanego argumentu i przypisana do wszystkiego, do czego będzie pasować. Rozważmy najpierw prawą stronę:

person = %{name: "Fred", age: "95", favorite_color: "Taupe"}

Tu widzimy, że zmienna person została porównana i powiązana z całą mapą fred. Spójrzmy na kolejne dopasowanie wzorców:

%{name: person_name} = %{name: "Fred", age: "95", favorite_color: "Taupe"}

Tu działa to tak samo jak w naszej pierwotnej funkcji w Greeter1, gdzie z dopasowanej mapy zachowaliśmy jedynie imię Freda. To, co uzyskaliśmy, to — zamiast jednej — dwie zmienne, których możemy użyć:

  1. person, odnosząca się do wartości %{name: "Fred", age: "95", favorite_color: "Taupe"}
  2. person_name, odnosząca się do wartości "Fred".

Teraz więc, kiedy wywołujemy funkcję Greeter2.hello/1, możemy użyć wszystkich informacji na temat Freda:

# wywołanie z pełną mapą
...> Greeter2.hello(fred)
"Hello, Fred"
%{age: "95", favorite_color: "Taupe", name: "Fred"}
# wywołanie jedynie z kluczem dla imienia (name)
...> Greeter2.hello(%{name: "Fred"})
"Hello, Fred"
%{name: "Fred"}
# wywołanie bez klucza dla imienia (name)
...> Greeter2.hello(%{age: "95", favorite_color: "Taupe"})
** (FunctionClauseError) no function clause matching in Greeter2.hello/1

    The following arguments were given to Greeter2.hello/1:

        # 1
        %{age: "95", favorite_color: "Taupe"}

    iex:15: Greeter2.hello/1

Zobaczyliśmy zatem, że Elixir dopasowuje wzorce na wielu poziomach, ponieważ każdy argument jest porównywany z przekazywanymi danymi niezależnie, co daje nam zmienne, których możemy użyć w naszej funkcji.

Jeśli zamienimy kolejność %{name: person_name} i person, uzyskamy dokładnie taki sam wynik, ponieważ każde z powyższych zostanie niezależnie porównane z mapą fred.

Zamieńmy więc miejscami zmienną i mapę:

defmodule Greeter3 do
  def hello(person = %{name: person_name}) do
    IO.puts "Hello, " <> person_name
    IO.inspect person
  end
end

Wywołajmy funkcję z tymi samymi danymi, co w przypadku Greeter2.hello/1:

# wywołanie z tym samym, starym Fredem
...> Greeter3.hello(fred)
"Hello, Fred"
%{age: "95", favorite_color: "Taupe", name: "Fred"}

Pamiętaj, że choć wygląda jakby %{name: person_name} = person było próbą dopasowania %{name: person_name} i zmiennej person, tak naprawdę każdy z tych elementów dopasowywany jest do przekazanego argumentu.

Podsumowanie: Funkcje dopasowują przekazane dane do każdego z argumentów niezależnie. Możemy tego użyć do powiązania wartości z oddzielnymi zmiennymi w funkcji.

Funkcje prywatne

Jeżeli nie chcemy, by inne moduły mogły wywołać naszą funkcję, możemy zdefiniować ją jako prywatną. Będzie można jej użyć tylko w module, w którym została stworzona. W Elixirze służy do tego słowo kluczowe defp:

defmodule Greeter do
  def hello(name), do: phrase <> name
  defp phrase, do: "Hello, "
end

iex> Greeter.hello("Sean")
"Hello, Sean"

iex> Greeter.phrase
** (UndefinedFunctionError) function Greeter.phrase/0 is undefined or private
    Greeter.phrase()

Strażnicy

Pokrótce omówiliśmy strażników w lekcji o strukturach kontrolnych, a teraz przyjrzymy się bliżej, jak można wykorzystać ich w funkcjach. Elixir, odszukując funkcję do wywołania, sprawdza warunki dla wszystkich strażników.

W poniższym przykładzie mamy dwie funkcje o takiej samej sygnaturze, ale wywołanie właściwej jest możliwe dzięki strażnikom sprawdzającym typ argumentu:

defmodule Greeter do
  def hello(names) when is_list(names) do
    names = Enum.join(names, ", ")

    hello(names)
  end

  def hello(name) when is_binary(name) do
    phrase() <> name
  end

  defp phrase, do: "Hello, "
end

iex> Greeter.hello ["Sean", "Steve"]
"Hello, Sean, Steve"

Argumenty domyślne

Jeżeli chcemy, by argument miał wartość domyślną, to należy użyć konstrukcji argument \\ wartość:

defmodule Greeter do
  def hello(name, language_code \\ "en") do
    phrase(language_code) <> name
  end

  defp phrase("en"), do: "Hello, "
  defp phrase("es"), do: "Hola, "
end

iex> Greeter.hello("Sean", "en")
"Hello, Sean"

iex> Greeter.hello("Sean")
"Hello, Sean"

iex> Greeter.hello("Sean", "es")
"Hola, Sean"

Należy uważać, kiedy łączymy mechanizmy strażników i domyślnych argumentów, ponieważ może to spowodować wystąpienie błędów. Zobaczmy jak może to wyglądać:

defmodule Greeter do
  def hello(names, language_code \\ "en") when is_list(names) do
    names = Enum.join(names, ", ")

    hello(names, language_code)
  end

  def hello(name, language_code \\ "en") when is_binary(name) do
    phrase(language_code) <> name
  end

  defp phrase("en"), do: "Hello, "
  defp phrase("es"), do: "Hola, "
end

** (CompileError) iex:8: def hello/2 defines defaults multiple times. Elixir allows defaults to be declared once per definition.
Instead of:

    def foo(:first_clause, b \\ :default) do ... end
    def foo(:second_clause, b \\ :default) do ... end

one should write:

    def foo(a, b \\ :default)
    def foo(:first_clause, b) do ... end
    def foo(:second_clause, b) do ... end

Domyślne argumenty nie są preferowane przez Elixira w mechanizmach dopasowania wzorców, ponieważ mogą być mylące. By temu zaradzić, możemy dodać nagłówek funkcji z naszymi argumentami domyślnymi:

defmodule Greeter do
  def hello(names, language_code \\ "en")

  def hello(names, language_code) when is_list(names) do
    names = Enum.join(names, ", ")

    hello(names, language_code)
  end

  def hello(name, language_code) when is_binary(name) do
    phrase(language_code) <> name
  end

  defp phrase("en"), do: "Hello, "
  defp phrase("es"), do: "Hola, "
end

iex> Greeter.hello ["Sean", "Steve"]
"Hello, Sean, Steve"

iex> Greeter.hello ["Sean", "Steve"], "es"
"Hola, Sean, Steve"
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!