Does having a private constructor on a value class negate the benefits of a value class?

936 Views Asked by At

I'm thinking of making a value class that has some guard on how it can be instantiated. For the sake of example, say I want a non-negative integer:

class NonNegInt private (val value: Int) extends AnyVal

object NonNegInt {
  def apply(value: Int): Try[NonNegInt] = Try {
    if (value >= 0) new NonNegInt(value) else throw new IllegalArgumentException("non-negative integers only!")
  }
}

My only worry is that the private constructor may make it impossible for the scala compiler to treat the NonNegInt as a primitive int. Is this true?

3

There are 3 best solutions below

1
On BEST ANSWER

If "treat as a primitive" here means "avoid allocation", then this indeed will not work, but not because of a private constructor.

As mentioned in Value Classes Guide

Another instance of this rule is when a value class is used as a type argument. For example, the actual Meter instance must be created for even a call to identity.

def identity[T](t: T): T = t
identity(Meter(5.0))

Basically, because identity[T] is parametrized, invoking it on a value type requires an allocation of an instance. Try[T] is the same situation: Try { ... } "block" is an invocation of a parametrized function Try.apply[T] with T being NonNegInt. This call will require an allocation of NonNegInt instance.

6
On

This is a hint:

scala> implicit class X private (val i: Int) extends AnyVal { def doubled = 2 * i }
<console>:7: error: constructor X in class X cannot be accessed in object $iw
       implicit class X private (val i: Int) extends AnyVal { def doubled = 2 * i }
                      ^

And this is definitive:

$ scala -optimise
Welcome to Scala version 2.11.4 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_11).
Type in expressions to have them evaluated.
Type :help for more information.

scala> :pa
// Entering paste mode (ctrl-D to finish)

class X private (val i: Int) extends AnyVal { def doubled = 2 * i }
object X { @inline def apply(i: Int) = new X(i) }

// Exiting paste mode, now interpreting.

defined class X
defined object X

scala> X(42).doubled
warning: there was one inliner warning; re-run with -Yinline-warnings for details
res0: Int = 84

You can use :javap -prv - to verify that there was an allocation.

But this is a better trick:

scala> case class X private (val i: Int) extends AnyVal { def doubled = 2 * i }
defined class X

scala> X(42).doubled
res1: Int = 84

scala> :javap -prv -
[snip]
  public $line7.$read$$iw$$iw$();
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0       
         1: invokespecial #19                 // Method java/lang/Object."<init>":()V
         4: aload_0       
         5: putstatic     #21                 // Field MODULE$:L$line7/$read$$iw$$iw$;
         8: aload_0       
         9: getstatic     #26                 // Field $line6/$read$$iw$$iw$X$.MODULE$:L$line6/$read$$iw$$iw$X$;
        12: bipush        42
        14: invokevirtual #30                 // Method $line6/$read$$iw$$iw$X$.doubled$extension:(I)I
        17: putfield      #17                 // Field res1:I
        20: return  

Footnote:

scala> case class X[A <: X[A]] private (val i: Int) extends AnyVal { def doubled = 2 * i }
defined class X

scala> X(42).doubled
res2: Int = 84
0
On

Your example code is making your actual question ambiguous. Your example code wraps the Int in a Try. If instead of using Try, you used a require statement in the companion object, then it's my understanding the code below would work (without losing the "primitive" benefits extending AnyVal offers). This would give you a runtime exception if/when there is an attempt to produce a negative value. The code uses a private constructor on the case class extending AnyVal. Then it uses the case class's companion object's apply method to enforce runtime constraints via a require statement.

If you really need to wrap the value using a Try, you can provide an additional companion object constructor to wrap apply to capture the exception. However, as is pointed out in other answers, you lose the AnyVal "primitive" quality when it is "contained" by a Try, Option, Either, etc.

WARNING: The code below will not compile in the REPL/Scala Worksheet. A case class extending AnyVal must be a top-level class; i.e. cannot be nested within the scope of another class, trait, or object. And both the REPL and Scala Worksheet are implemented by pushing all the code into an invisible containing class before executing.

object PositiveInt {
  def apply(value: Int): PositiveInt = {
    require(value >= 0, s"value [$value] must be greater than or equal to 0")
    new PositiveInt(value)
  }

  def tryApply(value: Int): Try[PositiveInt] =
    Try(apply(value))
}
case class PositiveInt private(value: Int) extends AnyVal

val positiveTestA = PositiveInt(0)
val positiveTestB = PositiveInt(1)
val positiveTestD = PositiveInt.tryApply(-1)) //returns Failure
val positiveTestD = Try(PositiveInt(-1))      //returns Failure
val positiveTestC = PositiveInt(-1)           //throws required exception