Proposal for Opaque Type Aliases

Again, it seems that opaques share similarities and differences with all of the other components (classes, type aliases and type members), but they are neither. They have their own unique semantics.

I believe that associating them with type would be confusing, as we can already see here the confusion between type aliases and type members, which are different but are mistaken as similar since they share the same keyword. Let’s not add yet another meaning into that soup.

As @kai shown, they could actually be achieved with Scala 2 (given that the performance is the same as what is proposed), so it’s only a matter of syntactic sugar. If that’s what this all about, I suggest making their syntax tailored specifically to their use case:

opaque Permission(i: Int) {
  def |(other: Permission): Permission = Permission(i | other)
  def isOneOf(permission: Permission): Boolean = (i & permission) != 0
}

object Permission {
  // provided by default and can be overridden with a different implementation
  def apply(i: Int): Permission = i
  // not provided by default
  def apply(name: String): Permission = ???

  val WriteOnly = Permission(2)
  val ReadOnly = Permission(4)
}

import Permission._

val readWrite = WriteOnly | ReadOnly // compiles
val readWrite: Int = WriteOnly | ReadOnly // doesn't compile

ReadOnly.isOneOf(readWrite) // compiles
ReadOnly.isOneOf(6) // doesn't compiles
ReadOnly.toDouble // doesn't compile

2 == WriteOnly // compiles and returns `true`
obj.isInstnaceOf[Permission] // doesn't compile
obj match { case p: Permission => ??? }  // doesn't compile

With sugar like that, and the other changes going into Dotty (‘new’ not needed when calling a constructor when not ambiguous, top level definitions), I think the companion object can be dropped.

An ‘opaque’ holds its methods in static scope, so holding values there is just more sugar:

opaque Permission extends Int {
  require(this >= 0)  // example of input validation
  def this(name: String) = ??? // alternate construction
  def |(other: Permission): Permission = Permission(this | other)
  def isOneOf(permission: Permission): Boolean = (this & permission) != 0

  // note, these values are static, just like the defs above.  These can not reference `this`
  val Write = Permission(2)
  val Read = Permission(4)
  val Execute = Permission(8)
}


import Permission._

val rw = Write | Read // compiles
val rw: Int = Write | Read // doesn't compile
val rwx = Write | Read | Execute

Read.isOneOf(rw) // compiles
Read.isOneOf(6) // doesn't compiles
Read.toDouble // doesn't compile

2 == Write // -- does not compile the author can add `def toInt: Int` or `def isInt(other: Int): Boolean`  if this is a real use case. Casting from an opaque to Any is not safe.
2 == (Write: Any) // compiles, returns true
obj.isInstnaceOf[Permission] // doesn't compile
obj match { case p: Permission => ??? }  // doesn't compile

I can’t disagree more. The entire point here is to introduce a new type that points to some underlying one. The keyword type is very precisely correct and clear…

5 Likes

I think that might be a bit confusing; how come a def has access to this but val doesn’t? I understand why that is but that seems like an irregularity comparing to classes and traits which share a similar syntactic structure.

I actually now think that it shouldn’t extend the wrapped type, as this gives the notion of a class, which it’s not. Also, the implementation there should be Permission(super | other); otherwise, this is endless recursive invocation.

Having the wrapped value as a “member” is better in multiple ways, I believe:

  1. No clear association with classes and inheritance.
  2. Actually represents the concept better - a type that wraps another type.
  3. Makes it clear that this type has only one immutable member (no other state).
  4. Reduces the need for this and super, as the wrapped type is a parameter with a name.

I guess I don’t have a strong opinion on that one. I’ll leave that to the discussion of multiversal equality.

It is very clear when you only consider this feature. When you consider the other features as well – type aliases and type members – this is becoming confusing, as they are all different things that behave differently and for different purposes.

Why not call everything a type? Why do we have classes and traits? Maybe we should just encode everything with zeros and ones, that is what this is all about after all.

This is yet another case of polysemy in the language (the other being implicit). Conflation of meanings, even if they share some similarities, is confusing and unproductive. We are not short of words, so why not use them?

An opaque type alias has the compile-time typing rules of an abstract type member, and the run-time semantics of a type alias.

Once you know that, everything is clear, and opaque type alias is a perfect name for this hybrid.

