Кортежи и списъци от ключове и стойности


В тази публикация ще разгледаме кортежите (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.