Add a 'use mod' semantic

I'm currently writing a game in Rust, and it is quite annoying how I have to declare and import modules. Currently, this is what I have to do:

mod camera;
use camera::*;

mod player;
use player::*;

mod collision;
use collision::*;

mod map;
use map::*;

mod enemy;
use enemy::*;

// etc

It would be much more convenient to be able to declare module and import its contents, like so:

use mod camera;
use mod player;
use mod collision;
use mod map;
use mod enemy;

This is much nicer syntax, especially if you have alot of modules.

1 Like
macro_rules! use_mod {
    ($($name:ident),+ $(,)?) => {
        $(
        mod $name;
        use $name::*;
        )*
    }
}

use_mod!(camera);
use_mod!(
    player,
    collision, 
    map, 
    enemy,
);
12 Likes

Even nicer would be if you could just write use player::*; without having to write mod at all. We should fix that.

8 Likes

What if I have something else in scope named player? Seems a little too subtle, though I guess you’d immediately see what happened.

1 Like

This may be more of an opinion, but I prefer imports to be really dumb and obvious, even if it's a couple extra lines. This is a papercut worth fixing.

For game engines and many other applications, nicely grouped preludes work well, but that may be what is already being done.

2 Likes

I'd rather reserve use mod for disambiguated imports:

use(mod) thing::foo; // imports only the module named `foo`
use(type) thing::Bar; // imports only the type named `Bar`
use(trait) serde::Serialize; // imports only the trait `Serialize` (excluding derive macro)
3 Likes

Rust allows shadowing; if you define something local named "player" that should shadow the implicit mod player.

Existence of mod is unusual, and confusing to new users. Most other programming languages only have an equivalent of use (as include/require or implicit), or they have modules name themselves from the inside (as if { mod foo; } not mod foo {}).

So I'd rather not add any more clever syntax here, especially one that mixes mod and use even more. One extra line is not too bad. You may even prefer to list the items explicitly rather than export *.

12 Likes

