Ideas for making Rust easier for Beginners

One of the current goals is to lower the learning curve. While I love rust’s explicitness and consitency in using core its concepts on the one hand, they are parts implemented in a way they make them an obstacle for beginners. Therefore I’ve created this list of things which I encountered as being confusing or unnecessarily complex while writing code.

Some of these ideas are purely about rust, while others are inspired by the best parts in other programming languages. Surely not all of those ideas can eventually be implemented, but I still think that they are a valuable resource for improving the rust experience, especially for beginners.

Simpler Generics

Currently, if I want write a function taking an Iterator, I have to write something like this, defining an imho pointless constant U:

fn take_iterator<U: Iterator>(iterator: U) {
    // ...
}

This requires extra effort for beginners (using generic the compiler optimizes away vs. using the actual implementation) and bloats the code. As the compiler knows about Iterator being a trait, why can’t it just create the generic-definition itself? If it’s about the explicitness, I’d be glad about something like this instead:

fn take_iterator(iterator: <Iterator>) {
    // ...
}

Shorten the Compiler’s Type Errors

When there are type mismatches, which can be caused by a variety of problems, the compiler tends to print extremly long lines creating badly understandable error messages. Those error messages should be broken down to fit in a 120 char line. It would be an extra sugar if the compiler would also propose specific solutions.

Side note: I love the “new” error format

Allow tests to work through visibility and scope borders

I like the default test system, but unfortunately it does not allow testing private methods in different files without add javasriptish complexity. It can be argued about wether one should test private methods, but I believe this decision should be made by the project and not the language or framework.

Additionally, one must write import statements which seem to follow a different logic than the normal imports. Ideally, you’d just get the environment you’re testing from the test-decorator.

The Module System in General

Thankfully, someone has already written this down in full length. Things have gotten better with the maturing documentation, but the core problem remains: https://withoutboats.github.io/blog/rust/2017/01/04/the-rust-module-system-is-too-confusing.html

From and To in the html docs.

This is a relatively small point: In the docs, only it type from or to which can be converted should be highlighted and the rest should be hidden. Currently it is hard to read and very repetitive. Take this as an example:

http://json.rs/doc/json/enum.JsonValue.html#implementations

Slicing and Negative Indexing

Python has a very powerful slicing syntax, which rust’s range are a mainly a subset of. Let me give some example on why this super convenient and should be supported in std for at least String and Vec:

"foobar"[:3]   == "foo"     # Eliding indeces
"foobar"[1:-2] == "oob"     # Negative means counting from the end
"foobar"[::2]  == "foa"     # The 3rd parameter defines the step
"foobar"[::-1] == "raboof"  # This is the official way of reversing a string
5 Likes

Regarding the generics point (I don’t have much to say on the others), I’ve seen similar suggestions many times, but so far not one that would scale to non-toy examples. The problem is that removing the explicit type parameter definitions is the only obvious way to simplify the syntax, but those explicit type parameters are necessary in all but the most trivial cases to disambiguate between signatures like these:

fn foo<T: Iterator>(x: T, y: T) -> T {}
fn bar<T: Iterator, U: Iterator>(x: T, y: U) -> T {}
fn bar<T: Iterator, U: Iterator>(x: T, y: U) -> U {}
fn bar<T: Iterator, U: Iterator, V: Iterator>(x: T, y: U) -> V {}

We could arbitrarily pick one of those signatures for fn foo(x: <Iterator>, y: <Iterator>) -> <Iterator> {} to desugar into, but I don’t think any one of them is overwhelmingly more common than all of the others.

In my opinion, unless a generics sugar can be applied to most of the generic function signatures newcomers are going to run into, all it would really do is given them an additional syntax to learn that only sometimes works, which will probably make Rust harder to learn rather than easier.

Did you have any specific ideas on how a generics sugar would scale to enough of these non-trivial cases to be worth introducing?

8 Likes

