Project

General

Profile

Feature #15567

Allow ensure to match specific situations

Added by ioquatix (Samuel Williams) about 1 year ago. Updated about 1 year ago.

Status:
Rejected
Priority:
Normal
Target version:
-
[ruby-core:91287]

Description

There are some situations where rescue Exception or ensure are not sufficient to correctly, efficiently and easily handle abnormal flow control.

Take the following program for example:

def doot
    yield
ensure
    # Did the function run to completion?
    return "abnormal" if $!
end

puts doot{throw :foo}
puts doot{raise "Boom"}
puts doot{"Hello World"}

catch(:foo) do
    puts doot{throw :foo}
end

Using rescue Exception is not sufficient as it is not invoked by throw.

Using ensure is inefficient because it's triggered every time, even though exceptional case might never happen or happen very infrequently.

I propose some way to limit the scope of the ensure block:

def doot
    yield
ensure when raise, throw
    return "abnormal"
end

The scope should be one (or more) of raise, throw, return, next, break, redo, retry (everything in enum ruby_tag_type except all except for RUBY_TAG_FATAL).

Additionally, it might be nice to support the inverted pattern, i.e.

def doot
    yield
ensure when not return
    return "abnormal"
end

Inverted patterns allow user to specify the behaviour without having problems if future scopes are introduced.

return in this case matches both explicit and implicit.

Updated by Eregon (Benoit Daloze) about 1 year ago

I think the workaround using a variable set after yield (success = true) is not too bad, and clear enough. And the performance would be fine in this case (i.e. there would be no overhead if the JIT profiles the branch and only one branch is taken, like TruffleRuby).

Ensure should always be executed no matter the circumstances IMHO, so I don't like to make it conditional with extra syntax.
Not executing ensure when using break, etc sounds like a bug in the vast majority of cases.

Updated by ioquatix (Samuel Williams) about 1 year ago

Eregon (Benoit Daloze) - thanks for your response.

Ensure should always be executed no matter the circumstances IMHO, so I don't like to make it conditional with extra syntax.

This isn't about not executing ensure, but allowing user to handle situations like "returned normally" vs "non-standard flow control".

I think the workaround using a variable set after yield (success = true) is not too bad, and clear enough. And the performance would be fine in this case (i.e. there would be no overhead if the JIT profiles the branch and only one branch is taken, like TruffleRuby).

Personally, I think it's ugly, and I also think it is inefficient. I also don't think it's clearly conveying what the user is trying to do.

The problem is, people write code like this:

begin
  ..
ensure
  abnormal_path if $!
end

This actually wrong and fails in the case of throw wrapped in catch as given in the original examples.

It's actually not clear from the code if this is what the user wanted or not. Because you can't express clearly what you are actually interested in.

There should be no overhead when exiting normally in the following situation:

begin
  yield
ensure when not return
  return :abnormal
end

As soon as you write code like:

begin
  yield
  success = true
ensure
  return :abnormal unless success
end

you guarantee that the code must pass through the ensure block. But your intention is, only execute this code if the code didn't return normally (or some other flow control).

So, I don't think the argument about always executing ensure holds up - this isn't about changing the semantics of ensure but extending it to handle explicitly what people are already doing, albeit probably incorrectly and inefficiently.

#3

Updated by ioquatix (Samuel Williams) about 1 year ago

  • Target version deleted (2.7)

Updated by shevegen (Robert A. Heiler) about 1 year ago

I was about to write a long comment, but then I noticed that my reply would be too long, so I shortened
this. So just a few key statements:

I don't like the proposed syntax suggestion here in particular:

ensure when not return

The reason why I dislike it is partially due to the syntax verbosity; but also because, more significantly,
because it appears to do conditional checks in very different means than have existed before in ruby.
I would expect "when" usually in a case-structure.

There was this other recent suggestion to also add "when", to allow for chaining after the addition of
the alias "then" to "yield_self", leading to chains of .when .then .when .then; and I also am not a huge fan
of using "when" that way. Of course there is more than one way to do something, but my own personal
preference would be to keep "when" reserved for case-when layouts.

As for catch/throw: I think it is a lot more common to see begin/rescue than catch/throw. Even if this
may be slower (e. g. some people doing control flow logic with begin/rescue), I think the intent and
clarity of intent is much clearer with control flows based on begin/rescue (and ensure) style layout
of code. So to conclude and finish my comment, I think it would be better to not introduce a more
complicated ensure conditional clause as proposed in the suggestion here. IMO you can re-structure
your code to keep ensure simple, so it should not be necessary to add additional conditionals into
ensure, even more so when these are unusual in regards to syntax - it would be the first time that I
would see "ensure when" and I don't quite like that syntax suggestion in this context.

Updated by ioquatix (Samuel Williams) about 1 year ago

shevegen (Robert A. Heiler) - thanks for your feedback.

The reason why I dislike it is partially due to the syntax verbosity

With regards to syntax, it should use existing keywords, otherwise it can cause more problems. The reason for not is already explained, because otherwise user may have to enumerate all other tags which is inconvenient and may break if new tags are introduced in the future. If you can propose better syntax, please do.

but also because, more significantly, because it appears to do conditional checks in very different means than have existed before in ruby.

This isn't really a conditional check like if, and it's not even a big change internally, it simply exposes a bit more of the exception handling mechanisms. It should be more efficient, because ensure when not return can be handled as part of exceptional flow control.

It's more similar to rescue than ensure in it's overhead. In MRI, rescue has some overhead, but it's possible to implement zero-cost exception handling.

The reason why I think this is a good idea is because Ruby provides a lot of flow control statements, but handling these abnormal flow control is very tricky and easy to get wrong. Statements like ensure ... unless $! is actually incorrect as well as inefficient. rescue Exception doesn't handle all ways a function can exit.

I think the key things to think about would be:

  • Is there a better syntax for this proposal?
  • Does introducing such feature have any practical downside?
    • Implementation cost.
    • Maintenance cost.
    • Does it introduce technical debt?
  • Does it solve a problem that can't be solved some other way?
    • ensure when not return can be partly solved some other way but with some overhead.
    • ensure when raise is similar to rescue Exception but doesn't require raise to resume default exception handling.
    • ensure when retry and other formulations (throw, return, next, break, redo, retry) have no equivalent right now.
    • ensure when ..., ..., ... is practically impossible, each case needs to be handled independently if possible.
  • Even if there are existing solutions, does this proposed solution communicate more clearly the intention of the programmer?
  • Do we want to expose this level of behaviour to user code?
  • Could we limit what tags you can match? I suspect the most important one would be ensure when not return, but I can also imagine it could be pretty useful to catch things like retry and redo.

Updated by ioquatix (Samuel Williams) about 1 year ago

One more thing to bear in mind is that you can avoid this feature if you don't like it. Because it's for some very specific situations. It doesn't affect existing code in any way. But for situations like mine where I have a handful of use cases, and efficiency is a concern (to an extent) as well as readability and correctness, I would welcome such a feature. If you don't need it you won't be affected by it, but if you need it, there is actually no other practical solution in the language right now.

Updated by jeremyevans0 (Jeremy Evans) about 1 year ago

ioquatix (Samuel Williams) wrote:

One more thing to bear in mind is that you can avoid this feature if you don't like it. Because it's for some very specific situations. It doesn't affect existing code in any way. But for situations like mine where I have a handful of use cases, and efficiency is a concern (to an extent) as well as readability and correctness, I would welcome such a feature. If you don't need it you won't be affected by it, but if you need it, there is actually no other practical solution in the language right now.

In the very rare cases where you want to treat a non-local, non-exception exit differently from a normal exit, as Eregon mentioned, there is a simple existing practical solution that is unlikely to be significantly less efficient:

def doot
  ret = yield
  normal_exit = true
  ret
ensure                                                                                                                                                                                                              
  # Did the block return normally
  return "abnormal" if $! || !normal_exit
end

I think we should definitely not add syntax in an attempt to make it easier to treat a non-local, non-exception exit differently than a normal exit, as doing so is usually a mistake.

You may not consider the above approach a simple and practical solution. However, considering you have already conceded that your proposal is for "very specific situations" and you only have "handful of use cases", even if you consider it neither simple nor practical, it's still easy and possible. New syntax is not necessary to support what you want, and I do not think new syntax should be added for "very specific situations" with only a "handful of use cases" when it is already possible to get the behavior you want with existing syntax.

Updated by ioquatix (Samuel Williams) about 1 year ago

So, jeremyevans0 (Jeremy Evans), I agree with everything you say.

That being said, this proposal goes beyond just that single case. For example, there is no easy way to capture retry or redo exiting a block.

Looking at your example, e.g.

def doot
  ret = yield
  normal_exit = true
  ret
ensure                                                                                                                                                                                                              
  # Did the block return normally
  return "abnormal" if $! || !normal_exit
end

I certainly prefer

def doot
  yield
ensure when not return                                                                                                                                                                                                             
  return "abnormal"
end

It's clearer what's going on (especially if function is more complex than 1 line of actual code), it should be more efficiently implemented by VM since it only affects non-normal flow control and there is no conditional checks required.

Updated by Eregon (Benoit Daloze) about 1 year ago

ioquatix (Samuel Williams) wrote:

For example, there is no easy way to capture retry or redo exiting a block.

Which is intended, it would break the semantics of those if they were caught, or it would be very surprising if some ensure blocks are not run in these cases even though they exit the scope.

I think there is < 1% case where it's sensible to not run ensure in this kind of conditions, and the workaround seems good enough for these rare cases.

it should be more efficiently implemented by VM since it only affects non-normal flow control and there is no conditional checks required.

Please don't assume that. A check will be needed too in the general case, and it can already optimize very well with the local variable (e.g., zero overhead on TruffleRuby if only using the normal path, or only the exception path).

Updated by Eregon (Benoit Daloze) about 1 year ago

ioquatix (Samuel Williams) Could you give one or two real-world examples where this would be useful?

Updated by matz (Yukihiro Matsumoto) about 1 year ago

  • Status changed from Open to Rejected

The language feature similar to ensure can be observed in many languages (unwind-protocol in Lisp, defer in Go, finally in Java and others), but neither of them considers the situation. That indicates there's no real-world use-case for it. So currently I believe there's no benefit enough the implementation cost and making the language more complex.

Matz.

Updated by ioquatix (Samuel Williams) about 1 year ago

Okay, thanks for your time and input. That makes sense.

Also available in: Atom PDF