/kassava

This library provides some useful kotlin extension functions for implementing toString(), hashCode() and equals() without all of the boilerplate.

Primary LanguageKotlinApache License 2.0Apache-2.0

Kassava

Build Status License Download Awesome Kotlin Badge

This library provides some useful kotlin extension functions for implementing toString(), equals() and hashCode() without all of the boilerplate.

The main motivation for this library was for situations where you can't use data classes and are required to implement toString()/equals()/hashCode() by:

  • hand-crafting your own :(
  • using the IDE generated methods :(
  • using Apache Common's ToStringBuilder and EqualsBuilder
    • customizable toString() format (can replicate Kotlin's data class format)
    • reflectionEquals() and reflectionToString() are super easy, but have awful performance
    • normal builders are still easy, but require lots of boilerplate
  • using Guava's MoreObjects
    • toStringBuilder() performs better than Apache, but still requires the same boilerplate, and the format is different to the data class format (it uses braces instead of parentheses)
    • there's no equivalent builder for equals() (you're meant to use Java's Objects.equals() but that's lots of boilerplate)
    • it's a large library (2MB+) if you're not already using it
  • or...something else!

Kassava provides extension functions that you can use to write your equals(), toString() and hashCode() methods with no boilerplate (using the kotlinEquals(), kotlinToString() and kotlinHashCode() methods respectively).

It's also really tiny (about 6kB), as it doesn't depend on any other libraries (like Apache Commons, or Guava). A special shoutout to Guava is required though, as the implementation of kotlinToString() is based heavily on the logic in Guava's excellent ToStringHelper.

How does it perform? Check the benchmark results below!

Quick Start

repositories {
    jcenter()
}

dependencies {
    compile("au.com.console:kassava:2.1.0")
}

Simple Example

// 1. Import extension functions
import au.com.console.kassava.kotlinEquals
import au.com.console.kassava.kotlinHashCode
import au.com.console.kassava.kotlinToString

import java.util.Objects

class Employee(val name: String, val age: Int? = null) {

    // 2. Optionally define your properties for equals/toString in a companion object
    // (Kotlin will generate less KProperty classes, and you won't have array creation for every method call)
    companion object {
        private val properties = arrayOf(Employee::name, Employee::age)
    }

    // 3. Implement equals() by supplying the list of properties used to test equality
    override fun equals(other: Any?) = kotlinEquals(other = other, properties = properties)

    // 4. Implement toString() by supplying the list of properties to be included
    override fun toString() = kotlinToString(properties = properties)

    // 5. Implement hashCode() by supplying the list of properties to be included
    override fun hashCode() = kotlinHashCode(properties = properties)
}

Polymorphic Example

Implementing equals() with polymorphic classes (mixed type equality) is rarely done correctly, and is often the subject of heavy debate!

For a thorough explanation of the problem and possible solutions, please refer to both Angelika Langer's Secret of equals() article, and Artima's How to Write an Equality Method in Java article.

In a nutshell, using instanceof is too lenient (and leads to implementations of equals() that are not transitive), and using getClass() is too strict (classes that extend in a trivial way without adding fields are no longer candidates for equality).

Kassava supports a solution that originated in the Scala world, and is proposed in the linked Artima article (and also implemented in lombok for those interested). The implementation makes use of a new interface SupportsMixedTypeEquality with a canEquals() method to achieve this. The purpose of this method is, as stated in the Artima article:

...as soon as a class redefines equals (and hashCode), it should also explicitly state that objects of 
this class are never equal to objects of some superclass that implement a different equality method. 
This is achieved by adding a method canEqual to every class that redefines equals.

Take a look at the unit tests for the kotlinEquals() method, and you'll see how the Point/ColouredPoint/anonymous Point scenario in the article works (and how classes that extend in trivial ways - with no extra fields - can still be considered equal with this method).

Here is an example of it in use (in a typical kotlin sealed class example).

Note the use of superEquals, superHashCode and superToString in the subclasses - these are lambdas that allow you to reuse the logic in your parent class.

import au.com.console.kassava.kotlinEquals
import au.com.console.kassava.kotlinHashCode
import au.com.console.kassava.kotlinToString
import au.com.console.kassava.SupportsMixedTypeEquality

/**
 * Animal base class with Cat/Dog subclasses.
 */
sealed class Animal(val name: String) : SupportsMixedTypeEquality {

    override fun equals(other: Any?) = kotlinEquals(
        other = other,
        properties = properties
    )

    // only Animals can be compared to Animals
    override fun canEqual(other: Any?) = other is Animal

    override fun toString() = kotlinToString(properties = properties)

    override fun hashCode() = kotlinHashCode(properties = properties)

    companion object {
        private val properties = arrayOf(Animal::name)
    }

    class Cat(name: String, val mice: Int) : Animal(name = name) {
        
        override fun equals(other: Any?) = kotlinEquals(
            other = other,
            properties = properties,
            superEquals = { super.equals(other) }
        )

        // only Cats can be compared to Cats
        override fun canEqual(other: Any?) = other is Cat

        override fun toString() = kotlinToString(
            properties = properties,
            superToString = { super.toString() }
        )

        override fun hashCode() = kotlinHashCode(
            properties = properties,
            superHashCode = { super.hashCode() }
        )

        companion object {
            private val properties = arrayOf(Cat::mice)
        }
    }

    class Dog(name: String, val bones: Int, val balls: Int? = null) : Animal(name = name) {

        override fun equals(other: Any?) = kotlinEquals(
            other = other,
            properties = properties,
            superEquals = { super.equals(other) }
        )

        // only Dogs can be compared to Dogs
        override fun canEqual(other: Any?) = other is Dog

        override fun toString() = kotlinToString(
            properties = properties,
            superToString = { super.toString() }
        )

        override fun hashCode() = kotlinHashCode(
            properties = properties,
            superHashCode = { super.hashCode() }
        )

        companion object {
            private val properties = arrayOf(Dog::bones, Dog::balls)
        }
    }
}

Benchmarking

While Kassava's usage is very readable and maintainable, how does it perform against the alternatives?

A Kassava JMH benchmark project was created to test this. You can see the test class implements all the variations of toString() and equals(), including:

  • normal implementation (boring old IDE-generated style)
  • Objects implementation (same as above, but with Java's Objects.equals() and Objects.toString())
  • Apache implementation
  • Apache reflection implementation
  • Guava implementation (for toString() only, there's no equivalent for equals()
  • Kassava implementation (with reused properties array)
  • Kassava implementation (with new array of properties each time)

The benchmark (using Kassava 1.0.0) was run on Travis with 10 warmup iterations, 5 test iterations, 1 fork, and measuring average time in nanoseconds. The raw results of the benchmark are:

Benchmark                                           Mode  Cnt     Score    Error  Units
EqualsBenchmark.apacheEquals                        avgt    5     5.365 ±  2.447  ns/op
EqualsBenchmark.apacheReflectionEquals              avgt    5   569.729 ±  5.990  ns/op
EqualsBenchmark.kassavaEquals                       avgt    5    84.647 ±  0.429  ns/op
EqualsBenchmark.kassavaEqualsWithArrayCreation      avgt    5    87.274 ±  0.520  ns/op
EqualsBenchmark.manualEquals                        avgt    5     5.665 ±  0.081  ns/op
EqualsBenchmark.manualObjectsEquals                 avgt    5     6.866 ±  0.042  ns/op
ToStringBenchmark.apacheReflectionToString          avgt    5  1484.542 ± 28.615  ns/op
ToStringBenchmark.apacheToString                    avgt    5   922.272 ± 52.431  ns/op
ToStringBenchmark.guavaToString                     avgt    5   344.156 ±  6.403  ns/op
ToStringBenchmark.kassavaToString                   avgt    5   416.654 ± 10.255  ns/op
ToStringBenchmark.kassavaToStringWithArrayCreation  avgt    5   420.433 ±  7.425  ns/op
ToStringBenchmark.manualObjectsToString             avgt    5   140.707 ±  2.457  ns/op
ToStringBenchmark.manualToString                    avgt    5   118.061 ±  2.196  ns/op

TLDR:

  • Kassava's equals() implementation is definitely slower than manual/Apache/Guava (approx 15x slower), but nowhere near as bad as Apache's reflection implementation (approx 100x slower)
  • Kassava's toString() implementation is only slightly slower than Guava (and faster than Apache!)
  • Apache's equals() implementation is faster than the manual implementation??? (Magic?)

Contributing to the Project

If you'd like to contribute code to this project you can do so through GitHub by forking the repository and generating a pull request.

By contributing your code, you agree to license your contribution under the terms of the Apache License v2.0.

License

Copyright 2016 RES INFORMATION SERVICES PTY LTD

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.