3 Likes

@sjrd has the precise definition, but to amplify that: the distinction between types and classes is central to Scala. It’s one of the topics I usually teach to folks the first day. They’re fundamentally different concepts.

This isn’t squishing everything into the word type – that word means something precise, specific and important, and it’s being used correctly here.

2 Likes

It might be clear for someone who’s working on the internals and is very proficient in Scala; it is not so clear for most developers. Just look a few comments above and see the confusion between type aliases and abstract type members.

And no, it doesn’t have the compile-time rules of an abstract type member. An abstract type member is just that – abstract – and only comes into play in conjunction with inheritance (anonymous or not). With opaques there is no inheritance; they are wrappers.

I don’t dispute that classes and types are different, but rather the other way around. My point is that we acknowledge the different between them and give them different words, and we should exactly the same for abstract type members, type aliases and opaques.

This attempt of linking these different concepts with the same keyword seem to me tantamount to trying and removing class from the language and replacing it with type. It wouldn’t be so hard to do as the two are mostly used with different constructs so there won’t be much (compiler) ambiguity. Obviously, that is a bad idea, and I pointed it out to try and illustrate why I think using type for opaques is a bad idea as well.

Anyhow, I don’t horribly mind it if the syntax looked like that:

opaque type Permission(i: Int) {
  def this(name: String) = ??? // alternate construction
  def |(other: Permission): Permission = Permission(this | other)
  def isOneOf(permission: Permission): Boolean = (this & permission) != 0
}

object Permission {
  def apply(yetAnotherConstructor: Double): Permission = ???
  val Write = Permission(2)
}

this could be replaced by something else. Having a fake constructor as in your example is confusing – the parameter can not be modified on input, it can not be public, val, var, etc… it shares nothing with an ordinary constructor list other than naming the variable.
this was the first thing that came to mind.

This construct shares more with object than traits or classes, so I am not concerned with objections about dissimilarity with trait or class semantics.

Yes, extends is not the best word. It was an existing keyword, I did not want to make a new one. I guess for or as may be more appropriate

opaque Permission as Int ?

No. this is of type Int not type Permission, so no recursion. That may be confusing, maybe a different name should be used, like inner or wrapped.

To be honest, I dislike the ‘fake member’ as much as I dislike ‘this’. I am not convinced whether a user supplied name is better than a fixed one either way. Both have advantages and disadvantages.

