[Pitch] Module selectors

Ah, I'd expect this feature to support disambiguation by the alternative spellings A::S.R and B::S.R.

(And if you further have modules C and D both vending A::S.R, it would be possible for MyModule to do file-level import and typealiasing just like A and B do in order to disambiguate them—inelegant, but an exactly equal amount of inelegance as what's actually required to dig you into the hole in the first place.)

If we feel that these scenarios will arise in practice with reasonable enough frequency, it may warrant nested qualification. But I think hoisting :: is sufficient to make what is currently inexpressible by any means finally expressible, and requiring contortions to call what requires contortions to declare as a trade-off for a simpler overall feature seems...fine or even actively worthwhile.

2 Likes

I think using parentheses for composition should “just work”:

(ModuleA::Type1).(ModuleB::Type2).(ModuleC::method)(args)

It might look ugly, but that’s a good thing. This is not a preferred pattern, so a bit of syntactic salt to discourage it is desirable.

But having it be possible, and in a clear composable manner, using parentheses in their natural way, can solve real problems

13 Likes

I don't feel strongly either way, but if this introduces a new syntax that affects type names, we may need to make changes to accommodate it in Swift Testing (which deals frequently with fully-qualified type names.) So please reach out to us before the feature is finalized so we can make sure to have test coverage for it, check if anything breaks in our macros, etc.

3 Likes

Sure, if such support falls out naturally from otherwise desired rules and their implementation, I'd agree we shouldn't ban it. If it doesn't actually compose without, for example, special implementation effort or having to invent further disambiguation rules, then I would argue that it ought to be independently motivated. In either case—possible or no—I think we're on the same page that it doesn't feel like it should be the preferred pattern, and my point is that we should make any tweaks necessary that hoisting the qualifier up to the front is possible.

It is certainly possible to imagine ourselves in a hole where (ModuleA::Type1).(ModuleB::Type2).(ModuleC::method)(args) would naturally be the least gross call site—but, clearly, something has gone awry. I for one would be interested in hearing about real-world scenarios where folks today are actually finding themselves in need of this sort of successive qualification.

Sure. If you want to get a head start thinking about this, the draft SwiftSyntax PR is here: SwiftSyntax support for module selectors by beccadax · Pull Request #3091 · swiftlang/swift-syntax · GitHub

1 Like

I'll take a look and message you if anything jumps out. Thanks!

Maybe I'm too close to the current design, but I find it really weird to imagine that in something like IonThruster::Spacecraft.Engine, the IonThruster part applies to Engine (which is pretty far away from it) but not to Spacecraft (which is right next to it). If we wanted the module name to apply to the last element, it'd make more sense to choose a postfix syntax (something like Spacecraft.Engine::IonThruster, though probably with a different operator) so the module is adjacent to the name it's qualifying—except that everywhere else in the language, nesting is expressed left-to-right, so that'd be weird in a different way.

I also think it's the wrong default. While the extension use case is sometimes needed and it's really nice to fit it in cleanly, I think people are going to be qualifying top-level names a lot more often than extension members. They're going to write Spacecraft::Spacecraft.Engine when they actually wanted (Spacecraft::Spacecraft).Engine and get the wrong behavior.

(edit: Realizing that this point about the syntax for top-level names being more important is something I should articulate in the proposal.)

In a construction like this, IonThruster ends up even further from the type it actually applies to. And the deeper the nesting, the more you have to stretch your memory and your visual parsing skills to match up modules to names. I really don't think this is better.

Actually, I don't think this statement is fair at all. Remember—one of the main use cases for module selectors is so that mechanically-generated code (module interfaces, macros, etc.) can defensively qualify everything to guard against potential conflicts that are difficult or impossible for them to detect. After all, module interfaces have to compile correctly even if their dependencies have independently evolved, and macros have access to very little semantic information. That means the "qualify everything" use case is pretty important.


Update: I've revised the proposal to move the justification for using :: into "Proposed solution" and to address alternative syntaxes more thoroughly:

8 Likes

Just because I don't see it in the list, is a lone : unworkable/ambiguous due to its use in other positions? My main gripe with :: is that it's two characters.

1 Like

It’s ambiguous with an argument label if it’s used in an argument list:

foo(bar:baz)    // ???

We could maybe disambiguate with a whitespace-sensitive rule, but that would be source-breaking.

2 Likes

Thinking about it for a couple extra minutes also yielded:

extension XCTest:XCTestCase { ... }
6 Likes

Under my proposal, Spacecraft::Spacecraft.Engine and (Spacecraft::Spacecraft).Engine are functionally equivalent in the case of importing two modules that both declare a top-level Spacecraft type. The first means “the Engine that is declared in the Spacecraft module and is nested within a Spacecraft type that is declared, extended, or re-exported by the Spacecraft module”, while the latter means “the Engine type nested within the Spacecraft type that is declared or re-exported by the Spacecraft module.” These only differ if the Spacecraft module re-exports or has a cross-import overlay for a module that the client has also imported. Using less ambiguous names:

//
// module ModuleA
//
struct Struct { }


//
// module ModuleB
//
@_exported import ModuleA

extension Struct {
  struct Nested { }
}


//
// client module
//

import ModuleA
import ModuleB

let _: ModuleA::Struct.Nested // error: declaration of 'Struct.Nested' not found in module 'ModuleA'
let _: (ModuleA::Struct).Nested // ok, refers to declaration of `Nested` in ModuleB’s extension of `Struct` that is re-exported from ModuleA

I guess programmatically-generated code (e.g. swiftinterface files) might wind up using the parenthesized version more frequently? I don’t grok the typical shape of swiftinterface declarations well enough to say.

For human programmers, I predict a far more likely scenario is the XCTest one, where you otherwise can’t access any of a module’s top-level non-type declarations because there’s a shadowing name in the way. Because types shadow modules, XCTest::XCTest.someMember is not very useful—it means the same thing as XCTest.someMember. (The exception is the unlikely-IMO case where you have imported two different modules that both declare someMember in extensions of class XCTest.)

The thing neither of our proposals has, but which other languages enjoy, is the ability at each dot or double-colon to know which exact symbol is being looked within. In C++, for example, you know that foo::bar::baz always means to look in the foo namespace for a symbol called bar, then to look in that symbol for a symbol called baz. Of course, you don’t know which library the definition lives in, since namespaces are not scoped to libraries, but usually that’s not the concern.

Because Swift namespaces symbols by module, but allows modules to extend types from other modules, I think it’s impossible to define a syntax with this behavior. That said, I believe my alternative has an edge over yours in that once you read the :: you know exactly which module the symbol being sought must be found in. In the design as pitched, you must proceed through the symbol, and if you happen to encounter a ::, you must mentally segregate the thing that immediately follows it from all the other .-separated components of the symbol name.

1 Like

This statement is true, but isn't what I want to optimize for; what I want is to resolve each component independently. Consider that if we're going to cover member lookups, we should handle the case where neither the base nor the member is a type:

mission.acquireBoosters()?.NASAEngines::measureExhaust()

How would you write this in prefix syntax?

2 Likes

If you have a package like this:

Module A:

struct T {}

Module B:

protocol P {
    func f()
}

Module C:

import B
extension P {
    func f() {}
}

Module D:

import A
import B
import C
extension T: P {}

Is T.f qualified by T.B::f, T.C::f, or T.D::f? Would some combination of those be valid? All three modules play a part in giving T its f member — module B defines f in P's interface, module C gives f's implementation, and module D conforms T to P.

4 Likes

I’ll also throw in that this syntax is used by C++ today for members as well as the more familiar top-level paths—not quite with the same behavior, but for a similar purpose:

struct B { virtual void foo(); };
 
struct D : B { void foo() override; };
 
int main()
{
    D x;
    B& b = x;
 
    b.foo();    // Calls D::foo (virtual dispatch)
    b.B::foo(); // Calls B::foo (static dispatch)
}

This is a precedent for :: binding tighter than . (also seen in the more mundane std::foo::MIN.abs() in both C++ and Rust), but also an anti-precedent in that the behavior is different.

3 Likes

Thank you @beccadax for raising this again! I remember seeing this situation reported in 2016 in this post: Fixing modules that contain a type with the same name

Your proposal makes sense to me.

I agree with this.

ModuleA::Foo.bar should simply be a shorthand for (ModuleA::Foo).bar. If there is any situation where they mean different things, then that is a readability problem. If further disambiguation is needed, then additional module selectors can be applied to later path components.

Personally, I would be fine with parentheses being required for module selectors on non-leading path components. As in, Foo.(ModuleA::bar) would be allowed, but Foo.ModuleA::bar would be an error.

I’m not tied to that idea, but I do think that requiring parentheses in non-leading positions would improve readability.

Edit: more accurately, Foo.ModuleA::bar would parse as (Foo.ModuleA)::bar, which would interpret Foo.ModuleA as the name of a module.

13 Likes

I don't presume to speak for @ksluder, but what I'm trying to argue at least is that in IonThruster::Spacecraft.Engine, the IonThruster part does apply to the whole thing and not to just Engine.

One would do this by reading :: as meaning something other than (I think) what you are proposing—that is, it doesn't require the LHS of the operator to be the module in which the RHS is declared; rather, it answers the question (in this case), "In the 'view' of the LHS module (IonThruster), what type is referred to at the top-level when one writes the RHS (Spacecraft.Engine)?"

Independently of any IonThruster-versus-Spacecraft shenanigans, I think reading :: as referring to the declaring module only is more fussy than we need or want to be. Beginners almost never know the difference between the standard library and Foundation, for example, and even advanced users shouldn't have to carry exact knowledge of who originally declared what through a chain of modules D-imports-C-imports-B-imports-A if it's a distinction without a difference.

To me, reading Foundation::Array as saying, "step into the shoes of Foundation and give me what that module thinks of as an Array" is much more pragmatic than, "ha!—trick question—there is no primary declaration of type Array in module Foundation."

I do agree that this use case is important, so thank you for re-emphasizing it. Perhaps I've not been clear in what I'm arguing: I'm not saying that this use case should definitely not be utterable at all.

Rather, I'm saying the syntax we choose for the overall feature should not be optimized for this use case. Indeed, since I can agree that mechanically generated code is one of the most compelling use cases for "qualify everything," it has least need to be optimized for readability.

Therefore, if hoisting the module to the left, or requiring parens, or both will make it easier for human-written, human-readable code, then we should give weight to considering those modifications even if the "qualify everything" use case then requires nested parens and/or more "stretching your memory"—I do not think the mechanical minds doing the stretching will much mind.

5 Likes

If I'm understanding correctly, this counter-proposal is that Module:: precedes the full name of the entity that you're trying to resolve? So the full elaboration of X.Y.Z, with each declaration in that chain being provided by modules A, B, and C respectively, would be C::(B::(A::X).Y).Z (parentheses presumably obligatory). Whereas in Becca's proposal, it's A::X.B::Y.C::Z or (if we decide to allow parentheses) (A::X).(B::Y).(C::Z).

In my conception of it, yes but that would be neither the point of the counterproposal nor the meat of it.

To declare X.Y.Z, the module that declares Z must necessarily import the module that declares Y which must necessarily import the module that declares X. For that reason, if we read :: as being more permissive than just the declaring module, then for almost any human-written code other than the most contrived, C::X.Y.Z would be sufficient for complete disambiguation—even if the ambiguity being resolved lies in there being two distinct X.Ys at the point of use, as long as only one of these is extended in C with a nested type Z.

With respect to mechanically generated code, either "fully qualified" spelling would be compatible with the counterproposal's rules if parens are always required, and both C::(B::(A::X).Y).Z and (A::X).(B::Y).(C::Z) could conceivably be supported. And if neither would actually be written by a human except in the rarest of circumstances and rarely read by one, I'd argue we don't have to settle on a "preferred" form or optimize for it.

I understand what you're saying about the module qualifier meaning "from the perspective of this module" but don't see why you think that that's a permanently stable thing, as if modules can never add dependencies.