In programming, we usually deal with numbers daily, sometimes without even noticing it. There is a nice offer of numeric types in Ruby, each serving a purpose, supporting features and having different behaviours.
Let’s have a look at what these types are, what performance and precision they provide and how to use them properly in our programs.
Numbers are Numeric
objects
The core parent class of all core numeric types is
Numeric
, itself inheriting from
Object
. It includes the Comparable
module and provides methods for
querying (e.g. #positive?
), comparing (e.g. #<=>
) or converting
(e.g. #floor
).
We don’t directly use this class, but those which inherit from it.
Numeric objects from core numeric classes such as Integer
, are single
immutable objects. A numeric object cannot be instantiated, because only one
instance of each number can exist. They are called
immediates.
a = 1
b = 1.0
a.object_id == 1.object_id # => true
b.object_id == 1.0.object_id # => true
In Ruby, almost everything is an object. This means operators such
as +
, -
can be defined as methods on core numeric classes. For example, you
can find the definition of the “plus” method in Ruby’s source
code: Integer#+
, Float+
. Syntactic sugar is added to the language to
make mathematical operations more natural to the human eye:
1 + 2
# is equivalent to
1.+(2)
# or even
1.public_send(:+, 2)
List of numeric inheriting classes
Integer
This class deals with any whole number, positive or negative, called integers in the classification of numbers.
If you used Ruby in the past, you may have encountered Fixnum
and Bignum
.
They respectively handled small/medium and large integers. While they are
technically still present and used in Ruby’s source code, they have been
deprecated from the public API since Ruby 2.4 in favour of Integer
.
It is still useful to remember that Ruby will handle small integers
with Fixnum
, and will dynamically switch to Bignum
once the number gets
larger and exceeds the capacity of Fixnum
, which depends on the system Ruby
runs on. Fixnum
can represent integers directly in memory without the need for
dynamic memory allocation, while Bignum
can represent larger integers as an
array of arbitrary-precision integers.
In Ruby, there is virtually no size limit for what an integer can be, as long as there is enough memory to store it.
Float
Floating-point numbers are real numbers with a fixed precision,
represented in a similar way as the scientific notation, usually in binary.
12.34
is represented as 1234 × 10-2, where 1234
is an integer
called the significant, 10
is the base and -2
is the
exponent.
It is a convenient way of representing decimal numbers. Because only the significant is, well, significant, floating numbers can technically store a larger range of numbers than a fixed-point system. The floating notion comes from the fact the number’s radix point is “floating” over the significant as the exponent is changed.
In Ruby, floats are represented using the native double-precision
floating-point (or just double) format defined by
the IEEE 754 technical standard. The standard itself provides limits that you
can find in the Ruby documentation: Float::MAX
and Float::MIN
.
Actual values depend on the platform or system Ruby runs on.
Precision of floating-point numbers
In Ruby, Float
supports decimal numbers up to a precision limit, directly
linked to how floating-point numbers are stored in memory. Floating numbers
are often considered imprecise, but that is true only when they are used
outside their precision limit, or in some arithmetic operations.
Because we usually store floating-point numbers in binary within a fixed
structure defined by the IEEE 754, some numbers cannot be accurately stored. In
the same way, the quotient 1÷3
has an infinite decimal
representation (0.3333333...
, called the recurring part), numbers
like 1÷10
have imprecision in binary due to the fact the recurring part is
stopped by the number of digits the computer will store for a float.
As this imprecision (called rounding error) happens in binary, the side
effects can be counter-intuitive for humans used to use numbers in base 10.
Thankfully, programming languages are smart enough to hide some of this
imprecision. So, when you define something as 0.1
, the actual stored value
is slightly inaccurate but it doesn’t show.
However, when such numbers are combined in arithmetic operations, the rounding error increases and cannot be hidden anymore. This leads to famous wrong results such as:
0.1 + 0.2 # => 0.30000000000000004
Rational
Rational numbers are every number that can be represented as the quotient of
two integers, the numerator and the (non-zero) denominator:
numerator/denominator
.
They are also useful for human comprehension. Our brain is more suited imagining
proportions and doing math with quotients rather than decimal numbers. In a
cooking recipe, it is more convenient to say “one-seventh (1/7
) of a cup of
flour”, especially if you have graduations, than “0.142857 cups of
flour”. Actually not the best example, please just use grams for
cooking.
In Ruby, there are three ways to define a rational number:
- using the
r
character on a quotient:1/3r
- using
#to_r
defined onString
and numeric types:"1/3".to_r
,1/3.to_r
- using the class constructor:
Rational(1, 3)
As for integers, there is virtually no limit to how big a rational number can get in Ruby, except for the memory limit of the system. The numerator and denominator are stored as regular Ruby objects.
Precision of rational numbers
They are particularly handy for representing exact (or finite) numbers that have an infinite decimal representation, such as 1÷3:
1 / 3.0
# => 0.3333333333333333, not _exactly_ 1÷3
1 / 3r
# => (1/3), exactly the number 1÷3
This precision fixes rounding errors which floating-point numbers suffer. As we mentioned earlier, the intrinsic imprecision of floats becomes noticeable in arithmetic operations. This is not the case with rational numbers.
11.times.inject(0) { |t| t + 0.1 }
# => 1.0999999999999999, not _exactly_ 1.1
11.times.inject(0) { |t| t + 1 / 10r }
# => (11/10), exactly 1.1
Complex
Complex (or imaginary) numbers are quite different as they are not part of
the real numbers classification. A complex number extends a real number
with an imaginary unit i
, itself defined so that i × i = -1
.
This means a complex number can always be expressed in the form a + b×i
,
where a
and b
are real numbers.
For this article’s purpose, it is only necessary to understand that a complex number in Ruby is a set of two numbers, called coordinates and can be declared in three ways:
- using
::rect
or::polar
class methods with one or two numeric arguments:Complex.rect(1.5, 1/3r)
- using
#to_c
defined onString
and numeric types:"3-4i".to_c
,1/3r.to_c
- using the class constructor:
Complex("+1-2i")
Just like any number, arithmetic can be done between complex numbers and other types of numbers:
a = Complex.rect(1, 2) # => (1+2i)
b = 1.2
a + b # => (2.2+2i)
In the same way, you can use a float or a rational to represent an integer
(1.0
, 1r
), any number can be expressed as a complex number even without an
imaginary unit, but there’s no real point in doing so. The real and
imaginary parts are stored as Ruby objects, leading to, again, virtually no
size limit.
BigDecimal
Ruby includes BigDecimal
in the standard library, which provides support for
very large or very accurate floating-point numbers. Because it is part of the
standard library and not the core one, you first need to require "bigdecimal"
before using it.
BigDecimal
enables to use decimal numbers without suffering from rounding
errors of floating-point numbers. As the precision is arbitrary and is
deduced or explicitly defined, BigDecimal
is way more suited for
calculations than floats:
11.times.inject(0) { |t| t + 0.1 }
# => 1.0999999999999999, not _exactly_ 1.1
11.times.inject(0) { |t| t + BigDecimal("0.1") }
# => 0.11e1, exactly 1.1
The most common way to declare a number with BigDecimal
is to use the class
constructor. It either takes one or two arguments:
- a number, not specifically a numeric as it accepts strings
- the precision requested, required for floats and rationals as it cannot be automatically deduced
Like other numeric types, BigDecimal
objects can be combined with other
types in arithmetic operations. The BigMath
module also provides
mathematical functions to increase precision in functions like trigonometry:
require "bigdecimal/math"
Math::PI
# => 3.141592653589793
BigMath.PI(10)
# => 0.31415926535897932364198143965603e1
But we will talk more about precision in the next section.
BigDecimal
objects are stored in a very similar way as Bignum
. Therefore,
there is again virtually no limit to how big such a number can get.
What about irrational numbers?
Irrational numbers are real numbers that are not rational, meaning they cannot be expressed as the ratio of two integers. Numbers like π or √2 are irrational.
In Ruby, irrational numbers are the only category of numbers that is not supported. All other categories are supported by the numeric classes defined earlier, to some limits like software memory.
Numbers like π or square roots are accessible, but they are floating-point approximations:
Math::PI
# => 3.141592653589793, not exactly π
Math.sqrt(2)
# => 1.4142135623730951, not exactly √2
Objects like BigDecimal
will increase the precision of these numbers but
will never reach their exact definition, unlike Rational
which will represent
exact definitions of numbers as long as we don’t try to convert them as floats.
BigMath.PI(50)
# => 0.314159265358979323846264338
# 32795028841971693993751058209749
# 44592309049629352442819e1
# More accurate, but still not exactly π
Precision
As we have seen in the previous section, precision with numbers is a central question. Different numeric types in Ruby are meant to deal with precision at different scales.
Integer
is precise and can practically deal with any whole number as long
as the computer’s memory allows it.
Float
suffers from some kind of imprecision which is noticeable as soon as
they are involved in calculations.
Rational
and Complex
are also precise, as long as they are not converted.
BigDecimal
drastically improves the precision of some numbers and reduces
rounding errors in calculations, although it is not immune to imprecision.
Precision is part of a tradeoff between range and speed. In Ruby, there is no numeric type that will perform greatly in these three domains.
When is precision necessary?
Should you avoid using Float
at all because you can’t do accurate math
with them? The short answer is no. The long answer is: it depends on your
use case. Floating-point numbers are good at speed and they are doing okay at
precision and range.
In some cases, high precision is not necessary. We will never access the final decimal representation of the number π, but we still use it in extremely accurate science, thanks to approximations that are precise enough for the use case.
In Ruby, dividing two integers produces a rounded result. And it’s okay in some
use cases like this one: I have a €50 note on me and I would like to buy as many
mangoes of €3 each as possible. 50/3
is not a round number, and Ruby will
return 16
. That’s all I need, I can buy 16 mangoes for €3 each with my €50
note, the lost precision isn’t important at this moment.
In some cases like currencies or dealing with money in general, exact
numbers are necessary. As usually math is involved when dealing with money,
floating-point numbers are not the right candidate. In programming, we
usually prefer to store currencies as integers. In Ruby, it is useful to use
BigDecimal
to manipulate currencies as it will accurately store the
provided number. There is also the possibility to use dedicated gems, like
money.
Performance
Performance is another aspect of the tradeoff to make when choosing the right numeric type. When precision is not that important, performance might be.
Floats have been around for almost as long as computers exist and have benefited from decades of improvements and tuning. Are we have seen previously, in Ruby floats are represented using the native double format defined by IEEE 754. This native representation allows the Ruby interpreter to leverage low-level operations provided by the underlying hardware, resulting in efficient computations.
Generally, Integer
will be the most performant class to use if you only
have to deal with integers. In decimal arithmetic, Float
is far more
performant than Rational
and even more than BigDecimal
. Here is a benchmark
you can reproduce at home if it’s cold and you would like to heat the room,
using benchable
:
require "benchable"
require "bigdecimal"
Benchable.bench(:ips, :memory) do
bench "Integer" do
1 + 1
end
bench "Float" do
1 + 0.0001
end
bench "Rational" do
1 + 1 / 1000r
end
bench "BigDecimal" do
1 + BigDecimal("0.0001")
end
end
Iterations/speed comparison:
Integer: 7535799.6 i/s
Float: 7015345.0 i/s - 1.07x slower
Rational: 3329727.4 i/s - 2.26x slower
Bigdecimal: 1421481.1 i/s - 5.30x slower
Memory comparison:
Integer: 80 allocated
Float: 80 allocated - same
Rational: 240 allocated - 3.00x more
Bigdecimal: 432 allocated - 5.40x more
As we can see, even if BigDecimal
is very convenient, it is also way less
performant than floats. In a context where high accuracy is not important,
favouring Float
can improve the overall performance of your application.
Extension
The previously mentioned numeric types are the only ones that are available in Ruby, natively. It doesn’t mean they are the only objects you can use in a Ruby program when dealing with numbers.
Inheritance
You can create your own numeric types based on existing classes.
The first method is to use inheritance with the Numeric
class. You could,
for example, want to implement a numeric class that deals with numbers in
another way than using Arabic numerals. It must implement the coerce
method for interacting with other numeric types. It probably should also
implement arithmetic operators (#+
, #-
, #*
, #/
) for operations, and
#<=>
for comparison as Numeric
includes Comparable
.
Delegation
A second method is to enhance an existing numeric type by delegating the
numeric part, with for example SimpleDelegator
. This allows to manipulate
the object as a numeric value while having the ability to extend new methods to
it.
class Meter < SimpleDelegator
def to_s
"#{__getobj__} meters"
end
end
distance = Meter.new(5000)
distance.class # => Meter
"I just ran #{distance}." # => "I just ran 5000 meters"
distance + 1000 # => 6000
The downside is it can be confusing to manipulate an object that is slightly more than a regular numeric, as shown in this great example by Avdi Grimm. For example, arithmetic operations will be cast:
distance1 = Meter.new(5000)
distance2 = Meter.new(2000)
(distance1 + 1.5).class
# => Float
(distance1 + distance2).class
# => Integer
(distance1 + distance2).to_s
# => "7000"
Value objects
A more advanced method to create your own numeric type is to use value objects.
Value objects can be complex as everything must be implemented from scratch,
like arithmetic operators, comparison operators, or logic related to dealing
with different types. For example, you could decide to have a Meter
object
that implements the multiplication operator #*
to only accept raw numbers.
Or, you could decide to support receiving Meter
objects and decide if the
returned object should still be a Meter
or a new SquareMeter
object.
I recommend watching Joel’s presentation to have a good understanding of what a number can be and how to be cautious with them.
You can find an example of value object implemented from scratch with
the notion of Angle
in the astronoby gem. Ruby 3.2 also introduced the new
core class Data
which helps create simple value-alike objects.
Conclusion
Numeric types are an essential part of computer science. Ruby provides a large list of types to support all categories of numbers, except irrational numbers, and different precision levels.
When we manipulate numbers in Ruby, we need to be aware of these types, features and limitations. Some domains require high precision, while others need performance. When both are necessary, Ruby offers some tradeoffs.
While Ruby is not known for being heavily used in fundamental science or data science projects, nothing in the language prevents it from reaching success in both disciplines. It is a language with incredible flexibility and a deep care for the developer’s happiness that allows for creating great maintainable software.