Тип-спецификации и поведения

Преди да говорим за OTP и мета-програмиране е добре да споменем една интересна
нотация, идваща от Erlang. Това е дефиницията на тип-спецификациите и защо
са полезни.
Част от тази спецификация са поведенията, които са ползвани доста в някои OTP
модули, с които ще се запознаем скоро.
Задаване на тип-спецификация
В документацията можете да видите всички възможни типове. Ние ще ви представим синтаксиса. Ще използваме модул със структура представляваща състоянието и дъската на играта ‘морски шах’:
defmodule TicTacToe.Grid do
@dimensions 3
defstruct [
state: :playing,
winner: nil,
turn: 0,
board: (
for _ <- 1..@dimensions, j <- [nil], do: List.duplicate(j, @dimensions)
)
]
end
Структурата държи:
- Състояние - дали играта се играе или е завършила. Използват се атомите
:playingили:finished. - Победител -
nil, когато все още няма победител или играта е патова. Иначе:xили:o - Ред - колко реда са изиграни до момента, започваме от
0. - Самото поле за игра - матрица. В случая списък от списъци с по три елемента. Всеки елемент може да е
nil,:xили:o.
Горните четири точки не са видни от самата дефиниция на модула и структурата.
Бихме могли да ги добавим в @moduledoc документация.
Но с тип-спецификации можем да ги направим видни от ‘пръв поглед’. Ето така:
defmodule TicTacToe.Grid do
@dimensions 3
defstruct [
state: :playing,
winner: nil,
turn: 0,
board: (
for _ <- 1..@dimensions, j <- [nil], do: List.duplicate(j, @dimensions)
)
]
@type state :: :playing | :finished
@type player :: :o | :x
@type player_or_nil :: player | nil
@type t :: %__MODULE__{
state: state, winner: player_or_nil, turn: non_neg_integer,
board: [[player_or_nil]]
}
end
Нека разгледаме какво направихме.
Първо дефинираме типът ‘състояние’ - state и казваме че това е или атомът
:playing или атомът :finished. Точно както го описахме по-горе.
Втората дефиниция на тип е player. Дефинираме типът като или :o или :x.
Сега ясно се вижда какви са символите, представляващи играч.
В някои случаи, да речем като искаме да опишем стойностите на полето winner,
използваме или стойност от player типа, или nil, затова дефинираме нов тип:
player_or_nil, който е player или nil. Можем да композираме типове както желаем.
Последната тип-спецификация е типът на структурата TicTacToe.Grid. Към този
тип ще можем да се обръщаме с TicTacToe.Grid.t. Прието е типовете на структури
да ги дефинираме с t в модула им. Нека да го разгледаме поле по поле:
stateполето е от типstate. Точно както го описахме по-горе ::playingили:finished.winnerполето е от типplayer_or_nil. Когато няма победител еnil, когато има е:xили:y. Отново - това описахме по-горе.turnеnon_neg_integer. Този тип-спецификация идва сElixir. В документацията може да разгледате всички такива типове.boardе списък от списъци отplayer_or_nilстойности.[]е списък.[[]]е списък от списъци.[[player_or_nil]]е списък от списъци отplayer_or_nilстойности.
Виждате колко лесно можем да видим всички възможни стойности на дадено поле на структурата сега. Тип-спецификациите ни помагат за това. Когато дефинираме структура е добра идея да я опишем с тип-спецификация и да опишем нейните полета с типове.
Често ще искаме да добавим или използваме типове за да опишем типовете на стойностите, които функции приемат или връщат. Всъщност има начин на опишем самата функция и какви типове приема и връща тя. Това става със тип-спецификацията за функции.
Тип-спецификации за функции
Нека да добавим функция set към TicTacToe.Grid. Тя ще взима Grid структура,
символ на играч и две числа - ред и колона. Нещо такова:
defmodule TicTacToe.Grid do
def set(grid, player, i, j)
end
Тази функция ще връща наредена-двойка. Ако е възникнал проблем, да речем полето,
сочено от i и j е вече заето, резултатът ще е {:error, <съобщение>}, ако
има успех, резултатът ще е {:ok, <grid-с-поставен-символ-на-играч-на-дадената-позиция>}.
Ето как ще опишем това с тип-спецификации:
defmodule TicTacToe.Grid do
@type mod_result :: {:error, String.t} | {:ok, TicTacToe.Grid.t}
@spec set(t, player, i :: pos_integer, j :: pos_integer) :: mod_result
def set(grid, player, i, j)
end
Първо дефинираме нов тип - mod_result : резултат от модификация на Grid.
Този тип е или наредена двойка от атома :error и низ - съобщение, или
наредена двойка от атома :ok и Grid. Точно каквото описахме за резултата
от set по горе.
Тип-спецификации на функции задаваме със @spec, следван от името на функцията,
аргументи в скоби, :: и тип на резултат.
В случая за функцията set казваме че приема 4 аргумента:
- Структура от тип
TicTacToe.Grid.t, можем да го реферираме просто катоtвътре в модула. - Стойност на типа
player, това значи:xили:o. - Ред - представен от
i, който е положително цяло число. - Колона - представена от
j, която е положително цяло число.
Като резултат на функцията задаваме тип mod_result.
Сега знаем какво приема и какво връща тази функция.
Още модулни атрибути свързани с тип-спецификации
Освен с @type, можем да задаваме тип спецификации с @typep. Това са скрити
типове, които не могат да се реферират от други модули, но могат да се реферират
от модулът в който са дефинирани.
Има още един начин за задаване на тип-спецификация. Говорим за типове, които
могат да се реферират публично, но структурата им не е видима. Те се дефинират
с @opaque.
Какво се има предвид? Да речем в iex, можем да прегледаме даден тип или типове:
iex> t TicTacToe.Grid.t
@type t() :: %TicTacToe.Grid{board: [[player_or_nil()]], state: state(), turn: non_neg_integer(), winner: player_or_nil()}
Ако бяхме дефинирали този тип като @opaque, щяхме да видим:
iex> t TicTacToe.Grid.t
@opaque t()
Скритите типове, дефинирани с @typep не се виждат. Ако искаме да видим всички
видими типове дефинирани в модул го правим с t <име-на-модул>:
iex> t TicTacToe.Grid
@type player() :: :o | :x
@type player_or_nil() :: player() | nil
@type state() :: :playing | :finished
@type t() :: %TicTacToe.Grid{board: [[player_or_nil()]], state: state(), turn: non_neg_integer(), winner: player_or_nil()}
@type mod_result() :: {:error, String.t()} | {:ok, TicTacToe.Grid.t()}
Още за типове можете да прочетете в документацията.
Нека сега разгледаме инструмента който прави проверки за типовете - dialyzer.
Dialyzer
Dialyzer е инструмент за анализиране на BEAM bytecode или Erlang source code.
Способен е да намери грешно ползване/подаване на типове, когато работи върху компилиран
код с тип-спецификации. Освен това намира недостижим/мъртъв код и ненужни тестове.
Името на Dialyzer означава DIscrepancy AnalYZer for ERlang или анализатор за несъответствия в Erlang код.
Хубавата новина е, че това, че Dialyzer работи върху BEAM bytecode, значи че можем да го ползваме да анализира Elixir.
Тъй като инструментът е малко неудобен за работа, има Elixir интерфейс - mix задача/библиотека, наречена Dialyxir.
Можем лесно да си добавим dialyxir като зависимост с:
defp deps do
[{:dialyxir, "~> <x>.<y>", only: [:dev]}]
end
И ще имаме на разположение mix задачата mix dialyzer.
Първият път когато го пуснем (с mix dialyzert) ще отнеме доста време.
Това е така, защото инструментът ще си построи PLT (Persistent Lookup Table) която
ще използва в последствие. Тя включва стандартната библиотека и затова отнема толкова време.
За щастие следващите пъти, когато го използваме в проекта ще минава бързо и ще проверява
само новите промени по кода.
Нека извикаме функцията set от предишните примери с аргументи, които не са правилни типове.
Да речем за player да подадем :y:
grid = %TicTacToe.Grid{}
TicTacToe.Grid.set(grid, :y, 1, 1)
Нека сега да пуснем mix dialyzer. Ще видим следният проблем:
The call 'Elixir.TicTacToe.Grid':set(
grid@1::#{
'__struct__':='Elixir.TicTacToe.Grid',
'board':=[['nil',...],...],
'state':='playing',
'turn':=0,
'winner':='nil'
},
'y',
1,
1
)
breaks the contract
(t(), player(), i::pos_integer(), j::pos_integer()) -> mod_result()
Това е добре, виждаме че не всички параметри, които подаваме на функцията са от типовете,
които са декларирани. Ако променим :y на :x, dialyzer ще приключи с успех.
Ако използваме тип-спецификации, не само че правим кода си self-documented и
по-разбираем, но когато ги съчетаем и с dialyzer е по-лесно да засечем проблеми, които
биха възникнали runtime.
Статично-типизираните езици имат проверка на типовете по време на компилация.
Ние можем да постигнем нещо такова с тип-спецификациите и dialyzer.
Поведения
Поведенията дефинират множество от спецификации за функции, които даден модул, ако иска да изпълни даденото поведение, трябва да дефинира и имплементира.
Те са нещо като интерфейси. Компилаторът ще ни нотифицира с warning ако даден
модул е дефинирал, че ще изпълни поведението, но някои от функциите не са
дефинирани и имплементирани.
Най-добре дадени концепции се разбират и овладяват с примери, затова ние ще
дефинираме поведение SynchronousCallHandler. Това е поведение на процес,
който получава и отговаря на съобщения синхронно.
defmodule SynchronousCallHandler do
@type state :: term
@callback init(args :: term) ::
{:ok, state} |
{:stop, reason :: term}
@callback handle_call(request :: term, pid, state) ::
{:reply, reply, new_state} |
{:no_reply, new_state} |
{:stop, reason} when reply: term, new_state: term, reason: term
end
Както виждате дефинираме нормален модул, но в него няма имплементирани функции, само тип-спецификации за функции.
Когато използваме @callback, ние декларираме функции на поведение. Тези
функции трябва да бъдат дефинирани от модули, които изпълняват поведението.
Първата функция на поведението е init. Идеята ѝ е да бъде нещо като конструктор
на процеса. Тя се извиква, когато процесът е създаден с аргументи от какъвто и да е тип.
Ако се върне {:ok, state}, процесът е успешно създаден и държи състоянието state.
Ако пък имплементацията върне {:stop, reason}, процесът не може да бъде създаден правилно и ще
бъде ‘убит’.
Втората функция, handle_call приема request, което е съобщение, изпратено от друг процес,
pid-a на процесът, който изпраща съобщението и текущото състояние.
Тя може да реагира по три начина:
- Да върне
{:reply, reply, new_state}.replyе съобщението което трябва да бъде изпратено на даденияpid. Точно това прави това поведение, поведение на комуникиращ синхронно процес - процесът, който ‘извиква’handle_call, чрез съобщение, ще трябва да чака за отговор. Също това извикване може да промени състоянието, новото състояние е последният елемент на наредената тройка, върната от функцията. - Да върне
{:no_reply, :new_state}- просто може да се промени състоянието. Отговор към процеса-изпращач би могъл да е атомът:ok. - Да върне
{:stop, reason}, което би трябвало да спре текущия процес с дадената причина.
Разбира се това поведение може да бъде имплементирано по различни начини. Състоянието може да бъде специфичен тип и handle_call ще може да приема различни типове request.
Самата логика за работа с една имплементация на SynchronousCallHandler трябва да дефинираме ние.
Ето една дефиниция:
defmodule Caller do
@type on_start :: {:ok, pid} | {:error, term}
@spec start(module, any) :: on_start
def start(module, args) when is_atom(module) do
starter_pid = self()
pid = spawn(fn ->
case module.init(args) do
{:ok, state} ->
send(starter_pid, {:ok, self()})
run(module, state)
{:stop, reason} ->
send(starter_pid, {:error, reason, self()})
exit(reason)
end
end)
receive do
{:ok, ^pid} -> {:ok, pid}
{:error, reason, ^pid} -> {:error, reason}
end
end
end
В модула Caller ще сложим няколко ‘клиентски’ функции за работа с SynchronousCallHandler-и.
Първата дефинираме по-горе, тя стартира един процес използвайки модул, имплементиращ SynchronousCallHandler.
Функцията start приема модула и аргументи, които да подаде на init и връща on_start резултат.
{:ok, pid}означава успех и имамеpid-а наSynchronousCallHandler-а.{:error, term}означава че процесът не може да функционира правилно с тези аргументи и е терминиран.
Говорим си за синхронни извиквания, затова всички функции в Caller работят синхронно - чакат отговор от SynchronousCallHandler имплементацията.
Как работи start?
- Пуска нов процес и в него извиква
initфункцията на имплементацията наSynchronousCallHandlerс подадените аргументи. - Ако
initвърне{:ok, state}- изпращаме отговор на процеса, извикалstart-{:ok, self()}. Извиквамеrun- това еrun loop-ът на процеса. - Ако
initвърне{:stop, reason}- изпращаме отговор на процеса, извикалstart-{:error, reason, self()}и излизаме с дадената причина. - Извикващият процес чака за отговор. Ако отговорът е, че всичко е наред връщаме
{:ok, pid}, aко има проблем връщаме{:error, <причина>}.
Използваме pid-а на процеса SynchronousCallHandler в pattern matching-а за да сме сигурни че получаваме отговори точно от него.
Нищо сложно. Нека сега да дефинираме run:
defmodule Caller do
defp run(module, state) do
receive do
{pid, msg} when is_pid(pid) ->
on_call_result(module.handle_call(msg, pid, state), pid, module)
_ -> :ok
end
end
defp on_call_result({:reply, reply, new_state}, pid, module) do
send(pid, {self(), reply})
run(module, new_state)
end
defp on_call_result({:no_reply, new_state}, pid, module) do
send(pid, {self(), :no_reply})
run(module, new_state)
end
defp on_call_result({:stop, reason}, pid, _) do
send(pid, {self(), :stop})
exit(reason)
end
end
Функцията run не е публична, тя е имплементация на трансформирането на изпратено съобщение
в handle_call извикване.
Това е едно извикване на receive, което пропуска непознати съобщения, за да не ни задръстват опашката от съобщения и прихваща {pid, <съобщение>}.
Когато едно такова съобщение е прихванато се използва модула, имплементиращ SynchronousCallHandler поведението. Извикваме му handle_call със съобщението, pid-а който получихме и текущото състояние.
Използваме on_call_result за да върнем отговор - подаваме му резултата от handle_call, pid-а и модула, имплементиращ поведението.
Функцията on_call_result има три случая:
- Да ‘отговори’ на извикващия процес с дадено съобщение, при което състоянието може да се смени. Разбира се
runсе извиква рекурсивно. - Да не отговаря - пак може да се смени състоянието, и трябва да се нотифицира процеса, че това е така с
:no_reply. Отново се извикваrun. - Да прекрати изпълнението на процеса. Изпраща отговор, че ще го направи и излиза с причината.
Това е цикълът на изпълнение на един SynchronousCallHandler.
Последната функция, която ще дефинираме в Caller е клиентска функция за лесно синхронно извикване на handle_call:
defmodule Caller do
@spec call(pid, msg :: any) :: :ok | :stopped | response :: any
def call(pid, msg) do
send(pid, {self(), msg})
receive do
{^pid, :no_reply} -> :ok
{^pid, :stop} -> :stopped
{^pid, response} -> response
end
end
end
Просто праща съобщение във формата {pid, <съобщение>} и чака за отговор - синхронно извикване.
Имплементация на поведение
Можем да иплементираме Wrapper от предната статия като SynchronousCallHandler.
Ето така:
defmodule Wrapper do
@behaviour SynchronousCallHandler
def init(state) do
{:ok, state}
end
def handle_call(:get, _, state) do
{:reply, state, state}
end
def handle_call({:set, new_state}, _, state) do
{:no_reply, new_state}
end
def handle_call({:get_and_update, f}, _, state) when is_function(f) do
{:no_reply, f.(state)}
end
def handle_call({:stop, reason}, _, _) do
{:stop, reason}
end
end
С директивата @behaviour дефинираме, че даден модул имплементира дадено поведение.
Както знаем трябва да имаме init и handle_call с правилните типове/резултати.
init просто взима състояние и го връща в наредена-двойка за успех. Все пак това е процес, опаковащ състояние. Състоянието ни е аргумента на init.
handle_call имплементира съобщенията ни за четене и модификация на състояние, както и за спиране на Wrapper.
Ето пример как го използваме:
{:ok, pid} = Caller.start(Wrapper, 5)
# {:ok, #PID<0.111.0>}
Caller.call(pid, :get)
# 5
Caller.call(pid, {:set, 6})
# :ok
Caller.call(pid, :get)
# 6
Caller.call(pid, {:get_and_update, &Kernel.*(&1, 2)})
# :ok
Caller.call(pid, :get)
# 12
Caller.call(pid, {:stop, :tired})
# :stopped
Process.alive?(pid)
# false
Лесно можем да имлементираме някаква структура от данни, да речем дърво, която живее в процес и операциите над нея.
Полиморфизъм
В статията за протоколи видяхме как можем да имплементираме функция, която може да работи с безкрайно много типове дата. Даже с такива, за които още не знаем - някой друг ще я имплементира. Друг начин да постигнем полиморфизъм е с поведения. Всъщност има и трети начин, който вече няколко статии ползваме.
Намерихме много интересна тема, дискутирана в mailing листата на Elixir.
Жозе казва, че можем да разделим кода си на три групи: процеси, модули и данни, които са взаимно-свързани.
Процесите изпълняват код, който е дефиниран и структуриран в модули, които работят с дадени типове данни.
Всяка от тези три категории има своя начин да постигне полиморфизъм:
- Можем да пратим едно и също съобщение на множество процеси. В зависимост от кода в процеса, ще имаме различно поведение. Не се интересуваме от процесите, важно е дали слушат за точно този тип съобщение.
- Когато викаме точно определена функция на модул, както по горе
module.handle_call, не се интересуваме какъв е модулът, важно е да имплементира тази функция. - Когато извикваме функция от протокол с аргумент даден тип - не се интересуваме от типа, важното е, че функцията е имплементирана за него.
Разликата между протоколите и поведенията е около какво се върти имплементацията - дали около тип данни или около модул.
Пишем протоколи, когато искаме някой да имплементира логика с определена форма за свой тип данни. Код, който искаме да бъде extend-нат за нов тип данни.
Пишем поведения, когато имаме код работещ с набор от функции и искаме някой друг да може да имплементира тези функции.
Когато пишем система, която може да се разширява с plugin-и, когато идеята ни е да пишем код, който може да сменя имплементации лесно,
когато самият ни код има места, които могат да се разширят от някой друг.
Заключение
В следващите статии ще говорим за някои поведения идващи с OTP платформата, които са написани с идеята да работят с клиентски код.
Досещате се че става въпрос за нещо, в което са намесени поведения и процеси.