1. Introduction
In this article, we’ll see how to create a deep copy of a Kotlin data class.
A deep copy of the object is an “actual” clone of the object. A deeply copied object does not depend on any object references created earlier, and all internal objects will be newly allocated in memory, unlike a shallow copy of the object.
Consider reading our article, Differences Between a Deep Copy and a Shallow Copy, to better understand the problem we’ll be solving here.
2. Shallow Copy
First, let’s define some data classes to work with:
data class Person(var firstName: String, var lastName: String)
data class Movie(var title: String, var year: Int, var director: Person)
2.1. Simple Object
One of the features of Kotlin data classes is that the compiler automatically derives the copy() function from all properties declared in the primary constructor. Calling the copy() function will create a new object with copies of all values from the current object.
Let’s copy() an instance of the Person data class:
@Test
fun givenSimpleObject_whenCopyCalled_thenStructurallyEqualAndDifferentReference() {
val person = Person("First name", "Last name")
val personCopy = person.copy()
assertNotSame(person, personCopy)
assertEquals(person, personCopy)
}
Since the Person object only has one level of properties, a shallow copy is as effective as a deep copy.
2.2. Complex Object
When objects have multiple layers of objects as properties, then the shallow copy will maintain object references of child properties:
@Test
fun givenComplexObject_whenCopyCalled_thenEqualAndDifferentReferenceAndEqualInternalReferences() {
val movie = Movie("Avatar 2", 2022, Person("James", "Cameron"))
val movieCopy = movie.copy()
assertNotSame(movieCopy, movie)
assertEquals(movieCopy, movie)
assertSame(movieCopy.director, movie.director)
movie.director.lastName = "Brown"
assertEquals(movieCopy.director.lastName, movie.director.lastName)
}
The reference of movieCopy is different from the movie and the contents of both objects are equal, just like in the example above. But in this case, movie.director and movieCopy.director are the same object because the function copies only the reference to the initial object. Therefore, when we modify a property in our initial object, it will reflect accordingly in all of its copies.
2.3. Protecting Our Objects from Shallow Copying
Making the lastName property immutable by changing var to val will prevent accidental modification of the director property for any copies. Nevertheless, movie.director and movieCopy.director still will be referring to the same object. The same applies to mutable collections, such as using MutableList instead of the immutable List.
3. Deep Copy
To prevent the issues associated with the shallow copy approach, we need to use a deep copy. It recursively copies all the contents from the object’s tree, making it completely independent from any objects created earlier. The key point here is that we would need to specify explicitly how to copy each specific property, otherwise, we’ll end up with a shallow copy of our object.
Let’s see what options we have to create a deep copy of a Kotlin data class.
3.1. Using copy() with Custom Parameters
As we mentioned before, the copy() function comes automatically with Kotlin data classes and we can’t define it explicitly, but internally, it looks like:
fun copy(title: String = this.title, year: Int = this.year, director: Person = this.director) = Movie(title, year, director)
This means that we can customize the parameters when calling copy(). To solve the issue from the above example, we need to enforce the creation of the new reference. We explicitly specify how to copy the director since it’s the only parameter passed by reference:
@Test
fun givenComplexObject_whenCustomCopyCalled_thenEqualAndNewReferenceAndDifferentInternalReferences() {
val movie = Movie("Avatar 2", 2022, Person("James", "Cameron"))
val movieCopy = movie.copy(director = movie.director.copy())
assertNotSame(movieCopy, movie)
assertEquals(movieCopy, movie)
assertNotSame(movieCopy.director, movie.director)
movie.director.lastName = "Brown"
assertNotEquals(movieCopy.director.lastName, movie.director.lastName)
}
As a result, movieCopy is now a deep copy of the movie object.
3.2. Implement Cloneable Interface
Due to close ties between Kotlin and Java, borrowing this approach to perform a deep copy seems obvious.
All we have to do is implement the Cloneable interface and override the clone() method while making it public, as it is protected by default. Inside the method body, we specify how to copy the internals of the objects:
data class Person(var firstName: String, var lastName: String) : Cloneable {
public override fun clone(): Person = super.clone() as Person
}
We may notice some similarities compared to the first approach where we used copy(). For instance, inside the Person class, we just call super.clone() since all of its properties will be copied by value. Another similarity between the copy() and clone() functions is that both of them create a shallow copy by default. That’s why, for the Movie object, we explicitly define how to copy the director property:
data class Movie(var title: String, var year: Int, var director: Person) : Cloneable {
public override fun clone() = Movie(title, year, director.clone())
}
As we may notice, similarly to the previous example, modification of the initial object doesn’t affect its copy:
@Test
fun givenComplexObject_whenCloneCalled_thenEqualAndDifferentReferenceAndDifferentInternalReferences() {
val movie = Movie("Avatar 2", 2022, Person("James", "Cameron"))
val movieCopy = movie.clone()
assertNotSame(movieCopy, movie)
assertEquals(movieCopy, movie)
assertNotSame(movieCopy.director, movie.director)
movie.director.lastName = "Brown"
assertNotEquals(movieCopy.director.lastName, movie.director.lastName)
}
3.3. Using JSON Conversion to Create a Deep Copy
Due to this limitation of needing to rely on good implementations of clone() for all object properties and any child object properties, another way to guarantee a deep copy of an object is to write it to JSON and then read it back. There are many different ways to do this that won’t be covered in this article, but for an example of how this might work, check out this article on processing JSON in Kotlin. It explains how to convert a Kotlin data class to JSON using the Gson library.
We should note that processing JSON in Kotlin is not limited only to this particular library. We can use any of the existing solutions, such as KotlinX Serialization, Jackson, or Moshi, to name a few.
The key point here is that deserialization creates new object references during JSON conversion back to Kotlin data classes. This is exactly what we need when creating a deep copy of the object.
4. Summary
In this tutorial, we looked at how to create a deep copy of a Kotlin data class.
We’ve seen that using the default implementations of existing tools allows us to create shallow copies. To create a deep copy of Kotlin data classes, we need to provide an explicit implementation to handle object references.
As we may notice, both described approaches look pretty similar. Using the copy() function seems to be more effortless and flexible, while using Cloneable.clone() requires more code.
All examples and code snippets from this tutorial can be found over on GitHub.