Mike Logaciuk

Podstawy języka Elixir (część druga)

07 Oct 2024

header

Spis treści

Wstęp

W poprzedniej części, omówiłem podstawy podstaw języka Elixir.

Dziś skupimy się na trochę już ciekawszych tematach, zaczynajac od struktur po protokoły etc.

Struktury

Struktury (ang. structs) to rozszerzona wersja map, udostępniająca tzw compile-time checks oraz wartości domyślne dla modułów.

Struktury definijemy z pomocą defstruct wewnątrz modułu (przyp. defmodule):

defmodule Course.User do
  defstruct name: "John", age: 27
end
john = %Course.User{}
alice = %Course.User{age: 18, name: "Alice"}

Struktury możemy aktualizować lub odczytywać:

alice.age
andrew = %{john | name: "Andrew"}
andrew
john

Struktury, jednakże nie dziedziczą żadnych protokołów jak mapy. Dlatego nie można enumerować po strukturze ani uzyskiwać dostępu tak jak w przypadku map np. john[:age].

O tym później w temacie dot. protokołów.

Wartości domyślne

W przypadku braku przypisania domyślnej wartości, do danego pola w strukturze domyślnie przypisywany jest nil:

defmodule Course.Another.User do
  defstruct mail: nil, name: "John", gender: "Male"
end
defmodule Course.Fake.Store do
  defstruct [:store, name: "XF001", town: "Zlotow"]
end

UWAGA: Pola bez domyślnej wartości (a co dalej - typu), muszą być ujętę na początku struktury.

Wymagane klucze

Dodatkowo mamy możliwość wymuszenia, które klucze mają zostać wypełnione podczas tworzenia struktury (ang. enforced keys) z pomocą @enforce_keys [:field]:

defmodule Course.Fake.Warehouse do

  @enforce_keys [:code]
  defstruct [:code, town: "Warsaw"]
end
wh_krk = %Course.Fake.Warehouse{:code => "CDK", town: "Cracow"}

Derive

Jak już wspomniano, struktury nie dziedziczą protokołów w takiej postaci jak np. mapy.

Istnieje jednak możliwość użycia @derive:

defmodule Course.Fake.Hub do

  @enforce_keys [:code]
  @derive {Inspect, only: [:code]}
  defstruct code: nil, town: nil, size: 4000
end

Protokoły

Protokoły (ang. protocols) są mechanizmem, który pozwala uzyskać polimorfizm w Elixirze.

Protokoły w Elixirze pozwalają na definiowanie wspólnego interfejsu dla różnych typów danych. Są one podobne do interfejsów w innych językach programowania, ale są bardziej elastyczne, ponieważ można je implementować dla istniejących typów danych bez modyfikacji ich definicji.

Możemy zobrazować użycie protokołów w kontekście sklepu internetowego, gdzie mamy różne typy produktów, a każdy z nich może być wyceniony w inny sposób.

Definiowanie protokołu

Najpierw zdefiniujemy protokół Pricable, który będzie miał funkcję price/1 do obliczania ceny produktu.

defprotocol Pricable do
  @doc "Returns the price of the product"
  def price(product)
end

Implementacja protokołu dla różnych typów produktów

Następnie zdefiniujemy różne typy produktów i zaimplementujemy dla nich protokół Pricable.

Produkt fizyczny
defmodule PhysicalProduct do
  defstruct [:name, :base_price, :weight]

  defimpl Pricable do
    def price(%PhysicalProduct{base_price: base_price, weight: weight}) do
      base_price + weight * 0.5
    end
  end
end

####

Produkt cyfrowy
defmodule DigitalProduct do
  defstruct [:name, :base_price, :file_size]

  defimpl Pricable do
    def price(%DigitalProduct{base_price: base_price, file_size: _file_size}) do
      base_price
    end
  end
end
Subskrypcja
defmodule Subscription do
  defstruct [:name, :monthly_fee, :months]

  defimpl Pricable do
    def price(%Subscription{monthly_fee: monthly_fee, months: months}) do
      monthly_fee * months
    end
  end
end

Użycie protokołu

Teraz możemy używać protokołu Pricable do obliczania cen różnych produktów:

physical_product = %PhysicalProduct{name: "Laptop", base_price: 1000, weight: 2}
digital_product = %DigitalProduct{name: "E-book", base_price: 15, file_size: 5}
subscription = %Subscription{name: "Streaming Service", monthly_fee: 10, months: 12}

IO.puts("Price of physical product: $#{Pricable.price(physical_product)}")

IO.puts("Price of digital product: $#{Pricable.price(digital_product)}")

IO.puts("Price of subscription: $#{Pricable.price(subscription)}")

Podsumowanie

Protokoły w Elixirze pozwalają na definiowanie wspólnego interfejsu dla różnych typów danych, co umożliwia elastyczne i rozszerzalne projektowanie systemów.

W powyższym przykładzie protokół Pricable umożliwia obliczanie cen różnych typów produktów w sklepie internetowym, niezależnie od ich specyficznych właściwości.

Inny przykłady

