Pre-RFC: Catching Functions

Kind of related, I think we should also make sure that the error path stays as an equal, not an afterthought. That makes Rust the robust language it is. When I read Rust code, I'm fully aware there could be errors.

What I mean, if I have a language with exceptions (let's say python), it is possible to write code that has no notion about errors, but they still can happen and get propagated. And then I don't know if the author decided propagating exceptions is the way to go, or if he just forget. Or I can just not notice this code might have to worry about errors when reading it. The error path feels less important in these languages, but it could be argued error handling is actually at least as important as the happy path.

And some more:

  • Consistency with the current systax
  • Not bloating the language too much
6 Likes

Wanting to make Rust error handling look more like throwing and catching exceptions.

I think this one can be drilled into deeper:

  • Wanting an explicit syntax for raising errors
  • Wanting fallible code to look as-similar-as-possible-but-no-closer to infallible code
  • Not wanting the existence of fallible code in a method to affect how infallible code in the method is written
  • Wanting a method that never fails, but is "fallible" for external reasons (like trait signatures) to have a body that looks the same as if it were infallible.

(Sorry for mostly writing the same thing three times there, but I couldn't decide which perspective I liked best.)

It could also mean things I don't think I've seen expressed, though please correct me if I missed them

  • Wanting finally blocks
  • Wanting an integrated construct that handles both try and catch, in the C++/Java sense
  • Wanting all methods to be allowed to throw, without it being visible in their signatures
  • Wanting all errors to propagate upwards without me needing to do anything

A bunch of my things from 2107:

  • Not wanting to type Ok(()) any more than one wants to type ()
  • Wanting to rearrange lines in a fallible method with minimal edits
  • Wanting to have ? after every fallible method call (that doesn't need custom handling)
  • Never wanting to write something like Ok(foo()?)
  • Wanting all returns in a method to take the same type
  • Not wanting a T -> Option<Option<...<Option<T>>...>> coercion
  • Not wanting this to be Result-specific
  • Particularly wanting this to work great with [T]::get, Iterator::min, i32::checked_mul, etc
  • Wanting to see the return type of the function written out
  • Wanting main in examples to still look good when it returns Result to allow ?
  • Wanting existing doctests to continue to work even if the signature of the implicit wrapper is changed to allow using ? in them
  • Not wanting a syntax that repeats information from the Try impl for a type
  • Wanting something that works with closures as well as fns

I think, for now, I'll go back to using my old amusing-but-clearly-unacceptable syntax for this feature to avoid bikeshedding:

fn add_opt(a: Option<i32>, b: Option<i32>) -> Option<i32> Āæ{ a? + b? }
8 Likes

Re @josh's post #240 in this thread, which contains too many referent paragraphs to quote: The topic of encouraging positive feedback from relative newcomers of the form "I don't like where this pre-RFC is going" is a big issue ā€“ one deserving of its own discussion thread. Perhaps there is a way to add a survey mechanism to pre-RFC discussions such as this one, where the intro to the survey encourages responses from the larger community but asks respondents to first read enough of the thread to not just be giving a kneejerk reaction.

The idea is to create a survey relative to a summarizing post that enumerates alternatives, such as @josh's later post #245, and ask for a quantified measure of agreement or disagreement with each issue. For example, on each issue the voter would be asked to provide a ranking of :-1::-1: (-2), :-1: (-1), :open_hands: (0), :+1: (+1),
:+1::+1: (+2), or simply not vote on that issue to avoid expressing an opinion.

The public display of the survey results would just show the current votes for each category on each issue, probably as a tuple. For example, based on the enumeration in the above-cited post, such a voting summary might look like

current totals:
(:-1::-1: , :-1:, :open_hands:, :+1:, :+1::+1:)
1. (0, 2, 8, 5, 3)
ā€¦
5. (6, 3, 2, 2, 7)
ā€¦

Each respondent's rankings on each issue would be recorded, so that they later could be queried privately by the primary pre-RFC proposer if their ranking was an outlier, signifying perhaps that they had an issue that had not previously been surfaced. Such a record would also provide a means of auditing for bots and similar misbehavior. However, the public display of the survey results would not provide attribution.

I would add one more thing under this: code should never be ambiguous in the sense that partially refactored code should not compile.

4 Likes

I'm not sure this is entirely possible. A return None with a Option<Option<Value>> return type would work with auto-wrapping or without. Similar conditions can arise with Result and other types as well.

Or having some value than can be value.into() to both a Result and Option type with a Result<Option<Value>, Error> return value.

I would be surprised if this were 100% solvable when the types of return expressions can refer to different parts of the return type depending on context.

These types might seem obscure, but I do run into them sometimes, even the Result<Result<T, E>, E> one. An example would be nested parsing, where I want to distinguish between inner and outer errors later on for alternatives and error reporting.

1 Like

@aturon, I just want to say thanks for your comments about the "killer combo" and demoralization, especially this line:

That is, where you sense inevitability, we sense exactly the opposite.

I felt a twinge of exactly this fear when I saw this thread (before I read it), and when I first heard murmurings about the recent module work. Rust's development is exciting, but sometimes, it can feel very hard to feel on top of how various RFCs and ideas are evolving. I'm sure that's true even for people who work on it full-time!

I have a lot of trust that folks like you, @nikomatsakis, @withoutboats, and others are always sincerely striving to do nothing but improve the language, but even that trust leaves room for a fear of being left in the dust. Hearing an explicit confirmation that you understand the "inevitability" dynamic is really reassuring.

13 Likes

Hmm... That seems to suggest that auto-wrapping would make it easier to write a new class of bugs. Do we know how common such patterns are in the wild?

2 Likes

I don't know about others, but I meet nested results from time to time, it's definitely not an exception. Just today I wrote a commit that had at least two instances of these.

And even quite common libraries contain such types and interfaces: futures::stream::Stream - Rust (poll is type def for Result<Async<T>>).

So I'd guess these do exist in practice.

If we introduce Ok wrapping in try blocks itā€™s reasonable to make this wrapping mandatory and to cover only one level of wrapping, i.e. this code will result in a compilation errors:

try fn foo() -> Result<u32, E> {
    Ok(1u32)
}

try fn foo2() -> Result<u32, E> {
    Err(e)
}

try fn bar() -> Option<Option<u32>> {
    1u32
}

At first it could look a bit strange, but without this strict rule it will be much harder to reason about code behaviour. Thus you example will look like this:

try fn foo() -> Result<Result<u32, E1>, E2> {
    // returns Err(e1)
    if a { throw e1; }
    // returns Ok(Err(e2))
    if b { return Err(e2); }
    // returns Ok(Ok(1u32))
    Ok(1u32)
    // writing simply `1u32` will result in a compilation error
    // same goes for `return Ok(Ok(1u32))` and `return Ok(Err(e2))`
}
3 Likes

I think this misses the point a bitā€¦ Suppose I start with the following function, which I now want to convert to be fallible:

fn foo() -> Result<u32, E1> {
  Ok(0u32)
}

If I simply change the signature, it still compiles, but might not be what the author expects:

try fn foo() -> Result<Result<u32, E1>, E2> {
  Ok(0u32)
}

This might be somewhat undesirable when doing large refactoringsā€¦

3 Likes

I think your example is overly artificial. You assume that two big changes will be introduced simultaneously: migrating to try fn and changing function return type. I believe such cases will be extremely rare to care about.

You donā€™t need two changes:

fn foo() -> Result<Result<i32, String>, String> { Err("foo".into()) }

would still compile when you add try. The same is true for

fn foo() -> Option<Option<i32>> { None }

And, thinking about it, so would

fn foo() -> Option<i32> { 23.into() }

because impl<T> From<T> for T is available. Fortunately, you canā€™t go from a T to a Result<T, _> via Into.

I think that last one actually bothers me even more.

3 Likes

This is one of many reasons I don't want to see auto-wrapping of any kind.

5 Likes

Forbid try functions/blocks where 2 or more levels of nested types implement Try? E.g. do not allow this?

try fn foo() -> Result<Result<u32, E1>, E2> {
    if a { throw e1; }
    if b { return Err(e2); }
    Ok(1u32)
}

P.S. This is the syntax to sway me from an opponent to a supporter. Also +1 for try and throw:

A tax on learners justified by a gain in ergonomics.

That would be a weird exception, right? Imagine I'm writing code. Everythings humming along. I'm planning to use this feature for consistency with the rest of my codebase. And... it doesn't work because my return type happens to be Result<Option<T>, E>? That seems like poor UX. What would the compile error even be for that?

2 Likes

These would also still compile if you, e.g., add a Some() around the None in the second case, or probably a variety of similar edits. Why is the case with try worse?

3 Likes

I'm glad we're talking about specific possible mistakes and confusions! This helps me quite a bit to understand what @josh means when talking about "type-based reasoning".

I think these examples are interesting, but that patterns like Option<Option<u32>> already carry some element of risk. Consider what I and others have reported: that we almost always forget to write the final Some and Ok in a function that uses ?. In that case, there is a pretty decent chance that this function (which type-checks) is wrong, and it should have returned Some(None):

fn foo() -> Option<Option<u32>> {
    let x = process()?;
    let y = process()?;
    None
}

Personally, I have found in practice that nesting types like option (Option<Option<..>>) and result (Result<Result<...>>) is pretty confusing at best. When I find myself tempted to do so, I usually wind up creating newtypes or custom enums anyhow, precisely to catch mishaps like the one above, and to make the overall intent clearer.

2 Likes

I want to clarify one more thing: I support the feature "for my personal use", to some extent. One of the questions I don't feel I know the answer to is whether this feature would "carry its weight" in terms of learnability, language surface area, overall impact on ergonomics, etc.

Ergonomically, I do think that we can do better when it comes to error handling in Rust, and that this feature would help -- but I also think a big part of the problem arises around iterators that yield results and other related areas, and this feature wouldn't help at all with that.

I am not yet sure if this would be a boon for learnability or not. I can see specific scenarios where it helps (e.g., working with libraries early on), but it also does increase the set of things to know, and that always comes at a cost.

5 Likes

I absolutely sympathize with the unpleasant surprise this was, but this is a deeply discouraging response. I had hoped that the Rust team would respond to this by iterating on feedback structure and shepherding, rather than by retreating into secrecy.

I would also be sad if the end result of this process were a retreat into secrecy, but I don't think that's really what anybody has in mind. It is important to recognize though that the current process can be a stressful and tiring endeavor for everyone involved. I for one appreciate @withoutboats being honest about the cost to themselves -- Rust team members are only human, after all -- and I also appreciate @josh and others for sharing their similar feelings of frustration.

In any case, I do think it's worth thinking more if there are changes we can make to try and get a more collegial (to use @withoutboats's word) setup. We've talked in the past about "motivation RFCs" and also looked at other interesting precedents, such as the TC39 Process, which has explicit stages like "strawman" and so forth, where the only goal is to get people to accept that there's an interesting problem that may be worth solving.

In the past when we've tried this, we've often found that separating "the motivation" from "possible solutions" is hard -- it's hard to describe the problem without sketching alternatives. But it may be worth taking another stab in that direction regardless.

I sort of like the idea of an explicit phase in the process that is aimed just at showing the problem, with the explicit premise that any possible solutions being shown are just there to demonstrate the vague kind of solution we have in mind.

I admit I don't quite know what this looks like yet -- it seems like something we have to try and few times.

9 Likes