Instantly share code, notes, and snippets.

@tpolecat /hmm.md Secret
Last active Dec 10, 2018

Embed
What would you like to do?

Case Classes w/ Smart Ctors

I just learned this trick from @OlegYch and it seems pretty good. Wondering if anyone knows of any problems with it.

People often try to constrain construction of a case class by making the constructor private and providing a partial factory method on the companion. For instance, a natural number class wrapping an Int:

case class Nat private (toInt: Int)
object Nat {
  def fromInt(n: Int): Option[Nat] =
    if (n >= 0) Some(Nat(n)) else None
}

We can construct instances with fromInt that behave as expected.

scala> Nat.fromInt(1)
res4: Option[Nat] = Some(Nat(1))

scala> Nat.fromInt(-1)
res5: Option[Nat] = None

scala> new Nat(42)
<console>:14: error: constructor Nat in class Nat cannot be accessed in object $iw
       new Nat(42)
       ^

However there are two holes:

scala> Nat.fromInt(42).get.copy(-67) // copy method is unsafe
res17: Nat = Nat(-67)

scala> Nat(-1) // oops, companion apply
res18: Nat = Nat(-1)

The traditional solution to this problem is to use a sealed trait or non-case class and hack up the rest of the machinery that makes it look like a case class, which is boilerplatey and irritating.


So, it turns out we can get around this by using the unlikely-sounding sealed abstract case class.

sealed abstract case class Nat(toInt: Int)
object Nat {
  def fromInt(n: Int): Option[Nat] =
    if (n >= 0) Some(new Nat(n) {}) else None
}

When we do this the behavior is what we want.

There's no companion apply.

scala> val n = Nat(10)
<console>:15: error: Nat.type does not take parameters
       val n = Nat(10)
                  ^

So we use the factory method.

scala> val on = Nat.fromInt(-1)
on: Option[Nat] = None

scala> val on = Nat.fromInt(10)
on: Option[Nat] = Some(Nat(10))

Pattern-matching and unapply work.

scala> val oi = on.collect { case Nat(n) => n }
oi: Option[Int] = Some(10)

scala> val n = on.get
n: Nat = Nat(10)

scala> Nat.unapply(n)
res11: Option[Int] = Some(10)

Our toInt field is public, and equals seems to do the right thing.

scala> n.toInt
res12: Int = 10

scala> Nat.fromInt(3) == Nat.fromInt(3)
res13: Boolean = true

And copy doesn't work.

scala> n.copy(-1)
<console>:18: error: value copy is not a member of Nat
       n.copy(-1)
         ^
@pkinsky

This comment has been minimized.

pkinsky commented Oct 18, 2016

Nice trick!

@MartinSnyder

This comment has been minimized.

MartinSnyder commented Oct 18, 2016

Sneaky!

@folone

This comment has been minimized.

folone commented Oct 18, 2016

Still needs a private constructor though, otherwise new Nat(-13){} would work.

@ccmtaylor

This comment has been minimized.

ccmtaylor commented Oct 18, 2016

Won't new Nat(10) {} create a new inner class on every invocation? I'd worry that this could lead to similar issues as java's double brace initialisation "idiom". This article goes into it in more detail https://blog.jooq.org/2014/12/08/dont-be-clever-the-double-curly-braces-anti-pattern/

@ccmtaylor

This comment has been minimized.

ccmtaylor commented Oct 18, 2016

It's probably not to bad, because since the only invocations are in the companion, that should be the only context that can leak, and the companion object instance will always be in memory anyway.

@tpolecat

This comment has been minimized.

Owner

tpolecat commented Oct 18, 2016

@folone only in the same source file since it's sealed.

@yawaramin

This comment has been minimized.

yawaramin commented Oct 19, 2016

Great trick! I've been confuzzled by the case class hole before, this will be awesome for that.

@ccmtaylor you can define a private implementor class and return instances of that (upcast to Nat) to keep class count down.

@malcolmgreaves

This comment has been minimized.

malcolmgreaves commented Oct 19, 2016

Wonderful! Thank you for sharing. This solution provides a really nice, elegant way to handle creating values with restricted ranges.

I can imagine using this idea with extends AnyVal for lightweight type "wrappers" around primitives with range restrictions.

@yawaramin

This comment has been minimized.

yawaramin commented Oct 19, 2016

@malcolmgreaves just tried it. Not doable, it seems--looks like extends AnyVal automatically makes the case class final, which means we can't make it abstract.

@dk14

This comment has been minimized.

dk14 commented Nov 10, 2016

Example with private-package constructor:

object pack {
  sealed abstract case class Nat private[pack](toInt: Int)
  object Nat {
    def fromInt(n: Int): Option[Nat] =
      if (n >= 0) Some(new Nat(n){}) else None
  }
}

scala> new Nat(-9){}
<console>:12: error: constructor Nat in class Nat cannot be accessed in <$anon: pack.Nat>
              new Nat(-9){}
                  ^

@greenrd

This comment has been minimized.

greenrd commented Dec 7, 2016

This is not needed for the situations where you would use extends AnyVal - extends AnyVal makes a class wrapping an Int (say) into just an Int at runtime, so the equals, toString, hashcode etc. will work as you expect, and you don't want copy anyway.

@SethTisue

This comment has been minimized.

SethTisue commented Jun 4, 2017

As of Scala 2.11.11 (with -Xsource:2.12) and Scala 2.12.2, this trick is no longer necessary. Thanks to scala/scala#5730, you can make the constructor private with case class C private (...) and then put your smart apply method in the companion object.

@wjlow

This comment has been minimized.

wjlow commented Jun 13, 2017

@SethTisue

Using Scala 2.12.2, you cannot seem to implement a smart apply method that returns an Option.

scala> case class Person private (name: String, age: Int)
defined class Person

scala> object Person {
     |   def apply(name: String, age: Int): Option[Person] =
     |     if (age < 0) None
     |     else Some(Person(name, age))
     | }
<console>:16: error: type mismatch;
 found   : Option[Person]
 required: Person
           else Some(Person(name, age))
                           ^
@michael-kendra

This comment has been minimized.

michael-kendra commented Jul 27, 2017

@wjlow You have to write Some(new Person(name, age)) because you've made the default apply private.

@jbgi

This comment has been minimized.

jbgi commented Aug 3, 2017

@SethTisue
this trick is still necessary with scala 2.12.2 because even with a private constructor, the copy method is still public :(
update: or alternatively also override the copy method (eg. to return an Option.)

@battermann

This comment has been minimized.

battermann commented Oct 20, 2017

@jbgi @SethTisue or

private def copy() = ()
@matthughes

This comment has been minimized.

matthughes commented Nov 20, 2017

@yawaramin, @ccmtaylor, @mpilquist

Compiler only creates one anonymous class there; it's not per creation, it's per unique inner class.

object NatSealed {
  def apply(toInt: Int): NatSealed = new NatSealed(toInt) {}
}

object Main extends App {
  override def main(args: Array[String]): Unit = {
    println(NatSealed(1).getClass == NatSealed(2).getClass)
  }
}
@dwijnand

This comment has been minimized.

dwijnand commented Nov 5, 2018

@jbgi

@SethTisue
this trick is still necessary with scala 2.12.2 because even with a private constructor, the copy method is still public :(
update: or alternatively also override the copy method (eg. to return an Option.)

Eliding the copy method entirely is being tracked (I think) by scala/bug#7884.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment