Value classes

on waitingforcode.com

Value classes

I am a class storing a single public and immutable value and at runtime I'm allocated on stack. What's my name ?

This new post from "One Scala feature per week" series presents value classes. The very first section mentions shortly value object pattern that is very similar to the main topic of the article. The next part part goes directly to the subject and presents some theoretical and important points about value classes. The 2 next parts shows some examples when value class doesn't and does need allocated memory.

Value object pattern

The idea behind value object pattern consists on representing data per value rather than per reference. It means that 2 different (in terms of memory addresses) value objects with the same value(s) are equal. From this short description you can already deduce that value objects are compared not through the identity (e.g. memory address, a database identifier) but by values they contain.

Value objects are usually small (e.g. amount with currency) and should be immutable in order to keep consistency in the business logic (since compared by values). Even though they could represent few values, they can still contain some helper logic. One of this kind of logic can be Java's String toUpperCase() method.

Value class

Scala's value class is very similar to the pattern described above. It also represents simple values that at JVM's level don't need an allocated place in the heap.

In order to create a value class, the following points need to be respected:

  • extending AyVal - the value class must extend scala.AnyVal. It's a root class for all value types. As specified in the documentation, it "describes values not implemented as objects in the underlying host system"
  • compile and runtime representations - the value class is represented differently on compile and run time. During the compilation it's exposed as a class. But during the execution (runtime), the value class is transformed to the value contained in it. For instance, the class Age(30) will be represented on runtime as int=30 allocated on stack and not on heap.
  • single, publicly accessible val parameter in the constructor - the value class can contain only 1 val parameter in the constructor. So the following is not a valid value class:
        class NotValueClass(val param1: Int, val param2: Int)
        
  • no additional vals, vars or nested elements (traits, classes or objects) - the value class can't contain any additional nested elements. So the following snippet doesn't represent a valid value class:
        class NotValueClass(val param1: Int) extends AnyVal {
            val param2 = param1 * 2
            var wasCalled = false
    
            trait NotValueClassNestedTrait
        }
        
  • only universal traits can be extended
  • no override for equals and hashCode methods
  • cannot be extended by other classes
  • must be a top-level class or a member of a statically accessible object - it means that value class can't be nested in another non-static class but it can be in an object that lets to access to it statically:
        // It's allowed - statically accessible member
        object ValueClassHolder {
          class Age(val age: Int) extends AnyVal
        }
        // Not allowed - to access the value class we must create an instance of NotStaticValueClassHolder
        class NotStaticValueClassHolder {
          class Age(val age: Int) extends AnyVal
        }
        
  • Universal trait

    A universal trait is a trait: extending Any, having only defs as members and not doing any initialization. An example of universal trait could be:

      trait Printable extends Any {
        def print(): Unit = println(this)
      }
      

    But under some circumstances, the value class will be compiled into an object and allocated on the heap:

    • when it's used in runtime type tests as for example in pattern matching
    • when it's considered as other type, for instance: def doSomething(someObject: Any) = someObject where someObject, even if it's defined as a value class, it's casted to Any type and allocated on the heap. The same rule applies for the case when the value class implements a trait (even universal one):
        trait ImplementedTrait
        class Implementation extends AnyVal with ImplementedTrait
      
        // For the case below, the value class will be allocated since it's
        // represented by another type ImplementedTrait
        def takeImplementedTrait(trait: ImplementedTrait)
        
    • when they're assigned to an array

    Value class compilation

    Before showing what happens under-the-hood when the value classes are compiled, let's show the tested code. To illustrate the compilation process, the below code will be used:

    case class Age(val value: Int) extends AnyVal
    
    case class AgeWithChecks(val value: Int) extends AnyVal {
      def isAdult(): Boolean = value > 18
    }
    
    case class AgeNotAnyVal(val value: Int)
    
    trait UniversalTraitAge extends Any {
      def isUniversalTrait: Boolean = true
    }
    
    case class AgeWithUniversalTrait(val value: Int) extends AnyVal with UniversalTraitAge
    
    // They don't compile
    trait NotUniversalTraitAge extends Any {
      val bornYear: Int
    
      def isUniversalTrait: Boolean = false
    }
    
    case class AgeWithNotUniversalTrait(val value: Int) extends NotUniversalTraitAge {
      override val bornYear: Int = 1900
    }
    
    case class AgeExtendedByOtherClasses(val value: Int) extends AnyVal
    
    case class AgeExtendingAgeExtendedByOtherClasses(override val value: Int) extends AgeExtendedByOtherClasses(value)
    
    case class AgeWith3Args(val value1: Int, val value2: Int, val value3: Int) extends AnyVal
    
    case class AgeWithValueClassDependencies(val age: Age) extends AnyVal
    
    case class AgeWithVarsAndVals(val age: Int) extends AnyVal {
      val birthdayDay: Option[String] = None
    }
    

    As you can correctly deduce, the compilator has some checks for the value classes definitions and the classes written after // They don't compile comment make the compilation fail with the following messages:

    Error:(21, 7) field definition is not allowed in universal trait extending from class Any
      val bornYear: Int
    
    Error:(32, 83) illegal inheritance from final class AgeExtendedByOtherClasses
    case class AgeExtendingAgeExtendedByOtherClasses(override val value: Int) extends AgeExtendedByOtherClasses(value)
    
    Error:(32, 12) case class AgeExtendingAgeExtendedByOtherClasses has case ancestor com.waitingforcode.types.AgeExtendedByOtherClasses, but case-to-case inheritance is prohibited. To overcome this limitation, use extractors to pattern match on non-leaf nodes.
    case class AgeExtendingAgeExtendedByOtherClasses(override val value: Int) extends AgeExtendedByOtherClasses(value)
    
    Error:(34, 12) value class needs to have exactly one val parameter
    case class AgeWith3Args(val value1: Int, val value2: Int, val value3: Int) extends AnyVal
    
    Error:(36, 46) value class may not wrap another user-defined value class
    case class AgeWithValueClassDependencies(val age: Age) extends AnyVal
    
    Error:(39, 7) field definition is not allowed in value class
      val birthdayDay: Option[String] = None
    

    Now when we try to see what happens if we compile only valid value classes (before the comment), we can write a simple main method and call javap -c -p on the compiled class:

    object ValueClassCompilationCheck {
      def main(args: Array[String]): Unit = {
        val age = new Age(10)
        val ageWithChecks = new AgeWithChecks(30)
        val ageWithUniversalTrait = new AgeWithUniversalTrait(40)
        val ageNotAnyVal = new AgeNotAnyVal(50)
      }
    }
    

    The command's output is:

      public void main(java.lang.String[]);
        Code:
           0: bipush        10
           2: istore_2
           3: bipush        30
           5: istore_3
           6: bipush        40
           8: istore        4
          10: new           #17                 // class com/waitingforcode/types/AgeNotAnyVal
          13: dup
          14: bipush        50
          16: invokespecial #20                 // Method com/waitingforcode/types/AgeNotAnyVal."":(I)V
          19: astore        5
          21: return
    

    As you can clearly see, the value classes are defined as simple variables allocated on stack. They're first pushed onto the operand stack and later stored in a local variable with istore_${n} instruction. A different output is generated for not value class (AgeNotAnyVal) where new operator is used to create a new object and initialize it later with invokespecial.

    Operand stack

    The operand stack is a LIFO (last-in-first-out) stack associated to each frame (the frame is created when the method is invoked). It consists basically on push and pop operations. Some JVM instructions send the values to the operand stack while the other ones load them in order to make some operations within.
    For instance, if two ints must be added, they're first sent to the operand stack. Later they're popped by a JVM instruction, added and the sum is sent back to the operand stack from where it can be used for further operations.

    Necessary allocation examples

    Now let's focus on the situations where, despite of the correct value class definition, the new object is allocated on the heap:

    object ValueClassAllocated {
    
      def main(args: Array[String]): Unit = {
        // Assign to an array
        val agesArray: Array[AgeArray] = Array[AgeArray](AgeArray(1))
    
        // Use in print(...) -> considered as other type or with implemented trait that is used in a method
        val ageWithInterface = new AgeImplementingInterface(4)
        showAge(ageWithInterface)
    
    
        // Use in pattern matching
        val age = AgePatternMatching(5)
        age match {
          case AgePatternMatching(_) => println("Got age")
          case _ => println("Got _")
        }
    
      }
    
      def showAge(age: Any): Unit = {
      }
    
    }
    
    case class AgeArray(val value: Int) extends AnyVal
    
    case class AgePatternMatching(val value: Int) extends AnyVal
    
    class AgeImplementingInterface(val value: Int) extends AnyVal
    

    And the bytecode instructions look like (some lines were removed for better readability):

    public void main(java.lang.String[]);
    Code:
       0: getstatic     #20                 // Field scala/Array$.MODULE$:Lscala/Array$;
       3: getstatic     #25                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
       6: iconst_1
       7: anewarray     #27                 // class com/waitingforcode/pattern/AgeArray
      12: new           #27                 // class com/waitingforcode/pattern/AgeArray
      17: invokespecial #30                 // Method com/waitingforcode/pattern/AgeArray."":(I)V
      20: aastore
      ..
      40: istore        4
      42: aload_0
      43: new           #50                 // class com/waitingforcode/pattern/AgeImplementingInterface
      46: dup
      47: iload         4
      49: invokespecial #51                 // Method com/waitingforcode/pattern/AgeImplementingInterface."":(I)V
      52: invokevirtual #55                 // Method showAge:(Ljava/lang/Object;)V
      55: iconst_5
      ..
      60: istore        6
      62: new           #57                 // class com/waitingforcode/pattern/AgePatternMatching
      66: iload         6
      68: invokespecial #58                 // Method com/waitingforcode/pattern/AgePatternMatching."":(I)V
    

    Unlike the case from the previous section, here we can observe the creations of 3 new objects allocated on the heap.

    The post shows the idea of value classes in Scala. The first section presented a more global idea of the value object pattern. The second part described the rules of the value classes creation. As we could see, some of rules make that even if the class extends expected parent and takes only 1 argument in the constructor, it'll be translated to an object and allocated on the heap. Both situations were illustrated in the code samples in 2 last sections. The analysis of the bytecode shown where the stack and heap allocation occured.

Share, like or comment this post on Twitter: