Removing `copy` operation from case classes with private constructors

As @lambdista mentioned on Twitter, it would make sense to remove the copy operation from case classes that have a private constructor.

Here is a copy of his motivating example:

case class Nat private (value: Int) {
  // Define a `copy` member so that the compiler does not synthesize one,
  // and make it private so that users can not even call it
  private def copy(): Unit = ()
}
object Nat {
  def apply(value: Int): Option[Nat] =
    if (value < 0) None else Some(new Nat(value))
}

Keeping the default public copy operation would defeat the purpose of having a private constructor because it would make it possible to create an invalid instance.

As mentioned in the Twitter discussion, opaque types already fix the issue of creating a “newtype” with controlled constructors. However, I think case classes with private constructors cover use cases that are not covered by opaque types because case classes support pattern matching and can have multiple fields:

case class Rational private (num: Int, denom: Int)

object Rational {
  def apply(num: Int, denom: Int): Option[Rational] =
    if (denom == 0) None else Some(new Rational(num, denom))
}

For comparison, the same example with opaque types would awkwardly look like the following:

opaque type Rational = (Int, Int)

object Rational {
  def apply(num: Int, denom: Int): Option[Rational] =
    if (denom == 0) None else Some((num, denom))

  implicit class Ops(`this`: Rational) extends AnyVal {
    def num: Int = `this`._1
    def denom: Int = `this`._2
  }
}

I think this gives enough motivation for a better support of case classes with private constructors. What do you think? An auxiliary question could be: should we replace the syntax of opaque tyes with value classes with private constructors?

As suggested by @buzden, instead of removing the copy operation, it would make more sense to give it the same visibility as the constructor. I’m not sure how to provide a smooth migration path for such a change, though.

8 Likes

Maybe, it would make sense to assign the same privateness status to the copy operation as the constructor has? I mean, if constructor is private[somepackage], why not to have copy function with the same private[somepackage]?

6 Likes

In general, it would make sense to have some control over which case class features will be implemented. Most people make heavy use of case classes, because they like some of the features, and then other features get added unintentionally.

Definitely! I’ve updated the introductory post with this idea.

2 Likes

Actually there is already an open issue on that matter: https://github.com/scala/bug/issues/7884. In particular, that comment suggests the same change as the one suggested by @buzden.

Looks like we need to have some specific abstractions (and those most people are looking for them) but we don’t have them and thus we have to use something close to those we have (i.e., case class’es in this case).

Maybe, we should try to formulate in which cases case classes are used but at the same time not all of the features are used/useful? And then, maybe we’ll figure out whether or not do we need something else except just a case class. Personally I don’t have any yet (assuming, correction of modifiers of copy).

See https://github.com/lampepfl/dotty/pull/5472, which also applies an analogous visibility restriction to the synthesized apply method of a case class.

5 Likes

Maybe there is some design space to explore for creating a copy-like method if there is an unapply/apply pair of a suitable shape.
e.g. if there exists some

object X{
  def apply(a: A, b: B): Option[X]
  def unapply(foo: X): Option[(A, B)]
}

Then if class X is the companion class of object X, a copy method is generated as

def notQuiteCopy(a: A = this.a, b: B = this.b): Option[X] = unapply(this).flatMap( _ => apply(a, b))

1 Like