Relative scoping for hierarchical ADT arguments

To avoid namespace pollution, I like to declare ADTs in hierarchies like so:

final case class Shape(geometry : Shape.Geometry, color : Shape.Color) 
object Shape {
  sealed trait Geometry
  object Geometry {
    case object Triangle extends Geometry
    case object Rectangle extends Geometry
    case object Circle extends Geometry
  }
  sealed trait Color
  object Color {
    case object Red extends Color
    case object Green extends Color
    case object Blue extends Color
  }
}

The problem is that to construct a new object from this composition can be rather verbose, depends on how deep the hierarchy goes.

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

I can of course use import to reduce the verbosity, but that recreates the namespace pollution I was trying to avoid.

I would like to propose a way to refer to an object/type in relation to the expected ADT argument. Using the example above:

val redCircle = Shape(.Circle, .Red)

In pattern matching:

val supportedShapes = shape match {
  case Shape(.Triangle | .Rectangle, _) => true
  case _ => false
}

In regards to syntax, if .Arg is not obvious enough we can also consider a using a concept similar to paths: ./Triangle or $.Trianlge, or anything else that would make it more clear.

What do you think?

2 Likes

This is taken straight from Swift, btw. However, as written, I think it may not have enough benefit for the complexity it adds to both teaching and the language implementation, and also it is very specific and lacks generalization to other related problems. (This adds full-blown TDNR for top-level selections [not methods as with extension methods/conversions], but only based on return type and only for enums – what about related problems like scope injection?)

2 Likes

I never coded in Swift, so I didn’t know. Thanks.
Here is the related section from Swift https://docs.swift.org/swift-book/LanguageGuide/Enumerations.html

I wouldn’t even bother with the dot. What is the point of it? In particular, if you have

trait T {}
object T {
  val a = new T {}
  object B extends T {}
  def c(): T = new T {}
}

def f(t: T) = ???

then f( ) automatically imports the companion object scope, so f(a) and f(B) and f(c()) just work. There are approximately zero downsides for this. If instead of being explicit, a, B, and c() were implicits (givens in dotty), they’d be found. So why not find them if something explicit is requested?

To keep it from getting unwieldy, we could restrict it to only sealed traits or classes (or enums, in dotty).

Then you just have

val redCircle = Shape(Circle, Red)

with no extra fuss. The dot seems unnecessary extra clutter. The chance for confusion is minimal. Linting tools could catch the case where the auto-import introduced an ambiguity. For example, if you have

def environment(light: Light, water: Water) {}
sealed trait Light {}
object Light {
  object Off extends Light {}
  object On extends Light {}
}
sealed trait Water {}
object Water {
  object Off extends Water {}
  object On extends Water {}
}

then environment(Off, On) throws away all the safety beyond just having a boolean. So linters could catch that and say something like, "ambiguity in inferred import: Light.Off imported, but not distinguishable from Water.Off".

I don’t think the generality problem that @kai mentions is actually much of a problem. Getting nice syntactic sugar for general classes of problems can require undue effort from the compiler. But this precise problem is already solved in a more difficult form for implicits, so in principle it should be achievable. (Whether the compiler architecture makes it easy enough to implement I do not know.)


Specifically, the rule I’d favor is

  1. For any expression where the type is known (e.g. because of type ascription or method parameter), and
  2. That type is a sealed trait or class (or an enum in dotty), and
  3. That trait or class has a companion object, then
  4. Every symbol in that companion with a type that is a subtype of the sealed trait or class is visible in this expression.

So you could do stuff like

val c: Shape.Color = Red
{ println(Square); Rectangle }: Shape.Geometry

as well. (The latter perhaps being questionable form, but I would admit it as a necessary consequence of simple lookup rules.)

6 Likes

It is true that we don’t need the extra ., but I was afraid of ambiguity. I didn’t consider the similarity with implicit resolution. Nice observation! So as long as the compiler can properly report ambiguity (and not allow shadowing) I’m in favor of your “dotless” proposal.

https://contributors.scala-lang.org/t/proposal-for-multi-level-enums/3344/26

refs to this one.

1 Like

@odersky I would love to get your take on this (more specifically @Ichoran’s modified proposal). Of course, I’m not aiming for Scala 3.0.

I see the usefulness! Some thoughts:

  • Not using a prefix dot looks a lot more natural
  • But the result would be general scope injection, which has great potential for good as well as bad, so it’s exciting and scary at the same time.
  • Restricting it to constructors could tame the abuses but looks a bit ad hoc to me.

So… not sure. Right now I am concentrating on 3.0, without looking too much at what could come afterwards.

4 Likes

Reviving this thread. I’m thinking of writing a formal SIP. Any further thoughts?

1 Like

