I Don't Like Imports

Posted

I think this is an unusual opinion, so I thought I would share.

I prefer not to import external symbols into the local scope. For example, I prefer:

let body = reqwest::get("https://kevincox.ca/feed.atom").await?.text().await?;

over

use reqwest::get; // At the top of the file.

// Inside some function.
let body = get("https://kevincox.ca/feed.atom").await?.text().await?;

Unambiguous

There are various reasons for this, but all the most important ones boil down to being unambiguous. When I am reading code I see library::Foo and know that it comes from library. I see crate::Foo and know it comes from elsewhere in my code and if I see just Foo I know it is from the current file (and possibly private, so I could be a bit more careful with how I use the API). I don’t need to guess or lookup what Foo I am working with. (And when I do inevitably guess I don’t need to have that background process asking “did I guess wrong?”)

I find when I am working on codebases where the convention is to import things I am very frequently tracking down the definition of some type or function as I don’t know whether it is local or from which library. This is especially painful when reading or reviewing code in a web interface. If you are reading a patch the imports are likely not expanded (if they didn’t change) and even if you have the whole file expanded you need to check if the referenced value is the global import from the top of the file or some local shadow.

Copy + Paste

Another thing I love about fully qualified names is that I can just copy and paste code around or move it from file to file without needing to figure out which imports are needed in the new file (and inevitably importing the wrong Error at least a few times). For the most part code in other files and examples online can just be pasted into the file and work.

It’s not some dramatic productivity boon, but it certainly makes cleanups and refactors much smoother and a frustrating tedious step that is necessary when working with import-heavy code.

But The Names Are So Long!

In some cases they are. But more often than not this is just a poor choice by the library author (in my opinion). For example in Rust the standard hash-map is std::collections::HashMap. But even that is just an alias. If you want to work on an Entry you are now typing std::collections::hash_map::Entry. I see no reason it couldn’t have been std::HashMap and std::HashMapEntry or std::HashMap::Entry. (I don’t mind a small amount of nesting when it is very strongly associated, but still don’t really see much point.)

I much prefer C++‘s style where almost everything is just under std. For example std::unordered_map. An iterator is slightly nested at std::unordered_map::iterator but if you ask me that is still a pretty reasonable name. And I would much rather see that in code than wonder what iterator we are working with.

If it hurts stop hitting yourself, don’t find a way to numb the pain.

Conflicts

I see this come up as an argument against, and I am pretty dumbfounded. Some people see that you have std::io::Error and std::fmt::Error. So they can’t both be std::Error. But that is kind of my point! When I see code referencing Error I don’t know which of error type from the dozens libraries that the code might be using it is! That is what makes the {library}::{name} pattern so great, it is conflict free. If I use that throughout my code I never have to worry if Write is std::fmt::Write, std::io::Write or crate::Write because it is spelt out! I would much rather the types be actually named std::FmtWrite and have everyone “agree” on the name (because they have no choice) than half the time having it called Write and half the time having it called FmtWrite, WriteFmt or DebugWrite (because some other Write was in scope and the programmer needed to come up with their own name).

This isn’t an imagined problem. I regularly find myself reading rustdoc documentation and needing to hover over a type name so that my browser displays the fully qualified path in the link target! I feel that which Error or which Write is being used is important enough that it be immediately visible, in both code and generated documentation.

Avoiding imports means that the library authors are responsible for solving conflicts. And we are back to the fact that when I see a type or function it is unambiguous and I don’t have to waste brain cycles figuring out what Error I am looking at.

But Sometimes They Are Really Long

I’m not a stickler to this rule. There are some cases where I will use imports. Sometimes the names are just too long and repetitive. In that case I strongly prefer a local use with a short scope. This ensures that I don’t need to look far to resolve the ambiguity.

use sentry::integrations::tracing::EventFilter;
match *meta.level() {
	tracing::Level::ERROR => EventFilter::Event,
	tracing::Level::WARN => {
		if module.starts_with("hickory_proto::") {
			EventFilter::Ignore
		} else {
			EventFilter::Event
		}
	}
	tracing::Level::INFO => EventFilter::Ignore,
	tracing::Level::DEBUG => EventFilter::Ignore,
	tracing::Level::TRACE => EventFilter::Ignore,
}

