Relative scoping for hierarchical ADT arguments

Your proposed rule relies on two criteria: make objects/classes available that are 1) subtypes of the expected type and 2) are located within the companion object of the expected type. The rule I’ve proposed is simply to remove the second rule, it is hence a simpler rule than the one you’ve proposed. And it is more useful too: it works for the many cases where subtypes are declared in the same scope as the sealed types, and it gives a useful ambiguity error when somebody writes code like my Foo.Bar/Bla.Bar example, because that is confusing and the compiler should enforce explicit disambiguation in this case.

In programming, constraints liberate :slight_smile: To have another rule that creates such limitations help the code reader understand where to search for objects.

1 Like

Actually it does the opposite. The rule I’ve proposed enforces explicitness where ambiguity exists. The rule you’ve proposed resolves that ambiguity in an arbitrary way and furthermore degrades the feature’s utility even for code where ambiguity doesn’t even exist (e. g. all case classes/objects declared in the same scope as the sealed type).

But it’s not ambiguity. You can stuff givens that conflict into all sorts of un-searched places, and it isn’t ambiguous at all: the rule is that givens from the companion are auto-searched, and the rest is up to you to bring into scope.

Whether or not it is is what we’re trying to decide here. And I’m pretty sure that if we have targed-typed name lookup, and we have two objects both named Bar that extend the target type, then yes, that should definitely be ambiguous.
It’s also completely beyond me why we should consciously break this feature for code where the derived types are not declared in the companion object but in the same scope as the sealed type. The way I see it, the proposed rule encourages code that should be discouraged (two objects with the same name extending the same sealed type), while breaking code that there’s nothing wrong with it. I don’t see the point of that.

The problem with companion-object search is that per Martin’s “no export export” rule, the simple orthogonal way to both have your hierarchy and search it too is unavailable.

But without extended search, the argument to not restrict the feature to enums alone is very thin. With enums, completeness matches exports perfectly. With anything else, you can have holes–and without the benefit of deeper hierarchies that do get completeness-checked if you have a sealed hierarchy.

So I think it makes more sense just to leave it to enums for now, and then consider what sensible extensions might look like to enable more use cases.

This says to me that you’re already thinking about it in terms of completeness. Completeness says: you had better cover both types. But relative scoping says: hahaha nope you can only talk about one.

If you take the completeness-first view, then it’s absolutely ambiguous. If you don’t, then why would something from the wrong namespace even matter?

And settling perspective issues like this is one thing that might benefit from more time, whereas with enums it’s far more obvious what ought to happen. (There we only have to worry about type unions.)

As long as I’m here chiming in on stuff, I want to ask one thing about the premise of this idea, from the original post back in 2020:

To avoid namespace pollution,

Why?

More specifically, what namespace usage do you consider “pollution”, and how do you separate it from not-pollution? Is pollution of a namespace a serious problem in your experience?

1 Like

Any import I need for just local use I consider polluting the namespace and especially if different import can collide and shadow each other. An import requirement just because I create an hierarchical ADT structure is redundant and cumbersome if we can achieve it otherwise without ambiguity.

The original post implied that the “pollution” was being avoided at the definition site, by nesting definitions inside a companion object (in two flavors: Geometry and Color being nested inside Shape, and the enum cases of those being nested inside their respective companions).

What I’m asking is, consider if all these definitions were at the same level – why is this pollution? FWIW, this would also bother me, but I can’t make a good argument for why (other than a vague notion of pollution against which I can’t really come up with a convincing practical argument).

I guess the theme I’m on about is that it feels like surprising things (like lexical scope which is context-sensitive) ought to need some real practical justification, beyond “I want to avoid something that feels icky, but if I do it makes me type more keystrokes, and that’s annoying”.

(No shade intended… I honestly have the same feeling as you about the example)

1 Like

Because subclass names become so general as to have higher risk of naming conflicts with other class hierarchies - and an another problem is that they start to read less clearly out of the nested context.

Consider modeling a weapons in a game using inheritance:

Weapon :> Ranged :> Automatic | Semiautomatic

If you use companion object nesting, it’s nice. But if you put Ranged and Automatic as top-level statements… Well those are very general words. You might also have a notion of an Vehicle :> Automatic. and we certainly don’t want to start naming with prefixes i.e. class AutomaticRangedWeapon

Third I think is the visual structure of definitions. If you just have a single parent class and then one level of subclassing (essentially an enum), then it looks alright to top-level them. But if you have 3+ leves of inheritance its not so clear.


Weapon
  Ranged
    Automatic
        AK47
    Semiautomatic
        M4A1

vs

Weapon
Ranged
Automatic
AK47
Semiautomatic
M4A1

But even in this example, there aren’t any conflicts. So what’s the practical effect of the pollution (other than not having to qualify all the names when you use them)?

