alexarchambault / data-class   0.2.7

Apache License 2.0 GitHub

case-classes with better binary compatibility story

Scala versions: 2.13 2.12

data-class

Build Status Maven Central

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.

Usage

Setup

Add to your build.sbt,

libraryDependencies += "io.github.alexarchambault" %% "data-class" % "0.2.6"

The latest version is Maven Central.

Development

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.

API

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 / toString implementations,
  • apply methods in the companion object for easier creation,
  • public copy methods corresponding to the generated constructors,
  • extend the scala.Product trait (itself extending scala.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 (field count: Int generates a method withCount(count: Int) returning a new instance of the class with count updated).

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)
}

shapeless

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 vals or lazy vals, see shapeless issue #934.

Adding fields

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)
}

Related work

  • 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)
  • @unroll support in Scala 3 should help evolving methods and classes a binary-compatible way