Relative scoping for hierarchical ADT arguments

Swift calls it Implicit Member Expressions

Thanks. No way I’m using “Implicit” for this :sweat_smile:

3 Likes

Shortcut member selection ?

Target member expression

More Devil’s advocate:

Way back we said

val loop3 = Map(0 -> 1, 1 -> 2, 2 -> 0)

was a great example of type inference that saves that unsightly boiler plate from Java. (Edited to remove even more boilerplate.)

Maybe backing out of the “declare all types” guideline (and go back to the “declare types that you have to put some thought into” guideline) is a gentler option than tweaking the language.

1 Like

Sorry for being late to the party, but I encountered this little nuisance myself multiple times as well. So i would really welcome a solution. But, if the starting dot cannot be omitted, please drop the whole idea, for it not only looks kind of ugly, but really is conceptually confusing, especially for beginners.

That said, the two places where spelling out companion object members is most annoying are in the (indirect) constructions and its definition, so i would limit the automatic import to those cases.

Furthermore, these automatic imports should for instance only work if they are allowed explicitly in the definition of the companion object. Maybe we can use the keyword export here (if it does not bite other use). This further reduces compatibility problems, the downside being that it will only work for new cases. The original definition can then be written like:

final case class Shape(geometry: Geometry, color: Color) 

object Shape :
  sealed trait Geometry
  export object Geometry { ... }
  sealed trait Color
  export object Color { ... }

and be used as:

val redCircle = Shape(Circle,Red)

in pattern matching, like before and in related methods:

def myCircle(color: Color): Shape = Shape(Circle,color)

but not in unrelated calls:

case class Model(geometry: Shape.Geometry, color: Shape.Color)  // works, spelled out
val model = Model(Circle,Red) // compiler error, not spelled out, no Shape in sight.
1 Like

Swift users have no beginners? I just understand this argument.

Yes, of course, but i cannot judge if this is conceptually difficult in Swift, maybe not, i don’t know enough about Swift. And probably nobody in this forum is really capable to judge if some new concept is really difficult for beginners, for the lack of beginners. But we know, and i experienced it myself, that the concept of companion objects can be difficult for beginners. And we also know that for example implicits are, for it is invisibility. This is a combination of both, so my fear is that the lonely dot, summoning the companion object before it, might be confusing. Maybe i am wrong, time will tell. And even if so, it still looks ugly.

Am i also curious about your opinion on the rest of my post.

1 Like

I agree; if this feature gets added, I wouldn’t teach it, until maybe much later as an “advanced” concept. The notation is very confusing too. It would lead to a lot of trial / error guessing behavior.

(Companion object by itself is not too bad to explain / teach. A good explanation is “stuff that doesn’t change from instance to instance can go here”. Students generally do OK with the instance concept.)

2 Likes

One important thing about the proposal is that nothing needs to be done in the declaration site to use this feature. Heck, you can even implement this in Scala 3 and have the feature available when using Scala 2 objects. “Ugly” is not a valid enough reason for me to drop this. Is using a $. lead any better?

That would be nice, but is it necessary? Are there a lot of libs out there were this would be an improvement? (Maybe so, its an honest question, i would really not know). But to me, it would be something that i need for myself. In that case there is no problem with changes on the declaration site, which would in turn ensure backward compatibility.

No, not better, both look weird, and from what i read in the posts above, i am not the only one. I would rather write Shape. instead. And if that feels to long, we can aways write import ...{Shape as $} somewhere at the top. So where is the gain then?

To me, the beauty in your proposal is that wherever we use Geometry or Color in there natural habitat (where Shape is present/dominant) they just work without further ado.

1 Like

The proposal outlines the gain and it’s not import aliasing.

in the solution proposed in this thread, one of the gains is that the companion object you’re selecting from is chosen based on type inference. in case of non-trivial type inference, the computed types can vary a lot depending on particular call site, so to emulate it with imports you would need to do the type inference manually and import from all inferred objects. anyway, after some deliberation, this argument looks somewhat weak (but still valid) to me, and other arguments (which revolve aroud uncluttering code) should be discussed instead.

however, if you go by the import ...{something as $} route then you probably need to introduce many short symbols sometimes and that’s a bit of ugliness already, e.g.

import html.{Tags as <, Attrs as ^, Colors as C} // ... and so on

you can bundle all of it together under one scope (that’s probably often the case with html constructs), but often you woudn’t want to.

should there be ambiguity at all? for me, the natural choice would be to inject scope separately per argument, so there would be no ambiguity:

// example invocation, copied from quote above, but with my comments
environment(
  // here the target type of this argument is `Light`, so we have injected `import Light._` just for this argument
  Off,
  // here the target type of this argument is `Water`, so we have injected `import Water._` just for this argument
  On
)

could you elaborate on that? the notation without leading dot still looks convincing to me.

the compatibility is on the source code level, not binary compatibility.

to minimize suprises when using notation without leading dot, we could require language import to enable the feature. compiler would always work as if the scope injection is enabled, but then the language import would be checked and if it’s not in scope, then throw compilation problem (waring or error).

example where it works:

import scala.language.scopeInjection

foo(injected) // works

example where compilation fails:

// no import from scala.language

foo(injected) // compilation fails. injection detected, but not allowed.

note that the above stuff is just a suggestion, food for thought. i haven’t deliberated much on it. maybe the notation with leading dot would be way easier to implement and maintain.

4 Likes

Without a leading dot you need to set precedence from outside scoping to relational scoping. So theoretically could be a code somewhere that accepts an argument Foo from external scope but once this feature is enabled and Foo exists in the relational scope then you have an unexpected error or worse, a silent change that isn’t picked up.

Language feature flag are a big no-no. Scala is trying to get rid of them, not add more (aside from things that are explicitly experimental).

I also wish to add a use-case that wasn’t discussed here thus far (and didn’t exist for me when I initiated the idea several years ago).

I call it “by type” argument assignment (instead of by order or by name), and it’s using opaque types + givens + implicit conversion to do something which I think is cool.

What I do is that I have a configuration context object where each value gets its own unique type

import wvlet.log.LogLevel //using an external logger library
import CompilerOptions.* //importing the new configuration types defined in the companion

final case class CompilerOptions(
    parserLogLevel: ParserLogLevel,
    linterLogLevel: LinterLogLevel,
    backendLogLevel: BackendLogLevel
)
object CompilerOptions:
  given default(using
      parserLogLevel: ParserLogLevel = LogLevel.WARN,
      linterLogLevel: LinterLogLevel = LogLevel.WARN,
      backendLogLevel: BackendLogLevel = LogLevel.WARN
  ): CompilerOptions =
    CompilerOptions(
      parserLogLevel = parserLogLevel, linterLogLevel = linterLogLevel, backendLogLevel = backendLogLevel,
    )

  ///////////////////////////
  // New Types
  ///////////////////////////
  opaque type ParserLogLevel <: LogLevel = LogLevel
  given Conversion[LogLevel, ParserLogLevel] = identity
  object ParserLogLevel:
    export  LogLevel.*

  opaque type LinterLogLevel <: LogLevel = LogLevel
  given Conversion[LogLevel, LinterLogLevel] = identity
  object LinterLogLevel:
    export  LogLevel.*

  opaque type BackendLogLevel <: LogLevel = LogLevel
  given Conversion[LogLevel, BackendLogLevel] = identity
  object BackendLogLevel:
    export  LogLevel.*

So when users run the compile command def compile()(using CompilerOptions)..., they can easily define non-default configuration options like so:

import lib.*

given options.CompilerOptions.ParserLogLevel  = options.CompilerOptions.ParserLogLevel.INFO
given options.CompilerOptions.BackendLogLevel = options.CompilerOptions.BackendLogLevel.DEBUG

compile()

I wish to enable the user just do:

given options.CompilerOptions.ParserLogLevel  = .INFO
given options.CompilerOptions.BackendLogLevel = .DEBUG

So in this “by-type” argument passing use-case we have:

  • An enumeration from an external library
  • Assigned values that are not essentially the same type as the destination type.

Without a leading dot, or some alternative explicit relational scoping syntax, it can easily bring unexpected naming collisions. And I think this is a very worthy use-case. I started implementing it in my library and it’s extremely convenient, especially since more than one command requires these options as a context. I can have dozens of different options and sub-option objects and the user just doesn’t need to care what goes where. The type-system takes care of everything.

1 Like

This is true either way.

case class Head(h: Int) {}
object Head {
  val head = Head(0)
}

def thing: Head =
  List(Head(1), Head(3))
  .head

What do we get? Head(1) or Head(0)?

I would say Head(1), just like with:

val x =
  1
  - 1 // {1 - 1} = {0}

vs

val x =
  1
  
  - 1 // {1; -1} = {-1}

So would I, but it’s not a different issue from the dotless case. You have to decide what takes precedence. Having thus decided, it’s not an issue any longer.

The point remains that you can change things far away (e.g. import an extension method) and behavior changes whether it’s dotless or dotted notation. The question is thus about the frequency and non-obviousness of collisions, not a qualitative this-collides-that-doesn’t. Or it suggests that another symbol or keyword is needed.

1 Like

It is. You’re referring to grammar rules. I was referring to scoping rules.

That’s indeed pretty bad. Sequences with leading . are very common. It’s a pitfall waiting to happen that any of these could accidentally become a constructor. It looks like relative scoping interacts badly with semicolon inference.

3 Likes