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?
answer
- count computed up front
- start + i * step, not repeated adding
- overshoot clamped to the limit
- by: and to: keywords
- no block: Enumerator::ArithmeticSequence
basics
~20 sFor 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 sA 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 linesf = 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
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.
Explain why adding 0.1 in a loop drifts and how step computes the count and each value from the start to avoid it.
Spot hand-written float counters in schedules and control code, and replace them with step or with integer counts scaled afterwards.
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