Scala – Imitating Case Class Apply
Introduction
Let’s create a simple case class with one String
:
case class RealCaseClass(value: String)
The common wisdom is that case classes provide many useful features out of the box:
- immutability
- pretty printing (overridden
toString
) - comparison based on values (overridden
equals
andhashcode
) apply
method in the companion object (to create instances without usingnew
)unapply
method in the companion object (to help pattern matching)copy
method (to easily create copies mutating only a subset of fields)
When I started learning Scala I was also told that the case class
is only a syntax sugar and that I could implement all of it myself. Today I would like to focus on the apply
method and find the way to implement case-class-like applicative behavior in a normal class.
Implementation
In a real case class we can create a new instance without using the new
keyword.
println(RealCaseClass("abc"))
//RealCaseClass(abc)
In Scala, using a method call syntax name(args)
on an object or an instance of a class is just a shorter way to call the apply
method:
println(RealCaseClass.apply("abc"))
//RealCaseClass(abc)
Knowing that we can start implementing our case class imitation. Let’s override the toString
method so that we can print the value instead of Imitation@60f82f98
:-)
class Imitation(val value: String) {
override def toString: String = s"Imitation($value)"
}
apply
is a factory method and should be static so it should be implemented in the companion object:
object Imitation {
def apply(value: String): Imitation = new Imitation(value)
}
Now we can try to use it:
println(Imitation("abc"))
//Imitation(abc)
Yeah! It works. Turns out though that there is still one thing related to the apply
method the original case class can do and the imitation cannot.
Seq("a", "b", "c").map(RealCaseClass).foreach(println)
//RealCaseClass(a)
//RealCaseClass(b)
//RealCaseClass(c)
Seq("a", "b", "c").map(Imitation).foreach(println)
//Compilation error: type mismatch; found: Imitation.type, required: String => ?
The map
function called on a sequence of strings expects a function that maps String
to any type (String => ?
) as an argument. Scala has no duck typing, so even though Imitation.apply
meets the requirements, we still have to explicitly tell the compiler that Imitation.type
indeed implements String => Imitation
.
The error also means that the compiler adds something to RealCaseClass
that we haven’t got in Imitation
yet. To see what it is we can use the “Java Class File Disassembler”, javap
(usually included with JDK). Let’s compare the class files for both companion objects (generated using Scala 2.11.7):
javap Imitation$.class
Compiled from "CaseClasses.scala"
public final class Imitation$ {
public static final Imitation$ MODULE$;
public static {};
public Imitation apply(java.lang.String);
}
javap RealCaseClass$.class
Compiled from "CaseClasses.scala"
public final class RealCaseClass$ extends scala.runtime.AbstractFunction1<java.lang.String, RealCaseClass> implements scala.Serializable {
public static final RealCaseClass$ MODULE$;
public static {};
public final java.lang.String toString();
public RealCaseClass apply(java.lang.String);
public scala.Option<java.lang.String> unapply(RealCaseClass);
public java.lang.Object apply(java.lang.Object);
}
We can see that the real case class companion object has the apply
method with the same signature as our imitation. There is also the second apply method Object => Object
– let’s ignore it for now. What our Imitation
companion object needs to work with the map
function is in this part:
extends scala.runtime.AbstractFunction1<java.lang.String, RealCaseClass>
Here’s our working version of the case-class-like apply imitation:
class Imitation(val value: String) {
override def toString: String = s"Imitation($value)"
}
object Imitation extends AbstractFunction1[String, Imitation]{
def apply(value: String): Imitation = new Imitation(value)
}
Seq("a", "b", "c").map(Imitation).foreach(println)
//Imitation(a)
//Imitation(b)
//Imitation(c)
Appendix 1: Bridge Methods
When we run javap
again on the new version of Imitation$.class
the output is:
Compiled from "CaseClasses.scala"
public final class Imitation$ extends scala.runtime.AbstractFunction1<java.lang.String, Imitation> {
public static final Imitation$ MODULE$;
public static {};
public Imitation apply(java.lang.String);
public java.lang.Object apply(java.lang.Object);
}
We can see that the second apply
method (Object => Object
) was added automatically by the compiler when we extended AbstractFunction1
. That’s the way Java deals with type erasure of generic type arguments. After compilation generic type arguments of AbstractFunction1
are lost so the apply method defined in that class becomes public Object apply(Object)
. To override it in Imitation$
the compiler needs to generate the second apply method there with the same signature. Those methods are called the bridge methods. If we run javap -c
to display the code we can see that all the second apply
does is casting and invoking the ‘original’ method.
Appendix 2: AbstractFunctionN
In the source code of scala.runtime.AbstractFunction1
we can see that it extends the scala.Function1
trait without adding anything. I guess it may exist for performance reasons or better interoperability with Java, I’m not sure.
When
object Imitation extends AbstractFunction1[String, Imitation]
is changed to
object Imitation extends Function1[String, Imitation]
Intellij Idea displays an inspection tooltip:
syntactic sugar could be used. Detects explicit references to FunctionN and TupleN that could be replaced with syntactic sugar.
Turns out that A => B
type that is required as an argument to the map
function is just a syntactic sugar for Function1[A, B]
. When we apply the proposed change we end up with a rather intuitive
object Imitation extends ((String) => Imitation){
def apply(value: String): Imitation = new Imitation(value)
}
and everything still works fine. If we run javap Imitation$.class
again we can see that over 20 different specialized apply
methods has been copied from the trait into the class bytecode. That’s why I thought that case classes may extend AbstractFunctionN
instead of FunctionN
to improve the performance.
Conclusion
Scala does a lot of work in the background to make sure we can use case classes in a pleasant way. Case class “behaves like a function” because the Scala compiler generates a companion object for that class that
- has an apply method so that we can call
MyCaseClass(args)
to create a new instance - extends
AbstractFunctionN
so that we can callcollection.map(MyCaseClass)
to create multiple instances at once