Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve the "Backwards compatible case classes" guide with Scala 2 and 3 specifics #2788

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions _overviews/tutorials/binary-compatibility-for-library-authors.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,13 @@ Again, we recommend using MiMa to double-check that you have not broken binary c
Sometimes, it is desirable to change the definition of a case class (adding and/or removing fields) while still staying backwards-compatible with the existing usage of the case class, i.e. not breaking the so-called _binary compatibility_. The first question you should ask yourself is “do you need a _case_ class?” (as opposed to a regular class, which can be easier to evolve in a binary compatible way). A good reason for using a case class is when you need a structural implementation of `equals` and `hashCode`.

To achieve that, follow this pattern:
* make the primary constructor private (this makes the `copy` method of the class private as well)
* define a private `unapply` function in the companion object (note that by doing that the case class loses the ability to be used as an extractor in match expressions)
* make the primary constructor private (for Scala 3, this makes the `copy` method of the class and `apply` in the companion object private as well)
* for all the fields, define `withXXX` methods on the case class that create a new instance with the respective field changed (you can use the private `copy` method to implement them)
* create a public constructor by defining an `apply` method in the companion object (it can use the private constructor)
* in Scala 2, you have to add the compiler option `-Xsource:3`
* (Scala 2) you have to add the compiler option `-Xsource:3`
* (Scala 2) define the `copy` method with all the current fields manually and set it as `private`
julienrf marked this conversation as resolved.
Show resolved Hide resolved
* (Scala 2) define a private `unapply` method in the companion object (note that by doing that the case class loses the ability to be used as an extractor in match expressions)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make this Scala 2 only? AFAIK it is also required in Scala 3.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not according to @lihaoyi

unapply: I think as of Scala 3 this will work right out of the box: unapply no longer returns Option[TupleN[...]] as it did in Scala 2, and instead just returns Person, with pattern matching just relying on the ._1 ._2 etc. fields to work. Thus, a p match{ case Person(first, last) => ???} callsite compiled against case class Person(first: String, last: String) should continue to work even when Person has evolved into case class Person(first: String, last: String, country: String = "unknown", number: Option[String] = None)

https://contributors.scala-lang.org/t/can-we-make-adding-a-parameter-with-a-default-value-binary-compatible/6132?u=sideeffffect

Copy link
Contributor

@julienrf julienrf Sep 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be a source incompatibility, but if someone links an already compiled program with the new version of the case class, then they may get a run-time crash. I think it is preferable to recommend making unapply private in both Scala 2 and 3.

* (Scala 3) define a custom `fromProduct` method in the companion object
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That one is interesting. We need to investigate further:

  • what is the current state of mirrors (without defining fromProduct)
  • demonstrate in a project example that defining fromProduct is enough to get type class derivation working on case classes with a private primary constructor, and that it works as expected when fields are added.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can thank @armanbilge for discovering this

http4s/http4s#7066 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That discussion needs to be resolved before we can advertise implementing fromProduct.


Example:

Expand All @@ -191,6 +193,8 @@ Example:
~~~ scala
// Mark the primary constructor as private
case class Person private (name: String, age: Int) {
// Ensure the `copy` method is private
private def copy(name: String = name, age: Int = age) = new Person(name, age)
// Create withXxx methods for every field, implemented by using the (private) copy method
def withName(name: String): Person = copy(name = name)
def withAge(age: Int): Person = copy(age = age)
Expand All @@ -216,8 +220,13 @@ case class Person private (name: String, age: Int):
object Person:
// Create a public constructor (which uses the private primary constructor)
def apply(name: String, age: Int): Person = new Person(name, age)
// Make the extractor private
private def unapply(p: Person) = p
// Implement a custom `fromProduct`
def fromProduct(p: Product): Person = p.productArity match
case 2 =>
Person(
p.productElement(0).asInstanceOf[String],
p.productElement(1).asInstanceOf[Int],
)
```
{% endtab %}
{% endtabs %}
Expand Down Expand Up @@ -254,20 +263,27 @@ alice match

Later in time, you can amend the original case class definition to, say, add an optional `address` field. You
* add a new field `address` and a custom `withAddress` method,
* update the public `apply` method in the companion object to initialize all the fields,
* add a new `apply` method to the companion object to initialize all the fields and adjust the old `apply`,
* tell MiMa to [ignore](https://github.com/lightbend/mima#filtering-binary-incompatibilities) changes to the class constructor. This step is necessary because MiMa does not yet ignore changes in private class constructor signatures (see [#738](https://github.com/lightbend/mima/issues/738)).
* (Scala 2) add the new field to the `copy` method
* (Scala 3) add a new case to the `fromProduct` method in the companion object

{% tabs case_class_compat_4 class=tabs-scala-version %}
{% tab 'Scala 2' %}
~~~ scala
case class Person private (name: String, age: Int, address: Option[String]) {
...
// Add the new field to the `copy` method
private def copy(name: String = name, age: Int = age, address: Option[String] = address) = new Person(name, age, address)
// Add the `withXxx` method
def withAddress(address: Option[String]) = copy(address = address)
}

object Person {
// Update the public constructor to also initialize the address field
def apply(name: String, age: Int): Person = new Person(name, age, None)
// Add a new `apply` with the address field
def apply(name: String, age: Int, address: Option[String]): Person = new Person(name, age, address)
}
~~~
{% endtab %}
Expand All @@ -280,6 +296,21 @@ case class Person private (name: String, age: Int, address: Option[String]):
object Person:
// Update the public constructor to also initialize the address field
def apply(name: String, age: Int): Person = new Person(name, age, None)
// Add a new `apply` with the address field
def apply(name: String, age: Int, address: Option[String]): Person = new Person(name, age, address)
// Add a new case to `fromProduct`
def fromProduct(p: Product): Person = p.productArity match
case 2 =>
Person(
p.productElement(0).asInstanceOf[String],
p.productElement(1).asInstanceOf[Int],
)
case 3 =>
Person(
p.productElement(0).asInstanceOf[String],
p.productElement(1).asInstanceOf[Int],
p.productElement(2).asInstanceOf[Option[String]],
)
```
{% endtab %}
{% endtabs %}
Expand Down