Not all errors are fatal(致命的). Some just indicate that you need to try again. Fortunately, Ruby provides a few interesting mechanisms(机制) that make it easy to "try again" - though not all of them are obvious or well-kown. In this post we'll take a look at these mechanisms and how they work in the real world.
Introducing retry(介绍一下retry)
Ok - this one is kind of obvious(常见的), but only you kown is exits. Personally, I was well into my Ruby career before I learned about the delightful(令人愉快的) "retry" keyword.
Retry is built into Ruby's exception rescuing system. It's quite simple. If you use "retry" in your rescue block it causes the section of code that was rescued to be run again. Let's look at an example.
begin
retries ||= 0
puts "try ##{ retries }"
raise "the roof"
rescue
retry if (retries +=1) < 5
end
# ... outputs the following:
# try #0
# try #1
# try #2
The are a few things to note here:
When retry is called, all of code in between begin and rescue is run again. It most definitely does not "pick up where it left off" or anything like that.
If you don't provide some mechanism to limit retries, you will wind up with an infinite loop.
Code in both the begin and rescue blocks are able to access the same retries variable in the parent scope.
The Problem With retry(retry的问题)
While retry is great it does have some limitations. The main one being that the entire begin block is re-run. But sometimes that's not ideal(明智).
For example, imagine that you're using a gem that lets you post status updates to Twitter, Facebook, and lots of other sites by using a single method call. It might look something like this.
SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")
# ...posts to Twitter API
# ...posts to Facebook API
# ...etc
If one of the APIs fail to respond, the gem raises a SocialMedia::TimeoutError and aborts. If we were to catch this exception and retry, we'd wind up with duplicate posts because the retry would start over from the beginning(如果我们能抓住这个超时异常,并且让代码重新开始)
begin
SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")
rescue SocialMedia::TimeoutError
retry
end
# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# and so on
Wouldn't it be nice if we were able to tell the gem "Just skip facebook, and keep on going down the list of APIs to post to."
Fortunately for us, Ruby allows us to do exactly that.
NOTE: of course the real solution to this problem is to re-architect the social media library. But this is far from the only use-case for the techniques I'm going to show you.
Continuations to the Rescue
Continuations(延长部分) tend to scare people. But that's just because they're not used very frequently and they look a little odd. But once you understand the basics they're really quite simple.
A continuation is like a "save point" in your code, just like in a video game. You can go off and do other things, then jump back to the save point and everything will be as you left it.
...ok, so it's not a perfect analogy, but it kind of works. Let's look at some code:
require "continuation"
counter = 0
continuation = callcc { |c| c } # define our savepoint
puts(counter += 1)
continuation.call(continuation) if counter < 5 # jump back to our savepoint
You may have noticed a few weird(不可思议的) things. Let's go through them:
We use the callcc method to create a Continuation object. There's no clean OO syntax for this.
The first time the continuation variable is assigned, it is set to the return value of callcc's block. That's why the block has to be there.
Each time we jump back to the savepoint, the continuation variable is assigned whatever argument we pass the call method. That's why we use the weird looking continuation.call(continuation) syntax.
Adding Continuations to Exceptions
We're going to use continuation to add an skip method to all exceptions. The example below shows how it should work. Whenever I rescue an exception I should be able to call skip, which will cause the code that raised the exception to act like it never happened
begin
raise "the roof"
puts "The exception was ignored"
rescue => e
e.skip
end
# ...outputs "The exception was ignored"
To do this I'm going to have to commit a few sins. Exception is just a class. That means I can monekypatch it to add a skip method.
class Exception
attr_accessor :continuation
def skip
continuation.call
end
end
Now we need to set the continuation attribute for every exception. It turns out that raise is just a method, which we can override.
BTW, the code below is taken almost vervatim from Advi's excellent slide deck Things You Didn't kown about Exception. I just couldn't think of a better way to implement it than this:
require 'continuation'
module StoreContinuationOnRaise
def raise(*args)
callcc do |continuation|
begin
super
rescue Exception => e
e.continuation = continuation
super(e)
end
end
end
end
class Object
include StoreContinuationOnRaise
end
Now I can call the skip method for any exception and it will be like the exception never happened.