Use statements & paths (and the epoch proposal)

There has been some previous discussion of ways to make the module system easier to use. While there wasn’t much consensus around any of the proposals in those discussions, one position that did have rather a lot of support was this: a common source of confusion is that use takes paths from the crate root instead of the local crate.

Previous proposals I had made about mod did not address this point of confusion at all. Mainly this is because I found no change that was all of: a) backwards compatible, b) simple to understand, c) an improvement over the status quo. However, if we weaken our restriction around backwards compatibility (and I’ll get to that in a second), I think there is a proposal that is both simple and a clear improvement over the current proposal.

With “import statements” (the use keyword) we perform name resolution in two steps:

  1. First, we look in the local module. (self::) If this name is not found, move to step 2.
  2. Second, we look in the crate root (::).

This would mean that any path which is valid in a module is also valid in an import statement in that module, but that import statements would also be able to bring in items from the crate root (without any ‘absolute path’ boilerplate). This seems to match users’ naive intuitions about what a use statement should do.

(Of course it would mean that use paths would be to some extent more contextual than they are today, since local modules could shadow crate-root modules. This is just lexical scoping though and affects most parts of name resolution today, just not use statements.)

But this would clearly be a breaking change. So how could we go about implementing it?

Pivoting on a new keyword

The most conservative way to make the change is to introduce a new keyword which has the semantics we want, and make no change to use. For example, we could introduce the import keyword:

mod foo {
    mod bar {
        struct Bar;
    }
    
    // works like use
    import baz::Baz;

    // also imports from submodules (and re-exports)
    pub import bar::Bar;
    
    // still an error:
    //  use bar::Bar;
}

mod baz {
    struct Baz;
}

The big downside of this approach is that we now have two keywords with very similar but subtly different semantics. Its no fun to have two tools in the language that do almost the same thing, and to tell people they just shouldn’t use one.

The way to solve this is the “epochs” proposal - we would add the import keyword sometime in the current epoch, and then remove use during the epoch shift. Possibly some day, if we feel we like the use keyword better than whatever alternative we use, we might reintroduce use as an alias. Maybe at another epoch later we would remove import, completing the pivot.

Relying on a mechanical change over

A slightly more radical way to perform the change is to perform it suddenly as a breakage at the epoch point. All current use statements can be mechanically transformed to work under the new semantics: the naive approach would be to prefix all non-self uses with ::, a slightly more advanced approach would be to check the AST for submodules that could be shadowed and only insert the :: if they would be necessary.

This has the downside of being non-incremental. There would be no ‘shared subset’ that worked the same on both epochs, so you couldn’t make the change to support the new epoch in advance of the epoch shift. However, it avoids having redundant forms in the language.

Conclusion

Ultimately, all three options - keep the status quo, new keyword, mechanical transformation - have downsides & its not obvious which would be the best one. I wanted to write this up both to put the idea of making this shift out to the world, and to use it as a case study for the kinds of changes and the strategies to implement them we could consider during an epoch shift.

3 Likes

I think we don’t yet have full information to decide what we want to do with the syntax of use declarations.

I believe that import statements is something which should be managed by the IDE/editor. If you program in Java or Kotlin, you almost never have to write imports by hand. We don’t have this level of support for Rust yet, but when it arrives, some of the current problems may just cease to exist.

It may turn out that these tools do not work as good as in Java, or that nobody uses them, but until we have at least some solution on the tool’s side, I think it’s premature to try to put a fix on the language itself.

In general the “IDEs will fix this” response to ergonomics & usability concerns leaves me feeling pretty frustrated and dissatisfied. It is worthwhile that the language be ergonomic and accessible in itself, regardless of what editor you use. Obviously some editors will always be more ergonomic than others (and this also depends on the background of the individual), but “just use an IDE” is an exclusionary non-answer when a user has a problem & we can’t design the language with that as our fallback.

However, specifically I don’t understand how an IDE could help the problem this is trying to address. This isn’t about use statements being repetitive or boilerplate, but about how their differences from normal paths is a significant impediment to learning. Is your proposal that users will just not learn how use works because its generated for them? I don’t know if there are languages that are used that way, but my expectation is that being able to auto generate a construct does not obviate the need to understand how to use that construct.

6 Likes

Please don't feel frustrated! :slight_smile: I am just trying to bring in more perspective: it seems like most of the Rust team is hugely based towards "unix is my IDE" philosophy which is understandable because good IDE tooling is virtually non-existent outside of the Java world (at least, that's what my background tells me).

Is your proposal that users will just not learn how use works because its generated for them?

I don't propose anything, my point is that IDEs can potentially change a picture significantly when it comes to usability and learnability.

For the problem at hand, I can imagine a hypothetical proposal "require that paths in use items are always absolute and start with '::'": basically, trade of boilerplate vs learning, but that is besides the point :slight_smile:

:heart: :smile:

1 Like

I've been wondering if we can make changes here too, but -- interestingly -- this is exactly the opposite of what I would do. Specifically, I'd probably keep use statements just as they are -- at least at the global level -- but I'd make absolute paths work in function bodies. At least for me, I get confused when I can't copy-n-paste a path from a use into a fn body.

That said, the reason that the rules are what they are is that it is very complex to make the name resolution algorithm work. Knowing precisely what a path refers to (in an absolute sense) without having to probe the results was important and useful to being able to ensure that the algorithm terminates and has a single, defined result and so forth.

It may however be that we can be more lax here, particular in fn bodies. @jseyfried, would you care to weigh in? I don't have this stuff cached in very well but I suspect you do. =)

One point of confusion I also had with use that these statements don’t interact with each other, e.g. I wanted to do:

use std::path;
use path::Path;
use path::PathBuf;

expecting that use is more imperative than declarative, and the first statement would make path immediately available as a name for the second. Now in hindsight I’m not sure if that’s a good idea, but I’m curious if the import would work like this?

I think it could be backwards compatible if you swapped the order:

  1. Look in the crate root
  2. Look in the local module.

Since currently Rust doesn't have the (2) step and it's an error, there can't be any code relying on it.

That would allow the crate root to shadow any name in a lower module though, right? That seems surprising.

This works (since 1.14-1.15 I believe) if you are in the root module, and

    use std::path;
    use self::path::Path;
    use self::path::PathBuf;

works in any module.

1 Like

This is interesting. What annoys me most about the current design is when I need to use self::. Maybe the ideal behavior would be to use the same two step look up rules for all paths? Of course I don’t fully understand the challenges in the name resolution algorithm.

1 Like

If we could make it work, it seems fine. I will say that in Ye Olden Days (before we instituted the current rule), something like use foo::bar could be relative to any point in the ancestor chain (i.e., it could be use self::foo::bar or use super::foo::bar or use super::super::foo::bar etc), and that was very confusing. One thing I really liked about the move to absolute imports was the sudden clarity about where everything was coming from. But being relative to self or root doesn’t seem quite as confusing.

Yes, but it has to be that way for compatibility. Currently we pull from the root only, so if we added local resolution first, you might get something local shadowing the thing in the root, breaking existing code.

This is exactly why I said in the original post that there was no solution that was backwards compatible and (in my estimation) an improvement. I agree with phaylon that having the crate root shadow the local namespace is very surprising.

The difference between "local & global" and "in every parent module" is the sort of thing that to me seems like it might look small on paper, but in practice would be a world of difference.

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.