@soronpo I would support this. Having used Swift, it’s a wonderful feature and saves tons of boilerplate. A whole zoo of awkward builder patterns and fluent APIs just disappear when passing typed ADTs as parameters becomes convenient (no need to pass fully qualified and need to import first)

I have libraries that would benefit from this. e.g. Scalatags which currently imports every html attribute polluting your local scope, would be much neater referencing those attributes as hierarchical ADTs with a leading . and keeping them cleanly separated from user-defined names in the local scope

The leading dot is a bit unusual, but in Swift it’s not a real problem, and it has tons of benefits. It allows this to be fully backwards compatible, unambiguous, simple to spec, requires no change to the definition-site syntax, and can benefit all existing function callsites without retrofitting

While eliding the leading dot may be nice, that would be an order of magnitude more complex a feature to spec out, and lose all the nice properties listed above. IMO the Swift-style leading dot is a better tradeoff vs a more generalized scope injection that eliding the leading dot would require

4 Likes

Looks like a good idea, I’m all for a new SIP !

On the dot question, it feels somewhat “unlike scala”, but I think I could actually get used to it, and the ambiguity it removes is well worth it

But does it?

val redCircle =
  Shape(.Circle, .Red)

can today be expressed as follows:

val redCircle = 
  import Shape.*, Geometry.*, Color.*
  Shape(Circle, Red)

The name space pollution is restricted to where you actually need the names. So I am not sure this will be such a big advantage in the end.

5 Likes

But then we reintroduce verbosity, I would say about as much as the original:

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

For two mentions it’s no big deal either way. I thought the argument was that it gets worse if there are more constructed elements.

1 Like

Yes, it does if you have multiple such definitions and you don’t want to pollute the name space.

val redCircle = ...
val blueTriangle = ...
val cyanSquare = ...

Where all these values are defined I see no point in polluting with the enum contents.
And this is just a simple example. Maybe my enum is actually somewhere.far.away.in.the.package.myEnum. Why do I need to import it, if the destination type already defines what it expects?

Yes, I see the argument. So if the destination type is an enum, its constructors are automatically in scope? (or, depending, accessible with a leading .). I think it will depend on the details. How general do we want this to be? Do we want to use new syntax or not?

I thought about it and:

  1. A leading . is must. It ensures compatibility and minimizes surprises.
  2. It can be applied where the destination type has a companion object. This includes opaque types. If no companion object is found for the type (E.g., if it’s a union type), then it cannot access anything.
  3. .xyz will mean “look in the companion object of the destination type to search for .xyz”. Whether we limit the available objects to those who directly produce the destination type, I’m on the fence here. I’ll later give an example to why have it more general.

On another note, how do we name this feature? “Relative companion accessor”?

1 Like

Yes, in Swift it’s not limited to constructors, but factory methods benefit as well.

e.g. the Scalatags equivalent in SwiftUI has all attributes be static methods, some of which take arguments. So instead if import scalatags.Text.all._, frag(color("red")) in Swift the equivalent is frag(.color("red")) with no import, where def color(s: String) is a static method in the Attr type.

So not just limited to the enums and primary constructors, but any Swift type with static methods (in Scala, any type with a companion object)

I heard “Swift” so I had to come. :slight_smile:
I would be greatly favorable for a SIP in this direction.

FYI, the way that it works in Swift is that every time the compiler resolves a name starting with a . it will look for a declaration in the static scope of the expected type that returns an instance of that expected type.

For example:

struct Vector2 {
  var x: Double
  var y: Double
  func translated(by d: Vector2) -> Vector2 {
    // Note: constructors are called `init` in Swift and they
    // live in the static scope of a type. They are equivalent
    // to an `apply` method in a companion object.
    .init(x: x + d.x, y: y + d.y)
  }
  static let zero = Vector2(x: 0, y: 0)
  static let unitX = Vector2(x: 1, y: 0)
}

let x = Vector2.zero.translated(by: .unitX)

In translated(by:), because the expected type is Vector2 (which is the return type of the method), we can write .init to avoid repeating the name of the type. In the call to translated(by:) the expected type of the argument is Vector2 and we can write .unitX to avoid unnecessary qualification.

The equivalent in Scala is easy to guess.

As a consequence, given

class Foo...
object Foo:
  def apply(x: Int) = ???

we can do:

fromFoo(.apply(4))

Here is a cursed idea: To also allow the following:

class Foo...
object Foo:
  def apply(x: Int) = ???

fromFoo(.(4))

You’re welcome

Edit: On second thought, this feels a lot like C++ initializer lists:

List<int> l = {1, 2, 3, 4, 5}
List<List<int>> ls = {{1, 2, 3}, {4, 5, 6}}
l.append({6, 7, 8});

Note that those are heavily disencouraged (and somewhat restricted) when the constructor only takes one parameter

1 Like