Relative scoping for hierarchical ADT arguments

Also, should the following be allowed ?

enum Tree
  case Leaf(x: Int)
  case Node(left: Tree, right: Tree)

val t: Tree = Node(Leaf(1), Leaf(2))
// aka
val t: Tree = ..Node(..Leaf(1), ..Leaf(2))

// fairly unambiguous because all expected types are the same
enum RedTree
  case Leaf(x: Int)
  case Node(left: BlackTree, right: BlackTree)

enum BlackTree
  case Leaf(x: Int)
  case Node(left: RedTree, right: RedTree)

val t: Tree = Node(Leaf(1), Leaf(2))
// aka
val t: Tree = ..Node(..Leaf(1), ..Leaf(2))

// Here we want enum classes from the inner point
enum Tree
  case Leaf(x: Int)
  case Node(left: Tree, right: Tree)

extension [T](x: T)
  def debug(s: String): T =
    print(s"$s: $x")
    x

// expectation: If an expression is valid,
// slapping a `debug` on it won't make it invalid

val t: Tree = Leaf(1) // valid
val t: Tree = Leaf(1).debug("t") // valid ?
// aka
val t: A = ..Leaf(1) // valid
val t: A = ..Leaf(1).debug("t") // valid ?

// Here we want the expected type of the whole expression to drive the relative scoping
// This might still work with expected type, but I'm fairly certain that there can be examples where it's not the case

Oh and we should probably also do something smart with unions:


val t1: Tree = Leaf(1)
val t2: Tree | Null = Leaf(1) // should also work
// aka
val t1: Tree = ..Leaf(1)
val t2: Tree | Null = ..Leaf(1) // should also work
1 Like

I agree that if this feature is added, we should explicitly handle nullable types where explicit-null is enabled. But other than that, it will only work for concrete types and not a union of concrete types. Too many things can be ambiguous and confusing if we enable that.

I agree with Martin that .. is not so nice. For a beginner it is cryptic.

I think this is much better

def changeTrafficLight(current: Color): Color = current match
  case Red => Yellow
  case Yellow => Green 
  case Green => Red

than this

def changeTrafficLight(current: Color): Color = current match
  case ..Red => ..Yellow
  case ..Yellow => ..Green 
  case ..Green => ..Red

We have the target Color type. We use documentation. If Color wasn’t an enum but defined as a sealed trait with case objects outside of the companion they would show up (as we could see in the docs and IDE hints).

If some of the counter-examples above had explicit return type it would be more reasonable to assume that exported members are in-scope.

I think the opt-in export modifier is fine. We use documentation. We use IDE support. We will not need to remember everything. We can always ourself shape the scope with imports and exports in wrappers. It is reasonable that the api designer is in control of the api and the scope after wild-card imports.

Companion objects provide factories and givens - so in that sense there is regularity. So I’m on the same page as @odersky and I’m not convinced that the export modifier would be a hassle, and I think it is an elegant semantics for solving the enum inconvenience.

I agree with your reasoning given the premises. I’m not sure the premises are correct, however, so this may need to be revisited.

However, I do readily agree that no-extra-syntax-at-all is superior to minimal-syntax-that-still-looks-visually-somewhat-jarring.

Is this actually the same situation, though?

Firstly, with infix, why is it a good thing for the community? What practical impact does it actually have? Doesn’t it primarily just make people stop using infix notation, so that people who don’t like infix don’t have to put up with code written by people who do?

For me it’s just been an exercise in tediously inserting infix into my own code, writing clearly less readable code because the library didn’t have infix on for whatever reason even though it’s a symmetric binary operator like intersect, and adding the “ignore infix warnings” to every file. I am constantly very tempted to just write less clear code and stop using infix because the feature makes it painful. I have exactly zero times benefited from it in any way whatsoever as someone who actually uses the feature.

However, I can understand that having less infix can, indeed, make things more regular; and some people weight syntactic simplicity and regularity more heavily than clarity-once-learned, and so I accept that I’m part of the community for which it “clashed with preferred coding styles”. But the solution has been to make infix use rare, hasn’t it? Even in cases where the library designer could have planned for the possibility but didn’t. Even in cases where the library designer did plan for the possibility but you know it might not work so you don’t check every method to see whether it will work or not.

Is this the kind of “very good thing” you envision for export is a class modifier in an object? That is, it should be rare, because not only do you have to plan for it, the type of planning that it takes is a maintenance burden (you have to think through every item, and then think it through again if you change the API)?

If the answer is “yes!”, then fair enough.


If we’re going with export and no modifiers, though, can you export an export, in the two different senses?

trait Thing {}
object Thing:
  export case class AThing(i: Int) extends Thing {}  // Proposed
  trait Subthing extends Thing {}
  object Subthing:
    case class TheThing(s: String) extends Subthing {}
  export Subthing.TheThing   // This is valid
  export export Subthing.TheThing  // Um...?

I’m not sure this is the right level of granularity. There are a lot of things isomorphic to nullability. For example, here are three ways to create a Json hierarchy where you have both a with-error and a without-error hierarchy:

// Encoding 1
case class JErr(msg: String) {}
trait Json:
  def range: (Long, Int)
object Json:
  export case class Str(s: String)(val range: (Long, Int))
    extends Json {}

type Js = Json | JErr

// Encoding 2
trait Js {}
case class JErr(msg: String) extends Js {}
trait Json extends Js:
  def range: (Long, Int)
object Json:
  export case class Str(s: String)(val range: (Long, Int))
    extends Json {}

// Encoding 3
trait Js {}
object Js:
  trait Json extends Js:
    def range: (Long, Int)
  case class Err(msg: String) extends Js {}
  export case class Str(s: String)(val range: (Long, Int))
    extends Json {}

As proposed, I think that none of the hierarchies allow the short form on things typed both Js and Json, admitting short-form matches on correct but not error cases, even though all of them nominally are equally good candidates for it to work. Whether we have no decorator or a .. is, I think, orthogonal to this issue. Rather, this is more of a concern about going beyond enums at all, because once you have as-you-please assembly of subtypes, it’s very hard to get out of “ambiguous and confusing”.

Agreed. Especially for enum, where it is so unambiguous what is meant, the dots seem like unnecessary and visually unpleasant clutter. However, columnar alignment

def changeTrafficLight(current: Color): Color = current match
  case ..Red    => ..Yellow
  case ..Yellow => ..Green
  case ..Green  => ..Red

makes it look a lot better. Columnar alignment makes a lot of things look better, but this example was chosen presumably to be a near-worst-case (and I agree it’s pretty bad) and at least to my eye it’s mostly okay once aligned. (Even if dotless is still cleaner, including when aligned.)

Needing to refer to documentation slows things down, so that is not an argument in favor. Nobody was saying that it wasn’t even discoverable, were they? It just makes it harder to gain fluency with libraries because you have more to learn.

And not everyone uses IDEs with that level of richness. In practice, some people do actually remember everything. Making the decision that “remembering everything is not a supported way to use Scala” is actually a pretty big thing, I think.

Furthermore, this presumes that the relevant IDEs are going to grok this new feature fast enough and display it helpfully to the user. Hover is easy enough (the presentation compiler can give the type), but the tricky part is autocomplete, where you need it to offer Heading when it’s typed HTMLElement. Otherwise, you type HTMLElement. and then you get Heading as an autocomplete, which has completely bypassed the feature.

So while I agree that it is possible to lean on IDEs for help with some trickier parts of language features, I don’t think this alleviates the concern much at all.



I propose that if we want this in sooner rather than later, we limit to enums for now, whether it’s with .. or bare; otherwise we need to tackle the use cases for people who are not using enums because they want something more complicated that isn’t supported by enums–but that will often break export too, so the use cases covered by explicit export but not enum are pretty thin.

(Enums can have companion objects and the companion can be extended with extension methods, so it’s not like you need a place to hang helper methods/classes. You need not-Enum because you’re doing something tricky e.g. using inheritance rather than composition in a way that enum doesn’t support, or nesting in a way that enum doesn’t support.)

If we limit it to enums, we can defer the sigil decision, because even if enums go in with nothing, we can always allow .. notation for them if we decide that expanding the feature is a good idea but only if .. is cool. Extra syntactic sugar for enums should be fine because they’re an extra-clear case (e.g. with lambdas we have extra-short forms for the most straightforward cases).

3 Likes

I don’t really see any significant potential for confusion here. When two enums have cases of the same name, we can simply print an error to that effect. This matches the behaviour we already have today when we use imports:

enum Foo:
  case Bar(i: Int)

enum Baz:
  case Bar(s: String)

import Foo.*, Bla.*
Bar("foo")

-- [E049] Reference Error: -----------------------------------------------------
1 |Bar("foo")
  |^^^
  |Reference to Bar is ambiguous.
  |It is both imported by import Foo._
  |and imported subsequently by import Baz._
1 Like
def changeTrafficLight(current: Color): Color = current match
  case Color.Red => Color.Yellow
  case Color.Yellow => Color.Green
  case Color.Green => Color.Red

This is annoyingly repetitive and we want to fix that. That’s the main objective. Every solution has to be measured by how well it addresses this objective and everything else is a bonus feature .

Agree. Let me bring a frontend/Scala.js perspective to the discussion. In UI programming, we constantly work with patterns like:

Button(
  color = Button.Colors.Primary,
  variant = Button.Variants.Outlined,
  icon = Button.Icons.Download
)

We can shorten this with imports:

import Button.Colors.*
import Button.Variants.*
import Button.Icons.*

Button(
  color = Primary,
  variant = Outlined,
  icon = Download
)

The call site becomes shorter and cleaner — but it still requires multiple imports that exist solely to make this syntax nicer.

From this angle, a language feature that lets us access members of a “target type” (such as enum cases or nested objects) without manually importing them would be a huge improvement. If the compiler can already infer the expected type at the call site, it feels natural that it could also look up the relevant members automatically.

To me, achieving that would be a real win for ergonomics.

3 Likes

Yes, I think infix and export would only be used infrequently. For export it’s “I really wish this could be an enum, but it needs to stay an open sum”. I am also fine with delaying export and restring this initially to enums only, until we see more convincing usage examples.

I know that the infix change did upset some people’s well-reasoned conventions. The problem was that the code I saw in the wild did not have clear and uniform conventions when to use infix, so the code was typically all over the place. For intersect and symmetric operations like it, there’s always the possibility to write them infix in backquotes. Did you try that? For me personally, I have found them to work well enough.

No, export as a clause does not allow any modifiers.

OK, so going back to the original example: An api-side export takes us to a bit less verbosity:

// This is real Scala 3.7.4
final case class Shape(geometry : Shape.Geometry, color : Shape.Color) 
object Shape:
  export Geometry.*, Color.*

  sealed trait Geometry
  object Geometry:
    case object Triangle extends Geometry
    case object Rectangle extends Geometry
    case object Circle extends Geometry
  end Geometry

  sealed trait Color
  object Color:
    case object Red extends Color
    case object Green extends Color
    case object Blue extends Color
  end Color
end Shape

You then can:

val redCircle = Shape(Shape.Circle, Shape.Red)

But we would like to:

val redCircle = Shape(Circle, Red)

The above could be achieved with further exports on top level of the package but then the namespace outside of Shape is “polluted”.

A proposal is that we could achieve this by allowing export in new positions to unlock target-type scope injection:

// this is hypothetical Scala
final case class Shape(geometry : Shape.Geometry, color : Shape.Color) 
object Shape:
  sealed trait Geometry
  object Geometry:
    export case object Triangle extends Geometry
    export case object Rectangle extends Geometry
    export case object Circle extends Geometry
  end Geometry

  sealed trait Color
  object Color:
    export case object Red extends Color
    export case object Green extends Color
    export case object Blue extends Color
  end Color
