elixir

Parsing Numbers in Elixir

Miguel Palhas

Miguel Palhas on

Parsing Numbers in Elixir

Like any modern programming language, Elixir has built-in tools for doing basic tasks, such as parsing numbers from strings. Although they're built-in and ready to use, it's useful to understand the underlying algorithms.

In this post we'll first explain how to convert strings to integers in Elixir. This will be quick and useful. After that we'll go straight down the rabbit hole and explain the underlying algorithms. This is the Alchemy we love. It may help you implement a parser for something similar, but it will definitely satisfy your curiosity about the mathematical ideas behind parsing numbers in Elixir.

The Quick (and Boring) Built-in

In Elixir, you can convert strings to floating point numbers using Float.parse/1:

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

This returns a tuple, where the first element is the parsed number, and the second is whatever was left of your input string once a non-numeric character was found. This is useful if you’re unsure whether your input contains additional data:

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

If you’re sure your input is a well-formatted floating point number with no additional characters, you can use a more direct approach:

1iex> String.parse_float("1.2")
21.2
3
4iex> String.parse_float("3 stroopwafels")
5** (ArgumentError) argument error
6    :erlang.binary_to_float("3 stroopwafels")

To satisfy our technical curiosity, let's dive in and see how this works internally. We won't implement everything that's required for reliably parsing floats and integers, but we'll learn enough to understand the fundamentals.

Down the Rabbit Hole

One way of thinking about number parsing is by decomposing a number into multiple components:

11234 = 1000 + 200 + 30 + 4

Using this knowledge, we can use a divide-and-conquer strategy to parse the number, by parsing each of its digits individually. Elixir’s pattern matching and recursive capabilities also fit in nicely here.

Parsing a Single Digit

Let’s start with a single digit integer for demonstration purposes.

1defmodule Parser do
2  def ascii_to_digit(ascii) when ascii >= 48 and ascii < 58 do
3    ascii - 48
4  end
5  def ascii_to_digit(_), do: raise ArgumentError
6end

The ascii_to_digit/1 function expects the ASCII code of a single digit and returns the corresponding integer. This should only work for actual numeric characters, which are in the 48 to 57 range of the ASCII table. Any other value will cause an exception to be raised.

Knowing the fact that numerical digits are declared sequentially in the ASCII table, we can simply subtract 48 to get the actual numeric value. This function will be a useful helper in the next section.

Parsing an Entire Integer

Now let’s add a function to handle an entire string containing an integer:

1defmodule Parser do
2  def parse_int(str) do
3    str
4    |> String.reverse()
5    |> do_parse_int(0, [])
6  end
7
8  def do_parse_int(<<char :: utf8>> <> rest, index, cache) do
9    new_part = ascii_to_digit(char) * round(:math.pow(10, index))
10
11    do_parse_int(
12      rest,
13      index + 1,
14      [new_part | cache]
15    )
16  end
17  def do_parse_int("", _, cache) do
18    cache
19    |> Enum.reduce(0, &Kernel.+/2)
20  end
21
22  # ...
23end

Here, the do_parse_int/3 function traverses the string, using two auxiliary arguments: a counter that increments with every new digit, giving us our current index in the traversal, and an array where we keep intermediary values.

Also, notice that we’re first reversing the string. This is because Elixir’s pattern matching only allows us to match the beginning of a string, not the end. We want to start from the least significant digit, which is at the right end of a number. So we first reverse the string, then make the traversal from left-to-right.

For each, digit, we’re multiplying it with 10^index. This means that for the string "1234" we end up with the following array:

1[1000, 200, 30, 4]

All that's left is to sum up all the elements, which is done once we match an empty string.

Note: An optimized version of this could run the Enum.reduce call on the original characters of a string, summing the digits right away instead of keeping a temporary list. We didn't do this here so that we could split the responsibilities a bit better and leave the code more readable.

Parsing Floats

To implement a parse_float/1 function, all that's left is to handle decimals. Fortunately, we can reuse our existing parse_int/1 function, along with a couple of fancy tricks to make everything work:

1defmodule Parser do
2  @float_regex ~r/^(?<int>\d+)(\.(?<dec>\d+))?$/
3
4  def parse_float(str) do
5    %{"int" => int_str, "dec" => decimal_str} = Regex.named_captures(@float_regex, str)
6
7    decimal_length = String.length(decimal_str)
8
9    parse_int(int_str) + parse_int(decimal_str) * :math.pow(10, -decimal_length)
10  end
11end

We define a @float_regex module variable, which holds a regular expression capable of capturing both the left and right side of a floating point number. The decimal separator, and it’s subsequent digits, are optional, so this regex will match "123" just as well as it matches "123.456".

Explaining the details of this regex is out of the scope of this article, but feel free to play around with it in your Elixir console.

When we run the regex, against our input, say "123.456", we end up with the following map:

1%{
2  "int" => "123"
3  "dec" => "456"
4}

We can now see where parse_int/1 comes in handy. It can be used on both parts to get 123 and 456, respectively. But how can we combine them to have the desired 123.456 as a result?

Again, math comes to our rescue. Multiplying the decimal part by 10^-3, where 3 is the length, gives us 0.456, which we can them add to the integer part to get the final result.

Our end result can parse integers and floats from strings.

1defmodule Parser do
2  def parse_int(str) do
3    str
4    |> String.reverse()
5    |> do_parse_int(0, [])
6  end
7
8  def do_parse_int(<<char::utf8>> <> rest, index, cache) do
9    new_part = ascii_to_digit(char) * round(:math.pow(10, index))
10
11    do_parse_int(
12      rest,
13      index + 1,
14      [new_part | cache]
15    )
16  end
17  def do_parse_int("", _, cache) do
18    cache
19    |> Enum.reduce(0, &Kernel.+/2)
20  end
21
22  @float_regex ~r/^(?<int>\d+)(\.(?<dec>\d+))?$/
23
24  def parse_float(str) do
25    %{"int" => int_str, "dec" => decimal_str} = Regex.named_captures(@float_regex, str)
26
27    decimal_length = String.length(decimal_str)
28
29    parse_int(int_str) + parse_int(decimal_str) * :math.pow(10, -decimal_length)
30  end
31
32  def ascii_to_digit(ascii) when ascii >= 48 and ascii < 58 do
33    ascii - 48
34  end
35  def ascii_to_digit(_), do: raise(ArgumentError)
36end

Unhandled Cases

This was a somewhat summarized demonstration of how a low-level number parser could work in Elixir. However, it does not cover every possible scenario one might want. Some things that weren't covered are:

  • Support for negative numbers
  • Support for scientific notation (e.g. 1.23e7)
  • More graceful error handling. If you’re building a parser for your own use-case, then error handling should also depend on what the use case is, as well as its conditions. Hence, it was not covered here.
  • Handling more numerical systems. Did you notice we’re using :math.pow(10,x) in a few places? Making that 10 configurable should allow us to support binary, octal or hexadecimal strings.

We'd love to know what you thought of this article, or if you have any questions. We’re always on the lookout for topics to investigate and explain, so if there’s anything in Elixir you’d like to read about, don't hesitate to let us know at @AppSignal!

Share this article

RSS
Miguel Palhas

Miguel Palhas

Guest author Miguel is a professional over-engineer at Portuguese-based Subvisual. He works mostly with Ruby, Elixir, DevOps, and Rust. He likes building fancy keyboards and playing excessive amounts of online chess.

All articles by Miguel Palhas

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps