Relative paths in Rust 2018

Thanks. That seems totally fine for a first cut. What would you think about taking this route?

Incidentally, this is true for me as well; I only ever use super in test submodules, to import things to be tested, and never use self.

3 Likes

FWIW, this has also been my experience, and I very much liked the clarity of the current Rust 2018 design for use statements (in the sense that they are completely "standalone" and do not require context to interpret).

So personally, I'm quite torn. I think either approach is a huge step forward compared to Rust 2015, and I'd be happy with either.

I do think that the proposal here is more ergonomic, and the uniform model of paths helps eliminate a possible source of confusion (and mistakes that even seasoned Rustaceans routinely make). But it's not a "game changer" and definitely has downsides.

3 Likes

I want to clarify that Iā€™m not against what is proposed here. My only concerns are about deadlines and the time it will take to implement forward-compat rules, etc.

Basically, if @nikomatsakis is happy, I am happy.

4 Likes

LGTM. It matches expectations I had before learning how modules actually work, so itā€™d definitely lower the learning curve for me.

use statements being relative and interacting with each other is such a common expectation, that the unexpected need for self:: was used in Underhanded Rust contest:

fn main() {
    use a as c;
    use c::Bank;
    let p = c::Person {};
    p.pay();
}
3 Likes

I'm sure everyone wants the best possible outcome for Rust long term, so if this is a key feature to be landed in Rust 2018, should we consider bumping back the release date of Rust 2018 to give proper time to evaluate and implement the very best solution (whichever that is)? I'd hate for a couple months of deadline pressure to negatively impact Rust for 3+ years.

From https://github.com/rust-lang/rfcs/blob/master/text/2314-roadmap-2018.md:

The intent is to ship Rust 2018 in the latter part of the year. The tentative target date is the 1.29 release, which goes into beta on 2018-08-02 and ships on 2018-09-13. That gives us approximately six months to put the product together.

If Rust 2018 was bumped into December (2018-12), would that really hurt that much? I've liked how the current rolling release model hasn't led to this kind of deadline-compromise so far for Rust.

9 Likes

Yeah, that would be a shame.

So, to return to @matklad's question, @aturon convinced me that indeed this same problem around macro expansion is present in today's design (because it leverages the prelude). Specifically, we currently have a fallback mechanism where extern crate names get lower precedence than other names. So if/when we support invoking macros via paths, it seems code like this (where crate1 and crate2 are extern crates in the prelude):

crate1::foo! { ... }
crate2::bar! { ... }

still cannot make progress, even in the existing Rust 2018 design with no changes.

(If we expand foo!, then it could shadow crate2, but it may be that crate2::bar! would shadow crate1 as well.)

That makes me feel worse overall :slight_smile: but better about this proposal, since I think I agree with @aturon that this proposal doesn't introduce any new ambiguities that were not already present.

I do think it speaks to the need to have explicit forms where the full path is clear. My impression was that these would be:

  • crate::foo -- foo is relative to the path root (ignores prelude)
  • self::foo -- foo is a path relative to this module (ignores prelude)
  • ::foo::bar -- foo is a crate name, and bar is relative to that crate
  • ::crate::bar -- relative to the crate root (just for consistency, so that crate can be used anywhere that a crate name is used)

This means that in the above scenario we can encourage users to rewrite to ::crate1::foo.

(We could also potentially do things like having procedural macros (and declarative ones, perhaps) declare the names they export -- that could tell us there is a correct execution order.)

3 Likes

Note that nobody implemented this interaction between prelude and macro expansion specifically, so there's no guarantee that it currently work correctly.
(libstd prelude never had macros it, so was never required in older Rust versions and it was omitted in implementation of feature(extern_prelude) because the implementation was a quick hack to start experimenting, and it's still unstable.)

We really need to support this case though, because macro paths (both single- and multi-segment) are resolved in lexical scope (this includes prelude) and there's no way to realistically change the rules here.

@jseyfried implemented a number of expansion/shadowing restrictions, but I can't guarantee they cover all cases and can't tell how they are related to restrictions necessary for the "full 1path" scheme.

2 Likes

As a Python 2 developer at dayjob, this made me very wary. While we haven't made a concerted effort to port to Python 3 yet, by far our most common from __future__ statement is absolute_imports, because some conflicts are just too much of a pain to solve without it.

However, you do bring up some mitigating factors later on.

For one, there is an explicit syntax for absolute imports, making it possible to disambiguate. This is one of the big things missing in Python; once you encounter the conflict, there's no good way around it without renaming something.

One other mitigating factor that you don't mention is that in Rust, you have to declare a module with mod foo, while in Python it's implicit based on the path name. That means that in Rust, to get ambiguity from a module as the same name as a crate or prelude item, you will have both of the declarations in the same file, which makes it a lot easier to see. Many of the frustrating issues in Python come from some refactoring, that suddenly leads to this issue, and it's with a module you've forgotten is even present on disk, and it's just complaining about some item not existing that you know exists in the module of the same name that you're looking for.

Another difference between Rust and Python that I think would mitigate this is how relative imports are treated in Rust and Python. In Rust, they are relative to self; so use mod::something is using something that is defined within the current module, and which is a sub-part of the current module; it's pretty obvious that it's right there. In Python, they are relative by directory path; you are importing from a sibling module, unless you are in an __init__.py file for the root of the package. So from mod import something is more equivalent to use super::mod::something in Rust. The fact that you could accidentally import from a sibling module that you didn't even know about (since maybe it's a big application, with lots of little modules maintained by different people) is one of the things that makes this a problem in Python, but a module that is in scope within a Rust module is something that has either been declared within it, or explicitly imported, which also helps to make it more obvious what you're referring to, and where potential conflicts might be coming from, than in Python.

