Instantly share code, notes, and snippets.

@tpolecat /hmm.md Secret
Last active Oct 5, 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.

Show comment
Hide comment
@pkinsky

pkinsky Oct 18, 2016

Nice trick!

pkinsky commented Oct 18, 2016

Nice trick!

@MartinSnyder

This comment has been minimized.

Show comment
Hide comment
@MartinSnyder

MartinSnyder commented Oct 18, 2016

Sneaky!

@folone

This comment has been minimized.

Show comment
Hide comment
@folone

folone Oct 18, 2016

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

folone commented Oct 18, 2016

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

@ccmtaylor

This comment has been minimized.

Show comment
Hide comment
@ccmtaylor

ccmtaylor 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 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.

Show comment
Hide comment
@ccmtaylor

ccmtaylor 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.

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.

Show comment
Hide comment
@tpolecat

tpolecat Oct 18, 2016

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

Owner

tpolecat commented Oct 18, 2016

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

@yawaramin

This comment has been minimized.

Show comment
Hide comment
@yawaramin

yawaramin 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.

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.

Show comment
Hide comment
@malcolmgreaves

malcolmgreaves 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.

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.

Show comment
Hide comment
@yawaramin

yawaramin 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.

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.

Show comment
Hide comment
@dk14

dk14 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){}
                  ^

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.

Show comment
Hide comment
@greenrd

greenrd 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.

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.

Show comment
Hide comment
@SethTisue

SethTisue 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.

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.

Show comment
Hide comment
@wjlow

wjlow 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))
                           ^

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.

Show comment
Hide comment
@michael-kendra

michael-kendra Jul 27, 2017

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

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.

Show comment
Hide comment
@jbgi

jbgi 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.)

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.

Show comment
Hide comment
@battermann

battermann Oct 20, 2017

@jbgi @SethTisue or

private def copy() = ()

battermann commented Oct 20, 2017

@jbgi @SethTisue or

private def copy() = ()
@matthughes

This comment has been minimized.

Show comment
Hide comment
@matthughes

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

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)
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment