Data Class With Backing Field


#1

Hi all,

I’ve run into an interesting situation when attempting to use a data class with a backing field.

data class Example(private val _str: String) {
  val str = _str
    get() = field.toUpperCase()
}

We can see that this public property is not factored into equality:

val ex1 = Example("hello")
val ex2 = Example("HELLO")
ex1 == ex2 // false

What would be the appropriate way to handle this situation? The actual data class we’re using has ~12 normal, public properties and then one property that needs the passed-in value to be upper-cased. I’m guessing it’d be best to not use a data class and manually handle this case, but I wanted to check before going that route as I :heart: data classes.


#2

Well, data classes are all about the parameters of the primary constructor. This means, the component1() will return the first parameter, equals, hashCode and toString will only care about the parameters, not about the other properties.

There’s another thing you might want to check in your code (see the bytecode and decompile it).
You’re actually creating 2 fields: _str and str, which I’m sure you didn’t intend.
If you intended to set str as the object is being constructed, use:

val str = _str.toUpperCase()

But if you intend to use _str as a backing field, use:

val str get() = _str.toUpperCase()

Anyway, this won’t fix your issue. I think you have to use a normal class and implement the methods manually.

Unless you don’t mind the str to be var, in which case, you can do this:

data class Example(var str: String) {
    init {
        str = str.toUpperCase()
    }
}

#3

I found an ugly hack, which I am ashamed of:

data class Example private constructor(val str: String) {
    constructor(_str: String, _ignore: Boolean = false) : this(_str.toUpperCase()) {
    }
}

The _ignore parameter is necessary, otherwise the compiler complains that the overloads are conflicting.
Don’t use this code :slight_smile:


#4

Slightly less ugly hack:

data class Example private constructor(val str: String) {
    companion object {
        operator fun invoke(str: String) = Example(str.toUpperCase())
    }
}

This way you’re not creating a new constructor, just a factory method that looks like one.


#5

A private constructor will become exposed by the copy method automatically generated for the data class. This means you can bypass the toUpperCase enforcement when creating new instances via copy.

I also had a use case where I needed to enforce some logic on construction. In addition I also needed the data class to be marshalled to/from JSON by com.fasterxml.jackson. Here is what I came up with.

data class Example(private var _field1: String, val field2: Int, val field3: Long) {
  init {
    _field1 = _field1.toUpperCase()
  }

  val field1 get() = _field1

  companion object {
    @JsonCreator
    @JvmStatic
    operator fun invoke(field1: String, field2: Int, field3: Long) = Example(field1, field2, field3)
  }
}

From the outside Example remains immutable, the generated copy method will also enforce the toUpperCase rule on new instances, the normal public properties don’t need any special handling*, and it can be marshalled to/from JSON as expected**. You do have to compromise and live with _field1 in your toString and as the parameter name in the copy method***.

* Other than being included in the @JsonCreator factory method

** @JsonPropertyOrder can be added to keep things in constructor order, otherwise field1 will show up last in the JSON string

*** Unless you override them of course