data-class allows to create classes almost like case-classes, with no public
unapply method and with binary-compatible copy overloads, making it easier
to add fields while maintaining binary compatibility.
Add to your build.sbt,
libraryDependencies += "io.github.alexarchambault" %% "data-class" % "0.2.6"The build uses Mill via the checked-in launcher. Run all tests with:
./mill dataClass.2_12_21.test.testForked
./mill dataClass.2_13_18.test.testForked
./mill test.compat.3_8_2.test.testForked
Check binary compatibility against applicable versions discovered from release tags with:
./mill __.mimaReportBinaryIssues
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.
Sources shared by Scala 2 and Scala 3 can use a case class and rename
@since to @unroll:
import dataclass.{data, since => unroll}
@data case class Foo(a: Int, @unroll b: Boolean = false)On Scala 2, @data expands the class and @since controls its compatible
constructor, apply, and copy overloads. On Scala 3, @data is a no-op and
@since is an alias for scala.annotation.unroll, so the case class and its
compatible overloads are generated by the compiler. Scala 3 currently requires
the -experimental compiler option to use @unroll.
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/toStringimplementations, applymethods in the companion object for easier creation,- public
copymethods corresponding to the generated constructors, - extend the
scala.Producttrait (itself extendingscala.Equal), and implement its methods, - extend the
scala.Serializabletrait.
It also adds things that differ from case classes:
- add
finalmodifier to the class, - for each field, add a corresponding
withmethod (fieldcount: Intgenerates a methodwithCount(count: Int)returning a new instance of the class withcountupdated).
Most notably, it does not generate an unapply method. Its copy methods have
the same binary-compatible overloads as its constructors (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 copy(n: Int = this.n, s: String = this.s) = new Foo(n, s)
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] // worksNote that with shapeless 2.3.3 and prior versions, Generic derivation may fail
if the body of the @data class contains vals or lazy vals, 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)
@unrollsupport in Scala 3 should help evolving methods and classes a binary-compatible way