Java hashCode method for a class with dates and max delta

111 Views Asked by At

In java what can be a good hashCode method for a class with a date, where objects are considered equal if the dates have a predefined maximum difference. Example class:

public class Test {
    private static final int MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000;
    private Date date;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Test other = (Test) o;
        return ((date == null && other.date == null) ||
                (date != null && other.date != null &&
                 Math.abs(date.getTime() - other.date.getTime()) < MILLISECONDS_IN_A_DAY));
    }
}

Should I explore a different strategy? Perhaps another comparison function, either an internal or an external one? Maybe implementing the Comparable interface would be better?

2

There are 2 best solutions below

5
Mr. Polywhirl On BEST ANSWER

I would advise against creating a custom equals() and hashCode() implementation.

You would either need to define a comparator for your class:

package org.example;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

public class ComparableDate implements Comparable<ComparableDate> {
    private static final long MILLISECONDS_IN_A_DAY = TimeUnit.DAYS.toMillis(1);

    private final LocalDateTime date;

    public ComparableDate(LocalDateTime date) {
        this.date = date;
    }

    public LocalDateTime getDate() {
        return date;
    }

    @Override
    public int compareTo(ComparableDate other) {
        if (this == other) return 0;
        if (other == null || other.date == null) return -1;
        if (date == null) return 1;
        long diffInMillis = ChronoUnit.MILLIS.between(date, other.date);
        if (Math.abs(diffInMillis) < MILLISECONDS_IN_A_DAY) return 0;
        return (int) Math.signum(diffInMillis - MILLISECONDS_IN_A_DAY);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ComparableDate that = (ComparableDate) o;
        return Objects.equals(date, that.date);
    }

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

    public static void main(String[] args) {
        ComparableDate a = fromString("2023-12-25T08:00:00Z");
        ComparableDate b = fromString("2023-12-25T12:00:00Z");
        ComparableDate c = fromString("2023-12-26T12:00:00Z");

        System.out.println(a.compareTo(b)); // 0 "equal"
        System.out.println(a.compareTo(c)); // 1 "not equal"
    }

    private static ComparableDate fromString(String timestamp) {
        return new ComparableDate(LocalDateTime.parse(timestamp, DateTimeFormatter.ISO_DATE_TIME));
    }
}

Or better yet, create a custom comparator:

package org.example;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

public class ComparableDate {
    private final LocalDateTime date;

    public ComparableDate(LocalDateTime date) {
        this.date = date;
    }

    public LocalDateTime getDate() {
        return date;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ComparableDate that = (ComparableDate) o;
        return Objects.equals(date, that.date);
    }

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

    public static void main(String[] args) {
        DateComprator comparator = new DateComprator();

        ComparableDate a = fromString("2023-12-25T08:00:00Z");
        ComparableDate b = fromString("2023-12-25T12:00:00Z");
        ComparableDate c = fromString("2023-12-26T12:00:00Z");

        System.out.println(comparator.compare(a, b)); // 0 "equal"
        System.out.println(comparator.compare(a, c)); // 1 "not equal"
    }

    private static ComparableDate fromString(String timestamp) {
        return new ComparableDate(LocalDateTime.parse(timestamp, DateTimeFormatter.ISO_DATE_TIME));
    }

    private static class DateComprator implements Comparator<ComparableDate> {
        private static final long MILLISECONDS_IN_A_DAY = TimeUnit.DAYS.toMillis(1);

        @Override
        public int compare(ComparableDate that, ComparableDate other) {
            if (that == other) return 0;
            if (other == null || other.date == null) return -1;
            if (that == null || that.date == null) return 1;
            long diffInMillis = ChronoUnit.MILLIS.between(that.date, other.date);
            if (Math.abs(diffInMillis) < MILLISECONDS_IN_A_DAY) return 0;
            return (int) Math.signum(diffInMillis - MILLISECONDS_IN_A_DAY);
        }
    }
}
0
rzwitserloot On

In java what can be a good hashCode method for a class with a date, where objects are considered equal if the dates have a predefined maximum difference.

Impossible!

