Hacker News new | ask | show | jobs
by jasode 2227 days ago
Now that there's been 5 years since v1.0, is there any consensus on design mistakes that Rust made? Any mistakes that people wish they could turn back time and do differently but can't because it would break compatibility with too much existing code out there? That's the more interesting list to me.
8 comments

There isn't much. Rust has deprecated some mistakes (like Error::cause). The editions mechanism allowed fixing some issues (like unintuitive module paths).

https://github.com/rust-lang/rust/issues?q=label%3Arust-2-br...

• Struct literal syntax should have used C99 syntax. The language uses `name:type` everywhere except struct literals which use `name:value`, and this gets in the way of adding new syntax (type ascriptions).

• `Box` is semi-magical. Maybe it could have been a regular struct. Or maybe magical all the way to allow placement new and destructuring.

• Types are in borrowed, owned-fixed-size and owned-growable variants, but naming of them is a bit ad-hoc. There's str/String, but Path/PathBuf (instead of e.g. String/StringBuf or path/Path).

• Split between libcore and libstd is awkward to manage and not a good fit for WASM. It could have been one libstd with feature toggles (this might still happen).

• Some people think split between Eq and PartialEq is an overkill, and just makes floats annoying.

There are things that are still unsolved in Rust, like umovable types and self-referential structs. But it's hard to say they're a mistake — as far as we know, they're a necessary limitation to make other useful features work.

Box being non-magical might still happen too.

(I disagree personally on struct literal syntax but you're not alone, it's true.)

What's magic in Box?
The "2018 edition" of Rust made breaking changes to the syntax (but the core stayed compatible, so 2015 edition and 2018 edition Rust can be used simultaneously on the same project).

They then said that they'd probably do the same thing in 2021.

Now they're debating whether a 2021 edition is needed since there aren't any breaking changes with broad support except for the removal of deprecated syntax and APIs.

This is strong evidence that the answer to your question is "no".

I don’t think there’s consensus around very large things, but there are some regrets that are commonly expressed about smaller stuff. For example, lots of people think the PartialEq/Eq split was a mistake. I like to half-joke that String should have been StrBuf. Macros have several flaws and are under-developed, etc.

There are also some thoughts about Rust-like languages with some differences, see https://boats.gitlab.io/blog/post/notes-on-a-smaller-rust/ as a prominent example.

I saw that the Piston developers created Dyon which could be seen as a Lua in the Rust spirit. No garbage collection, only lifetimes.

https://github.com/PistonDevelopers/dyon#list-of-features

One way to reason about what could have been done better from the beginning is looking at stuff that is marked as [deprecated] in the standard library. E.g. how the "try!()" macro was deprecated in favour of the "?" operator.
I would say the try!() macro is an exception to that rule: if the ? operator existed since the beginning, it would be seen as "too much magic" (in a language that already used up most of its "strangeness budget" in lifetimes/borrowing), while try!() is just a very simple macro you could write yourself, with no special compiler support. Only later, after people got used to try!() everywhere, the ? operator became viable, as "just a shortcut to try!() with better precedence (and it also works on Option)".

That is, there's a path dependence, where the existence of try!() made the ? operator viable.

>if the ? operator existed since the beginning, it would be seen as "too much magic" [...] That is, there's a path dependence, where the existence of try!() made the ? operator viable.

Yes, C++ creator Bjarne Stroustrup made a similar observation:

- For new features, people insist on LOUD explicit syntax.

- For established features, people want terse notation.

There seems to be an invisible "Overton Window" of evolving programming language features and syntax.

Having both the terse and the verbose is so valuable for onboarding.

I feel like the reason pointers as a mechanism in native languages is such a barrier to cross for a newbie is because they are this magical star symbol * just floating around doing... something?

If people started out writing pointer<string> foo instead of string* foo and had to use explicit derefrencing via name rather than by magical glyphs onboarding would be so much easier.

I think it applies to almost all programming concepts too. Starting a language with a baseline grammar of just function invocation -f(x) = y as <noun>.<verb>(<noun>...) - and extrapolating from there and introducing terse grammar progressively as shortcuts would serve much better to get concepts in heads rather than arcane ritualistic glyphs. Introduce x.add(y) then say x + y is the shorthand.

Rust does a really great job "functionizing" almost everything in the language - it has the add trait after all https://doc.rust-lang.org/std/ops/trait.Add.html. You can actually write tons of Rust in just function call form and make it look like Lisp.

