By: Matthias Berndt
History
| Date | Version |
|—————|———————————————————–|
| Oct 3rd 2024 | Initial Draft |
| Oct 3rd 2024 | Related Work |
| Oct 4th 2024 | Add paragraph about using a type check instead of equals |
| Oct 7th 2024 | Add paragraph about using unapply instead of equals |
| Dec 3rd 2024 | Change the approach to a magic CanEqual instance |
| Jan 3rd 2025 | Undo previous change, “magic CanEqual” has no benefits |
| Mar 6th 2025 | Add examples |
| Apr 11th 2026 | Incorporate feedback |
Summary
This proposal aims to make the strictEquality feature easier to adopt by making pattern matching
against singleton cases (e. g. Nil or None) work even when the relevant sealed or enum type
does not (or cannot) have a derives CanEqual clause.
Motivation
The strictEquality feature is important to improve type safety. However due to the way that pattern matching in
Scala works, it requires a CanEqual instance when matching against an object or a singleton enum case.
This is problematic because it means that pattern matching doesn’t work in the expected way for types where a
derives CanEqual clause is not desired.
By contrast, in languages like Haskell, an Eq instance is never required to perform pattern matching. It also
seems arbitrary that a CanEqual instance is required to match on types such as Option or List but not on
others such as Either.
A simple example is this code:
import scala.language.strictEquality
enum Nat:
case Zero
case Succ(n: Nat)
def +(that: Nat): Nat =
this match
case Nat.Zero => that
case Nat.Succ(x) => Nat.Succ(x + that)
This fails to compile with the following error message:
[error] ./nat.scala:9:10
[error] Values of types Nat and Nat cannot be compared with == or !=
[error] case Nat.Zero => r
[error] ^^^^^^^^
Possible fixes today
- add a
derives CanEqualclause to the ADT definition. This is unsatisfactory for multiple reasons:- it is additional boilerplate code that needs to be added in potentially many places when enabling this option, thus hindering adoption
- the ADT might not be under the user’s control, e. g. defined in a 3rd party library
- one might not want a
CanEqualinstance to be available for this type because one doesn’t want this type to be compared with the==operator. For example, when one of the fields in theenumis a function, it actually isn’t possible to perform a meaningful equality check. Functions inside ADTs are not uncommon, examples can be found for example in ZIO, cats or cats-effect It should be possible to match on such types without requiring aCanEqualinstance that couldn’t possibly work correctly in the general case.
- turn the no-argument-list cases into empty-argument-list cases:
enum Nat: case Zero() // notice the parens case Succ(n: Nat)The downsides are similar to the previous point:
- doesn’t work for ADTs defined in a library
- hinders adoption in existing code bases by requiring new syntax (even more so, because now you not only need to change the
enumdefinition but also everymatchandPartialFunctionliteral) - uglier than before
- pointless overhead: can have more than one
Zero()object at run-time
- perform a type check instead:
this match case _: Nat.Zero.type => that case Nat.Succ(x) => Nat.Succ(x + that)But like the previous solutions:
- hinders adoption in existing code bases by requiring new syntax
- looks uglier than before (even more so than the empty-argument-list thing)
For these reasons the current state of affairs is unsatisfactory and needs to improve in order to encourage adoption of strictEquality in existing code bases.
Proposed solution
Specification
The proposed solution is to not require a CanEqual instance during pattern matching when:
- the pattern is an
objectthat extends the scrutinee’s type, or - the pattern is an
enum casewithout a parameter list (e. g.Nat.Zero) and the scrutinee has thatenumtype (or a supertype thereof)
The semantics of pattern matching against a constant are otherwise unchanged, that is, equals will continue
to be invoked.
Examples
Example 1
def foo(vector: Vector[Int]) =
vector match
case Nil => 0
This example is not affected by this SIP: Vector[Int] is not a supertype of Nil.type. CanEqual is still required.
(Note: This code produces an unreachable code warning, but the branch is nevertheless taken (compiler bug #25933)).
Example 2
def foo(list: List[Int]) =
list match
case Nil => 0
This example is affected by this SIP. List[Int] is a supertype of Nil.type, and Nil is a case object, hence no
CanEqual instance is required (Note: the example still compiles without this SIP because that CanEqual instance
is available. But with this SIP, pattern matching will no longer require it)
Example 3
val TheAnswer = 42
def foo(i: Int) =
i match
case TheAnswer => 0
This example is not affected by this SIP: TheAnswer is not an object or enum case, CanEqual is required like before.
Example 4
def foo(either: Either[String, Int]) =
either match
case Left(_) => "l"
case Right(_) => "r"
This example is not affected by this SIP. It never required CanEqual as matching is performed using unapply.
Example 5
enum Foo:
case Bar
case Baz
def foo(x: Any) =
x match
case Foo.Bar => 0
This example is affected by this SIP: Any is a supertype of Foo and Foo.Bar is an enum case without a parameter list, hence no CanEqual instance is required.
Compatibility
This change creates no new compatibility issues and improves the compatibility of the strictEquality feature with existing code bases.
Alternatives
- It was proposed to instead change the
enumfeature so that it always includes an implicitderives CanEqualclause. This is unsatisfactory for many reasons:- doesn’t work for sealed types
- doesn’t work for 3rd party libraries compiled with an older compiler
CanEqualmight be undesirable for that type – doing general==comparisons is a more powerful operation than pattern matching, which can only compare for equality with the singletoncases, and hence pattern matching is possible for many types where a general==operation can not be implemented correctly.
- It was proposed to change the behaviour of pattern matching from an
==comparison to a type check, i. e. makecase Foo =>equivalent tocase _: Foo.type =>.- pro: the behaviour would be more consistent between
case classandcase objectmatching as matching against acase classalso does a type check - contra: it is a backward incompatible change. A prominent example is
Nil, whoseequalsmethod is overridden to return true for empty collections, even if these collections aren’t of typeList. Changing the behaviour would break such code - the author’s opinion is that, while this is an approach that he might have chosen in a new language, the practical benefits over the existing behaviour are marginal and that therefore the compatibility concerns outweigh them in this case
- pro: the behaviour would be more consistent between
- It was proposed to change the behaviour of
case objectso that it adds a suitabledef unapply(n: Nat): Booleanmethod and to havecase Foo =>invoke theunapplymethod (likecase Foo() =>does today) if one exists, falling back to==otherwise- pro: more consistent behaviour between
case objectandcase classasunapplywould be used in both cases - contra: behaviour of
matchstatements now depends on both the version of the compiler that you’re using and the compiler used to compile the ADT. - contra: incompatible change. If your
case objecthas an overriddenequalsmethod (like e. g.Nildoes), you now need to define anunapplymethod that delegates toequals, otherwise your code will break. - authors opinion: same as for 2. Fine if this was a new language, but the benefits aren’t huge and practical compatibility concerns matter more.
- pro: more consistent behaviour between
- It was proposed to drop the requirement for a
CanEqualinstance during pattern matching entirely and rely solely on the unreachability warning that the compiler emits when the scrutinee type isn’t a supertype of the pattern type. The author’s opinion is that this does not provide sufficient safety around theequalsmethod. As was explained above,CanEqualis not supposed to be available for types that cannot be meaningfully tested for equality, such asInt => Int. This proposal trivially allows equality comparisons between such types:val x: Int => Int = ??? val y: Int => Int = ??? x match case `y` => true case _ => falsestrictEqualitycurrently prevents this from compiling, and that is a feature, not a bug. It should also be pointed out that adding a: Anytype ascription will make all such comparisons compile, regardless of the type of the pattern. This is unfortunate as type ascriptions are normally a fairly safe and innocuous operation. The “philosophical” justification for nevertheless allowing matching againstcase objectand singletonenum cases is that in an ideal world, we wouldn’t even need to callequalsto test for equality with these – the only thing that is equal to a singleton is the singleton itself, and hence we could in principle use reference equality for these cases (the fact that we don’t is a mere concession to backward compatibility).Feedback …
- https://contributors.scala-lang.org/t/feedback-thread-for-strictequalitypatternmatching-new-in-3-8/7379
- https://users.scala-lang.org/t/your-experience-with-strictequalitypatternmatching-new-in-3-8/12208
- https://contributors.scala-lang.org/t/pre-sip-relaxed-strictequality-for-and/7356
- https://www.reddit.com/r/scala/comments/1r0ppxb/strictequalitypatternmatching_does_it_work_for_you/
The Feedback for the first iteration of this feature has been positive. If anything, the people who tried this wanted more of it. Specifically:
- For the cases specified above (case objects and singleton enum cases) it was suggested to get rid of the
CanEqualrequirement for==comparisons as well- I personally don’t see anything wrong with this idea, but I would consider it a distinct feature that should be discussed separately
- It was suggested to also make this feature work with
objects that lack acasemodifier- I agree with this. It was originally specified only for
case objects becausesealed trait/case class/case objectis the traditional encoding of ADTs in Scala, but limiting it to objects withcaseultimately serves no discernable purpose. - after positive feedback from the committee, I’ve now modified the specification accordingly
- I agree with this. It was originally specified only for
… and the conclusions I draw from it
Given the positive feedback, I would like to propose to the SIP committee:
- slightly relax the conditions when this feature applies: a
casemodifier forobjects should not be required- I’ve implemented the change here: https://github.com/scala/scala3/pull/25850
- promote this feature to stable (i. e. make it the default behaviour when
strictEqualityis enabled) in either 3.9 or 3.10, at the committee’s discretion
Related Work
- https://contributors.scala-lang.org/t/pre-sip-better-strictequality-support-in-pattern-matching/6781
- https://contributors.scala-lang.org/t/how-to-improve-strictequality/6722
- https://contributors.scala-lang.org/t/enumeration-does-not-derive-canequal-for-strictequality/5280
- Implementation: https://github.com/scala/scala3/pull/23803
- Implementation (stage 2) https://github.com/scala/scala3/pull/25850