Automatic "?" inside try {...} blocks

Most of those just… aren’t, though.

How does Drop and especially Deref affect control flow? Drop is a special case in this regard anyway: the whole point of RAII is that 1. we intuitively expect objects to be destroyed upon scope exit, and 2. we don’t want to manage memory manually, because that leads to errors. So Drop's automatic behavior actually prevents large classes of errors and is very easy to understand, because it’s expected. Furthermore, it really doesn’t do anything at the higher semantic level. It merely cleans up exactly when it is necessary (which is ensured by scopes, ownership, and dropck), and thus it can’t really lead to errors. This isn’t true of several (most) features proposed in this thread.

How does inference affect types? It doesn’t, really… it just deduces or computes types when possible, but it doesn’t change them.

Copy isn’t really potentially expensive as only trivially-memcpy-able types without heap allocation can ever be Copy, and usually they are very small. Deref isn’t allowed to be expensive, either. Either the documentation or the Rust book (I can’t remember which one, maybe both) says explicitly that AsRef, Deref, and similar conversions should only be implemented if the conversion is trivial or almost trivial, and that it must never involve expensive operations such as dynamic allocation or lots of copying.

6 Likes

Even tho I am not in favor of this particular proposal, it is not true that Rust forces everything to be explicit (c.f. type inference, default match bindings, etc.)

@H2CO3 did a great job explaining why the specific implicit things that exist in Rust are OK. Here is my clarification:

They are trivial to reason about. They do not have implications for performance or correctness or semantics.

Rust is a RAII language. It is expected that resources are managed through destructors (Drop), cleaned up automatically at the end of the scope. This is consistent behaviour with no exceptions. It is trivial to reason about (you know whatever is owned in your current scope will be destroyed at the end of the scope).

Type inference: again, trivial to reason about. If the compiler can easily infer the type from the context, so can you. The type of everything is obvious from the immediate context around it. This is precisely why inference is a good thing: having to type out obvious information is unnecessary clutter. It does not affect semantics or performance or anything else of importance in any way.

Copy, yes, while it does correspond to a runtime operation that is done implicitly, Rust guarantees that it will always be a single, simple memcpy. This means that it is incredibly cheap to do (modern CPUs are very fast at memcpy) and, again, trivial to reason about.

Deref and friends: they are specifically intended only for cheap and trivial, obvious conversions. Same things apply.

Same thing with autoreferencing for match.

Contrast all of this with proposals like implicit ? or implicit await. Both of these hide important information.

Implicit ? silently converts a type to another one. This is dangerous. It also means that it is no longer obvious what operations can fail. You have to know the signature of the function you are calling, which often requires looking up documentation. This is not trivial to reason about. It also introduces the possibility of an early return, which means that subsequent code will not execute if a fallible operation fails, and this not being obvious from the code is a very bad thing that can lead to unexpected surprises.

Implicit await hides the semantics of async functions. You cannot know how a given code calling a given function will behave, without consulting the signature of the called function. It makes reasoning about code non-trivial.

This is really my big objection. Any implicit thing in the language should only be something that is truly obvious from looking only at the immediate context where it is written, and trivial to reason about.

Please tone down the all-caps, etc.

Done :slight_smile:. I feel strongly about this issue and my previous post was maybe a little too-ranty.

@rpjohnst

We already have quite a few “implicit” things that affect control flow (panics, Drop, Deref), data types (inference, coercions, default binding modes), and potentially-expensive operations (Copy, Try conversions, also Deref).

You even call some of these out, but you don’t really justify why they’re okay while try/async would not be. There’s been quite extensive discussion here, more blanket “DO NOT” declarations do not really help advance that discussion.

Done. Addressed and clarified my views in detail. Hopefully this new post of mine advances the discussion in a more productive way :slight_smile:.

7 Likes

Couldn't agree more!

5 Likes

