Property include/exclude on Kotlin data classes
Asked Answered
T

11

52

Suppose I only want one or two fields to be included in the generated equals and hashCode implementations (or perhaps exclude one or more fields). For a simple class, e.g.:

data class Person(val id: String, val name: String)

Groovy has this:

@EqualsAndHashCode(includes = 'id')

Lombok has this:

@EqualsAndHashCode(of = "id")

What is the idiomatic way of doing this in Kotlin?

My approach so far

data class Person(val id: String) {
   // at least we can guarantee it is present at access time
   var name: String by Delegates.notNull()

   constructor(id: String, name: String): this(id) {
      this.name = name
   }
}

Just feels wrong though... I don't really want name to be mutable, and the extra constructor definition is ugly.

Topminnow answered 12/4, 2015 at 22:18 Comment(0)
M
28

I've used this approach.

data class Person(val id: String, val name: String) {
   override fun equals(other: Any?) = other is Person && EssentialData(this) == EssentialData(other)
   override fun hashCode() = EssentialData(this).hashCode()
   override fun toString() = EssentialData(this).toString().replaceFirst("EssentialData", "Person")
}

private data class EssentialData(val id: String) {
   constructor(person: Person) : this(id = person.id) 
}
Magenmagena answered 15/9, 2017 at 20:45 Comment(6)
equals should override "Any?"Eichman
Do you mean other should be Any? ? If so, maybe, but this way the compiler should use the Any.equals(any) when it knows that the rhs is not a Person. I suppose that there is an edge case where you have lost the type of the rhs...Magenmagena
This looks kind of cumbersome. Is this still the best solution up to date?Soares
Improvent for the equals fun : override fun equals(other: Any?):Boolean{ if(other != Person) return false return EssentialData(this) == EssentialData(other) } Fike
a more idiomatic way to write @dstibbe's improvement is: override fun equals(other: Any?) = other is Person && EssentialData(this) == EssentialData(other)Ingenuity
Nice improvement @KristopherNoronha.Fike
I
14

This approach may be suitable for property exclusion:

class SkipProperty<T>(val property: T) {
  override fun equals(other: Any?) = true
  override fun hashCode() = 0
}

SkipProperty.equals simply returns true, which causes the embeded property to be skipped in equals of parent object.

data class Person(
    val id: String, 
    val name: SkipProperty<String>
)
Irbm answered 22/2, 2020 at 16:20 Comment(3)
It's creative, so +1 for that, but this is not a solution I would employ. You have the extra .property on any access to a field whose lack of participation in the equals/hashCode of its containing class is frankly solely an implementation detail of that class. You could of course override get()/set() on that property to do this automatically, but ooof. Heavy for such a requirement.Topminnow
Yes, you are right. I just want to share my attempt.Irbm
Thank goodness Kotlin came along and saved us from the complexity of Java.Remontant
C
11

This builds on @bashor's approach and uses a private primary and a public secondary constructor. Sadly the property to be ignored for equals cannot be a val, but one can hide the setter, so the result is equivalent from an external perspective.

data class ExampleDataClass private constructor(val important: String) {
  var notSoImportant: String = ""
    private set

  constructor(important: String, notSoImportant: String) : this(important) {
    this.notSoImportant = notSoImportant
  }
}
Caracole answered 27/7, 2018 at 8:17 Comment(1)
I believe this is the recommended approach these days. kotlinlang.org/docs/…Knockknee
T
7

I also don't know "the idomatic way" in Kotlin (1.1) to do this...

I ended up overriding equals and hashCode:

data class Person(val id: String,
                  val name: String) {

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other?.javaClass != javaClass) return false

        other as Person

        if (id != other.id) return false

        return true
    }

    override fun hashCode(): Int {
        return id.hashCode()
    }
}

Isn't there a "better" way?

Tammara answered 7/6, 2017 at 13:42 Comment(1)
I think my solution is also not perfect, but has some benefits. Take a look.Caracole
A
3

Here's a somewhat creative approach:

data class IncludedArgs(val args: Array<out Any>)

fun includedArgs(vararg args: Any) = IncludedArgs(args)


abstract class Base {
    abstract val included : IncludedArgs

    override fun equals(other: Any?) = when {
        this identityEquals other -> true
        other is Base -> included == other.included
        else -> false
    }

    override fun hashCode() = included.hashCode()

    override fun toString() = included.toString()
}

class Foo(val a: String, val b : String) : Base() {
    override val included = includedArgs(a)
}

fun main(args : Array<String>) {
    val foo1 = Foo("a", "b")
    val foo2 = Foo("a", "B")

    println(foo1 == foo2) //prints "true"
    println(foo1)         //prints "IncludedArgs(args=[a])"
}
Arianism answered 12/4, 2015 at 23:27 Comment(2)
Interesting solution! I personally wouldn't trade a few lines of boilerplate assignment, e.g. val name: String = name in @bashor's example for inheritance from a Base class that serves to make up for a missing language feature.Topminnow
I agree, my solution is not very elegant. I just hacked it together for the fun of it and decided to share.Arianism
P
3

Reusable solution: to have an easy way to select which fields to include in equals() and hashCode(), I wrote a little helper called "stem" (essential core data, relevant for equality).

Usage is straightforward, and the resulting code very small:

class Person(val id: String, val name: String) {
    private val stem = Stem(this, { id })

    override fun equals(other: Any?) = stem.eq(other)
    override fun hashCode() = stem.hc()
}

It's possible to trade off the backing field stored in the class with extra computation on-the-fly:

    private val stem get() = Stem(this, { id })

Since Stem takes any function, you are free to specify how the equality is computed. For more than one field to consider, just add one lambda expression per field (varargs):

    private val stem = Stem(this, { id }, { name })

Implementation:

class Stem<T : Any>(
        private val thisObj: T,
        private vararg val properties: T.() -> Any?
) {     
    fun eq(other: Any?): Boolean {
        if (thisObj === other)
            return true

        if (thisObj.javaClass != other?.javaClass)
            return false

        // cast is safe, because this is T and other's class was checked for equality with T
        @Suppress("UNCHECKED_CAST") 
        other as T

        return properties.all { thisObj.it() == other.it() }
    }

    fun hc(): Int {
        // Fast implementation without collection copies, based on java.util.Arrays.hashCode()
        var result = 1

        for (element in properties) {
            val value = thisObj.element()
            result = 31 * result + (value?.hashCode() ?: 0)
        }

        return result
    }

    @Deprecated("Not accessible; use eq()", ReplaceWith("this.eq(other)"), DeprecationLevel.ERROR)
    override fun equals(other: Any?): Boolean = 
        throw UnsupportedOperationException("Stem.equals() not supported; call eq() instead")

    @Deprecated("Not accessible; use hc()", ReplaceWith("this.hc(other)"), DeprecationLevel.ERROR)
    override fun hashCode(): Int = 
        throw UnsupportedOperationException("Stem.hashCode() not supported; call hc() instead")
}

In case you're wondering about the last two methods, their presence makes the following erroneous code fail at compile time:

override fun equals(other: Any?) = stem.equals(other)
override fun hashCode() = stem.hashCode()

The exception is merely a fallback if those methods are invoked implicitly or through reflection; can be argued if it's necessary.

Of course, the Stem class could be further extended to include automatic generation of toString() etc.

Potentate answered 8/5, 2019 at 6:57 Comment(0)
R
2

Simpler, faster, look at there, or into the Kotlin documentation. https://discuss.kotlinlang.org/t/ignoring-certain-properties-when-generating-equals-hashcode-etc/2715/2 Only fields inside the primary constructor are taken into account to build automatic access methods like equals and so on. Do keep the meaningless ones outside.

Romp answered 25/5, 2021 at 15:22 Comment(0)
E
2

Here is another hacky approach if you don't want to touch the data class.
You can reuse the entire equals() from data classes while excluding some fields.
Just copy() the classes with fixed values for excluded fields:

data class Person(val id: String,
                  val name: String)
fun main() {
    val person1 = Person("1", "John")
    val person2 = Person("2", "John")
    println("Full equals: ${person1 == person2}")
    println("equals without id: ${person1.copy(id = "") == person2.copy(id = "")}")
   
}

Output:

Full equals: false
equals without id: true
Epistyle answered 25/2, 2022 at 15:29 Comment(0)
E
0

Consider the following generic approach for the implementation of equals/hashcode. The code below should have no performance impact because of the use of inlining and kotlin value classes:

@file:Suppress("EXPERIMENTAL_FEATURE_WARNING")

package org.beatkit.common

import kotlin.jvm.JvmInline

@Suppress("NOTHING_TO_INLINE")
@JvmInline
value class HashCode(val value: Int = 0) {
    inline fun combineHash(hash: Int): HashCode = HashCode(31 * value + hash)
    inline fun combine(obj: Any?): HashCode = combineHash(obj.hashCode())
}

@Suppress("NOTHING_TO_INLINE")
@JvmInline
value class Equals(val value: Boolean = true) {
    inline fun combineEquals(equalsImpl: () -> Boolean): Equals = if (!value) this else Equals(equalsImpl())
    inline fun <A : Any> combine(lhs: A?, rhs: A?): Equals = combineEquals { lhs == rhs }
}

@Suppress("NOTHING_TO_INLINE")
object Objects {
    inline fun hashCode(builder: HashCode.() -> HashCode): Int = builder(HashCode()).value

    inline fun hashCode(vararg objects: Any?): Int = hashCode {
        var hash = this
        objects.forEach {
            hash = hash.combine(it)
        }
        hash
    }

    inline fun hashCode(vararg hashes: Int): Int = hashCode {
        var hash = this
        hashes.forEach {
            hash = hash.combineHash(it)
        }
        hash
    }

