Skip to content

1 == 1.0 is true, but {1 => :a}[1.0] is nil

Level: 201 · anyone who has used a number as a Hash key

One line: Ruby's == compares an Integer with a Float by value — and exactly, so 2 ** 53 + 1 == (2 ** 53 + 1).to_f is false — but eql? and hash keep the two classes apart, so a Hash, uniq and a Set see two keys where Python's dict and set see one.

Ruby has four kinds of equality, and numbers are where the difference between two of them is easiest to see. == is numeric equality: it converts as needed and compares values, so 1 == 1.0, 1 == 1r and 1 == Complex(1, 0) are all true. eql? is what a Hash uses to decide whether two keys are the same, and it insists on the same class, so 1.eql?(1.0) is false and 1.hash is not 1.0.hash. Everything built on hashing — Hash, Set, uniq, tally, group_by — therefore keeps 1 and 1.0 apart, while everything built on == — include?, index, count, case/when, <=> — treats them as one. And when the two classes are compared, nothing is rounded first: an Integer too large for a double is compared against the Float's exact value, which is the same rule the Python library's Comparing an int with a float ↗ measures for Python.

Verified output of comparing_int_and_float_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. 1 == 1.0                             -> true                   == compares by numeric value across classes
 2. 1.eql?(1.0)                          -> false                  eql? also wants the same class; 1.eql?(1) is true
 3. 1.equal?(1.0)                        -> false                  identity; 1.equal?(1) is true: a small Integer is an immediate value
 4. 1.hash == 1.0.hash                   -> false                  a Hash key is found by hash then eql?, so these are two keys
 5. {1 => :a}[1.0]                       -> nil                    no key 1.0; {1.0 => :a}[1] is nil too
 6. h = {1 => :int}; h[1.0] = :float; h  -> {1 => :int, 1.0 => :float} two entries; h.size is 2
 7. [1, 1.0].uniq                        -> [1, 1.0]               uniq uses hash and eql?: two elements; Set[1, 1.0].size is 2, tally {1 => 1, 1.0 => 1}
 8. [1].include?(1.0), [1].index(1.0)    -> true, 0                include?, index and count use ==, so they cross the line
 9. 1 <=> 1.0                            -> 0                      ordered as equal; 1 === 1.0 is true, so case 1.0 when 1 matches
10. Integer === 1.0, Float === 1         -> false, false           a class test does not convert: case 1.0 when Integer does not match; 1.0.integer? is false
11. 2 ** 53 + 1 == (2 ** 53 + 1).to_f    -> false                  exact comparison: the Float is 9007199254740992, so 2 ** 53 + 1 > it is true
12. 10 ** 20 + 1 == 1e20                 -> false                  but 10 ** 20 == 1e20 is true: the Integer is not rounded to a Float first
13. 0.1 == 1/10r                         -> true                   true, yet 0.1.to_r == 1/10r is false: a Rational meets a Float as a Float, not exactly
14. 0 == -0.0, 0.0.eql?(-0.0)            -> true, true             one value and one key: {0.0 => :a}[-0.0] is :a
15. 1 == 1r, 1 == Complex(1, 0)          -> true, true             == crosses every numeric class; eql? crosses none: 1.eql?(1r) is false

Reading the output

Rows 1–4 are the four questions you can ask of 1 and 1.0. Equal in value, yes. eql?, no: it wants the same class. equal?, no: they are different objects — and 1.equal?(1) is true because a small Integer is an immediate value, the same object everywhere. And their hash values differ, which is the mechanical reason for everything that follows: a Hash finds a key by its hash first and confirms it with eql?, so a key that hashes differently is a different key. Rows 5–7 are that rule at work. {1 => :a}[1.0] is nil, assigning h[1.0] beside h[1] makes a second entry, and [1, 1.0].uniq keeps both, as do Set and tally. Row 8 is the other family: include?, index and count ask ==, so [1].include?(1.0) is true. Rows 9 and 10 are the two ways case can look at a number: when 1 uses ===, which for a number is == and matches 1.0; when Integer uses Integer === 1.0, which is a class test and does not.

Rows 11–13 are exactness. 2 ** 53 + 1 cannot be a double — to_f rounds it down to 2 ** 53 — and Ruby compares the Integer with that Float's exact value rather than converting the Integer first, so == is false and > is true. The same holds at 10 ** 20 + 1 against 1e20. Row 13 is the exception: 0.1 == 1/10r is true even though 0.1.to_r is not 1/10r, because a Float and a Rational are compared by turning the Rational into a Float — so that comparison is only as exact as the Float. Row 14 is the one pair of different floats that is one key: 0.0 and -0.0 are ==, eql? and hash alike, so a Hash treats them as the same key. Row 15 closes the loop: == crosses every numeric class, and eql? crosses none.

Compared with Python