[Edit: looks like @SheepKey already made some of the arguments in this reply in the meantime. Anyway, I'm keeping it here for reference.]

Oh, one more thing, which I'm not sure I explained well enough.

I do agree with this to some extent. I do think "explicit" is a helpful criterion though: it's very clear what it means. I also think there are several important cases where implicit is better than explicit, including type inference and RAII. I would also argue, however, that typically explicitness helps avoid confusion, since in the majority of the cases, when we write down some code, we are actively doing something with said code, or we are expecting it to actively do/affect/change something.

Incidentally, in the bigger picture, this is exactly why RAII and type inference shine as the prime example of good implicitness: they are helping us avoid noise or redundancy when we do know what is supposed and bound to happen anyway. Inferred types are guaranteed to be type-safe and consistent; RAII-based memory management ("ownership") is also guaranteed to be correct, to the extent that it is one of the most important pillars of Rust's memory safety model.

However, many other "convenience"-oriented proposals fail to make the distinction between language features and constructs that merely help avoid redundancy versus those that hide something bearing a semantically-important side effect. Early return from a scope is one such effect, since it affects control flow, consequently the exact operations which will be executed. This is why hiding such an early return can be a source of confusion and bugs.

3 Likes

I certainly agree that early-exit is something to be wary about- I've said so several times in this thread. But that doesn't make it non-trivial- it only makes it non-local, and only in a very simple and straightforward way. And again, we have plenty of non-local things, all of which you seem to be fine with...

So the real question is why the downsides of non-locality are fine in cases like RAII or panics or asserts, but not in the case of calling a fallible function, especially in the face of potential benefits like try-polymorphism. (To be perfectly clear: I do think we probably should keep ?. Just not that "waves hands explicit waves hands" is a great reason to do so.)

Ah, but in a different sense this is actually backwards. Under explicit await, you must consult the signature of the called function foo() to know whether it will run its fully body (the usual case) or do nothing until you await it (the async case). Under explicit async, you always know that foo() will run its full body regardless of its signature.

The explicit async thread discussed this at length, but it's not at all clear that knowing the locations of the suspension points is actually that important. Any function call, whether synchronous or asynchronous, can run nondeterministic code that affects shared state. The non-locality of "does this function call run its body" is quite possibly more confusing than the non-locality of "might this function call suspend," especially given the Dart designers' experience.

So again, the real question is why "suspension point locality" is more important than "deferred execution locality." (Again, I recognize that people want suspension point locality, I just want the conversation to move beyond that and into why they want it more than the alternatives.)

2 Likes

Ok, thank you for furthering the discussion. You are making some good points.

So the real question is why the downsides of non-locality are fine in cases like RAII or panics or asserts

RAII: I explained my view. Rust is fundamentally a RAII language. It is part of the ownership model and the fundamental way that resources are managed. It is universally expected throughout all Rust code. It is never surprising and always trivial to reason about.

Unlike RAII, which is consistent and expected everywhere, implicit ? makes the semantics inconsistent and confusing.

Panics and asserts: these are intended as a way to catch bugs. They should only happen on catastrophic failure where the most sensible thing to do is abort the program, because a bug has been hit (like some invariant being violated) and it is unclear how the program could proceed from there. They should not occur as part of the normal execution of a program. This justifies their non-locality. They are a way for the program to commit suicide in a controlled way, in situations where there is nothing better that can be done. At least that is the intention; of course these features can be misused in various ways, but that is what conventions are for.

To be perfectly clear: I do think we probably should keep ?. ... ...

Yeah, I understand you :). It is great to argue for the opposite side from your personal beliefs. It helps consider all sides better and see different arguments.

especially in the face of potential benefits like try-polymorphism.

In the name of considering arguments for the opposite side ... I feel like I haven't considered the benefits of implicit ? well enough. It seemed like such a clear-cut no-no. I will look at try-polymorphism in more detail and educate myself. Right now I don't have any valuable comments on it.

I certainly agree that early-exit is something to be wary about- I’ve said so several times in this thread. But that doesn’t make it non-trivial- it only makes it non-local, and only in a very simple and straightforward way.

Silently converting types and hiding things that can fail are also big problems. So far, we have seen at least 3 major problems with the implicit ? proposal. I feel like that is too much. Individually, these considerations might be fairly "trivial", but together, they introduce a lot of things that you have to be careful about. Since Rust's philosophy is to enable fearless programming without footguns/pitfalls as much as possible, anything like this that introduces such extra reasoning overhead goes against Rust's goals. I don't want to write in a language where I have to constantly worry about missing something that could go wrong. If I did, I'd be writing C/C++. This is possibly the single main reason why I love Rust and I'd hate it if new features were introduced that go against that. I love that Rust makes everything I need to consider about the correctness of my code obvious from the syntax and semantics of the language and enforces that I deal with all of it.