See also the u.r-l.o thread on this blog post: The Rust module system is too confusing - The Rust Programming Language Forum

Well, it's like lifetime elision, isn't it? A heuristic rule that handles the most common case is still worth it even if there are lots of cases it can't handle. In this case, I think syntax that would make functions with one generic argument easier to write would be worth it. I mean, take this case from the stdlib:

pub fn args<I, S>(&mut self, args: I) -> &mut Command
    where I: IntoIterator<Item=S>, S: AsRef<OsStr>

Being able to write that like this would, IMHO, be substantially less clunky:

fn args(&mut self, args: <IntoIterator<Item=AsRef<OsStr>>>) -> &mut Command

Half-baked proposal:

  • Generic parameters can be written inline in the argument list, as @konstin suggested. The thing in the angle brackets is either _ or a trait bound.

    fn takes_anything(arg: <_>) -> () { ... }
    fn takes_iterator(iterator: <Iterator>) -> () { ... }
    fn takes_stuff(h: <Hash>, ss: <Send+Sync>) -> () { ... }
    
  • By default, each such anonymous generic type is distinct, because that's the least surprising option. In other words,

    fn consume_two_iters(i1: <Iterator>, i2: <Iterator>) -> ()

    is sugar for

    fn consume_two_iters<T: Iterator, U: Iterator>(i1: T, i2: U) -> ()

  • But you can give them names if you want. You must do this if you want to reuse a generic in the return type:

    fn zip(i1: <T:Iterator>, i2: T) -> Iterator<(T, T)> { ... }
    fn identity(x: <T:_>) -> T { x }
    
  • Also, you don't have to predeclare generic type names to use them in a where clause:

    fn args(&mut self, args: I) -> &mut Command
        where I: IntoIterator<Item=S>, S: AsRef<OsStr>
    

Relatedly, when impl'ing traits generically, you straight up have to repeat yourself for no good reason:

impl<T> Take<T> { ... }
impl<R: Read> BufReader<R> { ... }
impl<W: Write> fmt::Debug for BufWriter<W> where W: fmt::Debug { ... }

Those could perfectly well be

impl Take<T> { ... }
impl BufReader<R:Read> { ... }
impl fmt::Debug for BufWriter<W:Write> where W: fmt::Debug { ... }

with no loss of expressiveness.

3 Likes

@konstin As long as I’m here, I would like to suggest to you that Python-style extended slices are a great suggestion…but they should be pulled out to their own thread. They don’t seem like a feature that makes the language easier for beginners, to me.

(Also, I would be remiss not to point out that [::-1] cannot be the “official way of reversing a string” in either Python or Rust, because it does the Wrong Thing with arbitrary Unicode. For a simple example, notice what happens to the umlaut when you do "Spın̈al Tap"[::-1] in Python3. A fully Unicode-safe string reversal algorithm would be at least as complicated as the Unicode casefolding algorithm.)

1 Like

Input from Rust newbie. I am working on radio spectrum analyser, just for fun: https://github.com/vchekan/rtl-scanner. My biggest problem for now is how hard it is to write iterator adapters. I know that return type inference is on rust developer’s radar but want to tell that this is so big that it would stop me from recommending it yet to somebody else. I am coming from higher level languages, Ruby, C#, Java and use iterator’s a lot. In C# it is particularly pleasant with code sugaring in “yeld” keyword which make writing iterator’s adapters pleasant, familiar and trivial. In Rust I’ve spent several evenings before I realized that return type inference is a well known problem. It would be nice to admit it in documentation so people do not smash their head against the wall. If there was one problem to choose to be solved, I would choose this one.

Another is documentation. It strangely avoids any assumptions that the reader has programming experience and is trying to explain as for non-programmer of age of 5. Let’s face the fact, Rust in the nearest future is choice of hacker minded people. Please, provide some references to Java and C++ programmers. For example, how traits are different from interfaces. Split section 4 of the book into several subsections please. Right now it contains 80% of the language.