It’s worth generalizing this question: what’s the point of namespaces at all? Why not have everything in one flat namespace, like C or Bash? Starting in 1995 or so, all language designers started putting things in namespaces by default, forcing users to deal with qualified names and imports and leading-dot-shorthand and other such complexity that dont exist in C or Bash; are they stupid?

If you can imagine the downside of a such a flat approach, you may be able to imagine the upside of languages like Java or Python deciding to put things in namespaces by default. And therefore the advantages of Scala putting things in namespaces, rather than having every name top level

1 Like

A bit of a strawman, isn’t it?

As a practical example, for a long time there was a practical reason not to perform this kind of nesting (cases inside of companions) due to various issues around directKnownSubclasses (the SI- number escapes me… 7046?). For the most part, we got along fine with flattening the things for this reason. It didn’t mean all namespaces disappeared into a Wild West shitshow of identifiers (because packages are still a thing).

It wasn’t a strawman because it illustrates that there is a case where clearly namespaces seem to be thought important. But in your previous comment, you asked, “why is this pollution?”

But, with an example of something that clearly seems like pollution is an issue, then onus is back on you to explain why that needs to avoid namespace collision, but this isn’t an issue.

Now that you’ve suggested that it’s not, let me suggest why it might be.

It’s fairly common for certain things to have a common API even if they’re not related by inheritance. For instance, Array and String both have a length method, but they have no common superclass or interface with length. Suppose you have

trait PetAction {}
object PetAction:
  enum Doglike extends PetAction:
    case Growl, Walk, Jump
  enum Bunnylike extends PetAction:
    case Hop, Jump, Nibble
  enum Catlike extends PetAction:
    case Growl, Climb, Jump

or the sealed trait equivalent.

val p: PetAction = Jump

This is clearly a namespace collision problem, induced by the fact that you actually wanted the same names but as applied to different animals, because Dog.Growl and Cat.Growl are not the same thing.

But they are all enums, and if anything should be promoted to top level, surely it’s the contents of enums which are auto-export (in the “don’t need my enclosing enum name” sense)

But wait! You think that, actually, there is a reason to have the common parts of Jump annotated, so you go back and do

trait Jump {}
...
  enum Catlike extends PetAction:
    ...
    case Jump extends Catlike with namespace.of.top.level.Jump

But now seeing Jump is even more collision-y.

Now, you could just say, “Well, look, if you want to design it like that, don’t use this feature.”

Fair enough.

But then the question is whether you should find out late in the game, where things start colliding, or early, because the subcategories didn’t even come up. That’s worth talking about.

The particular case is not the point but the general case. I want to operate by principles, rules I can follow that result in better software overall. It’s clear that in general, organizing more is better than organizing less, and so unless I see a strong disadvantage to following the hygiene rule, I’m just going to follow it out of principle. I don’t trust that I myself nor other programmers are capable of correctly performing a utilitarian calculus with all factors considered as to whether they should or should not follow good software rules in every scenario; it’s better to just always do things the right way with the assumption that it matters, rather than needing to justify best practice all the time. Wa are creatures of habit, and the way you do anything is the way you do everything.

(And, even in this particular case of a game, it does seem likely that I might use the word Automatic in some other part of my codebase, like Vehicle, so it’s future proofing.)

1 Like

I wouldn’t say that “getting along fine” is the goal.

Yes my codebases were still OK back in TypeScript and C#, but I specifically remember in those languages craving some way to arbitrarily nest type definitions and make low cost namespaces. Then I found Scala, and that Scala has companion objects that let me do this is a significant reason why I would choose it for an OOP experience.

class User(...)
object User:
  type Address = ...

is so much nicer to me than

class User(...)
type UserAddress = ...

I guess I’ll point out one of the more likely and annoying types of mistake now, rather than wait for another counterargument.

It’s very common for companions to have construction methods like of and from, if we allow methods at all. Even if we only allow classes, things like Many and Node and Const and so on are common things to have in big hierarchies.

The problem with unlimited-depth searching is that

def foo(p: Parent) = ???
foo(of(line))

might not be doing anything like what you think it is, because you might believe that there is

object Parent:
  export def of(s: String): Parent = ???

but in fact it’s

object Parent:
  sealed trait Foo extends Parent {}
  object Foo:
    sealed trait Bar extends Foo {}
    object Bar:
      export def of(s: String): Bar = ???

or even worse, in some other hierarchy (but which extends Parent, which is sealed, if is supposed to need to be).

Of course you will eventually figure it out. But it’s quite a hassle and there are a lot of possibilities of collision for common names once you start making larger hierarchies with all the richness you expect.

So I think that namespace collisions are worth thinking about seriously. The conclusion might be, “Well, yes, it happens, but it’s worth it: just spit out the ambiguity warning when it’s ambiguous, and trust people to say what they mean to solve the overspecificity issue–they just need to design well.” But it shouldn’t simply be dismissed.