Využití atributů během překladu
Nejednou jsem v aplikaci potřeboval použít data, která vzniknou konverzí souboru stažitelného z internetu a která jsou ze svojí podstaty téměř konstantní. Například přiřazení PSČ ke konkrétnímu městu nebo číselník hlavních měst podle kódu státu.
Problém má obvykle následující charakteristiky: Soubor s daty je před použitím potřeba transformovat do vhodné datové struktury. Málokdy se může stát, že je potřeba stáhnout novou verzi zdrojových dat. A když už je to nutné, tak v tom případě nevadí, že bude třeba vydat novou verzi aplikace (tato podmínka je důležitá). Bylo by také dobré, aby po stažení nové verze zdrojových dat proběhla automaticky konverze do datové struktury, kterou aplikace používá.
V Elixiru lze takový problém vyřešit elegantním způsobem - pomocí atributů modulu. Řešení navíc přinese další výhody: Konverze originálních dat do vhodné datové struktury proběhne během překladu aplikace (proto je nutná výše zmíněná podmínka). Data budou existovat na jednom místě a jejich použití z různých procesů bude neblokující.
Pro demonstraci použiji CSV soubor, který obsahuje informace o státech. Začnu založením nového projektu country
a stažením souboru se zdrojovými daty:
mix new country
cd country
curl -L https://datahub.io/core/country-codes/r/country-codes.csv -o country-codes.csv
A nyní již kód modulu lib/country.ex
, který provede celou magii:
defmodule Country do
@country_codes_file "country-codes.csv"
@external_resource @country_codes_file
@capital_city_by_code @country_codes_file
|> File.stream!()
|> Stream.drop(1)
|> Stream.map(&String.split(&1, ","))
|> Enum.into(%{}, &{Enum.at(&1, 7), Enum.at(&1, 28)})
@doc """
Returns capital city of country identified by country `code`.
## Examples
iex> Country.code_to_capital_city("HUN")
"Budapest"
iex> Country.code_to_capital_city("XXX")
nil
"""
def code_to_capital_city(code) do
@capital_city_by_code[code]
end
end
Trik spočívá v tom, že hodnota atributu @capital_city_by_code
se vypočítá během překladu modulu. Nejprve se vytvoří stream řádků zdrojového souboru (File.stream!
), vynechá se řádek s hlavičkou (Stream.drop(1)
), každý řádek se transformuje na list políček (String.split
) a ve finále se z jednotlivých řádků vytvoří mapa, kde klíč je kód státu (sloupec 7, protože začínáme od 0) a hodnota je název hlavního města (sloupec 28). Atribut s vypočítanou mapou se následně (stále během překladu) vloží do funkce code_to_capital_city
, kde se v ní již pouze vyhledává podle klíče (to samozřejmě až při běhu aplikace).
Pokud je zdrojový CSV soubor modifikován, překladač se to dozví pomocí atributu @external_resource
a modul Country
znova přeloží. Pokud by CSV soubor nebyl v modulu registrován jako externí zdroj, při pouhé změně CSV souboru by modul překompilován nebyl. K rekompilaci by došlo až při změně v modulu (nebo po mix clean; mix compile
).
Na závěr už stačí jen pročistit soubor test/country_test.exs
defmodule CountryTest do
use ExUnit.Case
doctest Country
end
a spusit testy:
mix test
..
Finished in 0.03 seconds
2 doctests, 0 failures
Více do hloubky
Není v tom nějaký háček? Z dokumentace lze vyčíst, že pokud je atribut použit uvnitř funkce, jeho hodnota se během kompilace přečte a vloží. Někoho by mohlo napadnout, že se takto uloží celá mapa do funkce code_to_capital_city
a při každém volání funkce se mapa musí zkonstruovat v paměti. To se naštěstí neděje, protože překladač konstantní strukturu, kterou se během překladu mapování kódů států na jejich hlavní města stane, uloží do takzvaného literálu, který je uložen v přeloženém souboru modulu. Během načítání modulu se pak literál zkonstruuje a uloží do paměti a z funkce je již pouze odkazován, jak lze vidět z vygenerovaného bajtkódu:
$ iex -S mix
iex(1)> :code.which(Country) |> :beam_disasm.file()
...
{:function, :code_to_capital_city, 1, 8,
[
{:line, 1},
{:label, 7},
{:func_info, {:atom, Country}, {:atom, :code_to_capital_city}, 1},
{:label, 8},
{:move, {:x, 0}, {:x, 1}},
{:move,
{:literal,
%{
"EGY" => "Cairo",
"LCA" => "Castries",
"VIR" => "Charlotte Amalie",
...
}}, {:x, 0}},
{:line, 2},
{:call_ext_only, 2, {:extfunc, Access, :get, 2}}
]},
...
Tam lze vidět použití literálu {:move, {:literal, ...
, konkrétně přesun do registru x0
. Pokud by se mapa konstruovala přímo ve funkci, v dekompilovaném kódu by místo použití literálu bylo k nalezení konstruování mapy pomocí :put_map_assoc
, jako například v následujícím kódu, kde výsledná mapa nemůže vzniknout během překladu, protože jedna z jejích položek je výsledkem voláni funkce String.capitalize
, která se volá až při běhu programu:
def code_to_capital_1(code) do
%{
"EGY" => String.capitalize("cairo"),
"LCA" => "Castries",
"VIR" => "Charlotte Amalie"
}[code]
end
O tom, jak jsou literály v beam souboru uloženy se lze dozvědět například zde nebo zde, o optimalizacích použití literálů, ke kterým v Erlangu (a tím i v Elixiru) došlo před několika lety, například zde. A nelze samozřejmě opomenout zdroj nepřeberného množství informací o vnitřnostech beamu - The BEAM Book.