Numeric data types in Ruby and when to use them

Rémy Hannequin

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 on String 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 on String 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.