I watched the discussion of SIP-ZZ Opaque Types this morning EDT; great stuff. Thanks for the email-friendly update too, @heathermiller.
The current draft is really nice and looks useful as well; thanks @non, @jvican, et al. Extending 3.5.1 Equivalence in the “companion knows” sense is a neat way to provide the code-that-knows block in a truly zero-cost way.
Some thoughts below on a few points that came up in the discussion, namely
- implementation via implicits (scalac) vs GADTs (dotty)
- companions restricted to opaque aliases
- implicit copying
- optional bounds
- multiple opaque types
- motivation: boxed Objects, unboxed functions
Implementation via implicits or GADTs
Under the stated equivalence rule, functions like these can be defined in e.g. opaquetypes.Logarithm
.
import collection.mutable.{Set => MSet}
def wrapSet(s: MSet[Double]): MSet[Logarithm] =
s
def mdl(m: Map[Double, Logarithm]): Map[Logarithm, Double] = m
// lest you still try mapping
def wrapFoo(foo: Foo[Double]): Foo[Logarithm] = foo
// where Foo is a user-defined trait
// just to make it really impossible
def wrapSomeMap[M[K, V] <: Map[K, V]](
m: M[Double, Logarithm]): M[Logarithm, Double] =
m
Some definition like this might be worth including in the example. This is great because it implies you can provide a =:=
or <:<
(or any equivalent subst
carrier) to expose as much of the equality as you want.
As such, I don’t think an implicit conversion story will work, because a pair of Double => Logarithm
, Logarithm => Double
conversions won’t lift into the type constructors above.
I think there’s a happier story here in the area of GADTs as @odersky mentioned with respect to Dotty. This should work because the type equality being locally visible is just how the handwritten module implementations of “High Cost…” handle all these, without resorting to implicits.
This is also a happier story because when you use the “translucent” style (i.e. upper-bounded with same type you intend to set the opaque type equal to), all the extra asInstanceOf
s go away, because the erasure of the “methods that know”, and indeed all methods that use the translucent type, match the unwrapped usage exactly. (The fully-opaque style has casts similar to what you get for generics; see “Erasure” below.) The big value-add of the Scala feature here is separating the erasure choice from the visible upper bound.
As an aside, it would be nice if GADT-style extraction of the equality/conformance from =:=
and <:<
worked in Scala; if this happened as a side-effect of making the equality work for opaque type companions, I wouldn’t look the gift horse…
Companions restricted to opaque aliases
This came up a couple times; I think the “only new types get companions” rationale works well here as a way to explain the distinction to users.
There are extra reasons that companions for proper aliases would be confusing. Take this opaque type:
opaque type Moo[Bar >: LB <: UB, Baz] = Baz
// where LB, UB are bound type names
The story for implicit search related to this opaque type is pretty simple, because it’s just like the one for classes. Look at Moo
, Bar
, and Baz
companions, walking up in the ordinary way.
But Moo
and Bar
will not be searched for a proper alias, as implied by the SIP under “Type companions”. After all, Moo[ClassLoader, Baz] = Baz
, no matter where Baz
occurs, wherever Moo
is visible.
It seems to me that a user who wants their type alias to behave “equivalent-but-not-quite” should really be reaching for an opaque or translucent type under this SIP, or any abstract-type mechanism, anyway.
Implicit copying
You can define an Ordering[Logarithm]
in Logarithm
's companion as follows.
implicit def ordInstance: Ordering[Logarithm] =
Ordering.Double
Of course, you’d rather just use implicits to find the instance, so you rewrite to
implicit def ordInstance: Ordering[Logarithm] =
implicitly[Ordering[Double]]
Of course, ordInstance: Ordering[Double]
in this context, so you diverge. (This is one of the rare things that’s easier to get right with subst
than with a visible type equality.)
“Wrong” return type inferred
Suppose you define
def add(l: Logarithm, r: Logarithm) = l + r
The most natural inferred return type is Double
, which is fine for proper aliases (where callers see Logarithm=Double
anyway), but is probably wrong for the intent of the programmer here. (It’s pretty similar to the problem you get if you try to write GADT code without declaring an expected type for the output of your match
.) I don’t have anything better than suggesting that inferred return types in opaque types’ companions ought to be warned about, but even that might have too many false negatives.
This problem doesn’t appear in the “High Cost” style because you’re always forced to declare all your full method signatures in a context where the opaque type doesn’t equal the expansion, i.e. the ML-style “signature” trait.
Optional bounds
I think that some people in the call thought this was addressed somehow, but I wanted some clarity on how you might add public bounds.
For reference, this is the “translucent” flavor of @alexknvl’s newts, and is also an option with Flow’s opaque type aliases. It’s satisfied in “High Cost” style by putting <:
and/or >:
on the publicly-visible abstract type member declaration. (I haven’t seen another system try to offer >:
as well, but several use cases come to mind.)
This might just be a syntactic difficulty; it’s obvious how to do it in “High Cost” style once you understand the signature/structure separation, but putting both =
and <:
on an opaque type
would be…weird, to say the least.
Flow supports this like so
opaque type Foo: VisibleUpperBound = PrivateUnderlyingType
Multiple opaque types
While discussing type companions, the possibility of using the containing block as “code-that-knows”, and relying on the parent path of the opaque type for finding associated implicits (but see scala/bug#10283 courtesy @Atry, but hey thanks for tackling it @TomasMikula) came up. This is intriguing for a few reasons:
- it makes “code that knows multiple opaque types” straightforward, which it is in “High Cost” style but is not possible without GADT-
=:=
in the current SIP;
- “code-that-knows” is alongside the opaque type, similar to “High Cost” style
- type companions may be eliminated, supposing #10283 is fixed.
So you write, say
object mod {
opaque type Foo = String
opaque type Bar = ClassLoader
def fb(m: Map[Foo, Bar]) =
// m: Map[String, ClassLoader] here,
// because both type equalities are
// visible
}
One big question would be “what happens when you subclass a class with an opaque type [member]?”
Having multiple opaque types is also supported in Flow, which gives modules a similar structure.
opaque type Foo = string
opaque type Bar = "classloader"
function fb(m: ((f: Foo) => Bar)[]): ((f: string) => "classloader")[] {
return m;
}
Motivation: boxed Objects, unboxed functions
The example focuses on Double
, but it might be worth mentioning that AnyVal
subclasses have the described boxing behavior even when they wrap non-primitive types (i.e. those that wouldn’t box at generic boundaries).
Since motivation came up in the meeting, avoiding boxing for performance was emphasized, but the convenience of the type equality is also a big deal, I think. That is, you can recycle functions, typeclass instances, et al without adding any mapping layer.
Erasure
Aside from equivalence, 3.7 Type Erasure should also be extended by the SIP.
- The erasure of an opaque type is the erasure of its right-hand side.
This makes it clear where it stands erasure-wise between alias types and abstract types (bullets 1 and 2 respectively).