skip to content

In Ruby, why does `1.0.step(2.0, 0.1)` end exactly at 2.0 when a `while` loop adding 0.1 each pass misses it?

level: middleimportance: nice to knowfreq 20%

answer

  1. count computed up front
  2. start + i * step, not repeated adding
  3. overshoot clamped to the limit
  4. by: and to: keywords
  5. no block: Enumerator::ArithmeticSequence

basics

~20 s

For floats, Numeric#step computes the number of values up front and yields start + i * step, clamping an overshoot to the limit, so rounding error never accumulates; a while loop that keeps adding 0.1 drifts and skips 2.0.

solid answer

~50 s

A hand-written loop such as `f = 1.0; while f <= 2.0; ...; f += 0.1; end` adds 0.1 repeatedly, and because 0.1 has no exact binary representation the error accumulates: the last value is about `1.9000000000000008`, and the next one is just above 2.0, so 2.0 is never produced. `Numeric#step` handles floats differently. When any argument is a `Float`, it calculates how many values fit from `n = (limit - start) / step`, rounding with a small tolerance scaled by `Float::EPSILON`, then yields `start + i * step` for each index, and if the final value rounds past the limit it yields the limit instead. `1.0.step(2.0, 0.1)` yields eleven values ending at `2.0`. The method also takes `by:` and `to:` keywords, raises `ArgumentError` for a zero step, returns `self` with a block, and returns an `Enumerator::ArithmeticSequence` without one.

code

ruby · 11 lines
ruby
f = 1.0
count = 0
while f <= 2.0
  count += 1
  f += 0.1
end
p count                              # => 10

p 1.0.step(2.0, 0.1).to_a.size       # => 11
p 1.0.step(2.0, 0.1).to_a.last       # => 2.0
p 1.step(by: 2, to: 7).to_a          # => [1, 3, 5, 7]

go deeper

for a junior

Recall that step counts by any amount, takes a limit and a step or the by: and to: keywords, and can count down with a negative step.

for a middle

Explain why adding 0.1 in a loop drifts and how step computes the count and each value from the start to avoid it.

for a senior

Spot hand-written float counters in schedules and control code, and replace them with step or with integer counts scaled afterwards.

for a principal

Set a rule that stepped physical quantities use integer units or step, never accumulated floats, and make reviewers check for it.

## The problem with adding a float in a loop Suppose a greenhouse controller must try every heater offset from 1.0 to 2.0 degrees in steps of 0.1. The obvious loop is: ```ruby f = 1.0 offsets = [] while f <= 2.0 offsets << f f += 0.1 end offsets.size # => 10, not 11 offsets.last # => about 1.9000000000000008 ``` The decimal 0.1 cannot be stored exactly in a binary `Float`. Each `+=` adds a tiny error, and ten additions push the running value just past 2.0, so the loop stops one value early. The bug is not in `while`; it is in **accumulating** a rounded step. (Why 0.1 is inexact is a floating-point topic of its own.) ## What `Numeric#step` does instead `Numeric#step` generates an arithmetic sequence from the receiver. Its documented forms are: - `start.step(limit, step) { |x| ... }` — positional arguments. - `start.step(by: step, to: limit) { |x| ... }` — keyword arguments. - `start.step(limit)` — a step of 1; `start.step(by: 2)` — no limit, so the sequence is infinite. When **any** of the receiver, limit or step is a `Float`, CRuby does not add repeatedly. According to the method's implementation notes and the C source in `numeric.c`, it: 1. Computes `n = (limit - start) / step`. 2. Decides the number of values by flooring `n` plus a small tolerance, then adding one. The rdoc summarises this as `floor(n + n * Float::EPSILON) + 1`; the C function `ruby_float_step_size` scales the tolerance by the magnitudes of the start, limit and step. Either way, the tolerance stops a count like 9.999999999 from losing the last value. 3. Yields `start + i * step` for `i` from 0 upward, so each value carries at most one rounding error instead of the sum of all previous ones. 4. If a computed value lands past the limit because of rounding, yields the limit itself. ```ruby 1.0.step(2.0, 0.1).to_a.size # => 11 1.0.step(2.0, 0.1).to_a.last # => 2.0 ``` Individual middle values can still show representation noise (one of them prints as `1.9000000000000001`), but the count and the endpoint are right. When all arguments are integers, `step` uses a plain integer counter; there is no rounding to worry about. ## Rules worth knowing | Situation | Behaviour | |---|---| | Positive step | yields values less than or equal to the limit | | Negative step | counts down, yields values greater than or equal to the limit | | Limit past the start in the wrong direction | yields nothing: `10.step(8)` is empty | | Step of zero | raises `ArgumentError` ("step can't be 0") | | No limit (`to:` omitted) | an infinite sequence; stop it with a condition | | With a block | returns the receiver | | Without a block, numeric arguments | returns an `Enumerator::ArithmeticSequence` | Some examples: ```ruby 1.step(10, 4).to_a # => [1, 5, 9] 10.step(1, -3).to_a # => [10, 7, 4, 1] 1.step(by: 2, to: 7).to_a # => [1, 3, 5, 7] 1.step(by: 2, to: 7).class # => Enumerator::ArithmeticSequence ``` Note that an integer step never jumps to the limit: `1.step(10, 4)` stops at 9. The clamp exists only to absorb float rounding, not to add an extra value. ## A schedule example Counting down works the same way. A controller that lowers a vent from 45 degrees to 0 in 7.5-degree moves can write: ```ruby 45.0.step(0.0, -7.5) { |angle| vent.move_to(angle) } ``` That yields seven angles, 45.0 down to 0.0, and the final `0.0` is guaranteed even if the arithmetic rounds, because a value that lands past the limit is replaced by the limit. The equivalent `while angle >= 0.0` loop with `angle -= 7.5` happens to be exact here, since 7.5 is representable in binary, but the same code with a step of 0.1 would not be, and nothing in the loop's text warns the reader which case they are in. ## `step` next to the other counting loops - `Integer#times`, `upto` and `downto` count by one. - `Numeric#step` counts by any non-zero amount, integer or float, up or down. - Ranges have their own `step`; that belongs with ranges rather than with the numeric loop methods. ## What to say in an interview - Accumulating a float step in a loop drifts; the end value may be missed or overshot. - `Numeric#step` avoids that for floats by computing the count first and each value as `start + i * step`. - It clamps a rounded overshoot to the limit, which is why `1.0.step(2.0, 0.1)` ends at `2.0`. - Zero is not a valid step, and a blockless call gives an `Enumerator::ArithmeticSequence` you can chain.

  • What does Ruby's 1.step(10, 0) do?
    It raises `ArgumentError` with the message "step can't be 0". `Numeric#step` checks for a zero step both with and without a block, so it neither loops forever nor returns an empty sequence.
  • What does Numeric#step return when called without a block in Ruby?
    With numeric arguments it returns an `Enumerator::ArithmeticSequence`, an `Enumerator` subclass that knows its start, end and step. You can chain `to_a`, `map`, `each_slice` or any other Enumerable method on it; with a block, `step` returns the receiver instead.

saying these in an interview costs you the question

  • Numeric#step with floats just adds the step repeatedly
  • A step of 0 makes step loop forever on the start value
  • 1.step(10, 4) also yields 10 because step clamps to the limit
  • step without a block returns an Array
  • Float rounding makes every float step sequence miss its limit