Numbers: A generic wrapper to use *any* custom Numeric type!

Hey, everyone!

Today I started writing a small library to perform computations with Complex Numbers. (ComplexNum, it is still very much unfinished). While working on that, I realized that I did not only wanted to be able to specify the real and imaginary parts as Integers or Floats, but also Decimals, Rationals or other numeric data types.

This made me realize that it was possible to create a more general ‘dispatching’ module, as long as each of the numeric data types followed the same API (i.e. the same Behaviour):

And thus Numbers was born.

What does it do?

It allows you to call Numbers.add, Numbers.sub, Numbers.mult, etc. regardless of what data types you are performing arithmetic with. As long as they are the same type (or one of them is a built-in data type such as an integer or float, in which case they will be attempted to automatically be converted to the custom data type), it will Just Work™.

Some Examples:

iex> alias Numbers, as: N

iex> N.add(1, 2)
3

iex> N.mul(3,5)
15

iex> N.mul(1.5, 100)
150.0

Using Decimals: (requires the Decimal package)

iex> d = Decimal.new(2)
iex> N.div(d, 10)
#Decimal<0.2>
iex> small_number = N.div(d, 1234)
#Decimal<0.001620745542949756888168557536>
iex> N.pow(small_number, 100)

Using Rationals: (requires the Ratio package)

iex> use Ratio, operators: false
iex> res = N.add(1 <|> 2, 3 <|> 5)
11 <|> 10
iex> N.mult(res, 10)

How does it work?

Simply put, the Numbers module accepts for most functions two arguments that should be of the same struct type (optionally, one of them could be an integer or float which is automatically converted by Numbers to the struct of the other argument). It will extract the module name of the struct, and call the functions named add, sub, etc. on that module.

So Numeric is a Behaviour, to follow. Numbers dispatches that behaviour, which means that you can build functions and data types that wrap any kind of number, including custom-built ones.

Some libraries that now use Number in practice, meaning that they can contain any kind of number and be able to perform mathematical operations on their contents, are Tensor and ComplexNum.

What data types are supported right now?

Right now, I know of the following data types that follow the behaviour:

  • built-in Integers
  • built-in Floats
  • Ratio for rational numbers.
  • Decimal for decimal numbers. (Does not yet formally implement the Numeric behaviour using @behaviour, I’ll create a Pull Request on its repository adding just that shortly. In the meantime, as it already informally follows the behaviour, it already simply works!).

