skip to content

In Ruby, how does `begin ... end while cond` differ from the `stmt while cond` modifier, and why does RuboCop's Lint/Loop flag it?

level: middleimportance: should knowfreq 38%

answer

  1. pre-test vs post-test
  2. plain-statement modifier can run zero times
  3. begin...end body runs at least once
  4. until is while with the test negated
  5. Lint/Loop: loop do ... break unless

basics

~20 s

A while or until modifier on an ordinary statement tests first, so the statement may never run; on a begin...end block it tests after, so the body runs at least once. Lint/Loop flags that special case and suggests loop with break.

solid answer

~40 s

`while cond ... end` and `until cond ... end` check the condition before each pass, and so does the modifier form `n += 1 while n < 3` — if the condition is false at the start, the statement never runs. Ruby treats one shape differently: when the modifier follows a `begin ... end` block, the body runs first and the condition is checked afterwards, like a do-while loop elsewhere. That special case surprises readers, because `(n += 1) while false` runs zero times while `begin n += 1 end while false` runs once. RuboCop's `Lint/Loop` cop therefore flags `begin ... end while/until` and recommends `loop do ... break unless cond end`. Both `while` and `until` evaluate to `nil` when their condition ends them.

code

ruby · 9 lines
ruby
n = 5
n += 1 while n < 3
p n   # => 5

n = 5
begin
  n += 1
end while n < 3
p n   # => 6

go deeper

for a junior

Recall that while and until test before each pass and that until is while with the condition negated; both end as nil.

for a middle

Explain the begin...end exception: the same modifier becomes a post-test loop and runs the body once, unlike on a plain statement.

for a senior

Justify rewriting post-test loops as loop with break, and know why that rewrite can change variable visibility and StopIteration handling.

for a principal

Treat Lint/Loop as a team readability rule: explicit exit points cost a line but remove a class of misread loop bounds in review.

## Two loop keywords, one rule Ruby has two condition-driven loop keywords: - `while cond` repeats the body **as long as** `cond` is truthy. - `until cond` repeats the body **as long as** `cond` is falsy; it is `while` with the test negated. Both check the condition **before** every pass. If the condition already ends the loop, the body never runs. The `do` after the condition is optional, and when the condition finally ends the loop, the whole expression evaluates to `nil`. ```ruby attempt = 0 until attempt >= 3 attempt += 1 end attempt # => 3 ``` ## The modifier form Like `if` and `unless`, both keywords can follow a statement as a **modifier**: ```ruby n = 5 n += 1 while n < 3 n # => 5 -- the test ran first and was false ``` For an ordinary statement, including one in parentheses, the modifier is still a **pre-test** loop. The statement may run zero times. ## The `begin ... end` exception There is one shape where Ruby changes the order. When the modifier follows a `begin ... end` block, the body runs **first** and the condition is checked after each pass: ```ruby n = 5 begin n += 1 end while n < 3 n # => 6 -- the body ran once before the test ``` This is Ruby's do-while. The language reference documents it as the way to write "a while loop that runs the body once before the condition". The parser records the difference as a flag on the loop node (Prism calls it `BEGIN_MODIFIER`), so the same keyword produces two different evaluation orders depending on what precedes it. | Form | Condition checked | Minimum runs | |---|---|---| | `while cond ... end` | before each pass | 0 | | `stmt while cond` | before each pass | 0 | | `(stmt) while cond` | before each pass | 0 | | `begin ... end while cond` | after each pass | 1 | | `loop do ... break unless cond end` | wherever you put the `break` | 1 if the break is last | ## Why `Lint/Loop` flags it The problem is not that the post-test form is wrong; it is that it **looks like** the modifier form and behaves differently. A reader who knows that `x while cond` may run zero times will misread `begin ... end while cond`. RuboCop's `Lint/Loop` cop, enabled by default, reports `begin/end/while` and `begin/end/until` and suggests an explicit alternative: ```ruby loop do reading = sensor.read break unless reading.nil? end ``` Here the order is visible: the body runs, then the exit test at the bottom decides. The cop's autocorrection is marked unsafe for two reasons documented in its source: 1. `Kernel#loop` takes a block, and a block opens a new scope, so a variable first assigned inside the body is no longer visible after the loop. In the example, `reading` must be initialised before `loop` if later code needs it. 2. `Kernel#loop` rescues `StopIteration`, so a body that raises it would end the loop quietly instead of propagating. ## A greenhouse example A controller must read a humidity sensor at least once and keep reading while the driver returns `nil` for a failed sample: ```ruby reading = nil loop do reading = sensor.read break if reading end ``` The same logic with the post-test form is shorter, but the `end until reading` at the bottom is easy to misread as a pre-test modifier: ```ruby begin reading = sensor.read end until reading ``` Both run the read at least once. A capped version — stop after a few failures and report — is usually what a real controller wants, and `Integer#times` expresses that count directly. ## What to say in an interview - `while` and `until` test first; the body can run zero times. - A modifier on a normal statement also tests first. - A modifier after `begin ... end` tests last, so the body runs at least once. - `Lint/Loop` prefers `loop do ... break ... end` because it makes the exit point explicit. - Both keywords evaluate to `nil` when their condition ends them.

  • Does wrapping the statement in parentheses, as in (n += 1) while n < 3, make it a post-test loop?
    No. Only a `begin ... end` block before the modifier switches Ruby to the run-first order. A parenthesised statement is still an ordinary expression, so the condition is checked first and the statement may run zero times.
  • Why must a variable be initialised before loop do when you rewrite begin...end until as Lint/Loop suggests?
    `Kernel#loop` runs a block, and a block opens a new local scope. A variable first assigned inside it disappears when the loop ends. `begin ... end` opens no scope, so the original code could read the variable afterwards; the rewrite needs `reading = nil` before the loop to keep that working.

saying these in an interview costs you the question

  • A while modifier always runs the statement at least once
  • begin...end while checks the condition before the first pass
  • until tests its condition only after the body has run
  • Putting the statement in parentheses turns it into a do-while loop
  • A while loop evaluates to its last body value when it finishes