Pre-RFC: Catching Functions

Strong -1 on pure. Analogy with Haskell is at most a very small advantage in my opinion. Any advantage is overruled by these downsides:

  • the name is excessively obscure for anyone who isn’t a Haskell programmer (i.e., most people)
  • I don’t even know whether the name makes a lot of sense in the Haskell context, but I’m pretty sure it makes no sense at all in the Rust context.
  • Even for Haskell programmers it’s a strained analogy, as you note.
3 Likes

Hehe… I think pure might be a bit unintuitive for none Haskellers (like myself) :stuck_out_tongue:

EDIT: I talked to the people at #haskell @ freenode and they didn’t particularly care for using the word pure here (since it would violate the laws of Applicative), so in order to not irk them, I retract the proposal of naming it pure and instead propose wrap or pass.

I’ve noticed a general trend of “Haskell programmers are very very few, and what they propose is obscure, so we can just dismiss it” which I don’t particularly care for. I’m not saying that you stand for such a view, but…

The fact that a term comes from Haskell is not an argument against it. An example of a term popularized by Haskell is associated type which was introduced by Chakravarty, Keller, Peyton Jones.

Haskell is a great language giving you unparalleled rapid prototyping, type safety (well… there’s Agda …), and so on. A lot of what is great about Rust, especially wrt. newer features come from Haskell. For me, Rust is the happy marriage of C++'s performance and Haskell’s ability to abstract and provide safety spiced with a borrow checker and move semantics.

Asserting that pure makes no sense for Rust is fine, but you give no reasons for why I should accept it.

I am somewhat detaching the meaning from what it means in Haskell and giving it new meaning, but not by a lot…

-- Here I am detaching `pure` from Applicative..
-- there are no interesting laws for Pure or "Pointed"...

class Pure f where
  pure :: a -> f a

instance Pure [] where
  pure x = [x]

instance Pure Maybe where
  pure = Just

instance Pure (Either e) where
  pure x = [x]

-- and so on..
// One difference here is that we don't use HKTs and an
// associated type instead which affords generality..
trait Pure {
  type PureVal;
  fn wrap(val: Self::PureVal) -> Self; // `pure` is reserved so..
}

// ^-- I'm not saying that this is the trait we should stabilize...

impl<A> Pure for Vec<A> {
  type PureVal = A;
  fn wrap(val: Self::PureVal) -> Self {
    vec![val]
  }
}

impl<A> Pure for Option<A> {
  type PureVal = A;
  fn wrap(val: Self::PureVal) -> Self {
    Some(val)
  }
}

impl<A, E> Pure for Result<A, E> {
  type PureVal = A;
  fn wrap(val: Self::PureVal) -> Self {
    Ok(val)
  }
}

// and so on..

Semantically, the only difference between pure in Haskell and what I am proposing here is the early return - otherwise, it’s all about wrapping a value in a container.

Another possible keyword: wrap

1 Like

There there an RFC or something that describes how Ok-wrapping works? I looked on this forum and in the RFC repo, but didn't immediately see anything.

I personally like this proposal as-is, but I wish it generalized better to return types other than Result. I’ve found the ? operator for Option very useful when implementing iterators that call other iterators, or when doing checked arithmetic. I expect that ? for Future will also be quite useful when it becomes available. It would be nice if the new function syntax did not require significantly more verbose signatures for these types. Unfortunately, I don’t have any clever solutions to suggest. :confused:

3 Likes

I appreciate that correction, thank you. I was purely stating the mental model I had for why &String turning into &str (whether at a return or otherwise) doesn’t bother me.

And I really appreciate the consideration.

My concerns with this proposal are like my concerns with the other Ok-wrapping proposals: this hurts learnability of the language, because you make something that was previously simply a more or less “normal” type, Result, even more special than it is right now. The ? operator is of the same kind, and its addition was problematic for the same reason. Also, like the other Ok proposals, this proposal is mostly a collection of syntactic sugar that isn’t even that sweet. This is less because the proposal is bad, more because there isn’t much “sweetness” to add.

The biggest advantage I see from adopting this proposal is that unlike try!(Err(expr)) throw expr evaluates to the never type in expression context, which allows for nicer code in some places. E.g. allowing code like:

let f = match something() {
    CoolEnum::Variant(v) => v,
    _ => throw LibError::OhNo,
}

The alternative that works now would be return Err(...) but I’m no big fan of it.

So overall I’m not a great fan of this proposal.

However, I think that this proposal is better than the RFCs proposed during the impl period. Especially I like the fact that this syntax is specifically opt-in instead of relying on the type checker and only performing the wrapping if the expression of the returned type is not a Result.

4 Likes

