Numeric types in Ruby
14 June 2022
I recently hit an issue with an existing application which was experiencing bugs
comparing two numbers—one stored as a Float
, the other as a BigDecimal
. I
had some existing idea this was bug-prone and not good practice, but the details
evaded me so I thought I’d spend some time looking into the representation of
numeric types in Ruby.
Why worry?
Why do we even need to worry about how our numbers are being stored? Ruby isn’t a strongly typed language, can’t we just let it do its thing and forget about it? In many cases, yes, but in some—as I discovered—no.
If we just present a number to Ruby, it will attempt a best guess at what class to use to represent it. Often this is appropriate:
> 86.class
=> Integer
> 4.9.class
=> Float
> (2+1i).class
=> Complex
But other times, not so much:
> 4/8
=> 0
> 0.1+0.2
=> 0.30000000000000004
What are these issues?
This isn’t the place to get into the intricacies of how floating point numbers are represented internally, suffice to say they are performant but inaccurate; sometimes this matters, sometimes it doesn’t. If you want to understand what’s going on, this is a great resource.
Numeric
and its subclasses
All numbers in Ruby are represented using one of the subclasses of Numeric. The
most important of these are Integer
, Float
, Complex
, Rational
and
BigDecimal
, and below I take a look at each of them. It’s worth noting you
cannot instantiate any of these. They are implemented as immediates, so are
immutable. There cannot be more than one instance of 5, for example.
There are a wide number of methods and checks they all support. Some of the most
useful (and self-apparent) are finite?
, integer?
, negative?
, nonzero?
,
positive?
, real?
and zero?
.
Integer (docs)
Probably the simplest of the subclasses, Integer
s represent whole numbers.
Historically, the two subclasses of Integer
(Fixnum
and Bignum
), were used
depending on the size of the integer—the former if it could be represented in a
native machine word, otherwise the latter. This is no longer the
case.
Float (docs)
As we saw above, Float
s are Ruby’s default for representing numbers with a
fractional part. They’re fast and often adequate but have their shortcomings:
primarily their use can lead to inaccuracies and unexpected results.
Complex (docs)
If you’re unaware of the mathematical concept of complex numbers, it probably means you’re unlikely to need to use them and don’t need to worry too much about how they’re implemented in Ruby. Basically, a complex number has two parts: one of them “imaginary”. We can deal with them as follows:
> 2+1i
=> (2+1i)
> Complex(5)
=> (5+0i)
> Complex(3,8)
=> (3+8i)
> 8.to_c
=> (8+0i)
Be aware of the possibly counter-intuitive nature of the real?
method when
applied to Complex
objects however—it will return false
even if the number
has no imaginary part.
Rational (docs)
Rational numbers are those represented by a numerator and denominator, and Ruby is happy to handle them:
> Rational(5)
=> (5/1)
> Rational(-7, 8)
=> (-7/8)
> 6.to_r
=> (6/1)
> 2/8r
=> (1/4)
Rational numbers are exact, but be cautious when comparing them to inexact data types.
BigDecimal (docs)
Finally, BigDecimal
provides support for very large or very accurate
representation of numbers with a fractional part. They are the recommended way
of representing financial data.
BigDecimal
comes from the standard library, so you will need to require
'bigdecimal'
in your project.
A few things to be aware of when working with the class:
- It will return infinity in some cases, such as division by zero.
- In others, it will return
NaN
(Not a Number), such as 0/0.NaN
is never equal to anything, including itself.
- Numbers too small to be represented using the currently defined precision will return zero.
The precision BigDecimal
uses is defined when you create the object: the
second argument to new
is the number of significant digits. There’s a good
description of how this works over on Stack
Overflow.