It always amazes me how much Kotlin is able to offer over “plain” Java, and data classes are no exception. In this post, we’ll explore how Kotlin’s data classes take all the boilerplate out of old-school POJOs, the power of built-in equals
, hashcode
, and copy
methods, and learn easy destructuring with generated componentN
helpers. Finally, we’ll check out a little gotcha when mixing inheritance with data classes.
Onward!
As a quick refresher, Kotlin is a modern, statically typed language that compiles down for use on the JVM. It’s often used wherever you’d reach for Java, including Android apps and backend servers (using Java Spring or Kotlin’s own Ktor).
If you’ve set up a POJO in Java before, you’ve probably dealt with some boilerplate-y code: getters and setters, a nice toString
for debugging, some overrides for equals
and hashCode
if you want comparability… lather, rinse, repeat, right?
Well, Kotlin didn’t like all this ceremony. They created a special type of class
to handle:
equals
function based on your constructor parameters (instead of not-so-useful memory references)toString()
value based on those constructor paramscopy
function to clone instances at-will, without piping between constructors yourself()
These are some pretty big wins over the POJO standards of yore. Not only will Kotlin handle all the getters and setters for you (since constructor parameters are publicly available by default), but it also gives you comparability for free!
Let’s learn how we can take a huge class declaration like this:
class UniversityStudentBreakfast { private int numEggs; public UniversityStudentBreakfast(int numEggs) { this.numEggs = numEggs; } public int getNumEggs() { return numEggs; } public void setNumEggs(int numEggs) { this.numEggs = numEggs; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UniversityStudentBreakfast breakfast = (UniversityStudentBreakfast) o; return numEggs == breakfast.numEggs; } @Override public String toString() { return "UniversityStudentBreakfast(" + "numEggs='" + numEggs + '\'' + ')'; } // don't get me started on copy-ability... }
…and turn it into a nice one-liner 😄
data class UniversityStudentBreakfast( val numEggs: Int, )
equals
traitLet’s start with huge value-add over standard classes: a built-in equality function based on our constructor params.
In short, Kotlin will generate a neat equals
function (plus a complimentary hashCode
function) that evaluates your constructor params to compare instances of your class:
data class UniversityStudentBreakfast( val numEggs: Int, ) val student1Diet = UniversityStudentBreakfast(numEggs=2) val student2Diet = UniversityStudentBreakfast(numEggs=2) student1Diet == student2Diet // true student1Diet.hashCode() == student2Diet.hashCode() // also true
⚠️ Note: Under-the-hood, this calls the equals
function for all constructor params when comparing. This sadly means memory reference issues can crop up again when your data classes contain lists or references to other classes.
toString
methodYes, data classes give you a nice toString
helper for simpler debugging. Instead of getting a random memory reference for our UniversityStudentBreakfast
class above, we get a nice mapping of constructor keys to values:
println(student1Diet) // -> UniversityStudentBreakfast(numEggs=2)
copy
traitKotlin’s copy
trait addresses a common pitfall of traditional classes: we want to take an existing class and build a new one that’s just slightly different. Traditionally, there’s two ways you could approach this. The first is to manually pipe everything from one constructor to another by hand:
val couponApplied = ShoppingCart(coupon="coupon", eggs=original.eggs, bread=original.bread, jam=original.jam...)
…but this is pretty obnoxious to pull off, especially if we have nested references to worry about duplicating. Option two is to simply admit defeat and open everything for mutation using apply {...}
:
val couponApplied = original.apply { coupon = "coupon" }
…but you may not like this approach if your team is working with functional programming techniques. If only we could have a syntax similar to apply
that doesn’t mutate the original value…
The good news? If you’re using a data class, copy
lets you do just that!
data class ShoppingCart( val coupon: String, // just a regular "val" will work val eggs: Int, val bread: Int, ... ) val original = checkoutLane.ringUpCustomer() val couponApplied = original.copy(coupon="coupon")
You’ll also notice that copy
is just a regular function call without an option for a lambda. This is the beauty of the Kotlin compiler — it generates all the arguments for you based on the constructor params 💪.
componentN
in KotlinWith data classes, each property is accessible as a component using extension functions like component1, component2, etc., where the number corresponds to an argument’s position in the constructor. You could probably use an example for this one:
data class MyFridge( val doesPastaLookSketchy: Boolean, val numEggsLeft: Int, val chiliOfTheWeek: String, ) val fridge = MyFridge( doesPastaLookSketchy=true, numEggsLeft=0, chiliOfTheWeek="Black bean" ) fridge.component1() // true fridge.component2() // 0 fridge.component3() // "Black bean"
You may be thinking, “OK, but why the heck would I fetch a value by calling component57()
?” Fair question! You probably won’t be calling these helpers directly like this. However, these are pretty useful to Kotlin under-the-hood to pull off destructuring.
Say we have a pair of coordinates on a map. We could use the Pair
class the represent this type as a Pair
of integers:
val coordinates = Pair<Int, Int>(255, 255)
So how do we grab the x and y values out of here? Well, we can use those component functions we saw earlier:
val x = coordinates.component1() val y = coordinates.component2()
Or, we can just destructure using parens ()
on our variable declarations:
val (x, y) = coordinates
Nice! Now we can let Kotlin call those ugly component functions for us.
We can use this same principle for our own data classes. For instance, if we want our coordinates to have a third z dimension, we can make a nice Coordinates
class, like so:
data class Coordinates( val x: Int, val y: Int, val z: Int, )
And then destructure as we see fit 👍.
val (x, y, z) = Coordinates(255, 255, 255)
⚠️ Note: This can get hairy when the argument order isn’t implied. Yes, it’s pretty clear that x
comes before y
(which comes before z
) in our Coordinates
example. But if an engineer absent-mindedly moves value z
to the top of the constructor, they could break destructuring statements across the codebase!
As you start getting comfortable with data classes, you may start using them as a type-safe object for every occasion.
But not so fast! Problems start to emerge when you start getting object-oriented. To expand on our Fridge
example from earlier, say you want a special data class with extra fields to represent your own kitchen mayhem:
data class Fridge( val doesPastaLookSketchy: Boolean, val numEggsLeft: Int, ) data class YourFridge( val servingsOfChickenNoodleLeft: Int, ) : Fridge()
In other words, you want to piggy-back off the first data class
and keep the equality and copy traits in-tact. But if you try this in a playground, you’ll get a nasty exception:
No value passed for parameter 'doesPastaLookSketchy' No value passed for parameter 'numEggsLeft'
Hm, it looks like we’ll need to duplicate our Fridge
constructor to allow all our values to pass through. Let’s do that:
data class Fridge( open val doesPastaLookSketchy: Boolean, open val numEggsLeft: Int, ) data class YourFridge( override val doesPastaLookSketchy: Boolean, override val numEggsLeft: Int, val servingsOfChickenNoodleLeft: Int, ) : Fridge(doesPastaLookSketchy, numEggsLeft)
…which leaves us with a much different exception 😬
Function 'component1' generated for the data class conflicts with member of supertype 'Fridge' Function 'component2' generated for the data class conflicts with member of supertype 'Fridge' This type is final, so it cannot be inherited from
Now it looks like there’s a problem with using override
on these constructor params. This comes down to a limitation of the Kotlin compiler: in order for the componentN()
helpers to point at the right value, data classes need to be kept “final.”
So, once you set those parameters, they can’t be overruled (or even extended).
Luckily, you can pull off our inheritance as long as the parent is not a data class. An abstract class would probably do the trick for us:
abstract class Fridge( open val doesPastaLookSketchy: Boolean, open val numEggsLeft: Int, ) data class YourFridge( override val doesPastaLookSketchy: Boolean, override val numEggsLeft: Int, val servingsOfChickenNoodleLeft: Int, ) : Fridge(doesPastaLookSketchy, numEggsLeft)
Yes, we still need to duplicate the parameters we want using override
, but it does give us some type safety for shared parameters between our data classes, all while keeping the equality, copy, and hashing traits in working order.
As you can tell, data classes offer some nice benefits with almost zero developer overhead. This is why I’d recommend using data
almost anywhere you use a regular class
for those added comparability benefits. So, go forth and rewrite some POJOs!
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]