I think more interesting would be something we're truly stuck with (at least until Rust 2.0, which may never come).
(Stuff that’s deprecated in the standard library is stuff we’re stuck with; they cannot be removed in editions.)
I've seen this said many times, but I don't really understand why.

Why couldn't a new Rust edition create a new std2021 (for instance), and automagically use it when importing a std submodule in a project using the new edition (and in the prelude)? The old implementation would still be imported using std in crates using prior edition, and could still be imported in the new edition, but named std2018.

If all the implementation (except the now deleted stuff) is put in the std2021 module and the old std2018 () simply re-export it (and re-implement the old stuff), you wouldn't break anything would you?

I'm probably missing something, but so far I don't know what and I'd be really happy if somebody enlightened me.

> Why couldn't

There may be possible ways of doing this, but you're approaching this from the wrong angle. Right now, this is not possible, due to policy. This policy is informed by the technical restrictions right now. There may be possible ways in the future to handle this, but as of right now, there are not.

The reason this is true right now is that there is one copy of the standard library for every program. So it needs to support all editions, because code from multiple editions may call standard library functions.

If we had multiple copies of the standard library, you may end up with issues where two different programs can't interoperate because they'd be using different versions of the same crate, and given that one of the most important features of the standard library is interoperation, this is a huge drawback for the stdlib specifically.