Making it a hard error also helps with issues in which you accidentally run into ambiguity, as then you will immediately see that there's an ambiguity, not a statement that some item is not found in a module that you expect to find it.

If the hard error is relaxed, and instead there is a disambiguation order, you will want to ensure that error messages about items not existing are explicit about the exact module being discussed, and they should probably also show the other module which does have the named item that was shadowed, to make it easy to debug the problem.

Glob imports can also make things a lot harder to track down in these conflicting cases. Here's a case we had recently in our Python codebase that blocked another developer for a while, and I needed to help sort it out, in part because Python doesn't provide very good errors when things are shadowed. I'll use some simple aliases for the modules in question.

  • app
    • gui
      • async_utils
      • particular_window
    • common
      • async_utils
    • messages

In particular_window, we had:

from app.gui import async_utils
from app.messages import *

But in messages, we had:

from app.common import *

Which meant that app.common.async_utils now shadowed the earlier import of app.gui.async_utils.

Let's see what will save us from this issue in Rust:

  1. Imports are not exported publicly by default, so the fact that someone had done from app.common import * wouldn't actually re-export things.

  2. Rust actually errors out on importing the same name into the same scope more than once, rather than shadowing.

  3. After writing this example out, I realize it's not actually an example of ambiguous relative vs. absolute item paths, but just another downside of glob imports and shadowing. However, glob imports could lead to this kind of thing happening more often, since you would be able to have:

    use somecrate::prelude::*;
    use util;
    

    And then get ambiguity errors if somecrate::prelude includes util. Of course, glob imports already have issues with conflicting names, this just adds one more place where they could.

On the whole, I think that given the fact that Rust has explicit exporting of names publicly, better error reporting by not allowing imports to shadow each other, and the fact that there is explicit syntax for both types of import, I think that on the whole the proposal sounds more reasonable in Rust than it is in Python 2.

One reason why it might be more desirable in Rust than in Python is also that they syntax for disambiguating is more heavyweight; in Python, it's from name import something vs. from .name import something for absolute vs. relative, while in Rust with only explicit syntax it would be use ::name::something (or even use extern::name::something, depending on the exact syntax chosen) vs. use self::name::something, which is a lot more cumbersome than just use name::something.

One of the biggest reasons I'd be in favor of this proposal is that it would increase the compatibility with Rust 2015 (for those cases where things worked at the top level of a crate without self, which I would imagine a decent number of people with simple single-module applications might have done), and decrease the migration burden/churn. If done along with the backwards-compatible :: behavior, very little code would be required to change. I think the only required changes would be places where the new design introduced an ambiguity that wasn't present in 2015 so you would have to disambiguate, and there would be optional changes in 2018 where you could drop extra :: qualifiers or shift everything to the "absolute imports everywhere" style more easily.

In general, even in editions which allow some more types of breaking change than ordinary releases, I think it's best to keep mandatory code churn low, as even fairly simple mechanical changes can make "git blame" information harder to trace, have the possibility of introducing bugs if there was some manual component to the transition process, make the code no longer compatible with older compilers that people might be using on LTS Linux distros, and so on.

5 Likes

Thinking about it more, I am really not happy with what we have currently in 2018, and I personally would not want to stabilize it, for the following reasons:

  • I agree with @phaylon that absolute paths without :: outside of use statements regress readability.
  • I agree with @gbutler that "is this path absolute?" should preferably be determined purely syntactically, and not depend on the set of visible identifiers/extern crates.
  • Although initially I was enthusiastic about the proposal in this thread, I now worry a lot that paths in use and elsewhere can have different semantics (previously, their difference was purely syntactic), which looks like it actually moves us away from 1path?
  • The current implementation breaks existing code. This is definitely not acceptable for the final version of the edition. Perhaps we shouldn't have included in it preview yet as well? I think a huge value of preview is an early testing of the transition experience. If today people transition via fixing a bazillion of errors, then the fallback which we (or should I say @petrochenkov :slight_smile: ) implement tomorrow will get less testing.

