Feature #20160
closedrescue keyword for case expressions
Description
It is frequent to find this piece of hypothetical Ruby code:
case (parsed = parse(input))
when Integer then handle_int(parsed)
when Float then handle_float(parsed)
end
What if we need to handle parse
raising a hypothetical ParseError
? Currently this can be done in two ways.
Either option A, wrapping case .. end
:
begin
case (parsed = parse(input))
when Integer then handle_int(parsed)
when Float then handle_float(parsed)
# ...
end
rescue ParseError
# ...
end
Or option B, guarding before case
:
begin
parsed = parse(input)
rescue ParseError
# ...
end
case parsed
when Integer then handle_int(parsed)
when Float then handle_float(parsed)
# ...
end
The difference between option A and option B is that:
- option A
rescue
is not localised to parsing and also covers code followingwhen
(including calling===
),then
, andelse
, which may or may not be what one wants. - option B
rescue
is localised to parsing but moves the definition of the variable (parsed
) and the call to what is actually done (parse(input)
) far away fromcase
.
With option B in some cases the variable needs to be introduced even though it might not be needed in then
parts (e.g if the call in case
is side-effectful or its value simply leading to branching decision logic).
The difference becomes important when rescued exceptions are more general (e.g Errno
stuff, ArgumentError
, etc..), as well as when we consider ensure
and else
. I feel like option B is the most sensible one in general, but it adds a lot of noise and splits the logic in two parts.
I would like to suggest a new syntax:
case (parsed = parse(input))
when Integer then handle_int(parsed)
when Float then handle_float(parsed)
rescue ParseError
# ...
rescue ArgumentError
# ...
else
# ... fallthrough for all rescue and when cases
ensure
# ... called always
end
If more readability is needed as to what these rescue
are aimed to handle - being more explicit that this is option B - one could optionally write like this:
case (parsed = parse(input))
rescue ParseError
# ...
rescue ArgumentError
# ...
when Integer then handle_int(parsed)
when Float then handle_float(parsed)
...
else
# ...
ensure
# ...
end
Keyword ensure
could also be used without rescue
in assignment contexts:
foo = case bar.perform
when A then 1
when B then 2
ensure bar.done!
end
Examples:
- A made-up pubsub streaming parser with internal state, abstracting away reading from source:
parser = Parser.new(io)
loop do
case parser.parse # blocks for reading io in chunks
rescue StandardError => e
if parser.can_recover?(e)
# tolerate failure, ignore
next
else
emit_fail(e)
break
end
when :integer
emit_integer(parser.last)
when :float
emit_float(parser.last)
when :done
# e.g EOF reached, IO closed, YAML --- end of doc, XML top-level closed, whatever makes sense
emit_done
break
else
parser.rollback # e.g rewinds io, we may not have enough data
ensure
parser.checkpoint # e.g saves io position for rollback
end
end
- Network handling, extrapolated from ruby docs:
case (response = Net::HTTP.get_response(URI(uri_str))
rescue URI::InvalidURIError
# handle URI errors
rescue SocketError
# handle socket errors
rescue
# other general errors
when Net::HTTPSuccess
response
when Net::HTTPRedirection then
location = response['location']
warn "redirected to #{location}"
fetch(location, limit - 1)
else
response.value
ensure
@counter += 1
end
Credit: the idea initially came to me from this article, and thinking how it could apply to Ruby.
Updated by austin (Austin Ziegler) 10 months ago
lloeki (Loic Nageleisen) wrote:
case (parsed = parse(input)) when Integer then handle_int(parsed) when Float then handle_float(parsed) rescue ParseError # ... rescue ArgumentError # ... else # ... fallthrough for all rescue and when cases ensure # ... called always end
I don't think that this would be necessarily more readable than a standard case or the new pattern matching case.
If #parse
is defined as:
def parse(input)
# parsing logic
rescue StandardError => e
[:error, e]
end
You could write the case/in
patterns like this (I think; I have not yet used pattern matching because the libraries I support are not yet 3.x only):
case parse(input)
in Integer => parsed_int
handle_int(parsed_int)
in Float => parsed_float
handle_float(parsed_float)
in :error, ParseError => error
# handle ParseError
in :error, ArgumentError => error
# handle ArgumentError
else
# all other cases — note that there is no assignment here
# but most parsing should probably be exhaustive
end
The ensure
case should be executed outside of the case
.
Yes, it means restructuring the parser a little bit, but I think better than mixing rescue
into case
.
The URI case requires a bit more work (extracting the get to a separate method):
def fetch(uri, limit = 10)
raise ArgumentError, 'too many redirects' if limit == 0
case response = get_response(uri_str)
in URI::InvalidURIError => error
# handle URI errors
in SocketError => error
# handle socket errors
in ArgumentError => error
# assume that ArgumentError is 'too many redirects'?
in StandardError => error
# handle other more general errors
in Net::HTTPSuccess
response
in Net:NTTPRedirection
location = response['location']
warn "redirected to #{location}"
fetch(location, limit - 1)
else
response.value
end
@counter += 1
end
def get(uri_str)
Net::Net::HTTP.get_response(URI(uri_str))
rescue => error
error
end
Updated by rubyFeedback (robert heiler) 10 months ago
Note that I find this example:
when Float then handle_float(parsed)
rescue ParseError
Easier to read than:
case (parsed = parse(input))
rescue ParseError
when Integer then handle_int(parsed)
I am also not certain how common it is to assign "in-line", that is the
variable "parsed = ".
In my own code I very rarely do such assignment styles, although I do
sometimes use it for individual when-lines, when I need the assigned
value only within that when-clause itself; I usually may handle longer
case/when structures via a dedicated method, where I ensure that everything
is as I need it to be, before letting case handle things. I do not really
have a dedicated pro or con opinion on the suggested functionality in
and by itself, though, so my comment should be more regarded as a
peripheral comment than one about the proposed functionality as such
in any way really.
Updated by kddnewton (Kevin Newton) 10 months ago
case (parsed = parse(input))
when Integer then handle_int(parsed)
when Float then handle_float(parsed)
rescue ParseError
# ...
rescue ArgumentError
# ...
else
# ... fallthrough for all rescue and when cases
ensure
# ... called always
end
This would make me very uncomfortable. else
for case
/when
is used as a default case. else
for begin
is used when error are not raised. These are fundamentally different concerns and concepts, and this would be overloading them.
If it were consistent with case
/when
it would jump to the else case if it did not match. If it were consistent with begin
/else
it would jump to the else
case if no error was raised. This would be very confusing and difficult to educate people about.
It's also not clear to me if a rescue
clause is attached to a case
statement if the rescue applies to just the value of the case
or if it applies to the entire statement. If an error is raised inside parse_int
in your example, does it go through the rescue
? What if the rescue
is added after the in
/when
clause?
I think this loses quite a bit more clarity than it gains.
Updated by lloeki (Loic Nageleisen) 10 months ago
If it were consistent with case/when it would jump to the else case if it did not match. If it were consistent with begin/else it would jump to the else case if no error was raised.
The idea is that:
-
else
applies when no case has matched, whether they are errors or return values -
ensure
applies always
If an error is raised inside parse_int in your example, does it go through the rescue
(I guess you meant handle_int, correct?)
Would it be clearer that error matching and return value matching sit at the same level if written this way?
case (parsed = parse(input))
when Integer then handle_int(parsed)
when Float then handle_float(parsed)
when rescuing ParseError
# ...
when rescuing ArgumentError
# ...
else
# ... fallthrough for all rescue and when cases
ensure
# ... called always
end
The core idea is that both a method signature and what said method can raise in exceptional cases (including what its internal dependencies can raise yet are uncaught by said method) are part of the method contract, so handling them both at the same level with case can make sense, which in turn makes else
and ensure
make sense as well.
Updated by lloeki (Loic Nageleisen) 10 months ago
If #parse is defined as:
This requires:
a) parse to be in your control
b) parse to handle every possible exception (including whatever it calls) for which one would want a rescuing clause to control flow.
extracting the get to a separate method
A somewhat generic alternative would be:
def wrap_error
yield
rescue StandardError => e
[:error, e]
end
case wrap_error { parse(input) }
when ...
That is the point: exceptions are a first class Ruby concept. To me it feels off to create wrappers (e.g with this new get method or the wrapper above), munge return values with in band metadata (this [:error, exception] return value), or split control flow in two parts (begin rescue followed by case when) when logically it is one control flow, this is why I felt there may be interest in such a proposal.
I do agree that pattern matching feels as much of a good potential candidate as any for such an exception rescuing feature. A specific pattern syntax could be used to match a given exception, and else
would make sense in that context:
case (parsed = parse(input))
in Integer then handle_int(parsed)
in Float then handle_float(parsed)
in raised ParseError
# ...
in raised ArgumentError
# ...
else
# ... fallthrough for all rescue and when cases
end
(please don't take in raised
as face value, could be in exception
, in rescued
, in !ArgumentError
, or some other special syntax)
Updated by austin (Austin Ziegler) 10 months ago
lloeki (Loic Nageleisen) wrote in #note-7:
If #parse is defined as:
This requires:
a) parse to be in your control
It does not. A parse wrapper could be written, just as I did with get
in the URI example.
b) parse to handle every possible exception (including whatever it calls) for which one would want a rescuing clause to control flow.
Not at all. rescue => error
and returning [:error, error]
is sufficient to handle anything that parse
does not already handle.
That is the point: exceptions are a first class Ruby concept. To me it feels off to create wrappers (e.g with this new get method or the wrapper above), munge return values with in band metadata (this [:error, exception] return value), or split control flow in two parts (begin rescue followed by case when) when logically it is one control flow, this is why I felt there may be interest in such a proposal.
Exceptions are part of Ruby, but exception handlers are not cheap.
I do agree that pattern matching feels as much of a good potential candidate as any for such an exception rescuing feature. A specific pattern syntax could be used to match a given exception, and
else
would make sense.
else
still does not make sense as Kevin Newton said in #5, as the begin/else/end
is a wholly different context than case/when/else/end
or case/in/else/end
. The former is for when no exception is thrown; the latter two are when there is no other match made. I don't see a way to reconcile that particular conceptual roadblock aside from your parser function returning something more meaningful.
Exceptions aren't really supposed to be used for flow control, which is sort of what you're doing here. throw/catch
is more related to flow control than exceptions, IMO.
I think that what you've presented here is interesting, but I could not see using it.
Updated by matz (Yukihiro Matsumoto) 9 months ago
- Status changed from Open to Rejected
According to the original intention, rescue
clauses in case
should only handle exceptions from the target expression, not those from the case
bodies. But the clause position could confuse readers of the source of exceptions to handle. So I'd rather use the option B in the OP. It's quite straight forward and intuitive (although not being fancy, nor too concise).
Matz.