Rust's Golden Rule
steveklabnik.com
steveklabnik.com
> It can also panic whenever a timer is created outside of a Tokio runtime.
Note that nothing in the signature tells you that, just the function documentation.
In practice, this ends up not being a big deal, IMHO. The ecosystem has largely coalesced around tokio as an executor. The only times I've run into it are when running Actix, which has its own global state on top of tokio.
But another part of me questions why it’s ok that calling a function in the wrong context is a runtime panic rather than a compilation error. Coming from a C background it seems pretty normal to just have a “tokio_context*” argument on tokio functions that would at least make the statefullness less obscure?
Maybe I'm just jaded from writing too much C, but it doesn't seem like a big deal to me to change sleep(duration) to sleep(ctx, duration). Three letters to be correct, transparent, and not have global state? Look at what a complete mess the C standard library is from where they used global state like that.
I'm sure there are lots of other ways you could do it, like make the context it's own struct and call a sleep impl for the struct or something?
I guess it's more common than what my personal experience suggests (which is that it never happens).
I don't have the time to shepherd this RFC (or something similar), but I really wish that the whole Async ecosystem was built on something less error prone / magic-y than ThreadLocalState.
Its not ideal to ever have any "hidden" functionality like auto-propagation, but it would be way better than the current state of async Rust.
In fact the equivalent Scala functionality is mentioned in the issue thread. They might be confusing but they are probably a million time better than actual mutable globals.
"Shared mutable global state" is "IO monad" all over.
(Ia! Ia! Ruskell fhtagn!)
Much of my background is in TypeScript, which encourages mixing and matching explicit and implicit return-types, and I don't think it's just bias when I say I broadly prefer that. Having the flexibility to decide which part of your code is driving your types in different circumstances is really helpful. And Rust already does this in lots of situations, like in variable declarations and generics; Rust closures even let you have inferred argument and return types, so it feels a little arbitrary that regular functions are one of the only places where explicit types are absolutely required
Caveats:
- Crate exports should definitely require explicit types, since you don't have all the callers available when you build your own code to see that you don't break the API without meaning to
- Rust of course tends to be used for slower-moving and more careful projects than TypeScript, so in general I'm less bothered by having to be explicit in a Rust project than I would be in a TypeScript project
- TypeScript types have no runtime footprint. They in no way affect the underlying data or how it's represented, they only describe data that has its own separate material reality. Rust being a low-level language, that isn't the case here! When the return type of a function changes, that isn't just for checking, it changes the actual runtime footprint of the function in a small way. So I could see this being a reason to keep types explicit. Though, again, Rust already does lots of type inference in other places with potentially much bigger runtime implications, like when .collect() infers the data structure the items are going to be allocated into.
In all: it doesn't matter to me that much, but if it were totally up to me, I'd strongly consider allowing inferred function return-types
An interesting thing is that for return types all you need is evaluating the body (which is why rustc already suggests the right type when writing `-> _`, it was super-easy to implement), while parameter type inference requires global inference (which requires a full-blown change to the type checker's implementation, not as worth it for a "mere suggestion").
That's very cool! I'm glad to hear it.
> An interesting thing is that
Ahh, this is an interesting point for sure; I chose return values basically at random, but I can see how that's easier than parameters are. Guess that would have been an ever so slightly more strong example on my part!
I think this would be an amazing feature, but it would require something like typescripts partial types, where essentially every object/struct exists as a sum type of atomic fieldname:field-type types.[1]
Rich hickey[2] has a great talk on how existing type systems break in scenarios where you'd want them to be forwards compatible, e.g. when relaxing what you accept as arguments or similarly increasing the guarantees that you provide on returned values. Partial types are one piece to that puzzle imho.
1: https://www.typescriptlang.org/docs/handbook/utility-types.h...
There he talks in part about software stability and how spec (the primary way to encode data and function contracts in Clojure), is about communicating what is required and provided by a function or library (among other things).
Rust's decision to make signatures mostly explicit (sans lifetime parameters here and there), is self-documenting and useful, but most importantly it creates an explicit contract that people can rely on.
Rust code adheres to semantic versioning so at least it will also adhere to not breaking callers without bumping the major version. Which the further discussion in the talk do not agree with. But the fundamental decision to make signatures explicit at least enables people to write stable software.
On the flip side, forcing type signatures means that I tend to shy away from more sophisticated types like fancy closures with lifetimes. Which can be frustrating because the usage is simple, but the type is sometimes not. But I think it's fine for function signatures to be a little harder to write for library authors and a little easier to understand for library users.
Hum... I don't think I do that. I did a lot when I was first learning it, but not now.
Now there's some pattern matching (`f :: a -> m b` immediately says something) and a lot of generalization (so I don't think "I have a list, I want to apply a function"; I think "I will apply a function, I want something I can map"). The `Applicative m => m a` comes before the `[a]`.
In Unison [0], when you change a function's type, it computes the number of call sites of that function (and callers of the callers, etc), and gives you a "todo-list" of functions you'll need to fix. As you go through the todo-list, the list of functions to fix only ever gets smaller. It can do this because the compiler knows the state of the codebase before and after your change. That means it can see the difference between "you changed a function's type, which broke the callers" and "you changed how you're calling a function, and your change didn't typecheck".
Do you mean something like this (using destructuring in arguments)?
struct Point {
x: i32,
y: i32,
}
impl Point {
pub fn x_mut(self { &mut x, .. }) -> &mut i32 {
&mut self.x
}
pub fn y_mut(self { &mut y, .. }) -> &mut i32 {
&mut self.y
}
impl Point {
pub fn calculate(&mut self) -> i32 {
let x = self.x_mut();
let y = self.y_mut();
*x * *y
}
}
}
I'd agree that it looks odd, but not very odd. Certainly there are odder syntaxes in Rust ;)1. Your function is no longer callable like p.x_mut(); you have to go through `Point::x_mut(p)` (seems kind of dumb that the first argument has to literally be called `self` and can't just be of type `Self`)
2. The compiler doesn't actually use the fact that you're ignoring the unspecified fields of `Self`, although it seems like that analysis should be trivial, given that rust already understands that `calculate(&mut point.x, &mut point.y)` isn't overlapping?
Is this what you had in mind?
struct Point {
x: i32,
y: i32,
}
impl Point {
pub fn x_mut(Self { x, .. }: &mut Self) -> &mut i32 {
x
}
pub fn y_mut(Self { y, .. }: &mut Self) -> &mut i32 {
y
}
pub fn calculate(&mut self) -> i32 {
let x = Self::x_mut(self);
let y = Self::y_mut(self);
*x * *y
}
}
The `self` on the second line of the `calculate` function still gets flagged with "cannot borrow `*self` as mutable more than once at a time", which I think is what you were talking about in your second point.I had no idea that the syntax above was actually valid in argument lists. Learned something new about Rust today.
I think it's important to Rust conceptually because the type system is one of the ways in which Rust achieves its most important goals, but that doesn't mean that it's exclusive to Rust or unimportant elsewhere.
I do find that lifetimes cause issues here that don't happen in, say, GC'd yet statically typed languages. Rust puts things in the signature that aren't there in many other places. I think the accessor/mutator example is so common when talking about this problem precisely because it's a pattern that's pretty normal in other statically typed languages, but doesn't cause any issues there, thanks to GC and/or lack of explicit lifetime tracking.
I expect this sort of thing scales up as you are putting more in the type signature. Simple manifestly-typed languages like C or Go can't say much in their type signature compared to Rust, so it is harder to notice as a principle, though I think it's still present if you examine it hard enough. (A good example of how learning another language can be the best way to advance in your "core" language.)
Haskell is an interesting supporting case for your core thesis; it can put a lot more in its type signature than most languages, but it also has inference, even pretty good inference considering how strong and complex the type system is. However, community practice is (and has been for a long time) to put type annotations on all your top-level functions, to a large degree for the same reason you are talking about. Haskell can get away without annotations to a large degree, but you'll be chugging along changing this & tweaking that, and suddenly your entire program explodes into a mess of type error messages attributed to all your code everywhere because of some change you made. Explicit annotations contain the blast radius of such changes and keep the compiler messages pointing at something much closer to the vicinity of what you actually changed and somehow broke. At the top expression level, type inference is treated more as a tool for determining the top-level types than a solution on its own terms.
In fact much of "debugging" when I was writing Haskell was to increasingly hand type-annotate stuff to catch where my assumptions were flawed. Definitely, top-level exported entries almost all get an explicit signature is it's part of the documentation.
Rust's level of type inference feels very natural to me and I don't miss what additional inference Haskell provided.
The biggest place I miss Haskell's inference is the repl; When futzing with a new library I very much appreciate being able to glue things together in ghci and ask what the type is. I don't know how to get something quite that general quite that easy in rust, although 'let _: () = my(expression)` is often good enough.
There is irust which seems to work well. I haven't used it enough find the limitation (eg. can you define new types at the prompt? I haven't tried).
Yep, I would agree with this. On my mental list of "stuff to blog about someday" is a post along these lines. For me, most Rust code fits into a sort of sweet spot, with enough types to make things helpful, but not so many that it causes pain. Other people have different preferences and/or goals when it comes to these sorts of things.
Incidentally, I was porting some Haskell code to Rust the other day, and ran into some of these differences. Oops, there's a lot more to this signature than I originally considered! This caused me to completely change the API of the code, given that what's natural for Haskell isn't always natural for Rust.
However, languages in the ML language family like StandardML and OCaml and Haskell do get away (sometimes) without explicit type signatures in function definitions. But I believe it's also considered good practice to declare them explicitly much of the time anyways. Because the alternative is, as the article pokes at, confusing error messages.
You're picking up on something, for sure. When I originally put metaphorical pen to metaphorical paper, I intended to talk about static types vs TDD. Then this background got a bit long, so I decided to just make it its own thing, with that topic to follow.
You're right on the "considered good practice" aspect, which I didn't call out specifically because I generally try not to frame things as "Rust good, other language bad," hence the "Rust vs what Rust would be with function-level inference."
Much of the time is very different from always.
For example, Haskell would be incredibly restrictive if every time you named a lambda you had to declare its type. People don't even declare most of their functions, just the top level ones.
Always declaring the types would lead to large functions that are less general... what is common in Rust. (But that's not the only reason they are common in Rust.)
(But well, you can certainly name them, as I've done that; I don't remember if you need to declare their type; and it is almost never useful.)
async fn send_peekaboo() {
let _r = std::rc::Rc::new(());
// async {}.await;
}
fn is_it_send(_: impl Send) {}
is_it_send(send_peekaboo());
If you uncomment the .await line, this crates a possibility of moving thread-unsafe Rc across threads, and the Future stops being thread-safe. Rust catches this problem correctly, but it is a case where function body leaks into the signature.But it would be unfortunate to have to rely on a tool in this way for basic language functionality.
In an Ideal World(tm), rust would work more like a functional language by:
- elide lifetimes across boundaries, warning if it weren't clearly reducible to less than static
- elide types across boundaries, failing that show a counterexample creating a paradox
- allow specialization and exclusion of type patterns
- discard some dyn and impl for simplicity rather than invent confusing constructs
- and of course add dependent typing :)