Кортежи и списъци от ключове и стойности
В тази публикация ще разгледаме кортежите (tuples), използвани много често като резултати от извикването на функции.
Ще разгледаме и списъците от кортежи от по два елемента - първият атом, а вторият произволна стойност, които се ползват за опционални, именувани параметри на функции, наречени списъци от ключове и стойности (keyword lists).
Кортежи (tuples)
На пръв поглед, кортежите изглеждат като списъци. Те са поредица от данни и могат да съдържат данни от различни типове.
Първата разлика спрямо списъците е, че те се дефинират чрез “къдрави” скоби:
{1, :name, "boo", 7.5}
#=> {1, :name, "boo", 7.5}
Следващата разлика, която всъщност е най-основна, спрямо списъците е, че данните на tuple се пазят последователно в паметта. По това кортежите приличат на масивите в други езици за програмиране. Достъпът до елемент по индекс е много бърз (O(1) за разлика от O(n) при списъците). Но както всеки тип в Elixir и tuple типът е immutable. Това значи, че всяка “промяна” на кортеж, добавяне или премахване на елемент, update на елемент води до копиране на целия оригинален tuple с приложена тази промяна.
Можем да гледаме на кортежите не като на колекция от стойности (каквито са списъците и речниците), а като на нещо атомарно, нещо цяло.
Именно поради това функциите на модула Enum
работят със списъци и речници, но не и с кортежи.
За какво се използват кортежите в Elixir, тогава? Ще се опитаме да отговорим на този въпрос в следващите няколко секции.
Резултати на функции
Много функции в стандартните библиотеки на Elixir и Erlang връщат tuple-и.
Това са функции, които биха могли да върнат грешка и обикновено или връщат {:ok, <резултат>}
или {:error, <причина>}
или пък просто :error
.
Видяхме такова поведение при функцията Map.fetch/2
:
{:ok, value} = Map.fetch(%{a: 4, b: 5}, :a)
#=> {:ok, 4}
:error = Map.fetch(%{a: 4, b: 5}, :c)
#=> :error
Можем да използваме pattern matching ако сме сигурни, че такава функция ще върне положителен резултат.
А можем и да използваме case
или функции с pattern matching по аргумента за да определим поведение спрямо резултата:
defmodule MapReader do
def value(map, key), do: Map.fetch(map, key) |> handle()
defp handle({:ok, val}), do: val
defp handle(:error) do
IO.puts("Tried to read the value for non-existing key!")
:value_not_found
end
end
MapReader.value(%{a: 5, b: 6}, :c)
#output: Tried to read the value for non-existing key!
#=> :value_not_found
Ако просто искаме грешка при неуспех на дадена функция, този тип функции обикновено имат версия със същото име, но завършващо на удивителен знак. Тези версии или просто връщат резултата, или “вдигат” грешка. Има много подобни функции в Elixir/Erlang и ще говорим отново за тях в публикацията за IO и грешки.
Има и други видове резултати за които се използват кортежи.
Да речем handle_call
callback функцията на GenServer
, която ще разгледаме, когато говорим за OTP връща резултати от сорта на {:reply, result, state}
или {:noreply, state}
.
Кортежите като записи
По-горе казахме, че можем да гледаме на кортежите като на цялостна структура, а не просто колекция от стойности. Стойност от тип tuple е комплексна стойност изградена от множество от данни от други типове. Рядко ще срещнем кортежи от повече от 5-6 елемента, най-често ще са в диапазона 2-4.
В този ред на мисли всеки tuple може да се разглежда като запис (record). Даже в Erlang има такъв тип, record, който е кортеж, чийто първи елемент е атом, който представлява името на записа.
Пример за запис е:
user_record = {:user, "Петър", "Петров", "pe60", 45}
#=> {:user, "Петър", "Петров", "pe60", 45}
require Record
#=> Record
Record.is_record(user_record)
#=> true
Можем да имаме множество такива :user
записи.
Elixir има свой тип записи, който е много по лесен за употреба и с по-добра производителност и се основава на речниците, наречен структура (struct). Най-добре е да ползваме него, когато имаме нужда от тип от данни съставен от други типове от данни. Ще поговорим за този тип в следваща публикация.
Ако даден кортеж не започва с атом, той не се приема за запис:
Record.is_record({1, 2, 3})
#=> false
Модулът Record
идва с помощни функции и макроси за дефиниране и използване на записи.
Можете да прочетете повече за него тук.
Кортежите като редове в таблица
Erlang/OTP идва с ключ-стойност база данни в паметта. Тази база се нарича Erlang Term Store или ETS и представлява множество от таблици. Всяка такава таблица е множество от редове, които са представени от кортежи. Всяка таблица има и позиция на ключ, която представлява позицията в записите-кортежи, чиято стойност приемаме за ключ за бърз достъп.
Пример за ETS таблица:
# Име Фамилия nick възраст
{"Петър", "Петров", "pe60", 55},
{"Милена", "Милева", "mila", 25},
{"Николай", "Цветинов", "meddle", 33},
Ще поговорим по-подробно за тези таблици в следваща публикация.
Достъп до елементите и дължината на кортежи
В Kernel
модула има функция, Kernel.elem/2
, за достъп до елемент на tuple.
Както казахме достъпът е много бърз (O(1)):
value = {1, 2.0, :three, "four"}
#=> {1, 2.0, :three, "four"}
Kernel.elem(value, 1)
#=> 2.0
elem(value, 5)
#=> ** (ArgumentError) argument error
elem(value, -1)
#=> ** (ArgumentError) argument error
Ако няма такава позиция в кортежа имаме грешка. Тъй като тези функции вътрешно представляват операции на ниво Erlang, грешката не е много информативна (често срещано поведение, когато използваме функции-wrapper-и).
Взимането на размера на tuple е също константна операция и също е част от Kernel
модула:
value = {1, 2.0, :three, "four"}
#=> {1, 2.0, :three, "four"}
tuple_size(value)
#=> 4
Функции за взимане на размер на някаква стойност, които имат в името си size
са винаги от порядъка на O(1).
Ако дефинирате *size*
функция на ваш тип е добре тя да има такава производителност.
От друга страна, функции, които имат в името си length
са линейни.
Такава е и Kernel.length/1
, която връща размера на списък. Имплементирахме наша версия на тази функция в секцията за списъци.
Модула Tuple
Чрез този модул е възможно да “променяме” кортежи. Разбира се това значи да копираме оригиналния tuple с добавен/изтрит или променен елемент. Точно и затова тези функции биха ни влезли в употреба рядко.
Най-важната от тях всъщност е Tuple.to_list/1
, която създава списък от кортеж.
Можем да правим множество промени към този списък и накрая, ако е нужно да си направим отново tuple чрез List.to_tuple/1
.
Друга интересна функция е Tuple.duplicate/2
:
Tuple.duplicate(:ok, 5)
#=> {:ok, :ok, :ok, :ok, :ok}
Просто създава N-елементен кортеж като използва първия си аргумент.
Останалите функции в модула са Tuple.append/2
, Tuple.delete_at/2
и Tuple.insert_at/3
.
Тяхната употреба е достатъчно ясна от имената им за да навлизаме в подробности, но ако все пак ви е интересно да видите примери с тях,
прочетете документацията
Списъци от ключове и стойности (keyword lists)
Преди да има истински речници, в Erlang за асоциативни списъци, алтернатива на речниците, са се използвали списъци от наредени двойки - атом-стойност:
[{:name, "Петър"}, {:age, 54}, {:family, "Петров"}]
#=> [name: "Петър", age: 54, family: "Петров"]
Сега имаме доста добра имплементация на речник и не се използват за това. Освен в някои Erlang-ски функции, разбира се.
Такива списъци се използват за функции с опционални, именувани параметри, както и когато искаме да имаме асоциативен списък в който да е възможно да имаме няколко еднакви ключа.
Има специален синтаксис за тези списъци:
iex(2)> [name: "Петър", age: 54, family: "Петров"]
#=> [name: "Петър", age: 54, family: "Петров"]
Употреба като опции на функции
Ако искаме да си дефинираме функция с keyword параметри можем да го направим така:
defmodule Math do
@sum_supported_options [:round, :format]
def sum(a, b, options \\ []) when is_number(a) and is_number(b) and is_list(options) do
with true <- Keyword.keyword?(options),
opt_keys = Keyword.keys(options),
true <- Enum.all?(opt_keys, fn k -> k in @sum_supported_options end),
round = Keyword.get(options, :round, :false),
format = Keyword.get(options, :format, :int),
{:ok, rounded} <- round_sum(a + b, round),
{:ok, formatted} <- format_sum(rounded, format)
do
{:ok, formatted}
else
{:error, error} -> {:error, error}
false -> {:error, :bad_args}
end
end
def sum(_, _, _), do: {:error, :bad_args}
defp round_sum(n, true), do: {:ok, Kernel.round(n)}
defp round_sum(n, false), do: {:ok, n}
defp round_sum(_, _), do: {:error, :bad_args}
defp format_sum(n, :int), do: {:ok, Kernel.trunc(n)}
defp format_sum(n, :float), do: {:ok, n / 1}
defp format_sum(n, :string), do: {:ok, String.Chars.to_string(n)}
defp format_sum(n, :charlist), do: {:ok, List.Chars.to_charlist(n)}
defp format_sum(_, _), do: {:error, :unknown_format}
end
По този начин си дефинираме две функции Math.sum/2
и Math.sum/3
. Или просто една, Math.sum/2
с опционални именувани параметри.
Идеята е, че когато съберем две числа, дали цели или с плаваща запетая, можем да искаме да ги закръглим или/и да ги форматираме.
В този пример проверяваме дали са ни подадени правилните опции, ако някакви са подадени, въобще.
По подразбиране имаме празен списък и използваме Keyword.get(keywordlist, key, default_value)
за да си дефинираме default стойностите за всяка опция.
Забележете как използваме with
тук.
Ако дори един от изразите на оператора пропадне, ще отидем в else
частта и ще match-нем проблема.
Само ако подадените ни опции са подмножество на @sum_supported_options
ще ги прочетем.
Оператора with
поддържа дефиниране на променливи като част от условията си и така дефинираме round
и format
само ако
условието над тях е изпълнено.
Тази функция връща tuple от типа {:ok, result}
или {:error, reason}
.
Това е едно добро приложение на кортежите, както споменахме в предишната секция.
Забележете и как такива tuple се ползват в match изрази в условията на with
израза.
Нека да видим сега, как използваме горната функция:
Math.sum(4, 5)
#=> {:ok, 9}
Math.sum(4, 5.2)
#=> {:ok, 9}
Math.sum(4, 5.2, format: :float)
#=> {:ok, 9.2}
Math.sum(4, 5.2, format: :string)
#=> {:ok, "9.2"}
Math.sum(4, 5.2, format: :string, round: true)
#=> {:ok, "9"}
Math.sum(4.4, 5.2, format: :string, round: true)
#=> {:ok, "10"}
Math.sum(4.4, 5.2, format: :string, round: true, something: :else)
#=> {:error, :bad_args}
Math.sum(4.4, 5.2, format: :tuple)
#=> {:error, :unknown_format}
Има много такива функции в модулите, които идват с Elixir.
В публикацията на тема “Основни типове” ви показахме функцията String.split/3
.
Друг интересен пример е if
:
n = 4
#=> 4
if(n == 5, do: 5)
#=> nil
if(n == 5, do: :five, else: n)
#=> 4
n = 5
#=> 5
if(n == 5, do: :five, else: n)
#=> :five
И така отново ви напомняме, че даже конструкция като if
в Elixir всъщност е написана на Elixir и се държи като проста функция (но е нещо малко по-различно - макрос).
Нека сега да разгледаме по-подробно модулите за работа с keyword списъци.
Модула Keyword
Модулът Keyword
е колекция от функции за достъп и модификация на списък от ключове и стойности.
Няма да навлизаме в подробности, това е работа на документацията.
Интересното е, че този модул и модула Map
имат много подобни функции.
Това е нормално защото keyword list-ите най-често се ползват като асоциативни списъци и точно това отразява модула Keyword
.
По горе видяхме пример как да ползваме Keyword
. Как да проверим дали списък е keyword list, да вземем всичките му ключове за да проверим свойство за тях, да четем стойност по ключ.
Една от разликите на списъците от ключове и стойности е, че те могат да имат повтарящи се ключове.
Нещо, което да речем HTTP заявките също могат да имат. Да речем ако имаме endpoint който поддържа множество типове на резултат и искаме някои от тях ще изпратим нещо като result_type=red&result_type=blue
.
Бихме могли да си имплементираме логиката, така че HTTP параметрите да ни идват като keyword list:
params = CustomLogic.to_params(http_params_string)
#=> [result_type: "red", result_type: "blue", another_param: "value"]
Ако искаме да вземем всички стойности за даден ключ можем да използваме Keyword.get_values/2
:
params = CustomLogic.to_params(http_params_string)
result_types = Keyword.get_values(params, :result_type)
#=> ["red", "blue"]
Тази функция връща списък от всички стойности на за даден ключ, който може и да се повтаря.
Заключение
В предишните няколко публикации разгледахме типовете в Elixir, типовете, които представляват колекции или множество от стойности и работата с тях.
В следващата публикация ще разгледаме два модула специализирани за работа с колекции - Enum
и Stream
.
Често, когато пишем код ще имаме нужда от колекции и модулите свързани с тях, като Map
и List
няма да са ни достатъчни.
Има доста общи операции, които можем да изпълним над колекции и те са имплементирани в Enum
и Stream
(lazy).
Ще разгледаме и използваме и едни много интересни операции, свързани с обхождане и генериране на колекции - comprehensions.