end Shape

The name space is not “polluted” and target-typing-based member selection works outside Shape where Shape stuff is expected like so and the api-designer can make a nice api:

val g: Shape.Geometry = Circle

This possibility would be discoverable in the api-docs by the export modifier. IDE-support could show hints and make completion etc.

@soronpo What do you think about that? Would it solve your original use case?

1 Like

Yes, but that lost much of the visual clarity (at least to me) while adding a third way to do the same thing, so it was even worse.

a plus (b times c)     // Very clear
a `plus` (b `times` c) // A bit cluttery
a.plus(b.times(c))     // A bit more cluttery

(a plus (b times c)).foo     // Downside: needs parens to call methods
(a `plus` (b `times` c)).foo // Still has downside and is a bit cluttery
a.plus(b.times(c)).foo       // Avoids downside at least!

For a feature that is all about syntactic elegance at the expense of regularity, backticks aren’t for me a winning tradeoff except in very rare cases.

I guess the question I have is whether that is worth an extra language feature, given that you can accomplish a similar thing with composition: just make the open part a parameter of one of the cases; and given that export as envisioned doesn’t easily support nesting.

Allowing method- and class-level export permits design flexibility that admits usage that is confusing to the user (incomplete coverage of export, method- rather than type- or language-construct- memorization of “what works”). It is potentially helpful, but the cost in learning and potential irregularity is very high compared to “enums have this cool trick”.

2 Likes

But they don’t pop out of nowhere, they’re related to the type of the object you need to create. It’s not that different from calling a method on an object: when you do that, you can refer to symbols defined in that object’s type without naming that type again: it’s "foo".indexOf("oo"), not "foo".String#indexOf("oo"), because that would clearly be very tedious. And yet we insist on that tedium when creating objects…

This one’s actually pretty simple: local variables must take precedence because there is no way to refer to them explicitly, whereas there is an explicit way to refer to enum cases.

Besides, for compatibility reasons we would probably want enum cases to have the lowest priority anyway. Just do a lookup like we would today, and if we don’t find anything, consider the enum cases. It’s possible to make it smarter than that (e. g. apply disambiguation rules similar to those used for method overloading), but for starters we should probably just do the simple thing and see how far it gets us.

6 Likes

I also think that there is not really a serious issue with ambiguity; The rule that unimported identifiers can pop up so long as they are cases of a known target type is easy to understand and immediately memorize. We can get this unqualified accessing today using imports, and let’s not pretend that people actually read imports at the top most of the time.

It is rare that you are going to accidentally shadow class names in the same file anyway, they’re practically always uppercased, and we already allow shadowing in the language with the assumption that programmers will handle it sensibly.

I think the general lookup without any leading sigil or export modifier would be best if possible to implement without conflicting with pre-existing features, and I wouldn’t intentionally limit the feature just because we are worried some people writing some questionable code might accidentally shadow and then have to fix it.

4 Likes

We should discuss how this feature is going to interact with into. At a minimum, when the expected type of an expression is into[A], where A is an enum type, the cases of that enum type must be considered.

enum E1:
  case X

def f(e: into[E1]) = ()
f(X) // this must compile

And how about this?

enum E1:
  case X
enum E2:
  case Y

given Conversion[E1, E2] =
  case E1.X => E2.Y

def f(e: into[E2]) = ()

f(X)

I tend to think this should work too. Otherwise it’s another one of those situations where two features work in isolation but it breaks when you try to use them together, and that always sucks.

Another case would be this:

enum E1:
  case X
enum E2:
  case X // note: case is named the same

given Conversion[E1, E2] =
  case E1.X => E2.X

def f(e: into[E2]) = ()

f(X) // is this ambiguous?

I’m a bit on the fence about this one, but I think the ambiguity can reasonably be resolved by prioritizing E2.X because it doesn’t require a conversion whereas E1.X does.

