Като всеки модерен език за програмиране, Elixir има вградени инструменти за извършване на основни задачи, като анализиране на числа от низове. Въпреки че са вградени и готови за използване, е полезно да разберете основните алгоритми.

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

Бързият (и скучен) вграден

В Elixir можете да конвертирате низове в числа с плаваща запетая с помощта на Float.parse/1:

iex> Float.parse("1.2")
{1.2, ""}

Това връща кортеж, където първият елемент е анализираното число, а вторият е всичко, което е останало от вашия въведен низ, след като е намерен нечислов знак. Това е полезно, ако не сте сигурни дали въведеното от вас съдържа допълнителни данни:

iex> Float.parse("3 stroopwafels")
{3.0, " stroopwafels"}

iex> Float.parse("stroopwafels? 3, please")
:error # This fails because the number needs to be at the beginning of the string

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

iex> String.parse_float("1.2")
1.2

iex> String.parse_float("3 stroopwafels")
** (ArgumentError) argument error
    :erlang.binary_to_float("3 stroopwafels")

За да задоволим техническото си любопитство, нека се потопим и да видим как това работи вътрешно. Няма да внедрим всичко, което е необходимо за надеждно анализиране на плаващи и цели числа, но ще научим достатъчно, за да разберем основите.

В заешката дупка

Един от начините да мислим за анализирането на числата е чрез разлагане на число на множество компоненти:

1234 = 1000 + 200 + 30 + 4

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

Разбор на една цифра

Нека започнем с едноцифрено цяло число за демонстрационни цели.

defmodule Parser do
  def ascii_to_digit(ascii) when ascii >= 48 and ascii < 58 do
    ascii - 48
  end
  def ascii_to_digit(_), do: raise ArgumentError
end

Функцията ascii_to_digit/1 очаква ASCII кода от една цифра и връща съответното цяло число. Това трябва да работи само за действителни цифрови знаци, които са в диапазона от 48 до 57 на ASCII таблицата. Всяка друга стойност ще предизвика изключение.

Знаейки факта, че числовите цифри се декларират последователно в ASCII таблицата, можем просто да извадим 48, за да получим действителната числова стойност. Тази функция ще бъде полезен помощник в следващия раздел.

Разбор на цяло число

Сега нека добавим функция за обработка на цял низ, съдържащ цяло число:

defmodule Parser do
  def parse_int(str) do
    str
    |> String.reverse()
    |> do_parse_int(0, [])
  end

  def do_parse_int(<<char :: utf8>> <> rest, index, cache) do
    new_part = ascii_to_digit(char) * round(:math.pow(10, index))

    do_parse_int(
      rest,
      index + 1,
      [new_part | cache]
    )
  end
  def do_parse_int("", _, cache) do
    cache
    |> Enum.reduce(0, &Kernel.+/2)
  end

  # ...
end

Тук функцията do_parse_int/3 обхожда низа, като използва два спомагателни аргумента: брояч, който се увеличава с всяка нова цифра, давайки ни текущия ни индекс при обхождането, и масив, където съхраняваме междинни стойности.

Освен това забележете, че първо обръщаме низа. Това е така, защото съпоставянето на шаблони на Elixir ни позволява да съпоставим само началото на низ, а не края. Искаме да започнем от най-малката цифра, която е в десния край на числото. Така че първо обръщаме низа, след това правим обхождане отляво надясно.

За всяка цифра я умножаваме с 10^index. Това означава, че за низа "1234" получаваме следния масив:

[1000, 200, 30, 4]

Всичко, което остава, е да сумираме всички елементи, което става, след като съпоставим празен низ.

Забележка: Оптимизирана версия на това може да изпълни извикването Enum.reduce върху оригиналните знаци на низ, сумирайки цифрите веднага, вместо да поддържа временен списък. Не направихме това тук, за да можем да разделим малко по-добре отговорностите и да оставим кода по-четлив.

Разбор на плувки

За да приложите функция parse_float/1, всичко, което остава, е да обработвате десетични знаци. За щастие можем да използваме отново нашата съществуваща функция parse_int/1, заедно с няколко фантастични трика, за да накараме всичко да работи:

defmodule Parser do
  @float_regex ~r/^(?<int>\d+)(\.(?<dec>\d+))?$/

  def parse_float(str) do
    %{"int" => int_str, "dec" => decimal_str} = Regex.named_captures(@float_regex, str)

    decimal_length = String.length(decimal_str)

    parse_int(int_str) + parse_int(decimal_str) * :math.pow(10, -decimal_length)
  end
end

Ние дефинираме модулна променлива @float_regex, която съдържа регулярен израз, способен да улови както лявата, така и дясната страна на число с плаваща запетая. Десетичният разделител и следващите цифри не са задължителни, така че този регулярен израз ще съвпада с "123" също толкова добре, колкото и с "123.456".

Обяснението на подробностите за този регулярен израз е извън обхвата на тази статия, но не се колебайте да си поиграете с него във вашата конзола Elixir.

Когато изпълним регулярния израз, срещу нашия вход, да речем "123.456", завършваме със следната карта:

%{
  "int" => "123"
  "dec" => "456"
}

Сега можем да видим къде parse_int/1 е полезен. Може да се използва и за двете части, за да получите съответно 123 и 456. Но как можем да ги комбинираме, за да имаме желания 123.456 като резултат?

Отново математиката ни идва на помощ. Умножаването на десетичната част по 10^-3, където 3 е дължината, ни дава 0.456, което можем да добавим към целочислената част, за да получим крайния резултат.

Нашият краен резултат може да анализира цели числа и плаващи числа от низове.

defmodule Parser do
  def parse_int(str) do
    str
    |> String.reverse()
    |> do_parse_int(0, [])
  end

  def do_parse_int(<<char::utf8>> <> rest, index, cache) do
    new_part = ascii_to_digit(char) * round(:math.pow(10, index))

    do_parse_int(
      rest,
      index + 1,
      [new_part | cache]
    )
  end
  def do_parse_int("", _, cache) do
    cache
    |> Enum.reduce(0, &Kernel.+/2)
  end

  @float_regex ~r/^(?<int>\d+)(\.(?<dec>\d+))?$/

  def parse_float(str) do
    %{"int" => int_str, "dec" => decimal_str} = Regex.named_captures(@float_regex, str)

    decimal_length = String.length(decimal_str)

    parse_int(int_str) + parse_int(decimal_str) * :math.pow(10, -decimal_length)
  end

  def ascii_to_digit(ascii) when ascii >= 48 and ascii < 58 do
    ascii - 48
  end
  def ascii_to_digit(_), do: raise(ArgumentError)
end

Неразгледани случаи

Това беше донякъде обобщена демонстрация на това как анализатор на числа от ниско ниво може да работи в Elixir. Въпреки това, той не покрива всеки възможен сценарий, който човек може да пожелае. Някои неща, които не бяха обхванати, са:

  • Поддръжка на отрицателни числа
  • Поддръжка за научна нотация (напр. 1.23e7)
  • По-елегантно обработване на грешки. Ако създавате анализатор за вашия собствен случай на употреба, тогава обработката на грешки също трябва да зависи от това какъв е случаят на употреба, както и неговите условия. Следователно, това не беше разгледано тук.
  • Работа с повече числови системи. Забелязахте ли, че използваме :math.pow(10,x) на няколко места? Правенето на този 10 конфигурируем трябва да ни позволи да поддържаме двоични, осмични или шестнадесетични низове.

Ще се радваме да разберем какво мислите за тази статия или ако имате въпроси. Ние винаги търсим теми за разследване и обяснение, така че ако има нещо в Elixir, за което искате да прочетете, не се колебайте да ни уведомите на @AppSignal!

Тази публикация е написана от гост-автор Мигел Палхас. Мигел е професионален свръхинженер на @subvisual и организира @rubyconfpt и @MirrorConf.

Първоначално публикувано в blog.appsignal.com на 24 юли 2018 г.