implicit await/async stuff

OK, I will admit that I perhaps should not have commented on this. I have almost no experience with writing async code and there are many people in this community with far more valid opinions, based on actual experience, to drive those discussions and make decisions about the best trade-offs. I don't really understand the implications. Now I feel like my view was misled, as I did not realise the 2-sided nature of the tradeoffs involved.

However, your post was insightful. Thank you! I learned new things. :slight_smile:

4 Likes

Honestly; I've often found that type inference is anything but trivial to reason about in some cases. Sure, you could write out the types if you wanted to; but when a whole lot of things are being inferred together and when I am writing something in a generic context, I have to rely on really good naming of functions for the semantics to be clear enough.

I don't think it is as easy as you portray things to be.

2 Likes

Fair enough. I guess people’s experiences vary and im biased towards my experiences.

I tend to follow the intention rather than the technicalities.

The intention behind type inference is to make code clearer when the types are obvious, allowing you to omit redundant information. That’s how I use it. If the types are obvious to me (and it feels redundant to write them down) and the compiler can infer them, I will omit them. If the code feels clearer to read with them written in, even if the compiler can infer them, I will write them out.

Yeah, I can see how someone who has different habits from me would have a different experience. If you don’t actively think about what is most clear and readable, but rather you always omit types unless the compiler complains and only add them in to make the code compile, then yeah I can see how you would be omitting types in many confusing places.

This brings up a great point that I don’t often see discussed enough. People usually tend to argue about new features mostly on technical merits. It would be great to also give more consideration to how those features fit with different people’s coding mindset and habits.

Edit: more clear examples (things that should be given some thought when evaluating a new feature):

  • how would a lazy person who just writes the minimal code to get things to work use the feature?
  • how would it affect the experience for a person who likes to copypaste code examples (from books/tutorials/stackoverflow/etc) and hack them until they work?
  • how would it be used by a perfectionist who focuses on the pure elegance of their code?
  • many others that i am missing
2 Likes

Or well named variables. Let's not forget that we often read code that others have written. We know that it works. We just need to be able to follow what's happening. Type inference helps with keeping the "noise" down, so that we can see what the code is doing. Yes we're hiding information. But, it's a trade-off that I'd make any day.

Edit: And let's not forget that Rust forces us to spell out function signatures. Type inference mostly only hides redundant stuff.

1 Like

Oh this happens all the time. The reason why technical arguments might seem more prominent is that technical arguments are more objective, and once raised tend to be impossible to disagree with. Arguments based on a particular "coding mindset" tend to be not only subjective, but impossible to form a consensus on. Other people have a different mindset, neither is "right" or "wrong", there aren't any arguments that would make someone "change sides", and there's no way to acquire convincing evidence that either is so much more popular that it should dictate a feature's design. (e.g., I think there's a lot of this going on with try/throw/catch discussions recently)

But more useful non-technical questions like:

do get raised in RFC discussions all the time, when the subject is broad enough for this to matter. If you look at some of the module reform RFCs and internals threads from last year you'll see dozens upon dozens of arguments getting made based on exactly these questions, some of which became major design goals in the final proposal (e.g., preventing "path confusion", where paths that are valid in the root module are invalid in any other module, because that specific inconsistency was a tremendous stumbling block for... well not even just newbies, it confused everyone).

4 Likes

Maybe. I feel like there was always going to be bikeshedding on it. However, I think the design decision more immediately responsible for this suggestion is renaming catch { ... } to try { ... }. The motivation here was that try { try_something()? } seems redundant:

