NonFatal and ControlThrowable (changed in stdlib)

IMO the version with the quotes proves my point even more strongly. Close to 1k actual custom control throwables out there? Only in public code? That’s more references than I’ve ever seen for something supposedly not used.

By comparison, the few times we’ve actually dropped (or deprecated without replacement) anything in Scala.js, we had 0 or 1 such reference in a GitHub search.

My guess would be that the great majority of ControlThrowable extensions exist for efficiency and that their authors didn’t care one way or other whether they are NonFatal or not. But I can’t be sure of course. We can consider deprecating ControlThrowable later. The importance is to find a way forward with ControlException that avoids the traps and pitfalls of almost-but-not-quite exceptions.

In retrospect, ControlThrowable makes perfect sense as the preferred exception mechanism for non-local returns. Non-local returns look like local returns and are in fact very hard to distinguish from them. And local returns are definitely not exceptions. So any mechanism supporting non-local returns should try as much as possible not to look like an exception either.

But non-local returns are deprecated in Scala 3. Their replacements can use a fresh approach, which should be firmly based on exceptions. That’s why ControlThrowable is no longer a good basis for these replacements.

I think this is fine except for the ControlThrowable deprecation.

Having the option to use ControlException seems fine for those cases where you don’t mind breaking the ability to write first-class control constructs. For people who are heavy users of threading and light (or inadvertent) users of exception-based control constructs, this is nice.

For others, it’s probably not. I know my usage of Scala tends to be a little atypical, but I make fairly heavy use of stackless exception-based control flow to unify an Either/Result-based approach and an exception-based approach. If ControlThrowable can evade exception handling, you can apply Try and friends liberally to make sure you catch things you might not be aware of. In contrast, with a ControlException that is caught, you have to be very careful to structure your control flow in between exception handling blocks (or you have to use special ControlException-aware handlers…but…why not just give people special ControlThrowable-intercepting handlers instead?).

This adds even more cognitive burden to the already-increased burden of losing the portability of code with return if nonlocal return goes away. It’s really nice to not have to care whether you use try or Try, whether you use while or foreach…but with local-only-return, you must choose. Even by retreating to use of ControlException you still have a high cognitive burden because of the interleaving problem!

Overall, the net effect of these changes seems to be to make error handling a lot more burdensome.

I think instead we should explore more carefully the opposite question: why are people losing control of their ControlThrowables? The report from rssh here literally says that async wrappers are doing the wrong thing. That is, the feature here is motivated by both library authors and library users writing code they shouldn’t be writing, and again, the “fix”…still results in broken code! It’s just less fatally broken, maybe, if you’re lucky!

It just feels like an inelegant bandage over a serious problem that makes things more awkward for people who are currently unwounded.

One solution, conceptually, would be to have closures have checked exceptions that are only checked when asked–and if you’re writing async code, you ask that you not leak any control constructs. Whether we can fully achieve this right now with givens is not clear to me. But unlike the papering-over-the-problem solution of deprecating the construct that is escaping because both users and library-writers have buggy code, it would be an actual solution that would enable them to write code which is more correct.

2 Likes

I would see it the other way. Try(block) should give an iron-clad guarantee that nothing escapes block, and that any abort inside block is reified as a Failure. The proper way to deal with it is to
re-throw the exception when the Try is accessed. This is a more unified and simpler model in my mind.

Otherwise you get into problems like exiting a simple Try(...) is OK, but exiting a Future(...), which calls Try(...), is not since the return kills the executor thread instead. This “not-quite-exception” concept is just too fragile. Exceptions require serious thought and are hard enough as they are. You don’t want a variant that behaves differently in key aspects.

But I see no problem in keeping ControlThrowable without deprecating it, at least for now.

I think having ControlThrowable available undeprecated would be good. (Or you could force people to think about it by making something else that supersedes ControlThrowable.)

I agree that not having a convenient thread-safe variant of Try is a problem.

I still worry about just dropping the changed behavior on people without so much as even a compiler warning. It is a breaking change to catch ControlThrowable. Maybe some pattern like we have with mutable and immutable?

Try and Success and Failure would be superclasses in scala.util as they are now.

But there would be a new scala.util.control.Try (and Success and Failure), which would have the current behavior of letting ControlThrowable through, and a scala.util.concurrent.Try, which would have the extra-safety behavior of catching miswritten control exceptions too.