Why does rust need mod to discover modules in other files? What could actually go wrong if this was implicit? (Apart from backwards compatibility at this point, nothing an edition couldn't deal with.)

You still need mod to declare nested modules inline in a file. And you could have a compiler error on ambiguity (both a file and an inline module with the same name).

I just don't see what sort of foot gun it is that explicit mod is supposed to guard against.

[NB: I implemented much of the support for C++20 modules in CMake.]

Sorry, this is largely a tangent to the thread, but I'd like to provide some experience here.

Sources specifying their module names is the source of a lot of extra complexity in builds. C++ has it because the standard cannot speak about "files" but instead only considers "translation units" (TUs). The core problem is that the build graph is now dependent upon file contents. This means that one must first look at (changed) sources to discover the order they need to be compiled in. While this doesn't change often, it must be considered in order to make a reliable incremental build (crucial in languages like C++ and Rust which tend to have longer compile times). I find it nice that Rust only needs to look at changed files to discover what needs to be included in the build graph and can statically determine compilation order from that. One also needs to consider cases such as "nothing provides such a module" and "multiple files provide that module" when modules are not discovered by reference and the filesystem.

7 Likes

This is very interesting but not an argument against implicit file name based models? Just against allowing the module name to differ from the file name (fully agreed on that one!)

Though this does raise the question of how #[path] to include generated modules from the build tree would work when there are implicit modules. This is used by e.g. protobuf.

2 Likes

I did say it was largely a tangent :slight_smile: .

Not sure why it would need repeated then. The filename is currently named either by the mod including it (possibly via #[path]) where it serves as the discovery mechanism or internally. The latter just seems excessively repetitive to me to include. The issue is "how do you get that file in the list of sources to look at". FWIW, I detest globbing for file discovery because you don't know if the build matches your expectations:

  • experimental files left in the tree?
  • removed file?
  • conflict files (foo.rs.BASE.<PID>.rs is generated by git for mergetool purposes)
  • directory mtime is unreliable, so you always need to readdir
2 Likes

Okay, this is an interesting argument for keeping the status quo. Thank you. Hopefully most of those would lead to build errors though.

  • Experimental/removed files can be an issue today as well: what if a file is removed from the parent file but not removed from the source tree, that can cause just as much problems down the line. So I think you would just get a slightly different set of error conditions that before. It would mean that this is a breaking change that would have to be done at an edition though for sure..
  • Conflict files I don't think would be valid as I don't think modules can have dots in their name.
  • I did not know about issues with directory mtime, I thought that would just lead to false positives not negatives.

All the above points are very interesting, but they are indeed tangential.

Personally, I do not consider these edge cases with experimental, conflict, etc. files as valid. Rust already complains by default about unused items such as variable names, and I see no reason to make an exception to this rule for unreferenced modules.

That said, there is a well-understood and unsurprising way to name the modules explicitly—simply add a dedicated 'project' file that lists the source files in the crate (or add a section to the Cargo.toml file...)

mod allows Rust to discover files without scanning the file system. It also means that unused/stray files aren't getting unexpectedly added to the project, and #[cfg] can be used to load them conditionally.

The mod solution being unusual doesn't mean that it's wrong or that other solutions are better, but it means it's harder to understand for new users. Rust's solution doesn't match people's intuition built on other languages, so they need to change their expectations, and learn "use vs mod". The "use mod" syntax sugar would be one more thing that they could misinterpret, and would need to learn.

OTOH, having use modulename automatically imply mod modulename where necessary would reduce the difference between Rust and other languages, and may save users from having to learn mod, without even losing benefits of mod (e.g. #[cfg(…)] use modulename::*; can still make a module conditionally), so this seems like a better solution to me.

11 Likes

Indeed, these are the downsides of Rust's peculiar design:

  • Manually specifying modules is a redundant approach that provides no additional value. Scanning the file system is not a significant concern and seems like a premature optimisation. What is the actual cost of doing the scan as a percentage of the overall build time? On the other hand, the cost in terms of ergonomics is substantial.

  • The compiler helpfully lints against dead code by default and requires the programmer to annotate unused named items. Ironically, this courtesy does not extend to stray files. The assumption that files cannot be unexpectedly added to a project is precisely what Rust allows. These files can indeed be added to the Git repository. This non-ergonomic default is inconsistent with the aforementioned behavior and should be reconsidered.

I propose that instead of maintaining an overly optimistic perspective and solely focusing on improving the implementation of the current design, we acknowledge and address the inherent flaws within the design itself.

When the 2018 path changes came about, the lang team considered and dismissed the option of removing the use of mod entirely (from standard usage) and relying on filesystem path / glob inclusion instead. The team decided that this was not the direction ideal for Rust, in large part because as a low-level language, using #[cfg] to conditionally include some module subtree is considered to be an important use case for conditional compilation. As opposed to something like go's conventional solution of IIRC *.target.go only getting included for the named target.

It's quite interesting to me to note that major scripting languages actually work in the same manner — for both Python and JavaScript, a file is never parsed unless you import it. The lack of separation between mod and use along with less strict AOT properties and the requirement that all methods be defined in the file that defines a type make this feel a lot different than in Rust (namely, there's no impact to importing a file or not other than names that are imported and heavily discouraged top-level side effects) but it's still how the language works. And since import is itself a dynamic instruction, whether a file is parsed can depend on arbitrary runtime conditions, although good style is generally to not do that.

If I were designing a new language, I would currently choose to use glob inclusion, with all files in a folder contributing to the same namespace. But for Rust, even with a file being mounted at multiple paths being a nightmare for IDEs, "mount" (mod) and "import" (use) being separate concepts is fine, good, and just something that developers need to get used to. use mod would make the confusion worse IMO and exacerbate the learner stumbling point of trying to use mod to access a sibling module when they want use.

While splitting like this is sometimes justified, it generally comes from developers bringing over habits from OOP languages that are non-idiomatic in Rust. You absolutely do not need to have a separate file for each type. Large files are fine (to a point), and unless there's a massive amount of code, to a first order of approximation you should have files only for the externally exposed module tree.

Any time you write a glob import, that's a code smell that maybe you aren't doing the idiomatic thing. Sometimes it is appropriate, but this is generally for public prelude modules that are designed to be glob imported from multiple places. If you're glob importing a private mod, the immediate question is whether it has any reason to be a separate module.

It could even be you want include! instead.

This is generally the purpose that my lib.rs and mod.rs end up taking — they exist to contain documentation and mount the module tree with mod and pub use, with actual code in the other name.rs files. That a module defines both (pub) items and submodules isn't unheard of, but it's certainly an exceptional case and not the norm.

10 Likes

The 2018 decision was flawed, as evidenced by the ongoing user complaints six years later.

Comparing Rust to scripting languages is irrelevant. Rust is a compiled systems language that should be evaluated against its peers, none of which exhibit this unusual design.

Conditional compilation, in particular, is not a strong argument for Rust's approach, considering that other systems languages that have this feature suffer from its drawbacks, such as the well-known "ifndef hell" in C and as I say none require this novel Rust feature.

There are several more effective ways to address this need, and Rust has not adopted any of the lessons learned from other languages. In particular, this violates the separation of concerns principle. A better approach would be to have a dedicated build file with the conditional compilation logic. Walter Bright (creator of the D language) has written several blog posts on this topic. To the best of my knowledge, all compiled languages nowadays aschew inline ifndef style inline conditional compilation in favour of a separate build script.

In general, the comments regarding object-oriented programming (OOP) languages and non-idiomatic Rust are inaccurate and misattributed. Regardless of the programming paradigm, large files should be avoided as they harm readability and increase the probability of errors (yes, even in Rust). Furthermore, encapsulation is not solely an OOP concept but rather a widely accepted programming practice that transcends specific paradigms.

The notion that glob imports constitute a code smell has been shown to be false. Despite evidence to the contrary, this misconception persists in various programming languages. Contrary to popular belief, having an extensive list of specific imports or use statements at the beginning of each file does not enhance readability. In fact, such lists are typically collapsed and concealed by editors and IDEs.

That is indeed a good approach. Regrettably, this is not as clean for the binary use case the main function and its use statements need to be placed with the mod statements in the main.rs file. Additionally, this is not codified and there are various code organisation approaches in the ecosystem, which can be confusing for newcomers.

1 Like

What evidence? Do you have a link to any peer reviewed studies to back this up? You are making some strong claims with phrases like "has been shown" and "despite evidence". Such claims should be backed up with solid sources!

I don't believe the main complaint about glob imports is about readability, but rather that it is brittle, specifically that it can cause your code to stop compiling with a newer version of a dependency that introduces additional symbols. In particular it means your code can break even when upgrading across semver compatible versions.

14 Likes

That, and also that it can result in code compiling when you should not have expected it to; I've spent too much time debugging code with multiple glob imports (e.g. use foo::*; use bar::*), where the code author was adamant that the symbol they were using came from foo, but it in fact came from bar, resulting in a bug.

6 Likes