Create a gist now

Instantly share code, notes, and snippets.

@tpolecat /hmm.md Secret
Last active Jun 20, 2017

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 commented Oct 18, 2016

Nice trick!

Sneaky!

folone commented Oct 18, 2016

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

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/

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.

Owner

tpolecat commented Oct 18, 2016

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

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.

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

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 commented Jun 13, 2017 edited

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