D's selective imports have effects you may not want

Posted 2023-11-06

The implementation of selective imports is lowering to an alias. This can be trouble, especially when used inside a struct or class definition.

Core D Development Statistics

In the community

Community announcements

See more at the announce forum.

Selective imports

Consider the following code:

struct S {
        import std.conv : to;

        int a;

        string toString() {
                return to!string(a);
        }
}

void main() {
        import std.conv;
        S s = S();
        auto str = s.to!string();
        assert(str == "0");
}

Do you think it works? Well, the fact I'm asking you that question surely hints that there's something strange about it, and indeed, there is: this code does not compile.

If you change the import std.conv : to; over to plain simple import std.conv;, it does compile, and works as expected. But with the selective import, you get a sea of error messages, which, when deciphered, tell you that there's missing required arguments given to the to function. But, why? You passed a UFCS argument, right?

Well, that's where things get tricky. Remember, a selective import is implemented internally with aliases. When you write import std.conv : to;, the compiler acts as if you wrote alias to = std.conv.to; after a privately renamed static import. An alias is considered a member name of the scope where it is declared.

This has some effects that are sometimes useful: you can use it to merge overload sets and to make one imported name a priority over another. But it also has effects that are not very useful, like adding the name to reflection results and to the local name lookup.

And that's what happens with the sample code: when you do s.to, member functions take precedence over UFCS. The s there is used as a namespace lookup, not a function argument. It then looks up the name to in there, finds it, sees that it does not need a this argument and thus doesn't pass s anymore.

When you do an ordinary import, the only name added to the local scope is the package std - yes, even with the regular import, you can auto str = s.std.conv.to!string(s); and it compiles; using s.std... as a namespace lookup through the s variable, then I have to explicitly pass the s argument as well since UFCS was not applied. All other names go through a second phase of import searches, which is known to not be a member function anymore so UFCS is permitted.

I advise avoiding selective imports. Many people use them in an attempt to avoid adding too much to the namespace, but ironically, as we saw here, they actually tend to have more effects on the namespace than a typical import. If you limit your use of them to top level modules or local imports in functions, it helps limit the damage - putting them in structs and classes are where much of the confusion is liable to come in - but even there, the behavior with overload set merging and preferred names, while often useful, is probably better to just write out with explicit intent, since many readers of D code may not realize these effects come from imports as well as aliases.

If you like the list of where names come from, a few potential alternatives are simple, ordinary comments, using the imported!"module.name".member feature in newer druntimes, or asking the compiler when you need it with things like pragma(msg, SomeName.mangleof) or (i hate it but it can work here for this) std.traits.fullyQualifiedName and friends.

I'd be kinda nice if the selective import didn't have side effects, but it does, and this is unlikely to change. So if you use them, at least be aware of things like this and probably try to avoid them inside struct/class/interface/etc. definitions.