This is a self-translation of an article I originally wrote in Japanese.
Hello. I'm Pocke. In this article I'll show you how to define a loop in Ruby that you can never escape from.
A loop you can escape
Ruby has the Kernel.#loop method. It's a method that evaluates its block over and over, forever.
# "hello" is printed forever
loop do
puts "hello"
end
Naturally, you can also break out of loop.
# Prints "hello" once, then leaves the loop
loop do
puts 'hello'
break
end
# Likewise, prints "Ruby" once and then leaves the loop.
# Calling `return` inside a method exits the method itself.
def f
loop do
puts 'Ruby'
return
end
end
f
# Prints "world" once, then leaves the loop by raising an exception
# (the program terminates abnormally)
loop do
puts 'world'
raise 'bye!'
end
A loop you can't escape
Now, unlike Kernel.#loop, let's think about a loop that you can't escape even with break, return, or raise. Surprisingly, Ruby lets you define such a method. Calling it infinite_loop, here's what it looks like: 1
def infinite_loop(&block)
tap do
yield
ensure
redo
end
end
# "hello" is printed forever
infinite_loop do
puts 'hello'
break
end
# "Ruby" is printed forever.
# You can't get out of the method.
def f
infinite_loop do
puts 'Ruby'
return
end
end
f
# "world" is printed forever.
# The exception never surfaces.
infinite_loop do
puts 'world'
raise 'bye!'
end
If you run this code, you'll see that you can't escape the loop even though you're calling break and friends. The part where the same method's code runs again even after you return is especially fun.
One warning before you try it: this program can't even be stopped with Ctrl-C. You'll need kill -9.
How the inescapable loop works
Here's the infinite_loop method again:
def infinite_loop(&block)
tap do
yield
ensure
redo
end
end
It's beautiful how everything except tap is made of keywords.
Here's what happens, step by step:
-
infinite_loopis called. -
Kernel.#tapis called. -
Kernel.#tapruns the block it received. - The
yieldinside that block runs, which runs the block passed as theblockargument.- This is where the caller of
infinite_loopruns whatever code they want.
- This is where the caller of
- After returning from
block, control always moves to theensureclause.- This is the key.
ensureexists precisely to write code that always runs, so it is evaluated no matter whether an exception was raised, orbreak, orreturnwas used.
- This is the key.
-
redois evaluated in theensureclause.-
redojumps back to the very beginning of thetapblock's evaluation. In other words, it goes back to just before step 3, and we loop.
-
So, by taking advantage of the fact that ensure is always evaluated, we managed to implement a loop with no escape.
How to escape the inescapable loop
Just as there is no shield that can block every spear2, there are ways out of this loop. Let's look at a few.
Kernel.#exit!
Let's start with the boring one. Calling exit! gets you out easily.
def infinite_loop(&block)
tap do
yield
ensure
redo
end
end
infinite_loop do
puts 'hello'
exit!
end
Running this prints "hello" exactly once and then the program terminates abnormally. That's because exit! terminates the process on the spot.
Note that Kernel.#exit will not get you out of this loop, because exit merely raises an exception.
TracePoint
You can also escape using TracePoint.
The heart of this loop is that the ensure clause always runs. But ensure has a weak spot too: if an exception is raised in the ensure clause, or a return happens there, that same ensure clause is not evaluated again.
def f
raise 'ex 1'
ensure
raise 'ex 2'
end
f
When you run this, the ex 1 exception sends us into the ensure clause, but the ex 2 exception does not send us back into that same ensure clause. If it did, you'd get an infinite loop trivially.
We can exploit this: if we raise an exception or return from inside infinite_loop's ensure clause, we can leave the loop. Concretely:
def infinite_loop(&block)
tap do
yield
ensure
redo
end
end
infinite_loop do
puts 'hello'
TracePoint.trace(:line) { it.binding.eval 'return' if it.lineno == 5 }
end
puts 'world'
Running this prints "hello" once, escapes the loop, and then prints "world" outside of it.
This works by using TracePoint's line event to return on line 5, where the redo in the ensure clause lives. You could raise instead of return and still escape, but return feels cleaner since no exception is thrown.
Wrapping up
That was an inescapable loop, and some ways to escape it. If you come up with other interesting ways out, please let me know!
-
My original implementation of
infinite_loopused recursion and blew the stack, but tompng came up with a much sturdier version usingredo. tompng is amazing. ↩ -
There's an old Chinese story about a merchant selling both a spear that pierces any shield and a shield that blocks any spear. When a customer asked what would happen if he threw the one at the other, the merchant had no answer. The Japanese word for "contradiction", mujun, is written with the characters for spear and shield. ↩
Top comments (0)