(There may be other technical issues, I am not an expert here, but that's the biggest hurdle as far as I know.)

This isn't the answer I wanted, but it's a good answer. Thank you
Sure but you don't have to use them, is the point I'm trying to make. They don't affect modern code and while it's annoying they are used in older code there is at least a path forward to "upgrade" the code to the modern equivalents.
Could a lint check for them and warn if some code uses them?

This way slowly the ecosystem could move and stuff could be eventually removed.

If you use something that's deprecated, you get a warning, yes.

> could be eventually removed.

It can not be used according to our stability policy. There's a lot of closed source Rust out there.

There has been talk on the internals forum of "gating" deprecated parts of the std on a new edition. So the deprecated feature will be hidden for crates that declare `edition = '2030'` but will be available for crates using an older edition. Essentially turning the warning into an error.

But this is just talk at the moment. Currently there is no mechanism to implement this. It would also be a challenge for documentation.

No, this was intentional. The point was to see how the user base used the language before adding permanent language features.
The planned const generics syntax is going to look pretty weird, because it builds on type generics. If Rust had chosen a different syntax for type generics, const generics might look less weird.

The futures design was built with epoll in mind, and now people are trying to wrap it round io_uring, they are feeling some pain. Would a different design have worked better, without massive drawbacks?

> The planned const generics syntax is going to look pretty weird, because it builds on type generics. If Rust had chosen a different syntax for type generics, const generics might look less weird.

Could you provide an example? Because I don't really see it, except by requiring that non-const generics also be explicitly annotated?

I'm thinking of the need to have blocks wrapping expressions in types [1]:

  const X: usize = 7;
  
  let x: RectangularArray<i32, 2, 4>;
  let y: RectangularArray<i32, X, {2 * 2}>;
Simple cases are fine, but i suspect a lot of real-world use of const generics are going to require blocks.

AIUI, this is only because <> was chosen as the container for type arguments, and > is also a legal operator in expressions. So, for example, if Rust had used Scala-esque [] for type arguments, this would not be a problem.

[1] https://rust-lang.github.io/rfcs/2000-const-generics.html#ap...

While I would prefer brackets, that would not be enought to solve all the problems since, there would be conflict with array syntax too.
But the square brackets used in array access nest, so the parsing is never ambiguous.

The reason > needs braces is that without them, if you're parsing and so far you've seen these characters:

  let y: RectangularArray<i32, X, 2>
You don't know if that final > is ending the argument list, or is a greater-than operator, with the rest of an expression coming after it.

But if it was square brackets, then a closing square bracket on its own can only ever be the end of the list:

  let y: RectangularArray[i32, X, 2]
Because if it was closing an array access, there would have to have been an opening square bracket:

  let y: RectangularArray[i32, X, a[1]
In case anyone ever stumbles across this thread, a couple of posts about this:

https://keleshev.com/parsing-ambiguity-type-argument-v-less-...

https://soc.me/languages/stop-using-for-generics

That's what I originally thought about, but the thing is `]` in and of itself is not an operator while `>` is, so

    foo<bar, qux>corge>
"is" valid syntax while

    foo[bar, qux]corge]
is not. The former requires some sort of disambiguation, while the latter is not. Put an other way, in terms of syntax `[]` always parses the same way (with `[` is infix and `]` terminates it), there can be ambiguity between indexing and generics but it doesn't really matter for the original parsing.

For `<>` however, the parsing itself can be ambiguous as `>` could either be the terminator of an earlier `<` or it could be the infix `>` operator. That is where the issue lies, you can't know how to build the AST without either explicit disambiguation, or infinite lookaheads.

I've been complaining for a long time that `Drop` is fundamentally wrong. Rust's "just write a new function" fixed C++'s "construction is mutation of location", but Rust's Drop makes the same mistakes as C++: destruction needs to be for consuming data, not borrowing it and mutating it.

Now, we can't just do

  drop<T>(T)
because of DSTs, so we we'll need a new type of consuming reference. And the dual to that, an initializing reference, would also solve the problem of creating DSTs with preallocated memory.

Now both could use MaybeUninit, but it would be better to just have types that vary with the CFG, so one can insure that no matter how one get to point b, the memory is now initialized.

> but Rust's Drop makes the same mistakes as C++: destruction needs to be for consuming data, not borrowing it and mutating it.

but Rust's drop isn't for that purpose. It's not for the actual cleanup of the struct and its children it is for additional cleanup before the children are deleted. So it has to be mutable. The compiler synthesizes the "delete children" code.

(I don't understand your point about drop<T>)

> It's not for the actual cleanup of the struct and its children it is for additional cleanup

I think that purpose is the tail wagging the dog, an explanation of the current method rather than an actual requirement.

The simpler thing to do is just have drop on the aggregate calls drop on the fields, just as new on the aggregate can call new on the fields.

This does not seem simpler to me, this seems easier to mess up. The most common use case is dropping all fields.

Furthermore, you still have to special-case Drop because now you have to support destructive destructuring for Drop types because it isn't allowed anywhere else.

And plus, if you forget to do this, the failure mode is a stack overflow.

The current design is absolutely based on practical requirements here, it is not a retroactive justification. This is by and large how destructors work, for good reason.

> (I don't understand your point about drop<T>)

Ah I meant to write the function signature:

> fn drop<T>(T);

contrasted with the:

> fn drop<T>(&mut T);

that we have today

Incompatibility between C enums and Rust enums forces to use integers instead, which leads to errors. (Rust doesn't allow to enum variables to be forward compatible, i.e. it cannot have a value outside of enum).
I think that's a good thing. A C "enum" is just a shorthand for declaring an int alias and some constants. You can do that in Rust easily enough.

A Rust enum is an actual enumeration type, which C does not have. This is far more powerful.

It's good thing, but it leads to error in code which must talk to C or network, where enum's are not cast in stone.
You really should not be casting data structures sent over the network directly to local data structures, unless you are using capnproto or another zero copy protocol that does that safely.
You can assign any value to a C enum, they literally are just integers.
> Rust doesn't allow to enum variables to be forward compatible, i.e. it cannot have a value outside of enum

AKA Rust's enums are type-safe, not aliases for integers with some named constants.

Rather than Rust's, I'd say the mistake is C's enums. If you don't want enums, don't have them.

I guess that's one place where Go did something good: they didn't want to improve on C's enums with proper ADTs so they just stripped out the entire thing, an "enum" is an integer and a bunch of constants. Which you can also use to represent these non-enums in Rust though it doesn't have the iota / step convenience. A simple recursive macro might be able to handle it though.

To future proof an enum in rust you use the #[non_exhaustive] annotation.

For C compatibility you have lots of options like #[repr(C)] or #[repr(i32)] to be C compatible. So not sure what you are referring to?

The problem is that #[repr(C)] enum is not compatible with C, even with #[non_exhaustive] annotation, and will never be, because C enum can be described in Rust terms as:

  #[repr(C)]
  enum Foo {
    A = 1, 
    B = 2,
    C = 3,
    UNKNOWN(i32),
  }
which is not possible to define in current version of Rust.
Those topics come up on reddit.com/r/rust quite frequently, along the lines of "What are the biggest mistakes in Rust" etc, you can find a bunch by searching something similar.
Three chars limit for keywords and some std types, e.g. `Vec`, `len`, `str`.
I respect your opinion, but I tend to disagree. The terseness is a feature I like.