The javadoc demands that your hashcode method cannot do this.

Specifically, these are the demands stipulated by the contract. There a bunch of them I need to name; the javadoc does not spell out that 'hashCodes cannot take differences into account'. Instead, it stipulates a bunch of things, and those things can be used to prove that you cannot take differences into account:

  1. If a.equals(b), then a.hashCode() == b.hashCode(). In other words, hashCode is related to the equals definition ("equality implies hashcode"). Note that the reverse does not hold; 2 objects whose hashcode is identical need not be equal.
  2. If a.equals(b) and b.equals(c), then a.equals(c). The associativity requirement.

Focusing on the associativity requirement, equals implementations cannot take differences into account. After all, if you did, you can trivially break the second rule. Let's say we consider any Foo equal to any other Foo if the value is less than 2 different:

final class Foo {
  int value;

  @Override public boolean equals(Object other) {
    if (other instanceof Foo x) return Math.abs(value - x.value) < 2;
    return false;
  }
}

This is broken - I can make:

Foo a = new Foo(2);
Foo b = new Foo(3);
Foo c = new Foo(4);

And now a.equals(b) and b.equals(c), but !a.equals(c) - thus, broken.

Trivially then having a hashCode that works like this is irrelevant - '2 objects need to have an identical hashcode if...' has no meaning, given that any 2 objects having the same hashcode has no meaning. The only meaning hashcode infers is if 2 objects have different hashCode - then that implies they cannot be equal. The reverse simply does not hold, hence, there is no point to such a question other than to support a certain equality relationship, but the relationship you want breaks a contract, so you can't have it.

so how do I do this?

Not with equals and hashCode, and consequently, not with vanilla HashMap and friends.

More generally you need to answer the question what you want to do with this concept because it's simply not clear what you expect things like a HashMap using such objects as keys is even meant to do in the first place.

For example, if you intend to use these things in a HashSet and thus ensure that no more than 1 item in a given 'span of time' can be present, that.. just doesn't make sense.

For example, if you don't ever want 2 Foo in your Set that have a difference in their value of 0 or 1, then I can either add both 2 and 4 to the set, or only 3 - but that's weird, did you really intend for this Set to be different depending on the order you add the items?

That breaks all sorts of rules! If I have a plain jane ArrayList and I add 2, 3, and 4, then I try to copy them into a HashSet with this rule, now the order of that list decides whether I get 2 and 4, or just 3. That's bizarre. Other code will not just act in ways that are not normally part of the deal (such as depending on order), but will outright break. Not in a nice 'throws an exception' way, but in a: Who knows what happens - the spec literally just says all bets are off if you fail to adhere to certain rules.

Okay, really though, how do I do this?

Other than handrolling all the infrastructure, the obvious solution is to lock down the 'windows of equality' in predefined non-overlapping ranges.

There is absolutely no issue with a Foo implementation like this:

final class Foo {
  int value;

  @Override public boolean equals(Object other) {
    if (other instanceof Foo x) {
      return x.value / 4 == this.value / 4;
    }
    return false;
  }

  @Override public int hashCode() {
    return this.value / 4;
  }
}

This has the property that e.g. 0, 1, 2, and 3 are all considered equal. But 3 and 4 are not (if the number divided by 4 and tossing away the remainder is equal - the object is considered equal). This is a weird definition of equality but breaks no rules. It's associative, commutative (a.equals(b) must imply b.equals(a), and it does here), identitive (is that a word? a.equals(a) is what I'm driving at - that MUST be true, and therefore trivially a.hashCode() == a.hashCode() - hashcodes have to be consistent).

The above 'locks down' the ranges that are considered equal: 0-3 are all equal, 4-7 are all equal, 8-11 are all equal, and so on.

You can apply the same concept to dates just as easily. For example, it's no problem if you want to consider any given timestamp to be equal to any other as long as they fall in the same exact week: Weeks don't overlap (well, you're going to have to find a definition of 'week' that doesn't; the usual definitions indeed adhere to this rule), so any given timestamp always falls in the same known week, and thus equality relationships based on week will have the required properties.