About borrowing: I think most complains are unwarranted. Better explanation will help though. It took me time to start thinking about references as Reader/Writer pattern. Many can read but only one can write. When I hacked device driver, I could not understand why my driver functions should be mutable until I realized that I do not want several functions to send state altering command to my driver simultaneously. Because before I though that if I do not modify memory, than I can mark driver functions as non mutable. This philosophy is not well reflected in the documentation.

What I liked. Borrowing (once you understand the reasoning behind it) Cargo. Huge win. Notion of unstable features. Gives time for function to mature and collect feedback. Realization that pipelining iterator filters most likely will end up with zero overhead.

3 Likes

There is already a plan to extend the impl Trait feature to function parameter, so you would have:

fn take_iterator(iterator: impl Iterator) {
    // ...
}

I think by default all the actual types must be different. If you have to constraint type, you will have to resort on the generic syntax.

Hm, I love <Type> syntax much better than the impl Type: using a keyword to name a type is too loud imo (remember C’s struct). I think that with some work on the parser’s side it won’t conflict with UFCS.

3 Likes

If <Type> is actually unambiguous, then it solves the problem with unusual priority of + in impl Trait1 + Trait2 as well.

1 Like

Suggested this idea at the relevant (I hope :slight_smile: ) issue: https://github.com/rust-lang/rust/issues/34511#issuecomment-278585458. Thanks @konstin!

1 Like

This syntax, in particular, seems like a really bad idea. Specifically: it already means something.

fn main() {
    let len = <[_]>::len(&[1, 2, 3]);
    println!("{}", len);
}

It’s used to explicitly switch from expression context to type context. I think it’d be a bad idea to have the syntax mean two radically different but similar-looking things.

The variable: <Trait> syntax can only be used in function heads, so it has a clearly separated scope from your example. But more importantly it is defined similar to current uses of the angular brackets, e.g. Box<Iterator>, so it should be quite intuitive to someone who has written rust before.

You may be interested in the in-progress rewrite of the book. If you have any feedback on the new version, please file an issue!

Of the last proposition (python slicing syntax) I would only keep the negative bounds; they look really handy and natural. OTOH the “step” conveys something else which does not belong here IMO.

+1 for @zackw 's view of the first proposition.

I’d rather have negative slice bounds cause a panic than silently corrupt the state.

The idea is that negativ indices should become an (imho extremely useful) feature, so there’s no point in panicking.

1 Like

An erroneous calculation resulting in a negative index ought to be dealt with stringently. Remember, Rust is trying to be a safe language.

Therefore such mistakes should, as a first line of defence, be caught at compile-time. And, when this is not possible, at the very least cause a panic so as to not have state corruption creep in. Rust already employs the fail fast principle to great succcess in many cases.

1 Like

If your value is only valid for non-negative integers, then you should have used a usize in the first place. This fully solves this problem on the compiler level, which btw is much more the rust way than panicking. Otherwise, why would you forbid using a great syntactic sugar only because you might have a bug in different part of the code? From the sight of language design this makes no sense. It’s like forbidding ring buffers because you could get to wrapping range by using the wrong index: The bug is still not the buffer but the index computation.

You don’t have to use negative indexing just to have indexing from the end. You can do that with a method, like:

assert_eq!(3, [1, 2, 3].end(0));
assert_eq!(Some(2), [1, 2, 3].get_end(1));

Way more readable than negative indexing, especially since it can be zero indexed (I’ve tried to use list[-0] too many times in Python…).

I’m a wary of negative indexing since it mixes poorly. It’s hard to tell whether v[-4..2] is empty, for example.

Hmm, .rev() does propagate RandomAccessIterator, but it has unstable idx, not Index. I wonder what it would take for something like [1, 2, 3].rev()[0] to work. I agree with the desire to keep using [] for indexing, even backwards, instead of methods that feel second-class.