[HN Gopher] Rust's Golden Rule
___________________________________________________________________
Rust's Golden Rule
Author : steveklabnik
Score : 140 points
Date : 2023-03-27 16:52 UTC (6 hours ago)
(HTM) web link (steveklabnik.com)
(TXT) w3m dump (steveklabnik.com)
| nu11ptr wrote:
| Wouldn't this be true of all statically typed languages that
| don't allow type inference in function signatures?
| steveklabnik wrote:
| I 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.
| jerf wrote:
| "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.
| asib wrote:
| I always explicitly annotate when writing Haskell, but I
| often like to remove types on functions (one at a time) to
| see what Haskell infers because it sometimes gives me a
| more general type that I prefer. But then I'll manually add
| in that annotation.
| FullyFunctional wrote:
| 100%
|
| 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.
| dllthomas wrote:
| > 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.
| steveklabnik wrote:
| > I expect this sort of thing scales up as you are putting
| more in the type signature.
|
| 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.
| nu11ptr wrote:
| I do think this is a nice thing in general and one thing I
| find annoying in Ocaml without an interface file
| c-cube wrote:
| You 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!
| cmrdporcupine wrote:
| I was confused by this, too. It feels like the target audience
| for this post must be people coming from dynamic-typed, late-
| bound languages? Hence the references to e.g. Ruby? Certainly
| Rust's immediate mainstream "competition" all have the same
| constraints.
|
| 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.
| steveklabnik wrote:
| > It feels like the target audience for this post must be
| people coming from dynamic-typed, late-bound languages?
|
| 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."
| marcosdumay wrote:
| > But I believe it's also considered good practice to declare
| them explicitly much of the time anyways.
|
| 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.)
| dllthomas wrote:
| To be fair, you don't need to declare the types of your
| lambdas in Rust, either.
| marcosdumay wrote:
| Rust lambdas are mostly equivalent to the stuff you don't
| name in Haskell.
|
| (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.)
| pornel wrote:
| You _can 't_ even name their actual types, but you need
| to use generics that fully specify their arguments,
| return types, mutability and ownership.
| j-pb wrote:
| > 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.h...
|
| 2: https://youtu.be/YR5WdGrpoug?t=463
| dgb23 wrote:
| Rich 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
| Georgelemental wrote:
| See also:
| https://smallcultfollowing.com/babysteps/blog/2021/11/05/vie...
| roarcher wrote:
| > 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 wrote:
| That 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 wrote:
| Oh 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 wrote:
| You're welcome, though I don't do any of that any more.
| bobbylarrybobby wrote:
| You 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?
| lalaithion wrote:
| This 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!
| msla wrote:
| This sounds like a job for monads, to be honest.
|
| "Shared mutable global state" is "IO monad" all over.
|
| (Ia! Ia! Ruskell fhtagn!)
| ahepp wrote:
| Can 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 wrote:
| Here's an example:
| 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.
| lalaithion wrote:
| It'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.
| ahepp wrote:
| Ah, 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.
| yazaddaruvala wrote:
| +1
|
| 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.
|
| https://github.com/rust-lang/rfcs/issues/2327
| oconnor663 wrote:
| I could be wrong, but I don't think Rust futures/tasks rely
| on thread-local storage anymore. Some early iterations of the
| feature did, but I think that was replaced with an explicit
| argument to the poll() function before stabilization. That
| said, the typical async/await syntax makes that invisible, so
| it definitely does feel a lot like TLS.
| gpderetta wrote:
| If I understand correctly, Autopropagators are basically
| implicit parameters , a.k.a typed dynamic scoping, which is
| exactly what you need to solve this problem.
|
| 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.
| hardwaregeek wrote:
| Having 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 wrote:
| > 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 wrote:
| IDEs 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
| ChadNauseam wrote:
| Tangentially 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/
| pornel wrote:
| There 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 wrote:
| Someone pointed this out to me on Twitter, it's a great point!
| brundolf wrote:
| I'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 wrote:
| It'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.
| dllthomas wrote:
| I think there's something to be said for allowing things to
| be inferred during rapid development that need to be pinned
| down before publishing, but once you've removed the technical
| barrier there's a big question of how you make sure the
| social side of it stays healthy.
| vlovich123 wrote:
| I think it might be nice to allow for inference at the
| pub(crate) level and below (or maybe only within a module if
| you wanted to be extra cautious). Now that I've had a lot of
| experience with TS, I'd say the probability that you
| accidentally change the type and the code doesn't fail is
| minimal, so the productivity gains of being kind to the user
| outweigh the value of extra verbosity. And ultimately that's
| all type checking is for - to make sure your types form an
| internally consistent system that you can reason about and
| avoid making mistakes on.
| shadowgovt wrote:
| Agreed, 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.
| estebank wrote:
| It's also not an all or nothing rule: `type Alias = impl
| Trait;` will have in effect the same issues that return and
| argument type inference could have, and I am convinced that
| the issues around diagnostics caused by type errors
| involving it can be made almost as clear as E0308 is
| already today. I also believe that Rust _can_ afford to
| have type inference in functions, but only for crate-local
| functions, I wouldn 't ever want to see a `pub fn foo(x: _)
| -> _ {}` be allowed. Your type signatures are a contract.
| You can have a "gentleman's agreement" within your own
| project, but you want a signed and notarized contract with
| everyone else :)
|
| 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").
| steveklabnik wrote:
| > I am convinced that the issues around diagnostics
| caused by type errors involving it can be made almost as
| clear as E0308 is already today
|
| 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!
| eterps wrote:
| I believe the first issue could be somewhat mitigated if the
| editor displayed the inferred types as virtual text.
| bombela wrote:
| You get this for all well known text/code/ide editors via rust-
| analyzer.
| ChadNauseam wrote:
| Rust-analyzer in VSCode does this for local variables, and it's
| very nice
| Ygg2 wrote:
| Same as IntelliJ Rust in CLion.
| timeon wrote:
| Isn't it also what rust-analyzer does in other editors?
| cmrdporcupine wrote:
| Jetbrain'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.
___________________________________________________________________
(page generated 2023-03-27 23:02 UTC)