If we had a way to conditionally deprecate scala.util.Try, then the compiler would flag at least most of the places of concern, and people could update the signature accordingly. Or we could just leave it as an opt-in feature so people could change all their signatures if they suspect a problem (or just as part of a style guide).

The reason to do it on the Try side rather than the ControlThrowable side is that there are legitimate reasons to want to bypass “ordinary” exception handling with control flow, but it is at the exception-handling use site that determines that, not the use site for the control-flow exception.

1 Like

This hypothesis holds at least for Scala STM and my (still non-published) dissertation code.

It is not true for my code. I use ControlThrowable/NonFatal pairs specifically because the former gets through the latter.

Because there isn’t a One Right Way to do most things in Scala, it’s hard to judge use patterns even from large individual projects, because each may have their own coding style.

2 Likes

The main argument for wanting to change to being caught by NonFatal seems to hinge on variations of “ControlThrowable is a not-quite-exception that tries to pretend it isn’t”, and that NonFatal should not try to be clever about maintaining that illusion. Basically saying that ControlThrowable is a leaky abstraction.

IMO, this is not what ControlThrowable pretends to be. ControlThrowable is very honest about being an exception (it’s in the name), but also about the fact that it won’t be caught by NonFatal. And conversely, NonFatal is very honest about not catching ControlThrowables.

It is clearly not a leaky abstraction. Its documentation tells you exactly what it does! Any argument against ControlThrowable based on the leaky abstraction property is therefore void to me.


Now, whether or not Try and/or Future should be using NonFatal or not, that is a more productive debate IMO.

For me, it makes perfect sense that Try only catches NonFatal. It’s the behavior I want.

Futures? I don’t know. To me, if a control-throwable escapes its executing thread, it is clearly a programmer mistake, similar to NullPointerExceptions. I consider those fatal in the sense that they should always be fixed at the source, instead of being recovered from. Catching them is always a code smell to me. You should instead prevent them from happening in the first place. Having them by-pass NonFatal is an effective way to do that, as they immediately show up in test failures and/or logs.

4 Likes
  • but NullPointerException is NonFatal now. Why ControlThrowble. should be different (?).

Maybe my first post in this thread was unclear… I want to say that we now have two concrete problems, from catching ControlThrowable as Fatal by execution engines, one - general, and the second - related to code transformations.

General:

  • Let us have some execution engine (i,e, spark, kafka driver, … something with scala UDF). which runs user code supplied as a jar. When the user makes an error, this error is reported as a failure of UDF. But not for ControlThrowable — when the user, for some reason throws ControlThrowable, then the execution engine is shut down instead of reporting an error in the user code.

Specific:

  • Let we want to reinterpret Scala code in some way. (my example is dotty-cps-async, but the same problem will exist for the runtime monadic reflection library, which was developed in EPFL, and for any shallow DSL embedding). If this reinterpretation touches constructions, which operate some control throwable (i.e. returning clause or hypothetical .?), then we should handle this in some special way
    - A) add try/catch for ControlThrowable for each operation (which means that user will pay some small price even if those features are unused) or
    - B). do some special compile-time translation (which can’t be 100% correct, because of the possibility of hiding throwing operations inside user function outside transformation)

But if we will fix execution engines not handle ControlThrowable, then such transformations will just work.

Like I said, it’s very possible that Future should be changed to catch more than NonFatal. I presented my hunch but I’m not an expert in that. Your argumentation doesn’t hold for Try or NonFatal in general, though.

Is there really no way to decouple non-local control-flow constructs (which seem useful in general!) from exceptions? (JVM byte-code has labeled jumps for a reason I guess).

In my mind using exceptions for control-flow looks just completely wrong. (And I think this is also what gets taught mostly[1]).

Exceptions are exceptional: They are there to recover on a higher level form really bad error situations. Most of the time you should not even try to catch them in your code, because recovery form a really bad error is very often impossible to correctly achieve locally anyway. (Only your supervisor may know what to do when you’re seriously ill, or die). Please see also the linked SO answers for that line of reasoning.

Languages like Java or Python are a terrible example OTOH, as they routinely (mis)use exceptions for control flow. I think we should not imitate that.

I like the idea of boundary / break that @odersky proposed on GitHub because it’s not an unbounded goto (like exceptions!) but still structured programming; but I think it should not be leaky.

