When does Rust prefer to be implicit?

Rust tends towards being explicit rather than implicit. For example, type coercion is done explicitly using as or into; and Rust is notable for encouraging functions to declare when they return an error and that the error is handled, even if only to pass it on.

However explicit isn't always the best choice so there are exceptions to this general philosophy. Here's the ones I can think of, are there any more?

  • Deref automatically "dereferences" one type as another type. Should only be used for smart pointers. To be honest I've never been entirely clear on what exactly counts as a "smart pointer" in Rust and what doesn't but String (which derefs to str) is a common and clear example of what definitely is one.
  • Drop. A function that implicitly runs when a type goes out of scope.
  • Copy. A type marked as copy can be implicitly copied without having to explicitly call a copy function. This contrasts with .clone().
  • Type inference can often avoid the need to explicitly write out types within the body of a function but function signatures themselves must be explicit.
  • Thread locals are automatically recreated for each new thread without having to explicitly call an init function.
  • Panic. Any function can potentially panic without declaring that it does. Generally speaking this mechanism is used for unrecoverable exceptions but I'd note that what is and isn't recoverable can depend on the context. Using catch_unwind may allow for try/catch style handling.
7 Likes

I'd also mention that the ? operator inserts a .into() to automatically convert errors to the function's return type. You can think of it as syntactic sugar that expands to the same thing as the old try!() macro.

6 Likes

Lifetime elision is one of such implicity (for convenience).
I saw some people who are confused by this rule, but it is quite convenient once we understand.

8 Likes

Async-await is another implicity.

async fn foo() -> T is treated as fn foo() -> impl Future<Output=T>.
Additionally, async-await converts the code into state machine, and it uses pin-related features to realize self-referential structs for such state machine.

Async-await contains huge implicit code conversions, and it would be not easy to understand details completely.

5 Likes

Method call syntax is another trick which goes in the "implicit" direction. It creates an ambiguity between trait methods and inherent methods, which is usually a good thing (as it allows easily extracting inherent methods into trait methods), but can lead to surprising behavior at times.

1 Like

To call an instantiated type where the type implements a member of the Fn* family, the compiler inserts a call to Fn*::call*(). If I made my own type which implements any combination of these traits I could potentially include code that does more than just "call" my object.

Receiver (self) type is also implicit.
Method fn foo(self) is same as fn foo(self: Self), and fn bar(&mut self) is same as fn bar(self: &mut Self).

I guess I should ask what exactly is meant by "implicit" because some of these examples don't seem implicit to me.