Innym ciekawy przykładem może być protokół Printable, który definiuje funkcję print/1, a następnie implementujemy ten protokół dla Receipt oraz Invoice:

defprotocol Printable do
  @doc "Prints the document"
  def print(document)
end
defmodule Receipt do
  defstruct [:number, :total]

  def new(number, total) do
    %Receipt{number: number, total: total}
  end
end

defimpl Printable, for: Receipt do
  def print(%Receipt{number: number, total: total}) do
    IO.puts("Receipt Number: #{number}")
    IO.puts("Total: #{total}")
  end
end
defmodule Invoice do
  defstruct [:number, :total, :tax]

  def new(number, total, tax) do
    %Invoice{number: number, total: total, tax: tax}
  end
end

defimpl Printable, for: Invoice do
  def print(%Invoice{number: number, total: total, tax: tax}) do
    IO.puts("Invoice Number: #{number}")
    IO.puts("Total: #{total}")
    IO.puts("Tax: #{tax}")
  end
end
receipt = Receipt.new("R-1234", 100.0)
invoice = Invoice.new("I-5678", 100.0, 23.0)

Printable.print(receipt)
Printable.print(invoice)

Zachowania (interfejsy)

W Elixirze zachowania (ang. behaviours) są mechanizmem podobnym do interfejsów w innych językach programowania. Pozwalają one definiować zestaw funkcji, które muszą być zaimplementowane przez moduł, który deklaruje, że implementuje dany behaviour.

  1. Definiowanie zachowań:
defmodule MyBehaviour do
  @callback my_function(arg :: any) :: any
end
  1. Implementowanie zachowań:
defmodule MyImplementation do
  @behaviour MyBehaviour

  @impl MyBehaviour
  def my_function(arg) do
    # Implementacja funkcji
    arg
  end
end
  1. Sprawdzanie implementacji:

Elixir sprawdza, czy moduł implementujący behaviour rzeczywiście definiuje wszystkie wymagane funkcje. Jeśli nie, kompilator zgłosi błąd.

Przykład

Załóżmy, że chcemy zdefiniować behaviour dla dokumentów sprzedaży, który będzie miał wspólne funkcje dla faktur i paragonów:

defmodule Examples.SalesDocument do
  @callback generate_number() :: String.t()
  @callback calculate_total() :: float()
  @callback print() :: :ok
end
defmodule Examples.Receipt do
  @behaviour Examples.SalesDocument

  @impl true
  def generate_number do
    "R-" <> :crypto.strong_rand_bytes(4) |> Base.encode16()
  end

  @impl true
  def calculate_total do
    # Przykładowa logika obliczania sumy
    100.0
  end

  @impl true
  def print do
    IO.puts("Printing receipt...")
    :ok
  end
end
defmodule Examples.Invoice do
  @behaviour Examples.SalesDocument

  @impl true
  def generate_number do
    "I-" <> :crypto.strong_rand_bytes(4) |> Base.encode16()
  end

  @impl true
  def calculate_total do
    # Przykładowa logika obliczania sumy z podatkiem
    100.0 * 1.23
  end

  @impl true
  def print do
    IO.puts("Printing invoice...")
    :ok
  end
end

Wątpliwości

Używanie interfejsów w Elixirze, na pierwszy rzut oka może się wydawać dodatkową pracą, ale przynosi kilka istotnych korzyści, szczególnie w większych projektach.

  1. Czytelność oraz dokumentacja

    Interfejsy pomagają w dokumentowaniu kodu, dzięki czemu łatwiej zrozumieć, jakie funckje muszą zostać zaimplementowane pod dany moduł.

  2. Wymuszanie implementacji

    Kompilator sprawdza, czy wszystkie funkcje zdefiowane w interfejsie są zaimplementowane w module, który deklaruje, że implementuje ten interfejs. W przypadku nie wykrycia, kompilator zgłosi wyjątek/błąd. Unikamy dzięki temu niepełnych implementacji.

  3. Bezpieczeństwo typów

    Adnotacja @impl true zapewnia, że funkcje są zgodne z definicją interfejsu. Jeśli sygnatura nie pasuje do definicji, otrzymamy błąd.

  4. Łatwiejsze testy

    Łatwo można tworzyć mock’i lub stub’y dla modułów implementujących interfejs.

  5. Modularność i rozszerzalność

    Interfejsy promują modularność, można łatwiej dodawać nowe moduły implementujące ten sam interfejs bez potrzeby modifkacji istniejącego już kodu.

Behaviours w Elixirze są potężnym narzędziem do tworzenia elastycznych i modularnych aplikacji, umożliwiającym definiowanie i egzekwowanie kontraktów między różnymi częściami systemu.

Protokoły kontra zachowania

W języku Elixir zarówno protokoły (ang. protocols), jak i zachowania (ang. behaviours) są mechanizmami, które wspierają polimorfizm, ale używane są w różnych kontekstach i mają różne zastosowania.

Protokoły (Protocols)

  1. Cel: Protokoły w Elixirze są używane do definiowania wspólnego interfejsu dla różnych typów danych. Dzięki nim można stworzyć zestaw funkcji, które różne typy danych mogą implementować w różny sposób.

  2. Polimorfizm ad hoc: Protokoły umożliwiają polimorfizm ad hoc, co oznacza, że można definiować, jak dana funkcja powinna działać dla różnych typów danych bez konieczności modyfikowania samych typów.

  3. Jak działają: Aby użyć protokołów, najpierw definiuje się protokół za pomocą defprotocol, a następnie implementuje się go dla różnych typów za pomocą defimpl.

  4. Przykład: Można zdefiniować protokół String.Chars do konwersji różnych typów danych na łańcuchy znaków.

Zachowania (Behaviours)

  1. Cel: Behaviours są używane do definiowania zestawu funkcji, które moduł musi zaimplementować. Często stosowane są do tworzenia abstrakcji, które będą implementowane przez różne moduły, np. w kontekście generycznych serwerów (GenServer), aplikacji OTP itp.

  2. Polimorfizm parametryczny: Behaviours wspierają polimorfizm parametryczny, gdzie różne moduły implementują ten sam zestaw funkcji, co pozwala na wymienne użycie tych modułów.

  3. Jak działają: Behaviours są definiowane za pomocą @callback i @macrocallback, a moduł, który implementuje dane zachowanie, musi używać @behaviour oraz zaimplementować wszystkie zadeklarowane funkcje.

  4. Przykład: GenServer jest przykładem zachowania w Elixirze, które moduły mogą implementować, aby działać jako serwery generyczne.

Podsumowanie

Podsumowując, protokoły są bardziej elastyczne i koncentrują się na polimorfizmie dla różnych typów danych, natomiast zachowania są bardziej strukturalne i służą do tworzenia wspólnych interfejsów dla modułów.

Typowanie (adnotacje typu)

W Elixirze typowanie danych odbywa się za pomocą adnotacji typu, które są częścią systemu dokumentacji i statycznej analizy kodu.

Adnotacje te są używane głównie przez narzędzia takie jak Dialyzer, które pomagają w wykrywaniu błędów typów w kodzie.

Typowanie funkcji za pomocą @spec

Adnotacje typu dla funkcji są definiowane za pomocą @spec. Poniżej kilka edge-case'ów.

Przykład prostej funkcji z adnotacją typu

defmodule Types.Math do
  @spec add(integer, integer) :: integer
  def add(a, b) do
    a + b
  end
end

Przykład funkcji z bardziej złożonymi typami

defmodule Types.User do
  @type t :: %Types.User{name: String.t(), age: non_neg_integer}
  defstruct [:name, :age]

  @spec create_user(String.t(), non_neg_integer) :: t
  def create_user(name, age) do
    %Types.User{name: name, age: age}
  end
end

Typowanie z użyciem @type i @typep

Można również definiować własne typy za pomocą @type (publiczne) i @typep (prywatne).

Przykład definiowania własnych typów

defmodule Types.Shapes do
  @type point :: {number, number}
  @type shape :: :circle | :square | :triangle

  @spec area(shape, number) :: number
  def area(:circle, radius) do
    :math.pi() * radius * radius
  end

  def area(:square, side) do
    side * side
  end

  def area(:triangle, base, height) do
    0.5 * base * height
  end
end

Typowanie z użyciem @spec i @callback

Jeśli tworzysz behaviour (interfejs), możesz użyć @callback do definiowania specyfikacji funkcji, które muszą być zaimplementowane.

Przykład użycia @callback

defmodule Types.MyBehaviour do
  @callback my_function(arg :: any) :: any
end

defmodule Types.MyImplementation do
  @behaviour Types.MyBehaviour

  @spec my_function(any) :: any
  def my_function(arg) do
    arg
  end
end

Przykład użycia Dialyzera

Dialyzer to narzędzie do analizy statycznej kodu, które może wykrywać błędy typów w kodzie Elixira.

Aby użyć Dialyzera, musisz najpierw wygenerować PLT (Persistent Lookup Table), a następnie uruchomić Dialyzera na swoim projekcie.

Kroki do użycia Dialyzera

  1. Dodaj Dialyzera do swojego projektu w

mix.exs:

defp deps do
  [
    {:dialyxir, "~> 1.0", only: [:dev], runtime: false}
  ]
end
  1. Zainstaluj zależności:

     mix deps.get
    
  2. Wygeneruj PLT:

     mix dialyzer --plt
    
  3. Uruchom Dialyzera:

     mix dialyzer
    

Typowanie danych w Elixirze za pomocą @spec, @type, @typep i @callback pozwala na bardziej precyzyjne definiowanie interfejsów funkcji i struktur danych, ułatwia utrzymanie i rozwijanie kodu.

Dodatkowe informacje

Więcej o typespec można znaleźć w oficjalnej dokumentacji tutaj.

Podsumowanie

Jak widać Elixir to język, który da się lubić i który potrafi zaskoczyć ciekawymi rozwiązaniami.

Mimo faktu bycia językiem funkcjonalnym, posiada jednak ciekawe elementy, umożliwiające pisanie choćby polimorfistycznego kodu.

Następna część znajduje się tutaj.

Referencje