Структури


С помощта на речниците в Elixir можем да създаваме нещо като свои собствени типове. Това са Map-ове с име и точно определени ключове, които са обвързани с модул, чиито функции обикновено боравят с тях.

Почти всички функции за работа с речници могат да работят със структури, затова е добре да сте запознати с публикацията на тема речници, преди да прочетете тази.

Дефиниция

Структурите, както казахме, представляват ограничени речници. Те са ограничени по няколко признака:

  1. Ключовете им задължително са атоми.
  2. Ключовете им са предефинирани.
  3. Много свойства на речниците не са валидни за структури. Да речем, достъп от сорта на some_struct[:some_atom] е невъзможен.

Структурите се дефинират в модул. Името на модула е името на структурата:

defmodule Person do
  defstruct [:name, :age, location: "Far away", children: []]
end

Създаваме структурата Person с четири полета. Полетата name и age нямат зададена определена default-на стойност, затова са nil по подразбиране. Създаваме инстанция на структурата по начин, подобен на този, с който създаваме речници:

pesho = %Person{name: "Пешо", age: 35, location: "Горен Чвор"}
#=> %Person{age: 35, children: [], location: "Горен Чвор", name: "Пешо"}

Лесно можем да видим какво представлява pesho:

inspect(pesho, structs: false)
#=> "%{__struct__: Person, age: 35, children: [], location: \"Горен Чвор\", name: \"Пешо\"}"

Както виждаме, има скрит ключ __struct__ със стойност името на структурата. Другата разлика с динамичните речници е, че структурите не имплементират някои протоколи, които ще видим в следващата статия. Хубава новина е, че Map модулът работи със структури:

Map.put(pesho, :name, "Стойчо")
#=> %Person{age: 35, children: [], location: "Горен Чвор", name: "Стойчо"}

Операторът за обновяване също работи:

%{pesho | name: "Стойчо"}
#=> %Person{age: 35, children: [], location: "Горен Чвор", name: "Стойчо"}

Също така:

is_map(pesho)
#=> true
is_map(%{"key" => "value"})
#=> true

map_size(pesho)
#=> 5
map_size(%{"ключ" => "стойност"})
#=> 1

Тези две Kernel функции проверяват съответно дали стойност е речник и колко ключа има даден речник. Функцията Kernel.is_map/1 връща true и за структури. А функцията Kernel.map_size/1 връща 5, макар pesho да има само 4 дефинирани ключа. Това е така, защото тя брои и скрития __struct__ ключ.

Можем да съпоставяме структури с речници и други структури от същия тип:

%{name: x} = pesho
#=> %Person{age: 35, children: [], location: "Горен Чвор", name: "Пешо"}
x
#=> "Пешо"

# Същото поведение:
%Person{name: x} = pesho
#=> %Person{age: 35, children: [], location: "Горен Чвор", name: "Пешо"}
x
#=> "Пешо"

#Но това не е валидно:
%Person{} = %{}
#=> ** (MatchError) no match of right hand side value: %{}

Последното поведение е нормално, защото Person има __struct__ ключ, както и стойности по подразбиране. Поради това никоя структура отляво не може да се съпостави с речник отдясно. Обратното е възможно.

Понякога искаме при създаването на дадена структура задължително да се задават стойности за даден ключ, или с други думи, да имаме задължителни полета. Това можем да постигнем чрез модулния атрибут @enforce_keys:

defmodule Person do
  @enforce_keys [:age, :name]
  defstruct [:name, :age, location: "Far away", children: []]
end

%Person{name: "Гошо"}
#=> ** (ArgumentError) the following keys must also be given when building struct Person: [:age]
#=>    expanding struct: Person.__struct__/1

%Person{name: "Гошо", age: 58, children: [%Person{name: "Пешо", age: 34}]}
#=> %Person{
#=>   age: 58,
#=>   children: [
#=>     %Person{age: 34, children: [], location: "Far away", name: "Пешо"}
#=>   ],
#=>   location: "Far away",
#=>   name: "Гошо"
#=> }

За какво и как да ползваме структури

Структурите се дефинират в модул – с идеята, че са нещо като тип, дефиниран от нас. В модула би трябвало да напишем специални функции, които да работят с този тип. Така имаме на едно място дефиницията на типа и функциите за работа с него.

Пример е MapSet:

inspect(MapSet.new([2, 2, 3, 4]), structs: false)
#=> "%{__struct__: MapSet, map: %{2 => true, 3 => true, 4 => true}}"

Това е структура. Вътрешно е представена с речник под ключа map. За да има уникални стойности, тоест да е множество, използва ключовете на този речник като свои стойности. При ключовете на речник, както знаем, няма повторения, затова той работи и стойностите са просто true.