I like idea behind throw, but T catch E as a sugar for Result<T, E> looks quite unnecessary to me and hides enum nature of error handling, which I think will hurt learnability. The only case for which it looks a bit useful is when return type is Result<(), E>, but I am not sure if it’s enough for such addition. Also as was noted by others usage of throw/catch will probably be a big source of confusion for newcommers. Same goes for Ok wrapping, while I too don’t like Ok(()) that much, I don’t think that hiding it behind magic is a good idea, especially considering that this behaviour will be non-generic.

I would like to have instead two new keywords (lets call them reta and retb for now) which will work in conjunction with this trait:

trait RetTrait {
    type A;
    type B;

    fn reta(val: Self::A) -> Self;
    fn retb<V: Into<Self::B>>(val: V) -> Self;
}

impl<T, E> RetTrait for Result<T, E> {
    type A = T;
    type B = E;
    
    fn reta(val: Self::A) -> Self {
        Ok(val)
    }
    
    fn retb<V: Into<Self::B>>(val: V) -> Self {
        Err(val.into())
    }
}

So if we’ll write reta 1; in case of Result it will be sugar for return Ok(1); and retb "error text"; will be sugar for return Err(From::from("error text"));. And it can be easily extended to other types.

2 Likes

I’ve often encountered the following ergonomic problem I have often had when handling errors: type inference getting undecidable because of two .into() coercions, the other being converting something (such as a string) into the error type in the first place, and then the .into() baked in the ? operator. This often happens with closures: you create and return an error from a closure that you pass to some higher order function, and then use ? after the call. Because the error type of the closure is inferred, not declared, the type inference has too much degrees of freedom and fails.

If an error-returning throw expression is known to the compiler, it might be possible to omit the implicit .into() in the cases it leads to problems with type inference.

Is it intentional that this basically the Try trait? Try in std::ops - Rust

1 Like

Re-litigating things doesn't make your case stronger; we did add ? to the language. So, if your argument is "we shouldn't add this for the same reasons we shouldn't have added ?, well, we did, so that's not very convincing.

2 Likes

Close, but my trait is not tied to the Result, so it can be used for Option and other types. Plus it utilizes Into trait for “error”.

@Centril You've retracted pure specifically but I feel like I owe you some answers, and for that matter I have a more general point to make regardless of the specific keyword used.

This is true. This term specifically is not popular (or even known) outside of Haskell circles, so the advantage of being a familiar term only applies to Rust programmers with Haskell background (a numerical minority), while for all others it's a completely foreign term that has to stand on its own. A term with Haskell lineage can of course be good at standing on its own — "associated type" is — but that is a merit of the term in question, independent of its lineage.

I have a rough idea why it might be a sensible name for the Applicative operation. But without referencing that (very strained) analogy, I don't see how any sense of the word "pure" is at all related to "wrapping a value in a container". Even in Haskell, "pure" just for wrapping something in a container, divorced from the notion of computation that Applicative has, is an odd name. In other words, how can a programmer make sense of the term without learning about, and constantly thinking in analogy to, Applicative?


Independently of the specific keywords used, it seems to me that replacing return in Ok-wrapped functions is a cure worse than the disease, in the sense that (at least in the little snippets shown here) it doesn't look significantly nicer than what you'd write in Rust today. I understand the objection to overloading return to do special wrapping, but adding yet another keyword that is virtually identical to return (and asking programmers to always choose the right one) seems like a big change to surface syntax for rather little gain.

It also jeopardizes at least one advantage of Ok wrapping, the edit distance when making a function fallible: You'd have to change all early returns and touch the tail expression(s), much as if Ok-wrapping did not exist, only now you're doing return -> $new_keyword instead of adding Ok(..) or Some(..) around the returned expressions.

4 Likes

Try is not tied to Result and is implemented, already, by Option as well. Your feature proposal seems to be isomorphic to @centril's.


There seem to be two threads of discussion in this conversation:

  • First, a conversation about whether lifting return Ok and return Err into two separate "return channels" is an idea worth pursuing at all.
  • Second, what the syntax for that lifting should be, whether it should use return for one of the channels, what the return type should look like, how we could support applying it to types other than Result, etc.

The majority of this conversation has been in the second vein. Though consensus isn't universal, I see a lot of sentiment that something here is worth pursuing. One thing I think we have widespread agreement on is that if we do this, it should be explicitly opted into somehow, local to this function (though @est31 has misgivings about the whole enterprise, they rightly identify this as a major difference from previous Ok wrapping discussions).

The big differences between different syntax proposals seem to be:

  • Should return and implicit return be used for the happy path?
  • How could we support applying this feature to return types other than Result. How important is that use case?
  • Should this function's header show -> T or -> Result<T, E> (regardless of whether or not we use return)?

