Always make 'case class' final
final case class
When you use case class
, make sure you make it final
.
final case class Foo(n: Int) // 👍
case class Foo(n: Int) // 🚫
Why final case class
?
Why? Extending case class
can cause unwanted behaviours.
You may say case class
can't extend another case class
so why do we need to worry about it?
case class WithNumber(num: Int)
case class Item(num: Int, name: String) extends WithNumber(num)
// case class Item has case ancestor WithNumber, but case-to-case inheritance is prohibited.
// To overcome this limitation, use extractors to pattern match on non-leaf nodes.
// case class Item(num: Int, name: String) extends WithNumber(num)
// ^
// Compilation Failed
case class
can't extend another case class
, but a regular class
can. What's wrong with it?
Let's have a look at this example.
case class WithNumber(num: Int)
class Item(num: Int, name: String) extends WithNumber(num)
// No more compile-time error
Now the problem is
equals
new Item(1, "A") == new Item(1, "A") // true, as expected
new Item(1, "ABC") == new Item(1, "XYZ") // true, what?! 😨
As you may know, this is because Item.equals
is actually calling super.equals
as it is a regular class meaning that it doesn't have its ownequals
. The super
class of Item
is WithNumber
so that ==
is the same as WithNumber(1) == WithNumber(1)
.
hashCode
There is another problem with hashCode
as well.
new Item(1, "ABC").## == new Item(1, "XYZ").## // true
new Item(1, "ABC").##
// Int = -1345110089
new Item(1, "XYZ").##
// Int = -1345110089
// or
new Item(1, "ABC").hashCode
// Int = -1345110089
new Item(1, "XYZ").hashCode
// Int = -1345110089
This can be a big problem when it's used with hash-based data structure.
case class WithNumber(num: Int)
class Item(num: Int, name: String, price: BigDecimal) extends WithNumber(num)
Set(new Item(1, "ABC", 123), new Item(1, "DEF", 456), new Item(1, "XYZ", 999))
// Set[Item] = Set(WithNumber(1))
Map(new Item(1, "ABC", 123) -> 1, new Item(1, "DEF", 456) -> 2, new Item(1, "XYZ", 999) -> 3)
// Map[Item, Int] = Map(WithNumber(1) -> 3)
toString
And of course, toString
doesn't show what you may expect but only what case class
shows.
new Item(1, "ABC").toString
// WithNumber(1)
Pattern Matching
Not to mention the broken pattern matching.
new Item(1, "ABC") match {
case Item(num, name) => println(s"num: $num / name: $name")
}
// not found: value Item
// case Item(num, name) => println(s"num: $num / name: $name")
// ^
// Compilation Failed
Using the super
case class
doesn't work either.
new Item(1, "ABC") match {
case WithNumber(num) => println(s"num: $num")
}
// constructor cannot be instantiated to expected type;
// found : WithNumber
// required: Item
// case WithNumber(num) => println(s"num: $num")
// ^
// Compilation Failed
So it's always good to make case class
final
final case class Item(num: Int, name: String)
Any Exceptional Cases?
newtype
There might be some exceptional cases. One of them is when using newtype library.
Since there won't be an actual case class
for newtype
, you don't need to make @newtype case class
final
.
So the following one is fine.
@newtype case class Id(value: Int)
Just case object
If you're wondering if case object
should also be final
, no it should not because you cannot extend object
so case object
is already final
.