Skip to main content

Stateful classes without equals, hashCode, and toString

Three diagnostics report public stateful classes that rely on the implementations of equals, hashCode, and toString from kotlin.Any.

DiagnosticSTATEFUL_CLASS_WITHOUT_EQUALS
Default severityError
Gradle propertystatefulClassWithoutEquals
Exemption@IntentionallyWithoutEquals or the combined exemption
DiagnosticSTATEFUL_CLASS_WITHOUT_HASH_CODE
Default severityError
Gradle propertystatefulClassWithoutHashCode
Exemption@IntentionallyWithoutHashCode or the combined exemption
DiagnosticSTATEFUL_CLASS_WITHOUT_TO_STRING
Default severityError
Gradle propertystatefulClassWithoutToString
Exemption@IntentionallyWithoutToString or the combined exemption

Combined exemption: @IntentionallyWithoutEqualsHashCodeOrToString

What they report

The checks flag a public or protected regular class that has at least one property storing its value in a backing field and that doesn't declare or inherit the corresponding implementation:

/**
* Describes a remote service endpoint.
*
* @property host network host serving requests.
*/
public class Endpoint(public val host: String)

Each member is checked independently. A class that implements toString but not equals and hashCode, for example, receives the first two diagnostics.

Rationale

State usually gives instances their meaning. Identity equality treats two instances with the same state as different values. Identity hashing carries that behavior into sets and map keys. An instance that only prints as Connection@1a2b3c4d reveals nothing in a log line, exception message, or debugger watch.

Generate all three members together so they use the same state. Poko does this for Kotlin classes without exposing the copy and componentN API of a data class. In IntelliJ IDEA, press Alt+Insert or choose Code | Generate to generate the methods without another library.

See the Kotlin library authors' guidelines on providing a toString method for stateful types.

Don't

/**
* Describes a connection to a remote service.
*
* @property host network host serving requests.
*/
public class Connection(public val host: String)

Do

Generate the members with Poko:

/**
* Describes a connection to a remote service.
*
* @property host network host serving requests.
*/
@Poko
public class Connection(public val host: String)

Or Do

/**
* Describes a connection to a remote service.
*
* @property host network host serving requests.
*/
public class Connection(public val host: String) {
public override fun equals(other: Any?): Boolean =
this === other || (other is Connection && host == other.host)
public override fun hashCode(): Int = host.hashCode()
public override fun toString(): String = "Connection(host=$host)"
}

Notes

  • An implementation inherited from any supertype other than kotlin.Any counts as provided, so a subclass that adds its own state is not reported for that member. Whether the inherited behavior should include the new state is left to the author.
  • Data and value classes get compiler-generated implementations and are not reported here. Data classes are reported by DATA_CLASS_PUBLIC_API instead.
  • Enum entries, objects (including companion objects), interfaces, and annotation classes are not reported. Enums and singleton objects have deliberate identity semantics, while interfaces and annotation classes can't hold backing fields.
  • A delegated property stores its value in the delegate, not in a backing field, so it doesn't make a class stateful on its own.
  • @PublishedApi internal classes are not reported because users can't reference them in source.

Exemptions

Apply @IntentionallyWithoutEqualsHashCodeOrToString when all three behaviors are intentional. For example, a sensitive handle may intentionally use identity equality and avoid rendering its state:

/**
* Describes a live connection to a remote service.
*
* @property host network host serving requests.
*/
@IntentionallyWithoutEqualsHashCodeOrToString(
reason = ExemptionReason.API_DESIGN,
description = "A live connection uses identity semantics and omits configuration from logs.",
)
public class Connection(public val host: String)

The individual @IntentionallyWithoutEquals, @IntentionallyWithoutHashCode, and @IntentionallyWithoutToString exemptions remain available when only some behaviors are intentional. For example, a class can acknowledge identity equality and hashing while still providing a safe toString.

Configuration

Poko

Poko supports Kotlin/JVM and Kotlin Multiplatform. Apply the version compatible with the Kotlin version used by the project. For Kotlin 2.4.0:

build.gradle.kts
plugins {
id("dev.drewhamilton.poko") version "0.23.0"
}

Annotate each class whose equals, hashCode, and toString should be generated with @Poko, as shown in the usage sample.

Check severity

apiWatchdog {
statefulClassWithoutEquals = WatchdogSeverity.WARNING
statefulClassWithoutHashCode = WatchdogSeverity.WARNING
statefulClassWithoutToString = WatchdogSeverity.WARNING
}

With direct compiler invocation:

-P plugin:org.jetbrains.kotlin.library.api.watchdog:diagnosticSeverity=STATEFUL_CLASS_WITHOUT_EQUALS:warning
-P plugin:org.jetbrains.kotlin.library.api.watchdog:diagnosticSeverity=STATEFUL_CLASS_WITHOUT_HASH_CODE:warning
-P plugin:org.jetbrains.kotlin.library.api.watchdog:diagnosticSeverity=STATEFUL_CLASS_WITHOUT_TO_STRING:warning

See also