Turning to the specific proposal of something like pass or succeed or reta, I think it has one very major downside that hasn't been noted as far as I've seen. An advantage of using the normal return happy path is that terminal Ok(()) can be eliminated. If we were to use pass, you'd have to end these functions with Ok(()) still, which would be quite at odds with the motivation for this feature.

6 Likes

Changing a return 0 to return an Err(SomeError), for instance. Or looking at a return of an enum value, deleting that enum value, and returning an appropriate error instead. Or looking at a None and turning it into an appropriate error, and changing a Some into an Ok.

I'm still a big fan of this direction. The only thing I'd change is fn foo() -> catches Result<T, E> which (to me) reads as "this function catches a Result". That feels off grammatically, though one can make sense of it. I prefer to think of it as the function body being a catch block, leading to this syntax:

fn foo() -> io::Result<()> catch {
    let x = read(input)?;
    let y = write(output)?;
}

The only difference to just using a big catch block within the function (other than saving a pair of braces, which is neat but not a big deal) would be return stopping at this outermost catch block and benefitting from the Ok-wrapping that's usually restricted to the tail expression. That's inconsistent with "normal" catch blocks within a function, but I think I'm okay with that because the catch block is "the whole function" so to speak: there's nothing after that catch block that I could skip with an early return.

Alternatively, if one is in favor of a separate keyword for the "happy path", that keyword could work the same for fn .. catch {} as for catch blocks.

12 Likes

The first is a case where you’d want to be careful, though I think the fact that you were previously doing such an unidiomatic thing (in Rust) as returning integers to signify errors would be enough signal to you for review, and you don’t really need the compiler to nudge you.

The third would just be a type error. If you go from Option<T> to T catch E, all your Nones are invalid return types.

In any event these are the sort of changes that would represent a complete refactor of the function, and my belief is that the compiler prompting you with type errors is not actually a necessary code review signal that we need to preserve highly. If you’re adding error handling to an already fallible function, you’re already reviewing the whole function. The edit distance case is about adding some other functionality that happens to be fallible.

Hi, would using pass and fail attributes rather than a trait

enum Result<T, E> {
   #[pass]
   Ok(T),
   #[fail]
   Err(E),
}

make an automatic return Ok(()) at the end of a -> Result<(),E> function look better?

For what it's worth from an outsider I like @Centril's pass/fail suggestion a lot and on the other hand don't find fn foo() -> usize catch io::Error all that readable.. That kind of hurts my Java developer's head

1 Like

I like that very much. "Optional" OK wrapping always seemed a bit too magical for me, whereas this RFC has clear rules for when it happens.

3 Likes

I actually really like this proposal. Let’s play a game! Try to figure out what the next function/block returns!

fn foo() -> i32 catch Error { ... }
fn bar() -> i32 catch as Option { ... }
fn baz() -> i32 catch ! as Future { ... }
catch Error { ... }
catch as Option { ... }
catch ! as Future { ... }

Could you guess all 6? If you need help, here is the insides!

{
    let val = may_fail()?;
    val + 1
}

This syntax is very ergonomic to read and write, while no information is hidden. The return values are obvious to the caller just from the signature (Result<i32,Error>, Option<i32>, Future<i32,!>) while allowing to use the same tools for everything that implements Try.


The proposal could benefit from slightly changing the following:

fn foo() -> i32 catches as Option {
    let x = maybe_value()?;
    if x < 3 {
        throw;
    }
    if x > 4 {
        return x-1;
    }
    x
}

Specifying the error type is optional, but required if the compiler can’t figure out the type just from the signature:

//The following functions are exactly the same!
fn foo() -> i32 catch io::Error { 3 }
fn foo() -> i32 catch io::Error as Result { 3 }
fn foo() -> i32 catch as Result<_,io::Error> { 3 }
fn foo() -> i32 catch as io::Result { 3 }

catch tells the compiler that the function returns something that implements Try, and is assumed to be Result unless specified otherwise with the keyword as.

return and throw are taking the inner value, with similar syntax to if there wasn’t error handling. If the inner value is of type (), you just type return; or throw;

It is clear what the function returns because “It’s right there in the signature, people!”.

Edit: This syntax looks better when throws is used:

fn foo() -> i32 throws io::Error { 3 }
fn foo() -> i32 throws as io::Result { 3 }
fn foo() throws as Option {}

Edit 2: Let’s refactor some code! Here is a huge function and it’s use:

fn huge() {
    // OMG I'm so huge
}
huge();

Let’s allow this function to possibly fail:

fn huge() throws as Option {

and we need to handle the error

huge()?;

Done.

We just changed 2 lines, and the function compiles once again and does exactly what it did before, no matter what the insides were, but we can add cases where the function will fail inside of it.

5 Likes