7 ms·
Rust's Golden Rule
- eterps 4y agoI believe the first issue could be somewhat mitigated if the editor displayed the inferred types as virtual text.
- ChadNauseam 4y agoRust-analyzer in VSCode does this for local variables, and it's very nice
- cmrdporcupine 4y agoJetbrain's Rust mode for CLion, etc. will do this all over the place -- display inferred/matching types for variables, pattern matches, function arguments, etc. But it would be unfortunate to have to rely on a tool in this way for basic language functionality.
- sacnoradhq 4y agoYes and no. It doesn't matter if a tool is required to elide behavior. 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 :)
- bombela 4y agoYou get this for all well known text/code/ide editors via rust-analyzer.
- nu11ptr 4y agoWouldn't this be true of all statically typed languages that don't allow type inference in function signatures?
- steveklabnik 4y agoI do not claim this property is unique to Rust, it's true. 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.
- nu11ptr 4y agoI do think this is a nice thing in general and one thing I find annoying in Ocaml without an interface file
- c-cube 4y agoYou can write explicit types in OCaml, it's actually quite smooth. Over the years my style has evolved towards that and now I almost always write at least the return type when I write a function!
- jerf 4y ago"Rust puts things in the signature that aren't there in many other places." 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.
- lalaithion 4y agoThis is what I find most annoying about rust's async ecosystem; many functions depend on being called within a certain executor, but this is not documented in the type at all. You should not provide, and arguably not be able to provide, functions which look like they can be called in any context but which require shared mutable global state!
- ahepp 4y agoCan you give an example of what you're talking about? I'm pretty new to async and Rust in general but I'm having a hard time thinking of an example (almost certainly a deficiency on my end haha).
- paulgb 4y agoHere’s an example: https://docs.rs/tokio/latest/tokio/time/fn.sleep.html#panics https://docs.rs/tokio/latest/tokio/time/fn.sleep.html#panics > 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.
- ahepp 4y agoAh, that makes sense. In fact, I was trying to unit test some time based code in tokio a month or two back, and it was really painful. I couldn’t figure out how to inject a fake clock into the runtime to test my timers.
- lalaithion 4y agoIt’s not a big deal because the ecosystem has coalesced around tokio, but it’s coalesced around tokio partially because it’s a big deal.
- davidatbu 4y agoIf one is using a function from tokio itself, is it not a reasonable expectation that one should check the docs as to whether the function requires the tokio executor?
- ChadNauseam 4y agoTangentially related. (I thought of this when thinking about what the OP said about rust forcing you to hardcode your function's types prevents spooky action at a distance.) 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". [0]: https://www.unison-lang.org/ https://www.unison-lang.org/
- j-pb 4y ago> Putting “here’s the fields I’m borrowing in the body” into the signature, in order to conform with the Golden Rule, looks very odd. 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.html#partialtype https://www.typescriptlang.org/docs/handbook/utility-types.h... 2: https://youtu.be/YR5WdGrpoug?t=463 https://youtu.be/YR5WdGrpoug?t=463
- Georgelemental 4y agoSee also: https://smallcultfollowing.com/babysteps/blog/2021/11/05/view-types/ https://smallcultfollowing.com/babysteps/blog/2021/11/05/vie...
- dgb23 4y agoRich Hickey also came to mind, but a different talk[0]. 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. [0] https://www.youtube.com/watch?v=oyLBGkS5ICk https://www.youtube.com/watch?v=oyLBGkS5ICk
- hardwaregeek 4y agoHaving explicit function types prevents Rust from feeling like an algebra problem. Languages like Haskell and OCaml make writing generic functions super simple and elegant, but it comes at the cost of having abstraction be the default. Therefore you end up reading signatures where the mental process is "so I have a parameter x of type a, then a parameter f of type a -> m b, then a return type of m b, hmm now in this case m = List, a = Int, b = Float". You end up having to do a lot of substitution and manual unification in your head. Which, to be fair, some people enjoy and find useful. More power to them! 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.
- marcosdumay 4y ago> You end up having to do a lot of substitution and manual unification in your head. 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]`.
- brundolf 4y agoIDEs close the gap a lot on type inference. Being able to hover a symbol and see the inferred type without mentally inferring it yourself is important
- brundolf 4y agoI'm gonna politely disagree and say, I don't think "spooky action at a distance" is very scary when it happens at build-time 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
- steveklabnik 4y agoIt's all good! Polite disagreement is how things move forward. I think if you truly twisted my arm, I wouldn't hate the idea of non-pub things being inferred, but no further than that. I'd still choose Rust's current design, personally, but that is in fact a preference.
- shadowgovt 4y agoAgreed, because when I think of signature-inferred-by-body, I don't think of TypeScript; I think of C++ and the nature of the error messages you get when you try to use a novel type in a container template.
- roarcher 4y ago> Putting “here’s the fields I’m borrowing in the body” into the signature, in order to conform with the Golden Rule, looks very odd. 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 ;)
- steveklabnik 4y agoThat syntax is not valid Rust, though you're right it's sorta similar to destructuring. I wasn't thinking of it specifically, but of some of the sorta-proposed syntaxes by various folks in blog posts or on internals. And yeah, it's not insurmountable of an issue.
- roarcher 4y agoOh I know, it's just an idea of a possible syntax. Something like that might look a little funny but it doesn't seem too terrible to me. Anyway, thanks for the work you do on Rust! It's always my first choice of language when I have one.
- steveklabnik 4y agoYou're welcome, though I don't do any of that any more.
- bobbylarrybobby 4y agoYou can already kind of do this with `pub fn x_mut(Self { x, .. }: &mut Self) -> &mut i32`, but 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?
- pornel 4y agoThere is one case where Rust breaks this rule for the sake of simpler syntax. Send of Futures: 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.
- steveklabnik 4y agoSomeone pointed this out to me on Twitter, it’s a great point!
- sacnoradhq 4y agoIs Steve still in ATX or no? Waves from around 11th & I-35
- culebron21 4y agoI don't get the point. Does this imply that function signatures are to be trusted and bodies not? As I'm working on an implementation, I can't be sure I choose the correct input/output in functions, hence I can't decide that signatures are the final source of truth. I can get wrong both. Or it just means a "soft" precedence of signature over body? Sure, I read signatures first and deeper into function bodies later.