And this case is obviously ambiguous:

enum E1:
  case X
enum E2:
  case X
enum E3:
  case Y // note: different name

given Conversion[E1, E3] =
  case E1.X => E3.Y

given Conversion[E2, E2] =
  case E2.X => E3.Y

def f(e: into[E3]) = ()

f(X) // obviously ambiguous

Another question I have about this feature: is it going to work for sealed types, and if so, how exactly? I think it should: any non-abstract class or object derived from the sealed type within the same file should be available through this mechanism. sealed types still have features that enum types don’t, like an abstract method in the sealed type that is implemented in its subtypes.

There are also multi-level enums to consider:
(They are not an accepted SIP yet, but it’s hard to argue either proposal is so important that breaking the other is irrelevant, see forum, SIP)

enum Animal:
  case enum Mammal:
    case Dog, Cat
  case enum Bird:
    case Sparrow, Pinguin

def foo(x: Animal) = ???

foo(Animal.Mammal.Dog) // Allowed
foo(Mammal.Dog) // Allowed ?
foo(Dog) // Allowed ?

This can be somewhat solved with .. (but it’s very ugly):

enum Animal:
  case enum Mammal:
    case Dog, Cat
  case enum Bird:
    case Sparrow, Pinguin

def foo(x: Animal) = ???

foo(Animal.Mammal.Dog) // Allowed
foo(..Mammal.Dog) // Allowed
foo(....Dog) // Allowed ?
// or the following, but it makes less sense
foo(..Dog) // Allowed ?

Maybe # is superior in this regard:

enum Animal:
  case enum Mammal:
    case Dog, Cat
  case enum Bird:
    case Sparrow, Pinguin

def foo(x: Animal) = ???

foo(Animal.Mammal.Dog) // Allowed
foo(#Mammal.Dog) // Allowed
foo(##Dog) // Allowed ?
// or the more sensible
foo(#.Mammal.Dog) // Allowed
foo(#.#.Dog) // Allowed ?

It’s not related to multi-level enums, but what values will be seen in scope- the entire sealed cases chain or just what is found in the target type’s companion. I’m inclined to support the latter, because it’s a simple rule that reduces ambiguity (e.g., what happens when two internal hierarchies have the same case name).

I will just say that I will never propose or accept a multi-token as a hierarchy indication (## or ….)

Could you give an example of what ambiguity you want to avoid here?
I can only see something like this:

sealed trait Foo
object Foo:
  case object Bar extends Foo

object Bla:
  case object Bar extends Foo

val _: Foo = Bar

And all I can say is that 1) you shouldn’t be writing that kind of stuff in the first place, and 2) that absolutely should give an ambiguity error, and not disambiguate in favor of Foo.Bar.

No, # (as I proposed it) is an expression that refers to the companion object of the expected type, similar to how _ is an expression that refers to the parameter of a lambda expression. Therefore you can’t use # after a period, just like you can’t use _ after a period.

For multi-level enums I would suggest making all cases available, i. e. foo(Dog), foo(Sparrow) etc. should all just work. I think it would be quite questionable to design an enum type where different cases have the same unqualified name, and nudging people away from that by giving them ambiguity errors is probably not a terrible thing. If they really want to do it, they can always spell it out completely, and those who write their enums in a sensible way are rewarded with greater convenience.

Nope, I wholeheartedly disagree. It’s not favoring one over the other. There is only one choice, the object that’s in the companion of Foo (which may need to be marked with export).

2 Likes

Could you elaborate? Why is Foo.Bar the correct choice to make here, even though Bla.Bar has both the same unqualified name and the correct type, just like Foo.Bar does? IMNSHO this is begging for confusion.

Yes, certainly, sorry I wasn’t clear enough. IMO, if this proposal moves forward, the objects being considered for relative scoping only belong to the target type’s companion, not all extensions of that type. That’s the most simple rule and is useful enough, IMO.