One of the things I like the most, is that the following structures both dispatch using Numbers internally as well as implement the Numeric Behaviour, meaning that they can be used as composite Numeric types, when the type they contain follow the Numeric Behaviour. (So you can do elementwise addition of multiple Vectors of Decimals, or even a Matrix filled with Vectors of Complex numbers of Floats). The possibilities are endless!

  • ComplexNum for complex numbers. (as long as the data type used for the real/imaginary parts itself follows Numeric.
  • Tensor for Vectors, Matrices and higher-order Tensors.

Tl;Dr: Numbers is a wrapper exposing the generic functionality all kinds of (custom or built-in) numeric datatypes have. So you can use it to make your code number-agnostic!

I look forward to any and all feedback from you!

Thanks,

~Wiebe-Marten/Qqwy

7 Likes

Note that I have published ComplexNum now as well, as its basic API is now finished and stable. (Although there might be some functionality missing, I don’t know :stuck_out_tongue_winking_eye: )

1 Like

I have added some more documentation to the library, as well as some better error handling when conversions happen.

I am of course eager to hear any feedback, especially on the way Numbers wraps other modules, and on the error handling it performs. :slight_smile:

I am curious why it uses a behaviour. Wouldn’t this be a natural use case for protocols?

There are two reasons for using a behaviour:

  1. Protcols only dispatch based on the first (leftmost) argument, which means that they cannot be used directly. I could ‘fake’ access to the Protocol functions by calling Protocolname.Structname.methodname because this is how protocols are defined underwater in Elixir right now, but as this might change in future versions, this is not a proper strategy.
  2. I wanted to be able to use data types that already existed (Ratio and Decimal), without needing to define protocols for them myself, as this would mean either all of them needing Numbers as direct dependency, or having many more auxillary packages that include the different protocols.

I am not sure if my reasoning is sound, but this is why Numeric is a Behaviour instead of a Protocol.

Looks great as always. ^.^

1 Like

Hey guys! I am back from vacation, during which I had some great new ideas.

Numbers has been completely overhauled!

A release candidate for version five (v5.0.0-rc0) is now available on Hex. Please give it a whirl and let me know if you find any rough edges.

This is a major version release because the internal structure is completely changed (and some old exceptions that used to be thrown at certain times no longer exist):

  • Instead of one Numeric behaviours as previously, a whole set of Protocols is now used, so types are no longer required to implement all of the operations if only some of them make sense for a certain numeric type (and when you get into more niche mathematical realms like the realm of elliptic curves for instance, these things do happen very frequently).
  • Using protocols also means that libraries are no longer forced to use the (semi-arbitrarily) function names that Numbers uses for the different operations.
  • Dispatching based on (consolidated!) protocols also means that the library should be a lot more performant. Although I have not done any benchmarks, in the old version of the library there were a lot of extra run-time checks going on that are now unnecessary.
  • A new library called coerce now handles the optional coercion of data types. Previously, coercion was something that was completely handled at runtime. Now, most of it already happens at compile-time, meaning that coercion is probably a lot faster too. coerce also has a lot clearer, more explicit way to define coercions, which I am very happy about as well.
  • No longer ‘ad hoc’ Decimal support. Decimal is now an optional dependency with an explicit version number, and a Decimal-implementation of the protocols is made conditionally using Code.ensure_loaded?, so you’ll only need to add Numbers to e.g. your Phoenix project to get started. Since Numbers will automatically coerce integers and floats to decimals if the other number in an operation is a decimal, this might really increase the readability of your mathematical algorithms. Thanks to @ericmj for telling me about optional dependencies, and also for Decimal itself.

I am very eager for feedback! I feel Numbers is getting quite mature now. :grin:

A full-fledged version will be released as soon as I’m convinced there are no glaring oversights anymore, and I’ll update ComplexNum, Tensor, Rational and FunLand at the same time to support the new version as well.

2 Likes

All right!

The full version has been released, and we’re now at 5.1.0, because optional, explicit opt-in support has been added for overloaded arithmetical operators.

Tensor, ComplexNum and Ratio have been updated to work with the new version as well.

1 Like

I’ve not really benchmarked elixir protocols to my protocol_ex implementation, but I whipped up a quick mini-Numbers protocol with add/2 and mult/2 and implemented it for integers, floats, Decimal, and MyDecimal (a Decimal-like thing I whipped up that duplicates Decimal’s functionality for add/mult but uses tagged tuples instead of maps, because screw maps for math-heavy code, and yes it includes the huge amount of :inf and so forth checks that the Decimal library does, to use as an accurate comparison, it’s internal format is {MyDecimal, s, c, e}), just for testing the comparison between elixir protocols and a more powerful protocol (not necessarily better, the more constrained elixir protocols may be easier for beginners, but they have a cost):

##### With input Decimal #####
Name                ips        average  deviation         median
MyNumbers      984.85 K        1.02 μs    ±73.51%        1.50 μs
Numbers        746.00 K        1.34 μs    ±55.76%        1.60 μs

Comparison:
MyNumbers      984.85 K
Numbers        746.00 K - 1.32x slower

##### With input Floats #####
Name                ips        average  deviation         median
MyNumbers       23.06 M      0.0434 μs    ±18.33%      0.0470 μs
Numbers          5.06 M       0.197 μs    ±35.11%       0.160 μs

Comparison:
MyNumbers       23.06 M
Numbers          5.06 M - 4.55x slower

##### With input Integers #####
Name                ips        average  deviation         median
MyNumbers       30.80 M      0.0325 μs    ±18.71%      0.0310 μs
Numbers          5.78 M       0.173 μs   ±283.66%         0.0 μs

Comparison:
MyNumbers       30.80 M
Numbers          5.78 M - 5.33x slower

##### With input MyDecimal #####
Name                ips        average  deviation         median
MyNumbers        4.19 M        0.24 μs    ±32.83%        0.31 μs
Numbers          2.78 M        0.36 μs   ±183.11%         0.0 μs

Comparison:
MyNumbers        4.19 M
Numbers          2.78 M - 1.51x slower

That is of course with Benchee complaining that everything is way too fast to test accurately so the numbers themselves are not really accurate, however the comparisons should be accurate (the #.##% slower parts).

1 Like

The earlier one was not ‘coercing’ either like Numbers was, here are some benches with and without coerce’ing, mine uses my own version of coerce using a single tuple protocol dispatch instead of nested protocols as Numbers uses.

##### With input Decimal #####
Name                              ips        average  deviation         median
MyNumbers                   1045.07 K        0.96 μs     ±9.77%        0.94 μs
MyNumbers - sans coerce     1034.26 K        0.97 μs     ±9.54%        0.94 μs
Numbers                      984.75 K        1.02 μs    ±73.51%        1.50 μs
Numbers - sans coerce        955.22 K        1.05 μs    ±70.26%        1.50 μs
Numbers - my coerce          918.44 K        1.09 μs    ±66.08%        1.50 μs

Comparison:
MyNumbers                   1045.07 K
MyNumbers - sans coerce     1034.26 K - 1.01x slower
Numbers                      984.75 K - 1.06x slower
Numbers - sans coerce        955.22 K - 1.09x slower
Numbers - my coerce          918.44 K - 1.14x slower

##### With input Floats #####
Name                              ips        average  deviation         median
MyNumbers - sans coerce       23.18 M      0.0431 μs    ±17.96%      0.0470 μs
MyNumbers                     14.49 M      0.0690 μs   ±112.54%         0.0 μs
Numbers - sans coerce         10.83 M      0.0923 μs    ±15.22%      0.0930 μs
Numbers - my coerce            9.05 M       0.110 μs    ±64.49%       0.150 μs
Numbers                        5.26 M       0.190 μs   ±268.90%         0.0 μs

Comparison:
MyNumbers - sans coerce       23.18 M
MyNumbers                     14.49 M - 1.60x slower
Numbers - sans coerce         10.83 M - 2.14x slower
Numbers - my coerce            9.05 M - 2.56x slower
Numbers                        5.26 M - 4.41x slower

##### With input Integers #####
Name                              ips        average  deviation         median
MyNumbers - sans coerce       30.02 M      0.0333 μs    ±27.10%      0.0310 μs
MyNumbers                     17.57 M      0.0569 μs    ±14.39%      0.0620 μs
Numbers - sans coerce         15.38 M      0.0650 μs   ±118.59%         0.0 μs
Numbers - my coerce           11.27 M      0.0887 μs    ±87.37%       0.150 μs
Numbers                        6.56 M       0.152 μs    ±24.13%       0.160 μs

Comparison:
MyNumbers - sans coerce       30.02 M
MyNumbers                     17.57 M - 1.71x slower
Numbers - sans coerce         15.38 M - 1.95x slower
Numbers - my coerce           11.27 M - 2.66x slower
Numbers                        6.56 M - 4.57x slower

##### With input MyDecimal #####
Name                              ips        average  deviation         median
MyNumbers - sans coerce        4.21 M        0.24 μs    ±48.64%       0.160 μs
MyNumbers                      4.19 M        0.24 μs    ±32.83%        0.31 μs
Numbers - sans coerce          3.99 M        0.25 μs   ±783.77%         0.0 μs
Numbers - my coerce            3.54 M        0.28 μs   ±212.81%         0.0 μs
Numbers                        2.92 M        0.34 μs    ±19.46%        0.31 μs

Comparison:
MyNumbers - sans coerce        4.21 M
MyNumbers                      4.19 M - 1.01x slower
Numbers - sans coerce          3.99 M - 1.06x slower
Numbers - my coerce            3.54 M - 1.19x slower
Numbers                        2.92 M - 1.44x slower
1 Like

Also what on earth is going on with the syntax highlighting on this forum? o.O

Version 5.2 has been released which performs better operator overloading:

Calling use Number, overload_operators: true no longer breaks operators that are used in guard clauses.
To clarify:

defmodule An.Example do
  use Numbers, overload_operators: true

  def foo(a, b) when a + b < 10 do  # Uses the normal guard-safe '+' operator (e.g. Kernel.+/2)
    42
  end
  def foo(c, d) do 
    c + d # Uses the overloaded '+' operator.
  end
end
2 Likes