Skip to content

SIP-41 - Sealed Types#43

Closed
scala-improvement-bot wants to merge 4 commits intomainfrom
add-sealed-types
Closed

SIP-41 - Sealed Types#43
scala-improvement-bot wants to merge 4 commits intomainfrom
add-sealed-types

Conversation

@scala-improvement-bot
Copy link
Copy Markdown
Contributor

This pull request has been automatically created to import proposals from scala/docs.scala-lang

@julienrf
Copy link
Copy Markdown
Contributor

Thank you @dwijnand and @liufengyun for submitting the proposal, I have assigned a team of reviewers to it.

Copy link
Copy Markdown
Contributor

@odersky odersky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very intriguing and ambitious proposal. I have one major concern: it looks to me that pattern arguments would not be supported (happy to be shown wrong on this).

Since the proposal is very far reaching, I think it would be important to do a deeper exploration. Ideally in the form of a paper submitted to a conference like the Scala symposium.

There's also a possible connection to dependent types and refinement types here. Maybe @mbovel should take a look to see whether something clicks.

Comment thread content/sealed-types.md
`Zero.type`, and `Neg`, using the `ordinal` method:

```scala
given TypeTest[Int, Zero.type] = (n: Int) => if ((n: Num).ordinal == 0) Some(n) else None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this would work for multiple sealed extensions of Int. (n: Num) has no special effect since it is an alias of (n: Int). But one could probably define a sealed-type-specific ordinal as a normal method. I.e.

def Num$ordinal(n: Int) = ...
...
given TypeTest[Int, Zero.type] = (n: Int) => if Num$ordinal(n) == 0 then Some(n) else None

Comment thread content/sealed-types.md
4. Each case must define a new type or singleton type

## Alternative Design

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I prefer the original design over the alternative. What's neat is that there is a single ordinal method that chooses the right alternative. That means we can just rely on normal sequential pattern matching. By contrast in the alternative design we risk ambiguity if the definitions of the individual TypeTests have overlapping conditions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I've held that non-ambiguity as core to the design.

Comment thread content/sealed-types.md
1. If the match on the value of the underlying type is not exhaustive, then the sealed type must be
declared `opaque`, in order to preserve the fact that the sealed type represents only a subset of
the values of the underlying type (e.g. positive integers)
2. No other type may be declared to subtype the opaque type `T`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that restriction is tricky to implement. Why do we need it?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I can't remember now. @liufengyun, anything pop in mind?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I remember, suppose Pos represents positive even or odd numbers, opaque type Pos = Int { ... }, here Pos is not a partition of Int.

Having another type type S <: Pos = Int can break the abstraction --- now negative numbers can take the type Pos.

Comment thread content/sealed-types.md
```scala
sealed type Num = Int {
case 0 => val Zero
case n if n > 0 => type Pos
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is neat, but what if I want to expose type arguments in patterns? I mean the typical general case of extractor base matching would be

selector match
  case Pat1(xs1) =>
  ...
  case PatN(xsN) =>

It seems there's no way to get to the extractor specific subpatterns?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So perhaps it's sadly too mechanic, but with the sealed type definition we're just splitting the type. Which enables case _: Pat1 =>. If you want to decompose Pat1 then you'd write a object Pat1 { def unapply(x: Pat1): (Int, String) = (x.foo, x.bar) }. Or, even more verbosely, with a non-allocating Pat1Extractor value class as a result type...

@odersky
Copy link
Copy Markdown
Contributor

odersky commented Jul 21, 2022

I see another alternative that should be explored. It's inspired by a very old paper:

Views: a way for pattern matching to cohabit with data abstraction by Phil Wadler

The idea is that we could also encapsulate the logic in a view method. E.g. for the example in the proposal:

enum IntSplit: 
  case Pos(x: Int)
  case Neg(x: Int)
  case Zero

  def view(x: Int): IntSplit = 
    if x < 0 then Neg(x)
    else if x > 0 then Pos(x)
    else Zero

Then the pattern match would look like this:

IntSplit.view(n) match
  case IntSplit.Zero    => 
  case IntSplit.Pos(x) =>
  case IntSplit.Neg(x) =>

It's naturally exhaustive.

All of this can be done with current Scala, so you might ask why is a language extension needed? The only reason I could see is we might want to avoid explicit call of the view method. This is not so much an issue for flat patterns like the one above, but becomes more burdensome for deep patterns since then we have to split up a single match into multiple ones in order to be able to insert the necessary view calls.

So this makes me think of defining some kind of implicit "pattern-view" conversion that is inserted when the patterns are part of a PatternView type. E.g. define a trait PatternView

trait PatternView[T]:
  def view(x: T): Any

and change the first line of the example above to

enum IntSplit extends PatternView[Int]

Then, if all cases of a pattern are cases from the same pattern view, wrap the corresponding view method around the selector before going into the unapply or comparison. During type checking, the view method calls would have to be
attached to the pattern. The pattern matching transform then would move them to be regular conversions of the selector.

view conversions are not regular implicit conversions. Indeed, regular implicit conversions cannot be used in pattern matching in the way I described.

Copy link
Copy Markdown
Contributor

@odersky odersky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in light of all this I'd say this needs to go back to the drawing board.

@dwijnand
Copy link
Copy Markdown
Member

That design incurs a boxing cost, which is part of the starting point of this proposal.

Copy link
Copy Markdown
Contributor

@Kordyjan Kordyjan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All in all, as I understand, the only improvement over Scala today is the elimination of boxing and a slight reduction of boilerplate code when the user is defining arbitrary equivalence classes over values of some already defined type.

I'm still unsure if this is enough to justify increasing the complexity of the language.

Moreover, I find the syntax proposed for the declaration of sealed types confusing. I think that if we decide to go on with the proposal, we should rethink the syntax aspect.

Comment thread content/sealed-types.md
Comment on lines +74 to +78
sealed type Num = Int {
case 0 => val Zero
case n if n > 0 => type Pos
case _ => type Neg
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this syntax is very confusing. I run a very simple test. I showed the snippet above to 6 folks who have experience both in Scala 2 and Scala 3 but do not know about this proposal.
None of them could guess what the snippet is supposed to do. After revealing what it desugars to, they unanimously agreed that it would be a constant source of confusion.

Firstly, I believe that even years after it landing in the language, googling "sealed types scala" would return results that are overwhelmingly about sealed traits. Sealed traits are in language much longer, there is much more written about them, all their descriptions probably contain the word "types" somewhere, and search engines are not very good at interpreting subtle differences in queries.

Another aspect here is that the syntax looks like a mashup between refined types, pattern matching and match types. this will definitely add to the confusion.

Lastly, and this is not a fatal flaw, but case 0 => val Zero looks really weird and, in my opinion, not very scala-like. I think it can be substituted with case 0 => 0.type or even just case 0 => type Zero it would make much more sense.

@julienrf
Copy link
Copy Markdown
Contributor

@dwijnand and @liufengyun, are you interested in continuing your work on this proposal?

@liufengyun
Copy link
Copy Markdown

Sorry for the late response @julienrf . This SIP is an exploration of the design space to extend exhaustivity check to opaque types and abstract type members.

Given the helpful feedback, I think it requires more thinking in a bigger scope and in terms of cost/benefit. I'm happy to close this SIP. WDYT @dwijnand ?

@dwijnand
Copy link
Copy Markdown
Member

I'm happy to as well.

@liufengyun
Copy link
Copy Markdown

For anyone interested in working in the area later, one lesson I learned is that boxing (which is a performance concern) should not play a too big role in language design. Too much concern for boxing unnecessarily restricts the design space. Backend compilers with inlining + (partial) escape analysis are pretty good at removing local boxing costs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants