data-class allows to create classes almost like case-classes, but with no
public unapply
or copy
methods, making it easier to add fields to them while
maintaining binary compatiblity.
Add to your build.sbt
,
libraryDependencies += "io.github.alexarchambault" %% "data-class" % "0.2.1"
The macro paradise plugin is needed up to scala 2.12, and the right compiler option needs to be used from 2.13 onwards:
lazy val isAtLeastScala213 = Def.setting {
import Ordering.Implicits._
CrossVersion.partialVersion(scalaVersion.value).exists(_ >= (2, 13))
}
libraryDependencies ++= {
if (isAtLeastScala213.value) Nil
else Seq(compilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full))
}
scalacOptions ++= {
if (isAtLeastScala213.value) Seq("-Ymacro-annotations")
else Nil
}
Lastly, if you know what you are doing, you can manage to have data-class be a compile-time only dependency.
Use a @data
annotation instead of a case
modifier, like
import dataclass.data
@data class Foo(n: Int, s: String)
This annotation adds a number of features, that can also be found in case classes:
- sensible
equals
/hashCode
/toString
implementations, apply
methods in the companion object for easier creation,- extend the
scala.Product
trait (itself extendingscala.Equal
), and implement its methods, - extend the
scala.Serializable
trait.
It also adds things that differ from case classes:
- add
final
modifier to the class, - for each field, add a corresponding
with
method (fieldcount: Int
generates a methodwithCount(count: Int)
returning a new instance of the class withcount
updated).
Most notably, it does not generate copy
or unapply
methods, making
binary compatibility much more tractable upon adding new fields (see below).
In the example above, the @data
macro generates code like the following (modulo macro hygiene):
final class Foo(val n: Int, val s: String) extends Product with Serializable {
def withN(n: Int) = new Foo(n = n, s = s)
def withS(s: String) = new Foo(n = n, s = s)
override def toString: String = {
val b = new StringBuilder("Foo(")
b.append(String.valueOf(n))
b.append(", ")
b.append(String.valueOf(s))
b.append(")")
b.toString
}
override def canEqual(obj: Any): Boolean = obj != null && obj.isInstanceOf[Foo]
override def equals(obj: Any): Boolean = this.eq(obj.asInstanceOf[AnyRef]) || canEqual(obj) && {
val other = obj.asInstanceOf[Foo]
n == other.n && s == other.s
})
override def hashCode: Int = {
var code = 17 + "Foo".##
code = 37 * code + n.##
code = 37 * code + s.##
37 * code
}
private def tuple = (this.n, this.s)
override def productArity: Int = 2
override def productElement(n: Int): Any = n match {
case 0 => this.n
case 1 => this.s
case n => throw new IndexOutOfBoundsException(n.toString)
}
}
object Foo {
def apply(n: Int, s: String): Foo = new Foo(n, s)
}
By default, the classes annotated with @data
now have a shape that
shapeless.Generic
handles:
import dataclass.data
@data class Foo(n: Int, d: Double)
import shapeless._
Generic[Foo] // works
Note that with shapeless 2.3.3
and prior versions, Generic
derivation may fail
if the body of the @data
class contains val
s or lazy val
s, see
shapeless issue #934.
In order to retain binary compatibility when adding fields, one should:
- annotate the first added field with
dataclass.since
, - provide default values for the added fields, like
import dataclass._
@data class Foo(n: Int, d: Double, @since s: String = "", b: Boolean = false)
The @since
annotation makes the @data
macro generate apply
methods
compatible with those without the new fields.
The example above generates the following apply
methods in the companion object of Foo
:
object Foo {
def apply(n: Int, d: Double): Foo = new Foo(n, d, "", false)
def apply(n: Int, d: Double, s: String, b: Boolean) = new Foo(n, d, s, b)
}
The @since
annotation accepts an optional string argument - a version
can be passed for example - and it can be used multiple times, like
import dataclass._
@data class Foo(
n: Int,
d: Double,
@since("1.1")
s: String = "",
b: Boolean = false,
@since("1.2")
count: Option[Int] = None,
info: Option[String] = None
)
This generates the following apply
methods in the companion object of Foo
:
object Foo {
def apply(n: Int, d: Double): Foo = new Foo(n, d, "", false, None, None)
def apply(n: Int, d: Double, s: String, b: Boolean) = new Foo(n, d, s, b, None, None)
def apply(n: Int, d: Double, s: String, b: Boolean, count: Option[Int], info: Option[String]) = new Foo(n, d, s, b, count, info)
}
- contraband relies on code generation from JSON or a custom schema language to generate classes that can be evolved in a binary compatible way
- stalagmite generates case classes with custom features via some macros (but doesn't aim at helping maintaining binary compatibility)