I think, if I would dig into how it works I would be annoyed if I would find (slow and heavyweight) exceptions—as this mostly kills the purpose as a fast “ejector seat” form some long running task. And I would be even more annoyed if those exceptions would interoperate in any way with regular, user-level, exceptions.

This whole discussion here wouldn’t be needed if control-flow would be cleanly separated form exception handling. This are imho two different topics and therefore should be handled completely separately. Mixing unrelated things always creates much to many gotchas that will bite people in unexpected and very annoying ways (as we actually just see here with the interaction of those control “exceptions” with everything around). @odersky is right that this is terrible. But making the already bad and leaky abstraction fully transparent as a consequence is even worse!

And while I'm here, maybe I should mention something that I don't understood in this context: Why the hell does Scala still include `return` (in the Java sense)? This should not exist at all as it's just two different ways to do the same thing. There is absolutely no advantage to have a `return` keyword in the language for *normal* function return. That's just one more thing to explain to new people: "Sure, you could write 'return' here at the end as in Java, but we actually never do that." Usual newbie reaction: "WTF?!".

If there would be no “regular” return in the language, there would be also no problem to use that keyword for non-local returns… And we could bikeshed whether break or return would match up better with boundary. :wink:

I’ve just remembered one likes to have “multiple returns” in some cases, and than you would need such keyword (even multiple returns are considered a code smell in some circles; but I would not sign this claim, I guess).

But how about streamlining this idea: Multiple returns get used for the same purpose as no-local returns in deeply nested scopes: To abort tasks early!

So I think I would rename return to abort (even in the case of non-local control flow), and make it at the same time an error to use abort as last statement. That way abort would be reserved to actually always abort something. (And it should be still not implemented as exceptions even in the case of no-local abort! As aborting something is not something exceptional, and not error handling, but part of the happy path of execution, imho). bounded / abort sounds also fine to me. I guess abort would even make sens intuitively in the context of Futures. This spontaneous idea looks like a win. :smiley:


[1] anti patterns - Are exceptions as control flow considered a serious antipattern? If so, Why? - Software Engineering Stack Exchange

[quote=“MateuszKowalewski, post:32, topic:6049”]
Exceptions are exceptional:

I think this is a fallacy. If you look at algebraic effect handlers (.e.g in Multicore OCaml) they are a generalizatiion of exceptions. Yet, exceptions are exceptional but effects are not? Another example: Smalltalk. It has no exceptions but non-local returns. Non-locat returns were promoted as a better way to express exceptions. So, does that mean if exceptions are expressed as non-local returns they become less exceptional?

This “exceptional” thing is a gradual property and therefore it leads to imprecise arguments. In my preferred conceptual framework, an abort is an abort, it does not matter how it is implemented. That’s much clearer and much safer. To see why, consider the worst thing that could happen in either model

  • Control-flow aborts are different from exceptions: Silent thread death and system starvation, e.g. when we do a non-local return from within a Future { ... }.
  • Control-flow aborts are a special case of exceptions: If you install a handler for all exceptions, you might accidentally log or otherwise reify an abort. In any sane program the exception and the abort will be re-thrown (either immediately or later). So, still a bug but easily detectable and fixable (just filter out control-flow aborts before handling the other exceptions).

In summary, having one abort model is always simpler, cleaner, and therefore better than two different ones, in particular if the difference between the two models is based on such an imprecise criterion as “being exceptional”.

An exception is, by it’s name, something unusual - a special case (note: nominal fallacy doesn’t apply here, as they were created and named with intent, rather than organically, and this happened relatively recently). The idea that algebraic effects would provide a generalized mechanism that could be used to implement them this doesn’t seem like it would change this, as algebraic effects can (and arguably should) be used for other things.

A general mechanism can be used to implement a special case, without changing the nature of either. Implementing something which should be uncommon and remarkable using an underlying mechanism that was intended for ordinary situations is pretty common. Implementing something which should be common and unremarkable using an underlying mechanism that was intended for exceptional situations is always asking for trouble.

Exceptions would be an exception to the way algebraic effects would generally be used, in the same way they’re currently an exception to the way the stack is handled. Everything about them is unusual.

1 Like

This is not the only area in which threads change everything. (C.f. synchronized and the like; spores vs closures; colored functions if you’re doing async; and so on.)

If there were some case other than threads, it would be a much stronger argument.

Otherwise, why not just say, “Oh, and when you have threads, make sure you handle control flow escape as well as exceptions.”? You have to think about them anyway or things will go wrong.