Direct style (Rust) instead of for comprehensions

My last point on Results vs checked exceptions because I don’t want to derail the topic any longer :slight_smile: .

(Depending on details, yes, even stackless exceptions can be more expensive than passing back an allocated error type. But, anyway, you did specifically ask how Rust improved over something like Java’s checked exceptions, and one of the big advantages is performance, even if Scala cannot fully recapture the same advantage. I was just answering your question!)

I understand what you are saying, what I’m trying to say is that, linguistically, rust Result and .? macro is essentially the same linguistic tool that java has, with the same language semantics, as in when the compiler forces you to handle, exhaustiveness, and how it propagates in signatures (let’s forget for a second that unchecked exceptions exists), in java you can even have a list of disjoint exceptions which rust doesn’t, but you already know all of this.

As language tools, I don’t find rust’s Result to be different from checked exceptions, and just like with checked exceptions in java where follow up languages decided to forgo, I think the problem here is the same. Rust gets away with it because the entire language was modeled with it from scratch, while java sadly has unchecked exceptions plus clumsy ergonomics around exceptions.

My hopes right now are in CanThrows+capabilities thing, plus erased being added as non-experimental.

How different does a feature have to be before it’s not the same linguistic tool? Is an anonymous function the same thing as an anonymous class? That’s about the level of similarity here.

Checked exceptions can’t be generic. Could they be made so? Sure. In Java, are they? No.

Checked exceptions are invisible by design until they’re caught. Result types aren’t. Could checked exceptions be made more visible (e.g. by requiring ? to explicitly mark that you recognize that you’re passing them on)? Sure. Are they? No.

Checked exceptions are expensive compared to result types on the failure branch (but cheaper on the success branch, usually). Could they be encoded in a way that made the failure branch cheaper (at the expense of some success-branch performance), to the point where you’d always just use it and not worry about it? Sure. Are they? No.

More to the point, having worked with both checked exceptions and result types (with ? in Rust and my .? analog in Scala), they feel quite different. That little bit of thought about, “oh, this is where the failure comes from” is hugely different from “wait it doesn’t compile, oh fine, just jam this exception type into the signature”. The extreme ease of grabbing and handling a failure type, or changing the type on the fly as needed, is also different.

So I really don’t think it’s meaningfully the same feature. Having low-cost result types with low-ceremony ways of getting the error path out of your hair is really helpful.

1 Like

I am interested in pursuing this further. One thing I am not sure about is the implicitness of the return target of ?. If we follow Rust, it’s the enclosing method except when it appears in a closure in which case it is the closure. Now, closures in Scala are next to invisible (e.g. call-by-name arguments are closures), so this could lead to gotchas.

One solution is to make the return target always the enclosing method. But then we pay the price of exceptions which can be high. And we also have all the pesky interactions with trys catching Throwable, which caused us to abandon non-local returns in the first place.

Another solution could be to make the return target the closest-enclosing try. I noted that something like this exists as an optional unstable feature in Rust: try_blocks - The Rust Unstable Book. That would make it clearer what goes on. As an additional benefit, we could wrap results of try blocks with embedded ?s in them with Ok automatically.

Why try and not some other construct? Mainly, I want to re-use the finally part. I think finally should work no matter whether the code finishes with an exception or an Err result.

What should the compiler do with the following program?

try
  xs.map: x =>
    val y = f()?
    g(y)

This would entail a non-local abort from a closure to its caller. If we don’t want to support this,
it would be a static error, and the diagnostics would be much clearer than if the compiler assumed the argument of the map returned a Result. If we figure out a good way to implement the non-local abort, we could also accept code like this at some later stage.

1 Like

I wonder: Would a non-local return implementation be acceptable under a language import? Previous non-local returns were criticized since they had a performance impact that was sometimes surprising since one was not even aware that the return was non-local.

But if an explicit opt-in via a language import is required, this might solve the problem. And then we could also support ? aborts to a non-local try under the same import.

Oh, that’s a nice feature! I’m not sure, however, that we can label this just try as opposed to something like try?. The reason is that for a large try-block, understanding its semantics should not depend on wading through every line searching for ? in order to even understand its return type (i.e. is it A or is it Result[A, E]?).

The value-add to this over a library function is that the compiler, unlike anyone else, can emit jumps, which means that if you write

try?
  val accumulator = foo()
  while something do
    accumulator += whatever?
  accumulator.result

you don’t even need to pack the body into an anonymous function; you can just directly run the logic, including emitting the otherwise forbidden goto from the failed whatever case to the end of the statement.

That is pretty nice, and not being able to do that is the only reason I wrote my library-version of ? as an obligate method body wrapper. Or, well, I meant to, but apparently I messed up and allow returns over the unwrapped portion:

// Works
def gift: Nice Or Naughty = Or.Ret{
  santa.getPresent().?.wrap
}

// Fails, hopefully with a comprehensible error message?
// Um...actually...this succeeds in my code..oops!  Okay, I guess??
def give: Nice Or Naughty =
  val santa = SantaFactory.getSanta
  val wrapped = Or.Ret{ santa.getPresent().?.wrap }
  wrapped.label("from the North Pole")

Anyway, at least in my usage, ? without non-local return is so hampered as to hardly be worth the effort. Unlike Rust, Scala makes extremely heavy use of byname parameters, closures, and other changes in execution context. Although it would be very helpful to have an annotation like @tailcall to warn when a jump is nonlocal, or possibly a requirement for an erased given to unlock the capacity (without actually adding any computational burden), or a language import (do those actually help anyone?), I just don’t see how anything else would work well.

For instance, what if you want to use using to handle closing your file? Can’t do it. What if you want to catch a known exception using Try? Nope. Use it as a map fallback with getOrElse? Nope. Having a feature that doesn’t compose with standard Scala code needs a much higher level of justification than one that does, I would think.

1 Like

Alternatively, opt-in with an annotation (@nonlocal return and val y = f()? : @nonlocal)? The second ruins the brevity of ? but maybe that’s okay.

When the point of a feature is brevity, ruining the brevity is not particularly okay.

2 Likes

The traditional hack is chess notation, f()?! The brevity preserves the soul of wit.

Haha, I had a ?! but removed it as having too unclear of a meaning (I don’t even remember which supposedly-sensible thing I had it do–bail out on success, I think?). I do have a ?+ (which lets you add an explicit mapping to the error) and ?* (which searches via given for some way to bail out with an error).