The scope is far larger than multiversal equality. See the discussion in this thread in the past. I would not want an Akka like send def send(Any: a) = ... to accept opaque types. Wrap it in a case class, or explicitly unwrap to int. Its not type safe to upcast these. I wouldn’t even want them to work in something like `s"{someOpaque}" unless that opaque explicitly has its own toString.
In short… I don’t think they are true subtypes of Any, because the language relies on runtime information for dispatch in many places (esp. pattern matching and OO polymorphism), and without runtime information they can not conform to the contracts of Any.

Thus, these values are not true subtypes of Any. You have to unpack one to get an Any value out, but can create a List[Permission] without doing so. Unfortunately, there is no escape from lack of safety, unless you forbid treating the type parameter in List[+A] covariantly if it is inhabited by an opaque type. There are probably other holes too, especially with union and intersection types. I don’t know what it would look like to try and patch them all or if its possible.

But, if the blatantly obvious passing of an opaque value to its ‘wrapped’ type is blocked, why not any other casting?

val x: Logarithm = ???
// why block this:
def square(d: Double) = d * d
square(x) // can't do it!
// what about this ?
def sneaky(d: AnyVal) = d match { case Double => d * d }
sneaky(d)
// or this?
def sneakyAny(d: Any) = d match { case Double => d * d }
sneakyAny(d)

I don’t understand why its not ok to supply a Logarithm for a Double parameter, but it is OK to substitute it for an Any parameter.

This is a compiler-only type, a user should have to unwrap or convert it before leaving where the compiler can track its type. IMO all the above should be invalid. Its not safe to implicitly erase an opaque type.

1 Like

I actually think the entire point is that they are not wrappers.

They are more like a disguise or costume. Hide the true runtme nature of the type from the compiler, and provide a different set of methods visible to the compiler.

5 Likes

I quite like that syntax.

I like the naming because there’s often a meaning to the value that’s being hidden, so allowing the programmer to give it a meaningful name is a win with almost no downsides from my perspective.

I like the scoped methods because it makes it easy to bring them into scope, and easy to see what does and does not have access to the hidden value.

I would suggest two changes to make it obvious this isn’t a class or object:

  1. Add a soft keyword hides to help hint that this isn’t a wrapper or a class, and that (i: Int) in the example isn’t a constructor parameter block.
  2. Refer to the hidden value by name, to reduce confusion about when you’re talking about opaque type alias, and when you’re talking about the value it hides. This should help avoid accidental recursion.
opaque type Permission hides (i: Int) {
  def this(name: String) = ??? // alternate construction
  def |(other: Permission): Permission = Permission(i | other.i)
  def isOneOf(permission: Permission): Boolean = (i & permission.i) != 0
  def lessThan(other: Permission): Boolean = i < other.i
}

object Permission {
  def apply(yetAnotherConstructor: Double): Permission = ???
  val Write = Permission(2)

  // Ideally, Permission would have an implicit scope, and this would be in it.
  // Apologies if the given syntax isn't up to date
  given as Comparator[Permission] = Comparator.fromLessThan(_ lessThan _)
}
1 Like

Fair enough, how about this:

opaque Permission { i: Int =>
}

I see that now, and I can’t say I’ve thought about it enough to make an opinion. My first observation though is that it’s unavoidable that opaques could be “upcasted” to Any, as type parameters act the same way:

trait Seq[A] {
  def head: A
  def isHeadInt: Boolean = head match {
    case i: Int => true
    case _ => false
  }
  def contains(elem: Any): Boolean = exists(_ == elem)
}

val permissions = Seq(Permission(0), Permission(2))
permissions.isHeadInt() // true
permissions.contains(0) // true
permissions.contains("lala") // false

And obviously there’s hashcode as well.

But they can, since all other types conform to the contract of Any, so they can rely on their wrapped types to fulfill that contract for them.

I don’t particularity like having a keyword followed by a parameters clause; they usually come only after an identifier — class name, function name, etc. How about this instead?

opaque Permission { i: Int =>
}

Just use the old syntax until we sort that thing out :slight_smile:

1 Like

Fair enough, and your proposal is close enough to self typing (and serves the same purpose), that it’s easy to reason about what it’s doing.

The question then becomes, how to we create these?

Is Permission.apply(Int) only created if there’s a synthetic companion object, and should it be created manually if a companion object is created by the programmer?

If Permission.apply(Int) isn’t created, what would it look like? It feels like it shouldn’t look like creating a class wrapper, as new Permission(4) looks way too much like what this isn’t. On the other hand, Permission(4) is a bit odd if you’re defining Permission.apply(Int) because you want to add some validation.

I’m guessing it could follow the rules of case classes. Is there any disadvantage to that? (it’s late and I can’t think clearly anymore)

I just realized how irregular this is, and I’m actually surprised that this compiles. How come an object can have abstract definitions?

1 Like

12 posts were split to a new topic: What’s in a type alias

The phrase “run-time semantics of a type alias” make me a bit uncomfortable – I never consider types to exist at run-time, so they can’t have any run-time semantics AFAIC. Is this the wrong intuition for opaque types or type aliases?

Your language has run-time semantics. It’s not because something disappears in the JVM bytecode that they don’t have run-time semantics. It’s just that the compiler encodes those run-time semantics in a way that does require leaving a definition for the type in the bytecode. But just the fact that the compiler leaves operations on those things in the bytecode means that something about them gets executed, so they have run-time semantics.

It’s not a wrong intuition for opaque types or type aliases. It’s a wrong intuition for the whole language, if you reason that anything that does not have a definition in the bytecode does not have run-time semantics.

Liberate yourself from thinking about the JVM. Think about what your source language means. When you code in Scala, do you always play the compiler to the JVM to reason about what your code does? Of course not: you reason about your code at the level of Scala. And that means you associate run-time semantics to the Scala language; not with the bytecode. The bytecode is an implementation detail.

2 Likes

I don’t think I understand how types have run-time semantics at all. I’ll leave the discussion to those who do, while I question my understanding of the language.