(And more often than not when I run into this case it isn’t because the name is intrinsically high-entropy, but because someone got carried away with hierarchy. Stop hitting yourself.)

Rust-Specific Notes

I’ve been using Rust as an example but been trying to avoid being Rust-specific. But it is my primary language, so I do have some thoughts to share. If you don’t care about Rust you can stop reading now.

Avoiding imports in Rust is going against the grain. I would recommend that all crates provide a flat API. However, for many projects avoiding use isn’t the right choice. Just follow the common coding convention and be happy. To see a real-world example of both a flat API and avoiding use you can check out rl-core which is a small project where I am quite glad I chose to break convention. A lot of my “for-me” or “internal” code is written in a similar style.

Provide a Flat API

By default, Rust wants to ship your file structure as your exports (Conway’s law). So for example mylib::Foo needs to be defined in src/lib.rs and mylib::submodule::Bar needs to be defined in src/submodule.rs or src/submodule/mod.rs.

With a single re-export per module this can be solved!

mod config; pub use config::*;
mod tracker; pub use tracker::*;

One extra statement to free you from hierarchy. Re-exports are well-supported by Rust so rustdoc and the compiler will pick mylib::Config as the path to show users rather than mylib::config::Config. (I don’t know the exact heuristics but when working like this there will be a single public export, so there isn’t much cleverness needed.)

pub use also only sets the maximum export visibility. This means that pub, pub(crate) and private things in the module will have the expected visibility. So the definitions in the files look the same as they normally would and the export lines only need to be added when you add or remove modules.

The only real downside of this approach is the src/lib.rs will see everything as local and can reference Config rather than crate::Config. But this is a very minor issue and the solution is not to put anything into lib.rs. (Keeping only module definitions and re-exports in lib.rs is a very common pattern anyways.)

The nice thing about just re-exporting everything is that it also frees you from deciding where to put things based on what you want the exported name to be. You don’t need to put std::collections::hash_map::HashMap and std::collections::hash_map::Entry in the same file to have them appear at the same place, you can put them into separate files if you wish or the same file if you need access to non-public APIs. (The Rust std implementation does actually re-export these from various places, re-exports are very common and not something to be feared.)

Personally I just put every single type and most functions into separate files. I find that it keeps everything simple and organized and I prefer switching between different files than different parts of a file when going back and forth. So for example in rl-core I define rl_core::Config in src/config.rs and rl_core::Tracker in lib/tracker.rs.

Traits

Traits are where things start to get hairy. To call a trait method as a method the trait needs to be in scope. So if you want to call foo.a_method() where a_method is somelib::TheType::a_method you need to use somelib::TheType.

However you always have the option to call the method using a fully-qualified path. For example somelib::TheType::a_method(&foo) or <Foo as somelib::TheType>::static_func(). For the occasional method call I often do this because I do like seeing where .a_method() comes from, it isn’t always clear if it is a method directly on foo or which trait defined it. However nesting a few calls with this quickly becomes messy and sometimes I make a practical choice to just use the use. I prefer to do this just for a short scope to help keep the origin information close to the code, but for very common traits I will sometimes add a global import like use std::io::Write as _ (make the trait methods available but don’t actually pull the name into scope).

std

The elephant in the room is that in Rust std (and core) love love love hierarchy and lots of names are super long. I wish I could have a long talk with whoever decided it was really important that std::sync::atomic really needed to be nested under sync. I think we would have been fine to reserve the top level std::atomic for these operations even though they are synchronization related.

It is what it is. I’m not going to advocate for The Great std Naming Reform even if I would prefer it. We have a pattern, and we can deal with it. Most of the names aren’t so long that they need to be imported so most of the time I just spell them out. Occasionally I find myself repeating a long name frequently and just import it. I have yet to be stricken by a bolt of lightning from the heavens.