    inline fun <T : Any> equals(
        lhs: T,
        rhs: Any?,
        allowSubclasses: Boolean = false,
        builder: Equals.(T, T) -> Equals
    ): Boolean {
        if (rhs == null) return false
        if (lhs === rhs) return true
        if (allowSubclasses) {
            if (!lhs::class.isInstance(rhs)) return false
        } else {
            if (lhs::class != rhs::class) return false
        }
        @Suppress("unchecked_cast")
        return builder(Equals(), lhs, rhs as T).value
    }
}

With this in place, you can easily implement/override any equals/hashcode implementation in a uniform way:

data class Foo(val title: String, val bytes: ByteArray, val ignore: Long) {
    override fun equals(other: Any?): Boolean {
        return Objects.equals(this, other) { lhs, rhs ->
            this.combine(lhs.title, rhs.title)
                .combineEquals { lhs.bytes contentEquals rhs.bytes }
            // ignore the third field for equals
        }
    }

    override fun hashCode(): Int {
        return Objects.hashCode(title, bytes) // ignore the third field for hashcode
    } 
}
Enyo answered 11/10, 2021 at 15:39 Comment(0)
M
0

My approaches, using Reflection and java.util.Objects.hash.

General advantages:

  • It does not rely on creating auxiliary objects or calc hash by hand.
  • No need to abandon val or remove props from the constructor.
  • You can extract this logic from the class declaration and place it in specific domains if you still need the standard equals to coexist.

Allowing specific fields and ignoring everything else

import java.util.Objects

// Added more props to exemplify better
data class Person(val id: String, val name: String, val age: Int, val email: String, val phone: String) {
    // The common `equals` implementation, nothing fancy being done except using the `visibleProps` list
    override fun equals(other: Any?) =
        this === other || (other is Person && visibleProps.all { it.get(this) == it.get(other) })

    override fun hashCode() = Objects.hash(*visibleProps.map { it.get(this) }.toTypedArray())

    companion object {
        private val visibleProps = setOf(Person::id, Person::email)
    }
}

fun main() {
    val p1 = Person("1", "Alice", 30, "[email protected]", "123456789")
    val p2 = Person("1", "Bob", 40, "[email protected]", "987654321")

    println(p1 == p2) // true, considering only id and email
    println(p1 === p2) // false
    println(p1.hashCode() == p2.hashCode()) // true
}
  • If you add new properties to the data class, they will always be ignored in equals and hashCode.
  • If you need to allow more properties, just add them to visibleProps set.

Ignoring specific fields and allowing everything else

import java.util.Objects
import kotlin.reflect.full.memberProperties

data class Person(val id: String, val name: String, val age: Int, val email: String) {

    override fun equals(other: Any?) =
        this === other || (other is Person && visibleProps.all { it.get(this) == it.get(other) })

    override fun hashCode() = Objects.hash(*visibleProps.map { it.get(this) }.toTypedArray())

    companion object {
        private val ignoredProps = setOf(Person::id, Person::email)
        private val visibleProps = Person::class.memberProperties - ignoredProps
    }
}


// Examples

fun main() {
    val p1 = Person("1", "Alice", 30, "[email protected]", "1234567890")
    val p2 = Person("42", "Alice", 30, "[email protected]", "1234567890")

    println(p1 == p2) // true, ignoring id and email
    println(p1 === p2) // false
    println(p1.hashCode() == p2.hashCode()) // true
}

  • If you add new properties to the data class, they will be considered in equals and hashCode automatically.
  • If you need to ignore more properties, just add them to ignoredProps set.
Metatarsus answered 3/7 at 22:58 Comment(0)
C
-1

You can create an annotation that represents the exclusion of the property as @ExcludeToString or with @ToString(Type.EXCLUDE) parameters by defining enum.

And then using reflection format the value of the getToString().

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class ExcludeToString

data class Test(
        var a: String = "Test A",
        @ExcludeToString var b: String = "Test B"
) {
    override fun toString(): String {
        return ExcludeToStringUtils.getToString(this)
    }
}

object ExcludeToStringUtils {

    fun getToString(obj: Any): String {
        val toString = LinkedList<String>()
        getFieldsNotExludeToString(obj).forEach { prop ->
            prop.isAccessible = true
            toString += "${prop.name}=" + prop.get(obj)?.toString()?.trim()
        }
        return "${obj.javaClass.simpleName}=[${toString.joinToString(", ")}]"
    }

    private fun getFieldsNotExludeToString(obj: Any): List<Field> {
        val declaredFields = obj::class.java.declaredFields
        return declaredFields.filterNot { field ->
            isFieldWithExludeToString(field)
        }
    }

    private fun isFieldWithExludeToString(field: Field): Boolean {
        field.annotations.forEach {
            if (it.annotationClass == ExcludeToString::class) {
                return true
            }
        }
        return false
    }

}

GL

Gist

Convocation answered 27/7, 2020 at 3:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.