Deref's functions are (1) overloading the * operator and (2) allowing deref coercion. Deref coercions are usually implicit, but you can make them explicit with as. * may be used explicitly, but is also used by . (that is, autoderef), which is... kind of implicit? Seems borderline to me, but it can be surprising if you're expecting . to be C-like rather than Python-like. (I don't think anybody in the Python community, which birthed EIBTI, considers the behavior of . to be a violation, although it can be overridden.)

I mean... that's just what a thread local is, right? The compiler doesn't do this implicitly, it does it because you asked for it by writing thread_local! (and then spawning a new thread).

It does, but again, that's just what the ? operator is. The fact there is custom syntax for it seems to me the exact opposite of implicitness. It's concise, sure, but you still have to explicitly use it if you want that behavior.

Once again, you have to explicitly write the async keyword if you want to use this feature.

I don't think the measure of implicitness is how much it changes the meaning of your code -- I think it's just whether you ask for it or not. The compiler doesn't automatically mark functions as async. Definitely it applies a complex transformation to the code and it may be hard to understand. But is it implicit? I mean, it's right there in the code. You can't use it by accident.


Now, as for answering the question, I think you just have to take each case individually. Implicitness can be confusing, but it might be balanced by other advantages. In another recent thread, @peerreynders called implicit reborrowing (there's another one) "a concession to developer ergonomics". Sometimes you can sacrifice some explicitness for the sake of code that is easier to write and easier to read.

Furthermore, implicitness can be good in moderation. To take one of your original examples, type inference is something that definitely has an air of implicitness, but the possible downside is greatly mitigated by the fact that the compiler doesn't (with a few exceptions) guess at types. If the types aren't fully specified, it's just a compile error: "You weren't explicit enough." Type inference cuts down on noise by allowing a little implicitness, but (in most cases) where the implicitness could be confusing, it falls back to requiring explicitness. Which is a pretty unambiguous advantage IMO.

12 Likes

This can be mitigated by keeping in mind that inherent methods have precedence over trait methods. And, if you want to be sure, there's always UFCS.

That's the feeling I had when reading this thread, too. Lifetime elision is just a special case in which it's more than obvious how lifetimes are related, thus the ceremony can be dispensed with. I wouldn't call it implicit though - for structs and impls there still is some mention of a lifetime being present, and for fns/methods it is still explicit what is meant because then there is at max only one parameter and one output.

I'm even inclined to say that most (all?) examples mentioned so far don't show implicitness in the sense that they can create confusing behavior, but instead are special cases of more general features that, when the full power of those features isn't needed, a lot of syntactic ceremony can simply be left out.

1 Like

Sort of! https://boats.gitlab.io/blog/post/2017-12-27-things-explicit-is-not/

4 Likes

This is one of those examples where I think the wording could be better. async is arguably not implicit, because you had to type it -- it's not that returning something the implements Future just does the rewrite without being asked.

I consider that very different from something like deref coercions, which are completely invisible.

4 Likes

For me variable name "shadowing" is a confusing implicit syntax.
I would much more prefer an explicit way to do the same:
let y = z; // is it shadowing or not? Not clear if we don't read carefully all the code before that.
lets x = y; // explicit shadowing wih a special syntax "lets". The intent of the coder is very clear and not confusing.
You got an error when compiling if :

  • you shadow a name that cannot be shadowed
  • you initialize a name that would shadow a prior name

I dont see a downside of doing this explicitly over implicitly. I see just more intent and more compiler help to catch silly syntax errors.

I too was surprised that Rust allowed shadowing at first. I have since come to appreciate it.

On the plus side it allows that what is conceptually the same thing with the same name to change it's type with out having to give it a new name. That sounds like an odd thing to want to do but I found that that in Rust it is very convenient and I have done it often. One does not need to add "warts" to names to get things done. For example having to have both an "x" as an integer and then something like "x_str" when turning it to a string.

In practice shadowing can rarely cause confusion. If one is following normal advice, keeping functions/methods short, using meaningful variable names, then it's not likely to be confusing. When it does your program does not work and most likely does not even compile so you soon see what to fix.

I don't like to see languages growing ever more syntactic cruft. I'm definitely not in favor of introducing new syntax just for this little supposed problem. Besides I don't even agree that it is doing it implicitly, it does what you say exactly. Also "let" and "lets" are too similar.

12 Likes

Don't misunderstand me. I love shadowing. But I cannot clearly express my intent to shadow or NOT to shadow. I would like that the compiler catches my errors when I misstype something like this.

The way I express the intent to shadow is by explicitly writing down the types (even if the code is otherwise clear), so that the contrast between types is really in your face.

For example:

let x: usize = 3;
let x: String = format! ("{}", x);

Exactly.

There are clippy lints if that would suffice for you.

I've had two bugs that took longer to fix than they should have because I inadvertently shadowed a variable. I don't know if new syntax is needed, though. I would be happy with a compiler warning or even an indicator in my IDE. I don't know if I would have thought to run clippy in those cases.

1 Like

You can get a warning and an indicator your IDE by doing #![warn(clippy::foo)] in main.rs. From a quick look at the list of lints, the one you're likely looking for is clippy::shadow_reuse or clippy::shadow_unrelated.

1 Like

I would say explicit is similar to "local reasoning": you can understand what's going to happen by looking around the place in the code you're currently reading, and don't need to read many other places.

So async is definitely quite explicit, you look at the function signature and know what the actual function signature will look like. ? Operator also only requires you to look at the call site and the function you're in, which is quite explicit. Auto-deref, OTOH, is not: you need to look at various Deref definitions - of the type at hand, the type it derefs into, and so on, until you find one that matches your requirement (e.g. has a given method). Method call is also a little more implicit, since you need to compare inherent methods with various implemented traits.

Regarding variable shadowing, I wouldn't call it implicit: it's very easy to reason which variable is currently at scope, reading through the scope. I think it should also usually be difficult to get two such variables mixed up, because of type checking; it is here that other implicit things, like autoderef, can make things more confusing. @alanhkarp, I'm curious, can you elaborate about those bugs?

Edit: the withoutboats blogpost above specifically refers to locality, and makes an argument about why this isn't what we should call explicit. I would still say local things are also more explicit, in the sense that it's "simpler" to find out what's going to happen. But it's true that in all of the above examples, what's going to happen is completely defined by the code - you just need to read more of it.

2 Likes