And there is actually a point here. The reason people wanted catch renamed to try was because, in languages with try { ... } catch { ... }, the fallible code goes in the try block, so if you think of try/catch { ... } as a block with fallible code in it, it makes more sense to call it 'try'. However, this is based on a superficial parallel! In languages with try { ... } catch { ... }, try { ... } on its own (if it's permitted) doesn't actually catch errors; it serves solely to mark code that can fail, the role served by ? in Rust. So of course someone would come along later and think that try { try_something()? } is redundant. By naming it try { ... }, we've hidden the fact that it's actually a catch operation—completely orthogonal to ?—and given it a name which suggests it does the same thing as ?.

1 Like

Eh, no, that's not the only reason, or even the main reason. In fact I think it'd be doing all sides of the debate a disservice to claim there is any single main reason, so I'm just going to link the comment where @scottmcm summarized all the arguments (afaik no new arguments have surfaced in the past month): RFC: Reserve `try` for `try { .. }` block expressions by Centril · Pull Request #2388 · rust-lang/rfcs · GitHub

2 Likes

This point:

It allows us to leverage peoples' intuitions from other languages

which is the most developed, mostly deals with exactly what I was talking about: mapping the Rust feature to try { ... } blocks in other languages as the basis for people to form intuitions. And I'm pointing out here that this is a case where that intuition is misleading, which I think challenges the assertion that people's intuitions about try { ... } are more useful than they would be for catch { ... }.

Edit: It could turn out on balance that try { ... } does encourage better intuitions. However, reasoning about what intuitions people will form is hard, and finding evidence to support that reasoning can be even harder. I think this thread is interesting just because its an actual tangible piece of evidence on the question. Obviously, you can't extrapolate from one data point, and getting evidence in support of try { ... } is going to be harder now that people won't be trying to form an intuition about catch { ... }. You could argue that the reason people pushed for try { ... } in the first place was because they weren't getting the right intuition for catch, but this is iffy because you could also say that this is a preference formed after reaching a correct intuition based on catch which has no bearing on whether one syntax is more intuitive than the other.

1 Like

OK, it is great that is being discussed!

I guess the fault is with me for not following those discussions :smiley: , which gave me that impression.

Many RFC discussions tend to get really really long and I don’t have the time or the attention span to read the whole thing. Also most of the info near the beginning of the thread quickly becomes irrelevant as it gets shot down later in the discussion. There is no easy way to start reading, since you don’t know if the information early on is important or not until you read the replies later on (which you can’t always understand if you haven’t read the earlier info, so a circular dependency :smiley: ).

It would be great if these discussions could somehow be made more accessible to people who haven’t been there from the beginning, without reading the whole thing. Perhaps with periodic “checkpoint posts” that summarise and reiterate the state of the discussion, so that one could start reading from there onwards and ignore everything prior? I’ve seen a few of the recent big discussions (async/await library support, try blocks) have at least one “checkpoint post” like this and I think it is good practice! It should happen more!

That said, this got quite off-topic at this point. (but then, this whole thread seems to be very off-topic at this stage…)

@aturon did something like that in the async/await RFC: He marked comments as "outdated" after they became irrelevant.


Edit: I really like the renaming to "try". The fallible code goes into the try block - just like in most other programming languages. Makes sense to me.

1 Like

Yeah that is another great thing to do!

I just wanted to share that I really appreciate such efforts and encourage it to happen more! It really helps.

Right. With function-local inference (what Rust has), the degree to which type inference is legible is proportional (to some extent) to the length of a function. The longer it is, the more it will start to feel like global type inference (which Haskell has, but Rust doesn't).

In Haskell, I typically use global type inference as a development tool where 1) I define a function (without type signature, unless I'm sure of what it will be...), 2) ask the REPL what the type of the function is, 3) take the type from the REPL and paste it as the signature, 4) start over at 1). I only keep one function globally inferred at a time. All functions I commit to VCS have their type signatures there explicitly. I also tend to write very small functions.

As a serial eta-reducer (who likes the point-free, or "point-less" according to some, style), I try to avoid let bindings :wink:

1 Like

...or outside the try block. The only consistent marker of where fallible code can be is ?. The only thing try { ... } is doing here is preventing errors from propagating to the function boundary, something that try { ... } doesn't do on its own in other languages.

Anyways, I can understand why people who already understand the feature might prefer 'try', I'm just not sure if this preference has any bearing on the relative intuitiveness of the two choices. I guess we'll find out based on how often people think that try makes ? redundant.

2 Likes

My prediction is that it will be endlessly brought up by people new to the language. They will constantly wonder why "try { ... }" doesn't work like other language and they will constant insist that Rust has a broken/deficient implementation of exception handling and try...catch...finally (instead of understanding what Rust error handling really is) and constantly insist that it needs to be fixed in the next edition.

2 Likes

To be fair, the only way to avoid this completely might be to have try…catch…finally :wink:. If the goal is not to have try…catch…finally because we think Rust can offer a better model, then this might just be an inevitability.

Edit: See below.