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
(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
.
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.
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"}
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 (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.
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
Następnie zdefiniujemy różne typy produktów i zaimplementujemy dla nich protokół Pricable
.
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
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
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
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)}")
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.
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)
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.
defmodule MyBehaviour do
@callback my_function(arg :: any) :: any
end
defmodule MyImplementation do
@behaviour MyBehaviour
@impl MyBehaviour
def my_function(arg) do
# Implementacja funkcji
arg
end
end
Elixir sprawdza, czy moduł implementujący behaviour rzeczywiście definiuje wszystkie wymagane funkcje. Jeśli nie, kompilator zgłosi błąd.
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
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.
@impl true
zapewnia, że funkcje są zgodne z definicją interfejsu. Jeśli sygnatura nie pasuje do definicji, otrzymamy błąd.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.
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.
defprotocol
, a następnie implementuje się go dla różnych typów za pomocą defimpl
.String.Chars
do konwersji różnych typów danych na łańcuchy znaków.GenServer
), aplikacji OTP
itp.@callback
i @macrocallback
, a moduł, który implementuje dane zachowanie, musi używać @behaviour
oraz zaimplementować wszystkie zadeklarowane funkcje.GenServer
jest przykładem zachowania w Elixirze, które moduły mogą implementować, aby działać jako serwery generyczne.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.
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.
@spec
Adnotacje typu dla funkcji są definiowane za pomocą @spec
. Poniżej kilka edge-case'ów
.
defmodule Types.Math do
@spec add(integer, integer) :: integer
def add(a, b) do
a + b
end
end
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
@type
i @typep
Można również definiować własne typy za pomocą @type
(publiczne) i @typep
(prywatne).
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
@spec
i @callback
Jeśli tworzysz behaviour
(interfejs), możesz użyć @callback
do definiowania specyfikacji funkcji, które muszą być zaimplementowane.
@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
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.
mix.exs
:
defp deps do
[
{:dialyxir, "~> 1.0", only: [:dev], runtime: false}
]
end
mix deps.get
mix dialyzer --plt
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.
Więcej o typespec
można znaleźć w oficjalnej dokumentacji tutaj.
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.