Lekser Ego w Elixirze część II – preoptymalizacje
Jarek i Wiktor udostępnili swój kod na githubie. Pierwsza rzecz, która rzuciła mi się w oczy, to ilość pracy, jaką wykonali poza kamerą. Ich rozwiązanie wspiera już kod w wielu linijkach oraz liczby. To nadal są proste zagadnienia, ale nie są trywialne. Dlatego też, w tym wpisie zajmiemy się pewnymi preoptymalizacjami, które ułatwią nam pracę w przyszłości.
Motywacja
Oryginalne założenie było takie, by implementacja w Elixirze była jak najbliższa tej w Javie. Dzięki temu osoby, które czytają mój kod, a nie znają Elixira, będą w stanie zrozumieć konstrukcję programu, patrząc i porównując go z programem napisanym w Javie. Jednak nie jest to dobra strategia. Lekser zaczyna się komplikować, a przenoszenie kodu strukturalno-obiektowego do świata funkcyjnego nie jest dobrym pomysłem. Z wielu powodów, ale najważniejszy to, że w efekcie otrzymujemy kod, który jest słaby. Dlatego ugryzę ten problem trochę inaczej. Kod będę tworzył w Elixirze. Zachowam spójność nazewniczą, o ile będzie to możliwe. Jednak przede wszystkim postaram się, by kolejne kroki były małe. Pozwoli to na lepsze wytłumaczenie różnych zagadnień i łatwiejszą analizę kodu przez czytelników.
Zacznijmy więc zabawę, ale najpierw małe przypomnienie. Kod wyjściowy wygląda w następujący sposób:
Listing 1. Kod wyjściowy
defmodule Ego.Lexer do
def tokenize(program) when is_binary(program) do
program
|> String.split("", trim: true)
|> token
end
defp token(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
defp token([], accumulator, buffer, _), do: accumulator ++ [:eof]
defp token(charlist, accumulator, buffer, :common) do
[h | t] = charlist
case h do
"(" -> token(t, accumulator ++ read_buffer(buffer) ++ [:open_bracket], [])
")" -> token(t, accumulator ++ read_buffer(buffer) ++ [:close_bracket], [])
" " -> token(t, accumulator ++ read_buffer(buffer), [])
"\"" -> token(t, accumulator ++ read_buffer(buffer), [], :text)
_ -> token(t, accumulator, buffer ++ [h])
end
end
defp token(charlist, accumulator, buffer, :text) do
[h | t] = charlist
case h do
"\"" -> token(t, accumulator ++ read_buffer(buffer), [], :common)
_ -> token(t, accumulator, buffer ++ [h], :text)
end
end
defp read_buffer([]), do: []
defp read_buffer(buffer) do
[buffer |> Enum.join("") |> String.to_atom()]
end
end
Odwrotna kolejność
Pierwszą drobną optymalizacją będzie sposób, w jaki tworzę listę tokenów. Dotychczasowy kod (Listing 1.) działał w taki sposób, że gdy mamy już gotowy
token, to zostaje on dołączony na końcu akumulatora. Jest to łatwa do zrozumienia konstrukcja, która jednak nie jest optymalna. W Erlangu
operator ++
działa w ten sposób, że kopiuje argument po lewej stronie, by wykorzystać go w nowej liście. Lepszym rozwiązaniem jest dodawanie nowych
elementów na początku listy, a następnie wywołanie Enum.reverse
, by uzyskać końcowy rezultat. Opisane jest to w
artykule The Seven Myths of Erlang Performance. W efekcie otrzymamy:
Listing 2. Zmiana sposobu tworzenia akumulatora
defmodule Ego.Lexer do
def tokenize(program) when is_binary(program) do
program
|> String.split("", trim: true)
|> token
|> Enum.reverse
end
defp token(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
defp token([], accumulator, buffer, _), do: [:eof] ++ accumulator
defp token(charlist, accumulator, buffer, :common) do
[h | t] = charlist
case h do
"(" -> token(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
")" -> token(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator , [])
" " -> token(t, read_buffer(buffer) ++ accumulator, [])
"\"" -> token(t, read_buffer(buffer) ++ accumulator, [], :text)
_ -> token(t, accumulator, [h] ++ buffer)
end
end
defp token(charlist, accumulator, buffer, :text) do
[h | t] = charlist
case h do
"\"" -> token(t, read_buffer(buffer) ++ accumulator, [], :common)
_ -> token(t, accumulator, [h] ++ buffer, :text)
end
end
defp read_buffer([]), do: []
defp read_buffer(buffer) do
[buffer |> Enum.reverse |> Enum.join("") |> String.to_atom()]
end
end
Jak widać, zmiana dotknęła wiele miejsc w kodzie. Jednak dzięki temu, że Elixir opiera się o dopasowanie wzorców, nie musieliśmy zmieniać API.
Operację odwrócenia realizujemy w dwóch miejscach. W funkcji tokenize
odwracamy listę tokenów, by otrzymać wynik. W funkcji `
read_buffer` odwracamy bufor przed jego zrzuceniem, bo bufor też jest akumulatorem, tyle tylko, że lokalnym.
No właśnie…
Nazewnictwo
Kolejny etap to doprowadzenie do porządku nazewnictwa. Funkcja read_buffer
wydaje się idealnym przykładem. Zadanie tej funkcji to odczytanie
zawartości bufora, bo chcemy go opróżnić, ale… opróżniamy bufor w innym miejscu. Taka mała podpucha, dla kogoś, kto lubi refaktoryzaować nazwy dla
samej refaktoryzacji nazw. Prawdziwym problemem jest funkcja token
, która produkuje listę tokenów. Odwróconą. Jak ją nazwać? Na obecnym
etapie tokens
będzie ok. Kolejnym drobnym problemem jest zmienna buffer
, w metodzie token(s)
dopasowanej do
pustego ciągu znaków. Jest nieużywana, więc można ją zastąpić znakiem _
. Ostatnia rzecz związana z nazewnictwem, będzie polegać na
pozbyciu się niepotrzebnego przypisania [h | t] = charlist
. To są w sumie drobne poprawki, które nie wpływają na kod.
Listing 3. Zmiana nazw
defmodule Ego.Lexer do
def tokenize(program) when is_binary(program) do
program
|> String.split("", trim: true)
|> tokens
|> Enum.reverse
end
defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
defp tokens([], accumulator, _, _), do: [:eof] ++ accumulator
defp tokens([h | t], accumulator, buffer, :common) do
case h do
"(" -> tokens(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
")" -> tokens(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator , [])
" " -> tokens(t, read_buffer(buffer) ++ accumulator, [])
"\"" -> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
_ -> tokens(t, accumulator, [h] ++ buffer)
end
end
defp tokens([h | t], accumulator, buffer, :text) do
case h do
"\"" -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
_ -> tokens(t, accumulator, [h] ++ buffer, :text)
end
end
defp read_buffer([]), do: []
defp read_buffer(buffer) do
[buffer |> Enum.reverse |> Enum.join("") |> String.to_atom()]
end
end
Nie wygląda to źle. Jednak jak zawsze w przypadku nazw można tu coś usprawnić. Kolejna zmiana jest znacznie ciekawsza.
Reprezentacja znaków – Charlisty
W Javie jest tak, że String
i Character
rozróżniamy na podstawie rodzaju cudzysłowu użytego do stworzenia obiektu. `
String to cudzysłów podwójny, zwany polskim cudzysłowem apostrofowym.
Character to cudzysłów pojedynczy, zwany brytyjskim. Istotne
jest tutaj to, że
String może zawierać wiele znaków, a
Charakter` to pojedynczy znak. W Elixirze jest trochę inaczej.
Co to jest Charlist
?
Podwójny cudzysłów definiuje nam String
. Pojedynczy definiuje tzw. Charlist
, czyli listę znaków. Jest to lista, która
zawiera liczby całkowite odpowiadające kodom poszczególnych znaków. I tak znaki (
i )
mają kody odpowiednio 40 i 41, więc
zapis ()
będzie odpowiadać [40, 41]
. Pojedynczy znak może być reprezentowany jako liczba lub też jako lista. Jest to
trochę zagmatwane i jak zaraz zobaczycie, może prowadzić do „dziwnych” konstrukcji. Najłatwiej jednak przyjąć, że w Javie będzie to po
prostu List<Character>
. Ale przecież String, który podzielimy na znaki, też jest listą. Tak, jest listą. Jednak w tym przypadku nie mamy
rozróżnienia na znaki wielobajtowe i jednobajtowe. W efekcie nie możemy łatwo odsiać pewnych znaków. Z drugiej strony nie musimy tak przejmować się
znakami wymagającymi znaku ucieczki np. znak końca linii.
Refaktoryzacja do Charlist
Zmiana jest dość prosta, ale znowuż, dotykamy praktycznie całego kodu.
Listing 4. Wprowadzamy charlist
defmodule Ego.Lexer do
def tokenize(program) when is_binary(program) do
program
|> String.to_charlist()
|> tokens
|> Enum.reverse()
end
defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
defp tokens([], accumulator, _, _), do: [:eof] ++ accumulator
defp tokens([h | t], accumulator, buffer, :common) do
case [h] do
'(' -> tokens(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
')' -> tokens(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator, [])
' ' -> tokens(t, read_buffer(buffer) ++ accumulator, [])
'"' -> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
_ -> tokens(t, accumulator, [h] ++ buffer)
end
end
defp tokens([h | t], accumulator, buffer, :text) do
case [h] do
'"' -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
_ -> tokens(t, accumulator, [h] ++ buffer, :text)
end
end
defp read_buffer([]), do: []
defp read_buffer(buffer) do
[buffer |> Enum.reverse() |> List.to_string() |> String.to_atom()]
end
end
Clue zmiany leży w warunku case
. Można ją przeprowadzić na dwa sposoby. W powyższym zapisałem case [h]
, ponieważ chcę
dopasować h
do kolejnych list znaków stworzonych za pomocą pojedynczego cudzysłowu. Zamiast tego mogę użyć dopasowania do wartości
liczbowych, ale takie rozwiązanie oznacza, że będę musiał znać kody znaków. Ta zmiana prowadzi nas do kolejnej, która jest pierwszą z dwóch zmian „na
przyszłość”.
Case
na cond
i co to jest if
W Elixirze istnieje kilka różnych struktur, które można zbiorczo nazwać instrukcjami warunkowymi. Różnią się one składnią i możliwościami, ale
docelowo są to różne odmiany dobrze znanego if
. Sam if
istnieje w Elixirze i służy do zapisu pojedynczego warunku. Jego
negacją jest unless
. Nie interesują nas one, bo nie mają tu zastosowania. Kolejną instrukcją jest case
. Dopasowuje on
argument, do kolejnych wzorców. Możemy wykorzystywać strażników do opisania wzorca. Jest to chyba najpowszechniejsza instrukcja warunkowa. Rzecz w
tym, że pracuje ona na dopasowaniu, a nie na warunku więc nie zawsze możemy ją wykorzystać. Mówiąc prościej jest ona odpowiednikiem javowego `
switch (upraszczając), a my chcemy mieć coś w rodzaju
if else if i pracować na funkcjach zwracających wartość logiczną. Do tego
właśnie służy
cond. Wykona on kolejne funkcje do momentu aż nie otrzyma wartości
true`. Czasami może oznaczać to spadek
wydajności, ale w naszym przypadku nie będzie to aż tak istotne.
Kod po zmianach będzie więc wyglądać następująco:
Listing 5. Zmiana case na cond
defp tokens([h | t], accumulator, buffer, :common) do
cond do
'(' === [h]-> tokens(t, [:open_bracket] ++ read_buffer(buffer) ++ accumulator, [])
')' === [h]-> tokens(t, [:close_bracket] ++ read_buffer(buffer) ++ accumulator, [])
' ' === [h]-> tokens(t, read_buffer(buffer) ++ accumulator, [])
'"' === [h]-> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
true -> tokens(t, accumulator, [h] ++ buffer)
end
end
defp tokens([h | t], accumulator, buffer, :text) do
cond do
'"' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
true -> tokens(t, accumulator, [h] ++ buffer, :text)
end
end
Na tym kończą sie proste refaktoryzacje, które nie dodawały nowych elementów do kodu. Jeden z użytkowników 4programmers, WeiXiao, stwierdził w komentarzu na mikro, że kod jest zwięzły. Czas go trochę rozsmarować i dodać struktury, które będą przechowywać nam więcej informacji.
Token jako struktura
Do tej pory w implementacji Jarka i Wiktora poszczególne tokeny były reprezentowane przez konkretne obiekty. Moja implementacja była pozbawiona tego elementu, ponieważ w znaczny sposób upraszczało to pisanie posta. Ot lenistwo… Teraz jednak wprowadzę odpowiednią strukturę, która będzie przechowywać dane.
Struktura Token
Elixir nie ma klas. Jest funkcyjny, więc ich nie potrzebuje. W zamian ma jednak struktury, które pozwalają na operowanie nazwanymi składowymi mapy. Efektywnie jest to mapa doposażona w kilka udogodnień jak np. ograniczenie nazw kluczy do pewnego zbioru. Struktury definiuje się w modułach, dzięki czemu nadal możemy w ramach tej samej przestrzeni nazw tworzyć funkcje. Zanim jednak utworzymy jakieś funkcje, stwórzmy strukturę.
Listing 6. Moduł Ego.Token
defmodule Ego.Token do
@enforce_keys [:kind, :value]
defstruct [:kind, :value]
end
Na razie nie potrzeba nam nic więcej. Javowy enum Kind
zastąpimy atomami i być może walidacją. Jedna uwaga. Atrybut modułu @enforce_keys
pozwala
na określenie obowiązkowych pól w strukturze. Bez niego jeżeli stworzymy strukturę, to nieprzypisane pola będą `
nil`.
Refaktoryzacja
Czas na wprowadzenie nowego formatu tokenów do kodu. Zaczniemy od przepisania testów, tak by obsługiwały nowy format.
Listing 7. Poprawione testy
defmodule Ego.LexerTest do
use ExUnit.Case
import Assertions
alias Ego.Lexer
alias Ego.Token
@moduletag :capture_log
doctest Lexer
test "()" do
result = Lexer.tokenize("()")
assert_lists_equal(result, [
%Token{kind: :open_bracket, value: '('},
%Token{kind: :close_bracket, value: ')'},
%Token{kind: :eof, value: ''}
])
end
test "( )" do
result = Lexer.tokenize("( )")
assert_lists_equal(result, [
%Token{kind: :open_bracket, value: '('},
%Token{kind: :close_bracket, value: ')'},
%Token{kind: :eof, value: ''}
])
end
test "(Print Hello)" do
result = Lexer.tokenize("(Print Hello)")
assert_lists_equal(result, [
%Token{kind: :open_bracket, value: '('},
%Token{kind: :atom, value: 'Print'},
%Token{kind: :atom, value: 'Hello'},
%Token{kind: :close_bracket, value: ')'},
%Token{kind: :eof, value: ''}
])
end
test "(Print \"Hello World\")" do
result = Lexer.tokenize("(Print \"Hello World\")")
assert_lists_equal(result, [
%Token{kind: :open_bracket, value: '('},
%Token{kind: :atom, value: 'Print'},
%Token{kind: :string, value: 'Hello World'},
%Token{kind: :close_bracket, value: ')'},
%Token{kind: :eof, value: ''}
])
end
test "(Print \"Hello ( ) World\")" do
result = Lexer.tokenize("(Print \"Hello ( ) World\")")
assert_lists_equal(result, [
%Token{kind: :open_bracket, value: '('},
%Token{kind: :atom, value: 'Print'},
%Token{kind: :string, value: 'Hello ( ) World'},
%Token{kind: :close_bracket, value: ')'},
%Token{kind: :eof, value: ''}
])
end
test "(Print \"Hello (\n) World\")" do
result =
Lexer.tokenize("""
(Print \"Hello (
) World\")
""")
assert_lists_equal(result, [
%Token{kind: :open_bracket, value: '('},
%Token{kind: :atom, value: 'Print'},
%Token{kind: :string, value: 'Hello (\n) World'},
%Token{kind: :close_bracket, value: ')'},
%Token{kind: :eof, value: ''}
])
end
test "(\u0061\u0301)" do
result = Lexer.tokenize("(\u0061\u0301)")
assert_lists_equal(result, [
%Token{kind: :open_bracket, value: '('},
%Token{kind: :atom, value: '\u0061\u0301'},
%Token{kind: :close_bracket, value: ')'},
%Token{kind: :eof, value: ''}
])
end
end
Oczywiście wszystko będzie od razu czerwone, bo zmiana jest bardzo głęboka. Poprawny więc kod. Będziemy to robić po kawałku, ponieważ zmian jest dużo i warto je omówić.
Na pierwszy ogień idzie przywrócenie do życia testów związanych z obsługą nawiasów oraz znaku końca pliku. To są bardzo proste zmiany, które szybko rozwiązują nam problem.
Listing 8. Podstawowe elmenty
defmodule Ego.Lexer do
alias Ego.Token
def tokenize(program) when is_binary(program) do
program
|> String.to_charlist()
|> tokens
|> Enum.reverse()
end
defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
defp tokens([], accumulator, _, _), do: [%Token{kind: :eof, value: ''}] ++ accumulator
defp tokens([h | t], accumulator, buffer, :common) do
cond do
'(' === [h] -> tokens(t, [%Token{kind: :open_bracket, value: '('}] ++ read_buffer(buffer) ++ accumulator, [])
')' === [h] -> tokens(t, [%Token{kind: :close_bracket, value: ')'}] ++ read_buffer(buffer) ++ accumulator, [])
' ' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [])
'"' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [], :text)
true -> tokens(t, accumulator, [h] ++ buffer)
end
end
defp tokens([h | t], accumulator, buffer, :text) do
cond do
'"' === [h] -> tokens(t, read_buffer(buffer) ++ accumulator, [], :common)
true -> tokens(t, accumulator, [h] ++ buffer, :text)
end
end
defp read_buffer([]), do: []
defp read_buffer(buffer) do
[buffer |> Enum.reverse() |> List.to_string() |> String.to_atom()]
end
end
Kolejny krok to poprawki w funkcji read_buffer
, tak by zamiast ciągu znaków emitowała odpowiedni token.
Listing 9. Obsługa atomów i zrzut bufora
defp read_buffer([]), do: []
defp read_buffer(buffer) do
value = buffer |> Enum.reverse()
[%Token{kind: :atom, value: value}]
end
No i nie działa… Dotychczas wykorzystywaliśmy tę funkcję do produkcji elixirowych atomów. To było OK, bo implementacja była prosta, ale z drugiej strony ukrywaliśmy w ten sposób klasę atomów, które reprezentują ciąg znaków. Wszystko było atomem! Teraz mamy kilka różnych klas, do których przyporządkowujemy byty, więc nasza funkcja pełniąca rolę emitera musi to jakoś obsługiwać. Na całe szczęście jest to bardzo prosta zmiana, ponieważ potrafimy opisać z jaką klasą mamy do czynienia. Gdzie? Mówi nam o tym parametr mode funkcji tokens. W efekcie kod będzie wyglądał w następujący sposób:
Listing 10. Działający kod
defmodule Ego.Lexer do
alias Ego.Token
def tokenize(program) when is_binary(program) do
program
|> String.to_charlist()
|> tokens
|> Enum.reverse()
end
defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
defp tokens([], accumulator, _, _), do: [%Token{kind: :eof, value: ''}] ++ accumulator
defp tokens([h | t], accumulator, buffer, :common) do
cond do
'(' === [h] -> tokens(t, [%Token{kind: :open_bracket, value: '('}] ++ read_buffer(buffer, :atom) ++ accumulator, [])
')' === [h] -> tokens(t, [%Token{kind: :close_bracket, value: ')'}] ++ read_buffer(buffer, :atom) ++ accumulator, [])
' ' === [h] -> tokens(t, read_buffer(buffer, :atom) ++ accumulator, [])
'"' === [h] -> tokens(t, read_buffer(buffer, :atom) ++ accumulator, [], :text)
true -> tokens(t, accumulator, [h] ++ buffer)
end
end
defp tokens([h | t], accumulator, buffer, :text) do
cond do
'"' === [h] -> tokens(t, read_buffer(buffer, :string) ++ accumulator, [], :common)
true -> tokens(t, accumulator, [h] ++ buffer, :text)
end
end
defp read_buffer([], _), do: []
defp read_buffer(buffer, kind) do
value = buffer |> Enum.reverse()
[%Token{kind: kind, value: value}]
end
end
Jest prawie dobrze. Ostatnią refaktoryzacją, którą można zrobić, to wyciągnięcie funkcji fabrykujących do modułu Ego.Token
. Tym samym
zmieni się też funkcja read_buffer
, która będzie tak jak dotychczas zwracać jedynie wartość bufora. Niestety od razu widać pewien
problem. Pierwotnie read_buffer
zwracało wartość opakowaną w listę albo pustą listę. Dzięki temu łatwo było nam dodawać listy. Teraz
zwracamy wartość lub pustą listę (sic!). Zatem możemy doprowadzić do sytuacji, gdzie zaczniemy emitować tokeny, które będą reprezentować puste listy (
bufor był pusty) i nie będą EOF. Dotyczy to tokenów reprezentujących atom i ciąg znaków. Ale i na to będzie prosta rada. Widać ją w
funkcji tokenize
. Wypłaszczamy listę tokenów. W efekcie nasz końcowy kod wygląda w następujący sposób:
Listing 11. Ego.Token po zmianach
defmodule Ego.Token do
@enforce_keys [:kind, :value]
defstruct [:kind, :value]
def open_bracket(), do: %Ego.Token{kind: :open_bracket, value: '('}
def close_bracket(), do: %Ego.Token{kind: :close_bracket, value: ')'}
def eof(), do: %Ego.Token{kind: :eof, value: ''}
def atom([]), do: []
def atom(value), do: %Ego.Token{kind: :atom, value: value}
def string([]), do: []
def string(value), do: %Ego.Token{kind: :string, value: value}
end
Listing 11. Ego.Lexer po zmianach
defmodule Ego.Lexer do
import Ego.Token
def tokenize(program) when is_binary(program) do
program
|> String.to_charlist()
|> tokens
|> Enum.reverse()
|> List.flatten()
end
defp tokens(charlist, accumulator \\ [], buffer \\ [], mode \\ :common)
defp tokens([], accumulator, _, _), do: [eof()] ++ accumulator
defp tokens([h | t], accumulator, buffer, :common) do
cond do
'(' === [h] -> tokens(t, [open_bracket()] ++ [atom(read_buffer(buffer))] ++ accumulator, [])
')' === [h] -> tokens(t, [close_bracket()] ++ [atom(read_buffer(buffer))] ++ accumulator, [])
' ' === [h] -> tokens(t, [atom(read_buffer(buffer))] ++ accumulator, [])
'"' === [h] -> tokens(t, [atom(read_buffer(buffer))] ++ accumulator, [], :text)
true -> tokens(t, accumulator, [h] ++ buffer)
end
end
defp tokens([h | t], accumulator, buffer, :text) do
cond do
'"' === [h] -> tokens(t, [string(read_buffer(buffer))] ++ accumulator, [], :common)
true -> tokens(t, accumulator, [h] ++ buffer, :text)
end
end
defp read_buffer([]), do: []
defp read_buffer(buffer) do
buffer |> Enum.reverse()
end
end
I gotowe.
Podsumowanie
Refaktoryzacje były proste i skomplikowane. Dały one elastyczniejszy kod, który będzie można znacznie łatwiej rozszerzać. Taka mała ciekawostka. Wczorajsza wersja miała 37 linii kodu. Dzisiejsza ma tyle samo. Przy czym nie pociągnąłem jej elixirowrym formaterem w domyślnych ustawieniach. Zmieniłem długość linii na 120 znaków.
Kolejne kroki to uzupełnienie mojego kodu o to, co Jarek i Wiktor dodali poza kamerą. Będzie wsparcie dla liczb i komentarzy. Kod jak zwykle na githubie.