Making union types even more useful

If you have a get you’ll make it flat again with list.map(_.get.show) where the match expansion of the union type Int | String still would be useful:

scala> trait C[A]:
     |   def get: A
     | trait Show[A]
     | object C:
     |    def apply[A](a: A): C[A] = ???
     | extension [A: Show] (a: A)
     |    def show: String = ???
     | given show_int: Show[Int] = ???
     | given show_string: Show[String] = ???
     | val list: List[C[Int] | C[String]] = List(C("asdf"), C(123))
     | list.map(_.get.show)
-- [E008] Not Found Error: ----------------------------------------------------------------------------------------------------
11 |list.map(_.get.show)
   |         ^^^^^^^^^^
   |         value show is not a member of Int | String.
   |         An extension method was tried, but could not be fully constructed:
   |
   |             show[Matchable](_$1.get)(/* missing */summon[Show[Matchable]])    failed with
   |
   |                 no given instance of type Show[Matchable] was found for an implicit parameter of method show
1 error found

Just because of nesting+erasure, it does not mean that scraping the match boilerplate isn’t useful…

scala> trait C[A]:
     |   def get: A
     | trait Show[A]
     | object C:
     |    def apply[A](a: A): C[A] = ???
     | extension [A: Show] (a: A)
     |    def show: String = ???
     | given show_int: Show[Int] = ???
     | given show_string: Show[String] = ???
     | val list: List[C[Int] | C[String]] = List(C("asdf"), C(123))
     | list.map(x => x.get match { case i: Int => ??? case s: String => ??? })
scala.NotImplementedError: an implementation is missing
  at scala.Predef$.$qmark$qmark$qmark(Predef.scala:344)
  at rs$line$3$C$.apply(rs$line$3:5)
  ... 34 elided

Or am I missing something?

The implementation was posing regular issues. Applications (method calls) like that could not be associated with a unique Symbol in the compiler. This was unlike all other field access and applications, which constantly broke invariants in the compiler. This means that it was a source of endless issues.

Removing that support meant that the compiler became more regular, and hence more robust.

that would work, but i see 3 related problems,

  • my formulation did not have a C.get method. what should be done in that case?
  • the compiler had to make an inference, which method should it call in order to check to the runtime type of A. what if C[A] had 2 methods that returned A?
  • the user never called or specified that that method should be called. that method may be doing RPC, reading from stdin, traversing and joining Futures, or who knows what.

in my opinion, the compiler should not be adding code at runtime that the user never indicated should be there.

it should also not be making assumptions about which methods are / arent safe to call, in order to figure out the runtime class of a type. it works fine with a simple boxing type, but is the same methodology safe for every class?

much like the @markehammons article you cited, i think the proper way to do this is wrapping the values, and capturing the given at creation time, to something like a Showable[A] that wraps value and type class together.

my formulation did not have a C.get method. what should be done in that case?

Well, a typical type class is a trait with at least one abstract method that utilize the type parameter, either as input or output type.

the compiler had to make an inference, which method should it call

I’m not sure I follow you. There is no “inference” as the call to get is made explicitly by the user. Or did I misunderstand you?

the user never called or specified that that method should be called.

Well, in my example the user did call the abstract member access method of the type class explicitly, so no magic needed:

list.map(_.get.show)

The “magic” needed is the inserted match. So if a union of types A | B | C ... is lacking method m and all types of the union actually have m then a match is inserted for each type so the method call can be made (i.e. a safe asInstanceOf).

2 Likes

One problem here is that union types are supposed to be commutative, but the match is order-dependent, unless we can prove somehow that all alternatives are disjoint.

1 Like

Aha, thanks! I haven’t thought about that. Hmmm…

As the methods that are being called would still be called on the same instance, I’d imagine this wouldn’t be an issue except in some very odd corner cases, right?

Even if (foo: A|A).bar expands out to a match, it should resolve to the same method call:

foo match {
  case a: A => a.bar
  case a: A => a.bar
}

The duplicate case isn’t ideal, but that could probably be optimized away (and in any case, shouldn’t hurt anything).

Even if it’s a typeclass with an invariant parameter, subtypes should still behave reliably because the parent class would need a forwarder to call the relevant subclass’s instance for it to compile anyway. It might be marginally more efficient if the subclass matches first, but even if the parent class matches first it’s unlikely to be unacceptably slower (or they’d fix their parent class implementation for all the other times when it would be called).

That argument (that dynamic dispatch will save us) works only if there is a common method bar that’s defined in a superclass of each branch. But in that case the current scheme of widening to the superclass works as well.

Sorry, I don’t think I was clear. I was only speaking to this case:

If they’re disjoint, desugaring to a match works better than widening, and if they’re not disjoint, the order shouldn’t matter either (regardless of how it’s defined). For the order to matter, (because under the union is a single concrete value) the only way I could think of for the match order to matter would be if the union contained a super and subclass and the instances were set up in such a way as to that it matters which typeclass instance resolves.

And at that point their code is going to be so unpredictable that the match order is going to be the least of their problems.

As an example, I was playing around in scastie with an example where widening wouldn’t result in being able to directly call .show, and I couldn’t find a matching order that resulted in undesirable behavior without breaking LSP.

1 Like
class A: 
  def foo = "A"
class B:
  def foo = "B"
val x = if p then A() else B()
x.foo

would give a different result if x was typed A | B or if it was typed B | A .

I think I’m missing something here, wouldn’t the resulting expansion match only the underlying value?

As I understand the proposal, this would be the expansion for A|B:

x match {
  case a: A => a.foo
  case b: B => b.foo
}

And this would be the expansion for B|A:

x match {
  case b: B => b.foo
  case a: A => a.foo
}

In either case, only the matching branch is going to execute, right?

2 Likes

Only if A and B are disjoint. Otherwise more than one branch can match, and only the first one will be executed.

I get that :slight_smile: , what I’m trying to understand is in what situations this would actually cause undesirable behavior.

The cases seem to be:

  1. Disjoint (A | B)
  2. Same type (A | A)
  3. Super & subtype (A | B where A <: B)

As far as I can tell, none of these cases should cause undesirable behavior:

  1. Unambiguously handled by the match
  2. Regardless of which case matches, the same method will be called on the same instance
  3. LSB should mean that the super and subtype should be interchangeable.
    If not, then in a program where a.foo and (b: B).foo do different things, match order mattering is probably the least surprising thing you’ll encounter :slight_smile:
5 Likes

Yes, I think you are right, as long as we restrict our focus to member selection. If there is an overlap then dynamic dispatch will provide the right method.

6 Likes

Clarification question on intent, in the case where the type signatures are not identical: does this capture the desired return type in this case?

class A() {
  def foo: Int
}
class B() {
  def foo: String
}

val input: List[A|B] = List(A(), B())
val output: List[Int|String] = input.map(_.foo)

I guess you mean input.map(_.foo)
Yes, List[Int|String] would be a type I’d like :+1:

1 Like

Yep :person_facepalming:

I think union types need to be more common in order to be more useful, which I assume would mean disabling eager widening—having to force them to stick around with type annotations at every point works against them.

What’s the rationale for widening something like if someBool then B() else C() to A early if B|C <: A holds for trait A; class B extends A; class C extends A? After all B() or C() on their own isn’t widened to A.

I think I remember asking this at some point before and being answered that it interfered with implicits (seems like a lot of things do :roll_eyes:) but couldn’t the rules for implicits be adjusted then, rather than make everything else conform?

2 Likes

do you mean if someBool then B() else C()?

Yes, typo. I edited my post. Although strictly speaking an unwidened type like A|B would still be valuable.