MapSet.union(MapSet.new([1, 2, 3]), MapSet.new([2, 3, 4]))
#=> #MapSet<[1, 2, 3, 4]>

Както споменахме, самият модул MapSet дефинира набор от функции за работа със структурата MapSet.

Това не означава, че можем да гледаме на структурите като на класове и на функциите в модулите им като на техни методи. Структурите не са класове. Elixir не е обектно-ориентиран език. Структурите са речници, които имат предварително дефинирана структура. Те са immutable и няма как тяхното състояние да се промени.

Ако създадем структура Post, която представлява блог пост, ние просто създаваме речник с предварително дефинирани полета, представляващ публикация. В модула му ще има функции за създаване на Post от файл или от друг източник, като и функции за някаква валидация. Няма нужда от сложни взаимодействия между структури, защото всичко e immutable. Действията, с които разполагаме, са прости, малки функции, които композираме.

Структурите задават определена форма на данни, чиято конструкция познаваме. Ако ги комбинираме с тип-спецификациите, за които ще говорим в отделна публикация, получаваме добра документация на данните, с които работим. Именно поради това можем да приемаме структурите като дефинирани от нас типове.

Някои структури, идващи с Elixir

В предишната секция ви показахме MapSet, която представлява множество. Модулът на тази структура съдържа функции за работа с множества като сечения, разлики, сравнения.

Друга такава структура е поредицата (Range), която вече сме показвали. Всъщност, когато говорихме за енумерации, споменахме както Range, така и MapSet. Как можем да направим наша структура енумерация, ще видим в следващата публикация.

Range е поредица с начало и край:

range = 1..10
#=> 1..10

inspect(range, structs: false)
#=> "%{__struct__: Range, first: 1, last: 10}"

first..last = range
#=> 1..10
first
#=> 1
last
#=> 10

Поредицата е от цели числа, като ако я обходим, ще минем през всички цели числа от началото до края ѝ:

range = 1..100
#=> 1..100

# Сума от 1 до 100:
Enum.reduce(range, 0, fn n, acc -> n + acc end)
#=> 5050

Както знаем, unicode codepoint-ите са цели числа, така че:

?a..?z
#=> 97..122

?а..?я
#=> 1072..1103

Enum.map(?a..?z, &(&1 - 32)) |> Enum.map(&<< &1::utf8 >>)
#=> ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
#=>  "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"]

Можем и да проверим дали дадено число се съдържа в дадена поредица:

5 in 1..10
#=> true

В Elixir регулярните изрази са имплементирани със структура и функциите за работа с тях са в модула Regex. Има съкратен синтаксис за създаване на регулярни изрази, но може да се използва и модулът:

phone_number = ~r/^\+?[\d\s]{3,}$/
#=> ~r/^\+?[\d\s]{3,}$/


{:ok, phone_number} = Regex.compile("^\\+?[\\d\\s]{3,}$")
#=> {:ok, ~r/^\+?[\d\s]{3,}$/}

inspect(phone_number, structs: false)
#=> %{
#=>   __struct__: Regex,
#=>   opts: "",
#=>   re_pattern: {:re_pattern, 0, 0, 0,
#=>    <<69, 82, 67, 80, 113, 0, 0, 0, 16, 0, 0, 0, 1, 0, 0, 0, 255, 255, 255, 255,
#=>      255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0,
#=>      ...>>},
#=>   re_version: "8.41 2017-07-05",
#=>   source: "^\\+?[\\d\\s]{3,}$"
#=> }

Има и специален оператор за match-ване на регулярни изрази, =~:

"+359 887 172 213" =~phone_number
#=> true

"0887172213" =~ phone_number
#=> true

"31" =~ phone_number
#=> false

Няма да навлизаме в подробности за регулярните изрази. За повече информация погледнете документацията на модула Regex. Регулярните изрази на Elixir ползват модула на Erlang :re и са perl compatible.

Други структури, които ще ползвате често, са Date, Time и DateTime. Ето пример за Time:

time = ~T[23:00:07.001]
#=> ~T[23:00:07.001]

inspect(time, structs: false)
#=> %{
#=>   __struct__: Time,
#=>   calendar: Calendar.ISO,
#=>   hour: 23,
#=>   microsecond: {1000, 3},
#=>   minute: 0,
#=>   second: 7
#=> }

Има и много други структури, идващи с езика, както и такива, които ще срещате и използвате, когато използвате различни библиотеки. В следващата статия ще разберем как да направим така, че различни структури да бъдат подавани на една и съща функция, а също и – спрямо това коя е структурата – да се изпълнява различна логика – по начин, който е разширим за нови структури.