At the end of the last chapter, we saw how all objects in Kotlin inherit three functions from an open class called Any
. Those functions are equals()
, hashCode()
, and toString()
. In this chapter, we’re going to learn about data classes, which are super-powered classes that are especially helpful when you’ve got an immutable class that mainly just holds properties.
In order to best understand data classes, let’s first visit each of the three functions above, and see what’s involved when we override them.
Overriding equals()
Reference Equality
Two fathers were crossing paths at the local park, with their daughters in tow, when each accidentally let go of his daughter’s hand.
Both of these girls were named Fiona. However, despite having the same name, they were two different girls, so when the fathers turned around, it was important that each collected his own daughter, not just the first girl he saw who was named Fiona!
Similarly, in Kotlin, there are times when we want to check to see if an object is the instance that we want. To do this, we can use the equality operator, which is two adjacent equal signs ==
. Let’s demonstrate this by creating a class to represent the two girls, and instantiate an object for each.
class Child(val name: String) val fiona1 = Child("Fiona") val fiona2 = Child("Fiona") println(fiona1 == fiona2) // false
When using the equality operator on these two objects, we see that they are not equal. In other words, they’re two different
Child
objects. That’s exactly what we want! After all, these two girls are different children, even though they both have the same name.Of course, if we assign the one
Child
object to two different variables, then the equality operator will indicate that those two variables are equal, because they both refer to the same exact object instance.val fiona1 = Child("Fiona") val fiona2 = fiona1 println(fiona1 == fiona2) // true
In the code above,
fiona1
andfiona2
are both assigned the same object, so they’re equal.This kind of equality is sometimes called reference equality,1 because as long as the two variables refer to the same object instance, then they will be considered equal. By default, Kotlin uses reference equality when we use the equality operator to compare two objects.
Value Equality
The next day, the two fathers were again crossing paths in the park. This time, they each accidentally let go of a five-dollar bill at the same time.
When they turned around, they looked at the dollar bills on the ground, but they weren’t sure which bill belonged to which person.
However, each bill is worth the same amount - five dollars. Unlike the previous day when it was important for each father to pick up his own exact daughter, in this case, it doesn’t matter whether each person picks up the exact bill that he dropped. As long as each of them picks up one of the five-dollar bills, it’s fine, because the two bills are equal to one another. In other words, some things are interchangeable as long as they have certain characteristics that are the same.
Similarly, in Kotlin, when objects are considered equal based on their property values rather than their identity, it’s often called value equality.2
Since we would consider two five-dollar bills to be equal to each other, we would probably want a
DollarBill
class to have value equality rather than reference equality. The following code is roughly the same as Listing 15.1 above, but with aDollarBill
class instead of aChild
class.class DollarBill(val amount: Int) val bill1 = DollarBill(5) val bill2 = DollarBill(5) // We want this to be true! println(bill1 == bill2) // false
As you can see, when we run this, we get
false
. How can we get this code to printtrue
?Under the hood, when we use the equality operator, it calls into the
equals()
function to determine whether the two objects are considered equal. The implementation ofequals()
that is handed down from theAny
class will check to see if the two variables refer to the same object, which is why reference equality is the default. So if we want the equality operator to act differently for a particular class, we’ll need to override theequals()
function in that class.In the case of this
DollarBill
class, one simple way to achieve this is to manually delegate theequals()
call to theamount
property. Let’s try overriding this function in theDollarBill
class. Note that in theAny
class, this function has a parameter of typeAny?
, and a return type ofBoolean
, so we’ll use the same types in ourequals()
function here.3class DollarBill(val amount: Int) { override fun equals(other: Any?) = amount.equals(other.amount) }
ErrorWell, that didn’t work. The problem is that the
other
parameter has a type ofAny?
, so that any two objects in Kotlin can be compared to each other, even if their types don’t match. Sinceother
might not actually be aDollarBill
object, it won’t necessarily have a property namedamount
.To fix this, we’ll need to first check whether the type of
other
isDollarBill
.
- If it is, then we can just use a smart cast to compare the
amount
values.- If it isn’t, then we can just return
false
.class DollarBill(val amount: Int) { override fun equals(other: Any?) = if (other is DollarBill) amount.equals(other.amount) else false }
With this change, two
DollarBill
objects will be equal to one another as long as…
- They’re both instances of
DollarBill
.- They both have the same value for the
amount
property.When we run the code again, we’ll see that
bill
andbill2
are now considered equal!val bill1 = DollarBill(5) val bill2 = DollarBill(5) println(bill1 == bill2) // true
So, by overriding the
equals()
function, we were able to give ourDollarBill
class value equality instead of reference equality! However, there’s another problem that shows up when we try to use this class with certain collection types. This brings us to thehashCode()
function.Overriding
hashCode()
Little Fiona is starting a collection of contemporary US dollar bills. In order to complete her collection, she’s going to need one bill of each of the seven denominations: $1, $2, $5, $10, $20, $50, and $100.
As you recall from Chapter 8, multiple objects can be stored in a collection type called a
Set
, which guarantees that each of its elements is unique. In other words, if you try to add an object to a set that it already contains, nothing will change.We can use a
Set
to keep track of Fiona’s dollar bills.val denominations = mutableSetOf<DollarBill>()
Fiona has collected bills of three different denominations so far: $1, $2, and $5. We can add those to her collection, and print out the size, just to make sure it’s got three unique items in it.
val denominations = mutableSetOf<DollarBill>() denominations.add(DollarBill(1)) denominations.add(DollarBill(2)) denominations.add(DollarBill(5)) println(denominations.size) // 3
Perfect!
One day, Fiona finds a one-dollar bill, but can’t remember if she already collected that one. What happens when we try to add that second one-dollar bill to the set?
val denominations = mutableSetOf<DollarBill>() denominations.add(DollarBill(1)) denominations.add(DollarBill(2)) denominations.add(DollarBill(5)) denominations.add(DollarBill(1)) // duplicate entry! println(denominations.size) // 4
Yikes! Instead of rejecting the duplicate, the
denominations
set happily included it as a fourth element! Why did this happen? After all, we overrode theequals()
function in theDollarBill
class back in Listing 15.5!
Set
itself is just an interface, and when we callmutableSetOf()
it returns an implementation ofSet
, calledLinkedHashSet
.A
LinkedHashSet
does not primarily use theequals()
function to determine whether it already contains an object. Instead, it starts with thehashCode()
function, and only callsequals()
when an object with the same hash code already exists in that set.4This is why we’re supposed to override
hashCode()
any time we overrideequals()
. In our case, since we’re already delegating to theamount
property forequals()
, we can just do the same forhashCode()
.class DollarBill(val amount: Int) { override fun equals(other: Any?) = if (other is DollarBill) amount.equals(other.amount) else false override fun hashCode() = amount.hashCode() }
Easy! With this change, when we run Listing 15.9 again, the set will correctly disregard the duplicate one-dollar bill, resulting in a size of 3 instead of 4.
val denominations = mutableSetOf<DollarBill>() denominations.add(DollarBill(1)) denominations.add(DollarBill(2)) denominations.add(DollarBill(5)) denominations.add(DollarBill(1)) // duplicate entry! println(denominations.size) // 3 - Success!
Well, our
DollarBill
class is almost doing everything we want. Before we see how data classes can make our lives easier, let’s override that third and final function from theAny
class,toString()
.Overriding
toString()
Although we’ve used the
println()
function frequently, we haven’t examined it closely yet. As you know, we can pass an argument to this function, and it prints it out to the screen. We’ve often passed it a string, like this:println("Hello, Kotlin!")
However, we aren’t limited to passing strings; we can pass literally any object to this function! For example, we can send it an instance of our
DollarBill
class.println(DollarBill(100))
When we run this, we’ll see this printed out:
DollarBill@64
When we pass an object to the
println()
function, it will call into thetoString()
function on that object. By default, this function returns a string that looks like the one above. This string has three parts to it:
- The name of the class
- An
@
sign- The hash code of the object, in hexadecimal5
As you recall, the
DollarBill
class is delegating thehashCode()
call to theamount
integer in Listing 15.10. For an integer, the hash code is simply the value of the integer itself. So, if we assign the value 100 to an integer variable, its hash code is also 100. However, sincetoString()
converts that hash code to hexadecimal, it appears as 64.The
toString()
function in this class would be much more helpful if it were to show the name of the class and the value of theamount
property in decimal rather than hexadecimal. While we’re at it, it’d be helpful to include a label indicating what that number represents (i.e., “amount”).Let’s override the
toString()
function and return a string that includes those things.class DollarBill(val amount: Int) { override fun equals(other: Any?) = if (other is DollarBill) amount.equals(other.amount) else false override fun hashCode() = amount.hashCode() override fun toString() = "DollarBill(amount=$amount)" }
After making this change, when we call
println()
with aDollarBill
object, we’ll get more helpful output.println(DollarBill(100))
DollarBill(amount=100)
Fantastic!
Well, we’ve made a lot of changes to our
DollarBill
class now. Let’s review everything that we did.
- We made it so that two instances of
DollarBill
are equal to each other, as long as they have the sameamount
values. In other words, we gave it value equality instead of reference equality.- We made it so that they are also treated as equal in sets and maps.
- We created a more helpful implementation of
toString()
.We achieved all of these things by overriding the three functions from the
Any
class -equals()
,hashCode()
, andtoString()
. Now that we understand why we might want to override these functions, it’s finally time to see how data classes can make our lives much easier!Introduction to Data Classes
After all the changes we made, here’s the code that we ended up with:
class DollarBill(val amount: Int) { override fun equals(other: Any?) = if (other is DollarBill) amount.equals(other.amount) else false override fun hashCode() = amount.hashCode() override fun toString() = "DollarBill(amount=$amount)" }
The
DollarBill
example has been rather simple. After all, it only has a single property. What would happen if we tried to achieve the same things for a class that includes multiple properties? ImplementingtoString()
would be straightforward. However,equals()
would be a bit more difficult, andhashCode()
would be even more involved!The good news is that whether our class has a single property or a dozen, Kotlin can automatically accomplish everything that we’ve already done in this chapter - overriding
equals()
,hashCode()
, andtoString()
- and all we have to do is declare our class to be a data class. To do this, we just add the keyworddata
before the class declaration, like this:data class DollarBill(val amount: Int)
In this code, we haven’t provided an override for
equals()
,hashCode()
, ortoString()
. In fact, thisDollarBill
doesn’t even have a class body at all! And yet, in just one line, this class does everything that Listing 15.16 does!val bill1 = DollarBill(100) val bill2 = DollarBill(100) bill1 == bill2 // true mutableSetOf(bill1, bill2).size // 1 println(bill1) // DollarBill(amount=100)
Again, the
DollarBill
class only includes a single property, but data classes can easily provide structural equality and a nicetoString()
result for classes that have multiple properties. For example, here’s anAddress
class with three properties.data class Address( val street: String, val city: String, val postalCode: String )
As this next code listing demonstrates, instances of
Address
use value equality, and they print out nicely.val address1 = Address("123 Maple Ave", "Berrytown", "56789") val address2 = Address("123 Maple Ave", "Berrytown", "56789") address1 == address2 // true mutableSetOf(address1, address2).size // 1 println(address1) // Address(street=123 Maple Ave, city=Berrytown, postalCode=56789)
We’ve seen how data classes give us a lot of power, automatically generating useful implementations of
equals()
,hashCode()
, andtoString()
. The superpowers don’t stop there, though! Data classes also include a function calledcopy()
, which we’ll look at next.Copying Data Classes
The properties of a data class can be declared with either
val
orvar
, but Kotlin developers tend to use data classes primarily for immutable data. In other words, it’s common to use onlyval
properties in a data class.As you know, when a class has a mutable property, we can simply assign it a new value. For example, here we have a data class that represents a book. Its
title
property is read-only, but itsprice
property is mutable.data class Book(val title: String, var price: Int)
When the price of the book increases, we can just set its new value.
val book = Book("The Malt Shop Caper", 18) // The price just went up! book.price = 20
It’s easy enough to change the value of the
price
property when it’s declared withvar
. But what about properties that are declared withval
?Naturally, we can’t change the value of a
val
property, but instead, we can create a copy of the entire object, substituting the new value in that copy. This approach is similar to the one we used in Chapter 8 when we created new lists by adding an element to an existing list with the plus operator.Let’s update the
Book
class so that theprice
property is declared withval
.data class Book(val title: String, val price: Int)
With this change, when the price goes up, we can create a new variable called
newBook
, which has the same title as the original, but with the new price.val book = Book("The Malt Shop Caper", 18) // The price just went up! val newBook = Book(book.title, 20)
Now, this is easy enough to do with just two properties, but the more properties we add, the more tedious it becomes to make a copy. To demonstrate this, let’s add four more properties to the
Book
class.data class Book( val title: String, val price: Int, val author: String, val width: Int, val height: Int, val isbn: String, ) val book = Book("The Malt Shop Caper", 18, "Slim Chancery", 6, 9, "020516918K") // The price just went up! val newBook = Book(book.title, 20, book.author, book.height, book.width, book.isbn)
This is a lot of boilerplate, and it’s easy to accidentally get something mixed up when relaying the values from the old object to the new one. In the code above, did you notice that the height and width values got swapped?
To avoid all that boilerplate and reduce the likelihood of these kinds of errors, data classes have a powerful function called
copy()
. For the book example above, instead of manually relaying each property to theBook
constructor, we can just callcopy()
on the original book, and give it a new value forprice
, like this.val newBook = book.copy(price = 20)
The
copy()
function has a parameter for each property in the data class. Since ourBook
data class has 6 properties, itscopy()
function has a total of 6 parameters, each of which defaults to the current value of the property.When calling the
copy()
function, simply include named arguments for any properties that you want to change, and omit arguments for any properties that you want to stay the same. In Listing 15.26, we only provided theprice
parameter, sonewBook
will have a price of20
, but all other properties will have the same values that they had in the originalbook
object.As you can see, the
copy()
function is an incredibly powerful way to work with immutable classes, allowing our code to effectively change data, without actually mutating the individual properties!There’s one more feature that data classes provide, which is the ability to destructure its properties. Let’s dive in!
Destructuring
When we’ve got a lot of values that all pertain to some concept, it usually makes sense to put them together. For example, if we’ve got a title, price, author, width, height, and an ISBN, we usually don’t want to deal with them as individual variables. Instead, we want them grouped all together in one structure, such as a
Book
class.Here’s the
Book
data class that we used earlier, which groups together all of the variables mentioned above.data class Book( val title: String, val price: Int, val author: String, val width: Int, val height: Int, val isbn: String, )
When we assemble values into a single class like this, it’s clear that these different values are all associated with one another and that, taken together, they represent the concept of a book. This usually makes it easier for developers to understand - for example, it’s clear that the
title
represents the title of a book, not the title of a movie. This also makes it convenient to pass all of these properties from one function to another - instead of passing each property as a separate parameter, we can just pass theBook
object as a whole.6So again, it often makes sense to put associated values together into a structure.
Sometimes, however, it makes sense to separate the individual values back out of the structure, so that they’re stored in individual variables.
This is called destructuring. Of course, we could do this by hand. For example, in the following code, we pull out all six properties from the
book
object into separate variables, some of which have names that differ from the original properties.val title = book.title val cost = book.price val author = book.author val widthInInches = book.width val heightInInches = book.height val isbn = book.isbn
When working with a data class, rather than manually extracting each property to a variable, we can use a destructuring assignment to pull out each property to a variable automatically. To do this, rather than declaring a single variable name, declare multiple variable names with commas, and put them all inside parentheses, like this:
val (title, cost, author, widthInInches, heightInInches, isbn) = book
This does the same thing as the code in Listing 15.28, but all in a single assignment statement. Note that the values will be assigned based on the order that the properties appear in the primary constructor of the data class. In the case of Listing 15.29 above, this means the
title
variable will be assigned the value ofbook.title
, thecost
variable will be assigned the value ofbook.price
, and so forth.Each of the values that come out of a destructuring assignment - title, price, author, and so on - is called a component.7
Finally, note that we don’t have to assign all of the components when using a destructuring assignment. For example, if we only need to pull the title and price out of a
book
object, we can choose to only include two variable names within the parentheses.val (title, cost) = book
Destructuring and the Standard Library
Destructuring doesn’t only apply to our own data classes. Some of the types in Kotlin’s standard library can also be destructured. For example, back in Chapter 9, we used a class called
Pair
, which can be used with destructuring assignments.val association = "Nail" to "Hammer" val (hardware, tool) = association
One of the most common ways to use destructuring is with lambda parameters. For example, instead of an individual association, let’s say we’ve got a map of the hardware and tools.
val toolbox = mapOf( "Nail" to "Hammer", "Bolt" to "Wrench", "Screw" to "Screwdriver" )
When we loop over these tools, the lambda parameter has a type called
Map.Entry
, which has two properties - akey
and avalue
. Here’s how we used it way back in Listing 9.20.toolbox.forEach { entry -> println("Use a ${entry.value} on a ${entry.key}") }
A
Map.Entry
object can be destructured, so instead of usingentry
as a parameter for this lambda, we can use parentheses and two variable names, like this:toolbox.forEach { (hardware, tool) -> println("Use a $tool on a $hardware") }
When we do this, the argument to this lambda will get destructured into two different variables - the first is the entry’s key, and the second is the entry’s value. In the code above, the key will be assigned to a variable named
hardware
and the value will be assigned to a variable namedtool
.Destructuring here is a great idea, because
hardware
andtool
are terms related to the problem that our code solves. We might say that these terms are in the problem domain or business domain. Contrast these terms with those like entry, key, and value, which are more focused on the data structures that we’re using to implement a solution. We might say that those terms are in the technical domain.Now let’s consider the case where we want to print out only the tools in the toolbox, but not the corresponding hardware. Naturally, we could just do this:
toolbox.forEach { (hardware, tool) -> println("Found a $tool") }
Here, the
hardware
variable is irrelevant to the lambda. A value is assigned to it, but we’re not using it, so it’s just noise. In other words, it’s something we have to read when looking at this code, but it doesn’t affect the way the code works. In cases like this, where one of the components isn’t needed, we can substitute an underscore_
for the name of the irrelevant variable, like this:toolbox.forEach { (_, tool) -> println("Found a $tool") }
With that change, we no longer have to concern ourselves with a
hardware
variable that isn’t used.Destructuring Non-Data Classes
Destructuring isn’t limited to data classes. Any object can be destructured, as long as its class includes the right functions. The secret is to add functions called
component1()
,component2()
,component3()
, and so on. These are calledcomponentN()
functions. For example, here’s theChild
class from Listing 15.1 at the beginning of this chapter, but this one adds a new property for the child’s age.class Child(val name: String, val age: Int)
If we want to add the ability to destructure this class, we don’t have to convert it to a data class. Instead, we can simply add functions called
component1()
andcomponent2()
, where each one returns one of the properties.class Child(val name: String, val age: Int) { operator fun component1() = name operator fun component2() = age }
With this, we can now use destructuring to pull out the child’s name and age.
val children = listOf( Child("Fiona", 5), Child("Jack", 7) ) children.forEach { (name, age) -> println("$name is $age years old.") }
Note that we had to include the
operator
modifier on the two functions in Listing 15.38. This is the first time we’ve created an operator function. When a function includes theoperator
modifier, it can still be called like any other function, but it also serves some special purpose - and the particular purpose that it serves depends upon the name of the function. When a function is named as they are in Listing 15.38 (i.e., likecomponentN()
), that special purpose is that the function will be used when the object is destructured.We can even create an extension function to add destructuring to a class for which we don’t have the source code. For example, with a little ingenuity, we can create our own extension functions to destructure a
Double
into its integer and fractional parts.operator fun Double.component1() = toString().split(".").first().toInt() operator fun Double.component2() = toString().split(".").last().toInt() val (integral, fractional) = 108.245 println(integral) // 108 println(fractional) // 245
So, destructuring can be helpful in certain situations, and data classes are one way to easily add a destructuring capability to our classes!
Limitations of Data Classes
As we’ve seen in this chapter, data classes give us a lot of convenience!
- They create
equals()
andhashCode()
functions for value equality.- They create nice-looking
toString()
output.- They make it easy to create copies of an object.
- They can be used with destructuring assignments.
However, we also give up a few things when we declare a class to be a data class, so let’s take a quick look at those.
Data Classes and Inheritance
The first and most significant disadvantage of a data class is that it cannot be extended by another class. In other words, you cannot add the
abstract
oropen
modifier to a data class. However, the data class itself can extend another class.Even though a data class can extend another class, if that superclass requires constructor arguments, things get complicated quickly, because of the second disadvantage, which relates to constructor parameters.
Constructor Parameters
The second disadvantage is that all of the constructor parameters in a data class must be property parameters. In other words, each one must be declared with either
val
orvar
. This means it’s not possible to add a constructor parameter that is only relayed to a superclass constructor.open class Product(val id: String) data class Book(id: String, val title: String) : Product(id)
ErrorAlso, a data class must have at least one parameter in its primary constructor.
Finally, while a data class may have properties that are not part of its constructor, they will not be regarded in any of the functions that are generated. For example, in the following code, the
serialNumber
property is not in the primary constructor.data class DollarBill(val amount: Int) { var serialNumber: String? = null }
A property like
serialNumber
won’t be considered inequals()
,hashCode()
, ortoString()
, because it’s declared in the body of the class, but not in the primary constructor. It won’t be possible to callcopy()
with it, and it won’t be used for destructuring assignments. To demonstrate this, the following code shows how twoDollarBill
objects are considered equal, even though they have different values for theserialNumber
.val bill1 = DollarBill(5).apply { serialNumber = "QB12345678T" } val bill2 = DollarBill(5).apply { serialNumber = "IE87654321C" } println(bill1 == bill2) // true, despite different serial numbers
Even with these disadvantages, data classes are tremendously helpful, especially when dealing with immutable classes that are primarily meant to hold properties.
Summary
In this chapter, we learned all about data classes and destructuring, including:
- The difference between reference equality and value equality.
- How to manually override
equals()
andhashCode()
to achieve value equality.- How to manually override
toString()
so that our classes work well withprintln()
.- How to declare a data class so that we don’t have to manually override
equals()
,hashCode()
, ortoString()
.- How to use the
copy()
function to make it easier to create an object based on another object’s values.- How to use a destructuring assignment when working with data classes.
- How to enhance a non-data class to enable destructuring assignments.
- How to use extension functions to enable destructuring assignments.
- The limitations of data classes.
In the next chapter, we’ll introduce another class modifier, which will enable us to account for every possible subclass of an interface or abstract class. See you then!
Thanks to James Lorenzen for reviewing this chapter!
This is sometimes also called identity equality. ↩︎
You might also hear this referred to as content equality or structural equality. ↩︎
When overriding a function, we often use the same parameter types and return types that are specified by the same function in the superclass. However, Kotlin also allows you to specify a more general parameter type or a more specific return type. This feature is called variance, we’ll learn more about this in Chapter 19 when we look at how variance works for generics. ↩︎
Note that this same problem also applies to maps, because
mutableMapOf()
returns a hash-based implementation calledHashMap
. ↩︎Most of us have grown up using numbers where each digit has one of ten possible values - 0 through 9. Since there are ten possible values, this is sometimes called the Base-10 or decimal numeral system. Hexadecimal is a numbering system where each digit can have up to 16 values, so it’s also called Base-16. After 0-9 come A, B, C, D, E, and F. For example, the number “C” in hexadecimal represents the number 12 in decimal, and the number 10 in hexadecimal represents decimal 16. ↩︎
Although this is convenient, it’s also worth considering how many of the object’s values are actually needed in the function. When you pass more values than a function needs, it’s called stamp coupling, and it can limit the reusability of the function. For example, if the function only needs a title, it might be best to pass only the title rather than the whole book object, because then it could work for more than just book titles - perhaps it will also work for movie and song titles. ↩︎
The term “component” is broad and can refer to many things within the disciplines of software development and system architecture. Here, we’re just using the term in the very narrow context of destructuring. ↩︎