Equality Testing Across Unrelated Types


#1

Java equals contract has no problem if you equate two completely unrelated types. The following code prints true in Java and is completely valid with equals/hashCode contract:

import java.util.Objects;

public class Tester {

  static final class Foo {
    public final String v;

    Foo(String v) { this.v = v; }

    @Override
    public int hashCode() { return Objects.hash(v); }

    @Override
    public boolean equals(Object obj) {
      if (obj instanceof Foo) return Objects.equals(v, ((Foo) obj).v);
      if (obj instanceof Bar) return Objects.equals(v, ((Bar) obj).v);
      return false;
    }
  }

  static final class Bar {
    public final String v;

    Bar(String v) { this.v = v; }

    @Override
    public int hashCode() { return Objects.hash(v); }

    @Override
    public boolean equals(Object obj) {
      if (obj instanceof Foo) return Objects.equals(v, ((Foo) obj).v);
      if (obj instanceof Bar) return Objects.equals(v, ((Bar) obj).v);
      return false;
    }
  }

  public static void main(String[] args) {
    System.out.println("Equal? " + (new Foo("test").equals(new Bar("test"))));
  }
}

Here it is converted to Kotlin via IntelliJ’s converter:

import java.util.*

object Tester {

    internal class Foo(val v: String) {

        override fun hashCode(): Int {
            return Objects.hash(v)
        }

        override fun equals(obj: Any?): Boolean {
            if (obj is Foo) return v == obj.v
            return if (obj is Bar) v == obj.v else false
        }
    }

    internal class Bar(val v: String) {

        override fun hashCode(): Int {
            return Objects.hash(v)
        }

        override fun equals(obj: Any?): Boolean {
            if (obj is Foo) return v == obj.v
            return if (obj is Bar) v == obj.v else false
        }
    }

    @JvmStatic
    fun main(args: Array<String>) {
        println("Equal? " + (Foo("test") == Bar("test")))
    }
}

This fails to compile with: Operator '==' cannot be applied to 'Tester.Foo' and 'Tester.Bar'. Now, I of course understand why this is and casting the LHS or RHS of the binary equal op to Any lets it compile and runs and returns true (or just using .equals explicitly instead of the operator). I’m aware of a few posts here that touch on it such as Strange behavior with equality checking but that talks about “open” or not. Questions:

  1. Is it a bug that the Kotlin converter generates code that doesn’t compile from Java?
  2. Why can two JVM objects that satisfy the Java equals/hashCode rules not be compared using the equality operator in Kotlin? Surely this should be a warning, not a compile failure.
  3. Is it a bug that several statements on http://kotlinlang.org/docs/reference/equality.html are false because of this? (I asked a similar issue about equality at When == isn't equals(), but the majority of my posts never get any answers despite my efforts to ask clear questions and make the concerns clear)
  4. Is it a bug that IntelliJ says, when I write Foo("test").equals(Bar("test")), “Call replaceable with binary operator” which is clearly false?

I am hoping to avoid the “but, why would you do that” conversations and stick to the Java contract/spec/rules and the Kotlin docs/rules here.


#2

Before I attempt to answer, what do you mean when saying?:

Java equals contract has no problem if you equate two completely unrelated types

By “no problem”, are you referring to the fact that it compiles and runs?


#3

Thanks for responding! I am referring to https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object- and https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#hashCode-- when mentioning the “contract” of equals and hashCode. Basically, no general caller of Java equals should presume any type similarities IMO. There’s a reason why some of the methods on Objects use generics but Objects.equals accepts two objects.


#4

Regarding the comment about generics, the equals method was defined before generics existed in Java. Generics were added to the language in a backwards compatible way so that defective scenarios would still compile and run (a good example is the flawed way of treating arrays as covariant in order to allow array utils like sorting before generics existed)

I’ll try to get back to the original question when I get more time.


#5

No, I specifically said Objects.equals and “accepts two objects” which was created in Java 1.7, not Object.equals which accepts a single object. I am intimately familiar with Java the language and the JVM, I have written my share of compilers for it.

I look forward to answers to my original questions, and thanks again for responding. I am happy to create YouTrack issues for anything.


#6

The Objects.equals method was intended to reduce the null-check clutter from equals implementations since Java isn’t null-safe. It would be inconsistent for Object.equals method to accept Object and for Objects.equals to constrain that to only objects of the same type (so this was the most backwards consistent implementation).

Ok, back to the original question, although not strictly required, there is an implicit assumption / strong recommendation that equality be mathematically sound. That is, a == b if and only if a >= b && a <= b. The Comparable interface is a strong nudge in the right direction as you can only compare instances of the same type (it was defined after generics became available). If you want to allow instances of different types to be equal then you are effectively preventing any of those types from implementing comparable because a.equals(b) should imply a.compareTo(b) == 0 but the comparable interface doesn’t allow that.


#7

I think your confusing something that can be ordered with something that can be equal or not. While the former implies the latter, the inverse is definitely not true, and on the JVM and elsewhere equality is not reliant on ordering, is orthogonal to it, and there is definitely not an implicit assumption or strong suggestion that equality needs to have anything to do with comparable (granted, comparable has strong suggestions about equality, but I am not referring to orderable objects).

And despite all of that, the other questions specifically refer to Kotlin docs being wrong, IntelliJ inspection being wrong, and the Java-to-Kotlin converter being wrong… all due to the compiler disallowing equality checks on unrelated types (improperly IMO).


#8

It’s not a confusion and the response is directly applicable. I worded my response carefully when stating that this type of strange equality prevents those types from implementing the Comparable interface (because it would fail the strong recommendation for a.equals(b) <=> a.compareTo(b) == 0 for types that implement the Comparable interface)


#9

Replying to @cretz original question:
1: yes that’s a bug in the converter. My understanding is that the converter makes it easier to convert they don’t promise it will work with 100% of the code. In my experience it makes a pretty good at making it bearable to convert a project in a reasonable amount of time.

2: That actually looks suspicious, if that was a conscious design choice the documentation should state that.

3: IMHO yes. those statements seem to be true if the 2 objects have the same types.

4: Seems like a bug again, that should be suggested only when the 2 types are the same (or whatever are the exact rules in kotlin for ‘a == b’ to compile).


#10

@cretz: I think you are asking the right questions and I assume there are bugs in the converter and in the idea hinting.
Have you already tried to create bug issues in YouTrack for the points 1. and 4.?

I did this a few times. People upvoted the issues and then they get fixed.
If you post links to the issues here (which is common) I and possibly many others will upvote these.
I would be glad if you upvoted one of my tickets too :wink:
https://youtrack.jetbrains.com/issue/KT-26202


#11

Thanks! I usually try to approach the community for non-obvious bugs. Clearly, in this case, JetBrains specifically decided not to compare unrelated types (I should say “never related types” because if they were “open” it’d be enough to make the compiler happy IIRC).

But I have created and voted on many issues. I’ll probably have to create a few here. I have definitely run across issues like you posted with default values for lambda params. I don’t upvote the inspection ones as much as I do compiler errors, but sure (I was watching a similar one at https://youtrack.jetbrains.com/issue/KT-19244).


#12

According point 2 my personal opinion is that Kotlin’s == is not the same as equals and I think that’s a good language decision.

  1. When I define similar types, their instances should not be equal (I think) because why would I even need different types for them? But their extending children could be equal (most of the time).
  2. Let’s assume we have classes A and B such that A.equals( B() ) but not B().equals( A() ) and assume Kotlin would allow equality check on these different types. What is the result of A()==B()? And shouldn’t equality be symmetrical, that is it has the same result as B()==A()?

#13

When I define similar types, their instances should not be equal (I think) because why would I even need different types for them? But their extending children could be equal (most of the time).

The use case is rare but it happens, and we might be working with Java types. So a == b should be disallowed, but if we changed to a as Any == b that’s ok? That’s the annoying part, is Kotlin only complains when it knows they are unrelated so we have to trick the compiler. I guess I don’t mind if the docs are updated the specifically say "Unlike Java, unrelated non-open types cannot be checked for equality.

This is a bit unrelated and I believe anyone doing this would be violating the symmetric and transitive properties of the suggested equals contract in Java as mentioned here: https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object-. What I’m talking about is something that is not considered wrong on the JVM/Java at all, just in Kotlin, and it’s an undocumented deviation (in fact, the documentation is misleading into thinking it does just use the equals method).


#14

Yes you are right. My train of thought was on the wrong track about what you are questioning here.

So the question for point 2 is still open and can be rephrased: Why two instances of non-open classes (that satisfy the Java equals/hashCode rules) can not be compared using the equality operator in Kotlin?

Surely there is some design decision behind it! Anyone know about this?


#15

@stangl0r One reason is that allowing this would effectively prevent you from implementing Comparable for those 2 classes (because you would be allowed to compare them for equality but won’t be allowed to use compareTo on them since compareTo requires the same type). So this is a strong hint that we’re going in the wrong direction.

There are other reasons as well. For one, this choice would force you to break encapsulation if you want private properties to be used for determining equality (by either making them accessible or passing them around).

Another major reason is when you want to start allowing inheritance (open classes). You won’t be able to implement equals in a way that correctly deals with generated classes while meeting all the properties from the equality contract.


#16

Yes, the java-to-kotlin converter is not perfect, so it looks like a bug.

I don’t know the exact reason behind this rule, but I guess it helps to prevent more errors than it hurts with these rare case false positives.

Could you quote ones that are false?

It sure is. It has been already reported as https://youtrack.jetbrains.com/issue/KT-25050, but then it was closed as designed. I think we should reconsider that resolution.


#17

talking about equals : am the only one that consider that equals should be in an interface Equalisable<T> where <T:Equalisable<T>> (like enum), ad not in Any ?


#18

That would not be be interoperable with Java. Other than that, yes, it would make more sense that way (C# went that way).


#19

Thanks for responding!

Cool, created https://youtrack.jetbrains.com/issue/KT-26263.

Sounds like a text book case of a warning instead of an error. Can downgrading it to a warning be considered?

Maybe “false” is too harsh, but at the least, “misleading due to undocumented behavior”. E.g.

Structural equality is checked by the == operation (and its negated counterpart !=). By convention, an expression like a == b is translated to: a?.equals(b) ?: (b === null)

Technically this is true at runtime, but what this tells a developer reading the guide is that they can expect to use one for the other. Same with:

I.e. if a is not null , it calls the equals(Any?) function, otherwise (i.e. a is null ) it checks that b is referentially equal to null.

Just has an undocumented feature of also checking, at compile time, that a or b are not classes, open classes, or are related.

I have added a comment on that issue requesting it be reopened.

Overall, this is a perfect case for a warning (like using == to compare unrelated arrays) instead of an error and causes more problems than it solves as an error. Imagine if many of the warnings became errors in the language based on this kind of equality check? But if it is part of the semantics of the language that anyone building a Kotlin compiler is required to fail on this case, at the very least it deserves to be documented.


#20

I think misleading is too harsh of a word as well because the quoted statements are 100% correct. Furthermore, the documentation only describes a one-way / one-direction mapping so I don’t find it misleading at all. However, I do agree that it’s a good idea to add extra documentation stating that this operator is only allowed for comparing variables that have a non-empty type intersection.