The key questions here are
- How much new namespace is accessible?
- How discoverable is the source of the names?
- When there is shadowing, how does that manifest?
- How practical is it to manually disambiguate?
I think the feature can be of value if we come up with good answers to these things.
The answer to (1) is “probably not that much”, but it depends on how deeply nested things are. One fairly common pattern for ADTs is to nest inside companion objects, sometimes multiple layers. This should be an explicit case that we think about:
trait Json {}
object Json:
trait JObject extends Json:
def get(s: String): Json
object JObject:
case class JMap(m: Map[String, Json]) extends JObject:
def get(s: String) = m.get(s)
trait JOrderedMap extends JObject:
def apply(i: Int): (String, Json)
object JOrderedMap:
case class JMapMap(ix: Map[Int, String], jm: Map[String, Json]) extends JOrderedMap:
def apply(i: Int) = get(ix(i))
def get(s: String) = jm.get(s)
// And so on
If we start building out hierarchies like this, do we end up with untenably much namespace always active? The “fragile base class” issue is bad because although there isn’t very much namespace, often, it is especially likely to collide because subclasses think about the same types of things as superclasses and therefore may use the same names.
Discoverability is the main reason to use a sigil like . or #. This tells you how to restrict your search: is it in the usual places (e.g. imports), or is it relative scoping?
If one is using an IDE, or a LLM with access to the presentation compiler, this isn’t really an issue. Everything can be made highly discoverable, especially if it compiles. (If it doesn’t, search for “what were you expecting to work here” is harder.)
However, if one isn’t using fancy tools that employ a lot of machinery behind the scenes to help, or one is but it isn’t revealing what you need, what does one do? There are various language-level tricks that could be employed to make it less arduous. For instance, one could say that a type ascription (foo: @) is a syntax error like it is now, but it is also a signal to the compiler to verbosely explain all the helpful stuff it may know about foo–which foo is it (or which things labeled foo could be known), preferably with line number to definition, etc..
It doesn’t have to be this, but having some sort of trivially easy “hey compiler help me out here” trick would help discoverability when the normal means fail (and they will).
This is a great solution to (3)–you can’t be more obvious than detecting the ambiguity and reporting it!
Relative scoping doesn’t change the usual rules for using a longer path to the namespace you want. So (4) shouldn’t be much of an issue. The key is that when doing (3), the compiler should output verbatim the minimal way to get each option, so that when one collides, one can quickly pick what one means.
If one wanted to get extra-fancy, one could find some way to tell the compiler to hide parts of the namespace. So if you’re using colors, and someone has defined a conflicting Green and Red as status indicators, you could
val Green = true
val Red = false
@hide("Green", "Red") locally:
val c: Color = Red
Or whatever we decide the syntax is. This would allow us to both have more pedantic collision detection (rather than difficult-to-intuit shadowing rules), but also allow clean usage without having to go through gymnastics to control the scoping of the colliding terms.