Python draws the line in a different place. == between an int and a float is the same exact comparison — rows 11 and 12 print the same answers, and the Python library's twin page is about exactly that — but Python has no eql?, and it requires that objects which compare equal hash alike, so hash(1) == hash(1.0) is true and 1 and 1.0 are one dict key, one set element and one Counter entry. Row 6 shows the consequence that surprises people: assigning h[1.0] when h[1] exists replaces the value but keeps the key spelled 1. Row 3 shows is in the role of equal?, with CPython's small-int cache making one is one_again true. Rows 9 and 10 are match: a literal pattern case 1: uses == and matches 1.0, while a class pattern case int(): is an isinstance test and sends 1.0 to case float():. Row 13 is the one place Python is the stricter of the two: 0.1 == Fraction(1, 10) is False, because a Fraction compares with a float exactly, where Ruby converts the Rational to a Float and says true. complex(1, 0) behaves like Complex(1, 0), and -0.0 is one key in both languages.

Verified output of comparing_int_and_float_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. 1 == 1.0                             -> True                   == compares by numeric value across types
 2. type(1) is type(1.0)                 -> False                  no eql?; (1).__eq__(1.0) is NotImplemented, so float.__eq__ answers instead
 3. one is one_f                         -> False                  identity; one is one_again is True: small ints are cached in CPython
 4. hash(1) == hash(1.0)                 -> True                   equal numbers must hash alike, so these are one key
 5. {1: 'a'}[1.0]                        -> 'a'                    found; {1.0: 'a'}[1] is 'a' too
 6. h = {1: 'int'}; h[1.0] = 'float'; h  -> {1: 'float'}           one entry, and the int key kept its spelling; len(h) is 1
 7. list(dict.fromkeys([1, 1.0]))        -> [1]                    the uniq idiom keeps one; {1, 1.0} is {1}, Counter {1: 2}
 8. 1.0 in [1], [1].index(1.0)           -> True, 0                in, index and count use ==, so they cross the line
 9. 1 < 1.5, 1 <= 1.0                    -> True, True             ordered as equal, no <=>; match 1.0: case 1 matches
10. isinstance(1.0, int), isinstance(1, float) -> False, False           a class pattern does not convert: match 1.0 takes case float(); (1.0).is_integer() is True
11. 2 ** 53 + 1 == float(2 ** 53 + 1)    -> False                  exact comparison: the float is 9007199254740992, so 2 ** 53 + 1 > it is True
12. 10 ** 20 + 1 == 1e20                 -> False                  but 10 ** 20 == 1e20 is True: the int is not rounded to a float first
13. 0.1 == Fraction(1, 10)               -> False                  a Fraction meets a float exactly, and 0.1 is not one tenth; 0.5 == Fraction(1, 2) is True
14. 0 == -0.0, hash(0.0) == hash(-0.0)   -> True, True             one value and one key: {0.0: 'a'}[-0.0] is 'a'
15. 1 == Fraction(1), 1 == complex(1, 0) -> True, True             == crosses every numeric type, and so does hash: hash(1) == hash(Fraction(1)) is True

So the portable rule is: between an Integer and a Float, == means the same thing in both languages, and the difference is entirely in what a hash-based container considers one key. Code that moves from Python to Ruby and uses mixed Integer and Float keys — a total keyed by count / 2, say — will find its entries split in two; to_i or to_f on the way in makes the keys one class.

Try it

  1. Write same_key?(a, b) that reports whether a Hash would treat a and b as one key — a.eql?(b) && a.hash == b.hash — and print it beside a == b for the pairs (1, 1.0), (1, 1r), (1.0, 1.0), (0.0, -0.0), ("a", "a"), (:a, "a") and (2 ** 64, 2 ** 64). Confirm each answer with {a => 1, b => 2}.size.
  2. For k from 50 to 55, print whether 2 ** k + 1 == (2 ** k + 1).to_f and what (2 ** k + 1).to_f.to_i - 2 ** k is. Where does the answer change, and why is it that k? (Row 9 of Integers never overflow has the other half of the answer.)
Solution to 1

comparing_int_and_float_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: same_key?(a, b) says whether a Hash would treat a and b as one key
# (eql? and hash must both agree), and a real Hash confirms each answer.

def same_key?(a, b) = a.eql?(b) && a.hash == b.hash

pairs = [[1, 1.0], [1, 1r], [1.0, 1.0], [0.0, -0.0], ["a", "a"], [:a, "a"], [2 ** 64, 2 ** 64]]
puts format("%-22s %-22s  %-6s %-10s %s", "a", "b", "a == b", "same_key?", "keys in {a => 1, b => 2}")
pairs.each do |a, b|
  puts format("%-22s %-22s  %-6s %-10s %d", a.inspect, b.inspect, a == b, same_key?(a, b), { a => 1, b => 2 }.size)
end

Verified output of comparing_int_and_float_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

a                      b                       a == b same_key?  keys in {a => 1, b => 2}
1                      1.0                     true   false      2
1                      (1/1)                   true   false      2
1.0                    1.0                     true   true       1
0.0                    -0.0                    true   true       1
"a"                    "a"                     true   true       1
:a                     "a"                     false  false      2
18446744073709551616   18446744073709551616    true   true       1

See also