The core feature which makes me feel uneasy is the addition of extern crates to the prelude (which I was enthusiastic initially about as well). I think if we remove this feature, we won't regress ergonomics measurably, reduce the numebr of changes in this edition, and will keep the door open both to adding them to prelude later (which gives us perceived one path, because absolute and relative paths will looks the same syntactically) and to making :: mandatory in use (which gives us real one path).

The only thing we lose by removing extern crates from the prelude is that you'll have to write ::regex::Regex instead of regex::Regex in function bodies if you don't import regex. This seems to be a relatively rare use-case, and you have to write the leading :: today anyway.

That is, the path reform strives to achieve two goals:

  • change the root namespace from "root module" to "set of crates", to improve readability and mental model,
  • make paths in and outside use work the same in more cases.

The current solution to the first problem ([::]crate:: prefix) looks robust. The solutions to the second problems seem more ad-hock, and risk introducing new problems.

4 Likes

The ā€œ1pathā€ model (aka ā€œimports are resolved in lexical scopeā€), if itā€™s feasible, would also naturally provides a solution for some old existing issues like

2 Likes

That's one of the features of the module changes I'm most excited about.

On the contrary, I expect to often reference paths from external crates without a use, for things I only need once. Ideally I'd like to never write :: if at all possible.

9 Likes

This is a trade off. We trade two characters for an ability to see if the path is absolute without context.

As the difference in ergonomics is really small, I think that if ::foo::Bar is rare today (which I think is true), then foo::Bar will be rare as well.

A separate argument against extern crates in prelude: this will make code completion less useful, because it will pollute the global namespace with a lot of lowercase identifiers, which would clash with local variables. In contrast, with explicit :: prefix/imports, code completion has a much better idea of which identifiers are relevant.

13 Likes

I'm really confused:

This is not only acceptable, it's intentional! It's a new mental model in a new edition. Making (automated, relatively narrow) breaking changes limits implementation complexity and optimizes the readability & learnability of the new edition, because people can't accidentally write old-style code that doesn't fit into the mental model.

And yet, while you don't want to break existing code, you also say this:

Both of these are way more breaking than the other changes! So I'm not exactly sure what you're advocating here.


For what it's worth, I'm happy either way regarding extern crates in the prelude. Leaving them out makes it a bit clearer where things come from, but putting them in is more powerful than merely removing a few ::s:

  • As @aturon has described it, the RFC version of the changes "breaks" paths in the root module to make them work like paths in sub-modules; the prelude version "fixes" paths in sub-modules to make them work like paths in the root module.
  • The mental model of "lexical" scope being inherited from parent modules has plenty of precedent, and while that's not exactly what we're getting here, the precise way in which we differ is also the precise way in which Java works. (That is, there are no nested packages in Java, they only fake it with .-ed names, making only the "top level" heritable.)
  • It gets us a valuable half of 1path without forcing leading :: in uses. That :: is noisy, redundant the vast majority of the time, doesn't help the mental model, and overall sacrifices ergonomics for edge cases.

That first point is also a valuable argument for this relative path proposal. use Enum::*; and pub use some_module::foo (without self::) are possible in 2015 root modules, and making it impossible everywhere is, while at least consistent, an unfortunate regression in ergonomics. Getting consistency by making it possible everywhere, while slightly less clear about where things come from, is not without its benefits.

4 Likes

I think we can only make existing warnings into error with edition change? The epoch RFC says:

When opting in to a new epoch, existing deprecations may turn into hard errors, and the compiler may take advantage of that fact to repurpose existing usage, e.g. by introducing a new keyword. This is the only kind of breaking change a epoch opt-in can make.

This is not the case with the current implementation: no fallback is implemented, so code breaks without a warning.

We can add a warning.

1 Like

The intended strategy, from what I understand, is to add warnings to the 2015 edition, as @centril says. Thereā€™s nothing about the current design that prevents thisā€”you can adjust your 2015-edition code so that it works the same across both editions, thus silencing the warnings; flip the switch; then begin using the new functionality.

1 Like

We have warnings (with suggestions that can be automatically applied!). But you have to enable them explicitly -- they are in the rust_2018_migration lint group (I think that's the name, anyway). This is what happens when you run rustfix.

2 Likes

I stand corrected, great!

Adding

#![feature(rust_2018_preview)]
#![warn(rust_2018_compatibility)]

to the lib.rs indeed just shows the warnings, without breaking the build

1 Like