Problem: xəritədə var, amma tapılmayan element

Komanda yaddaşda axtarış üçün Map<CustomerKey, Customer> saxlayır. Açar sinfində iki sahə var: ölkə kodu və vergi nömrəsi. equals və hashCode hər iki sahədən generasiya edilib. Hər şey işləyir, ta ki bir servis metodu artıq xəritədə olan açarın vergi nömrəsindəki səhvi düzəldənə qədər. Bundan sonra map.get(key) null qaytarır, map.containsKey(key) false verir, amma xəritəni dövrə ilə gəzəndə element görünür. Məlumat yerindədir, ona çatmaq mümkün deyil.

Bu, equals/hashCode müqaviləsinin ruhunun pozulmasının tipik əlamətidir; metodlar özlüyündə düzgün görünsə belə. Bunun qarşısını almaq üçün müqavilənin nə dediyini və hash əsaslı kolleksiyaların onu necə işlətdiyini bilmək lazımdır.

Müqavilə

Object sinfinin Javadoc-u qaydaları müəyyən edir. Sadə dillə:

  1. Reflexive: x.equals(x) həmişə doğrudur.
  2. Symmetric: x.equals(y) yalnız və yalnız y.equals(x) doğru olduqda doğrudur.
  3. Transitive: x.equals(y) və y.equals(z) doğrudursa, x.equals(z) də doğrudur.
  4. Consistent: müqayisədə iştirak edən sahələr dəyişmədikcə təkrar çağırışlar eyni cavabı verir.
  5. Null-safe: x.equals(null) false qaytarır və istisna atmır.
  6. hashCode uyğunluğu: iki obyekt bərabərdirsə, onların hashCode() dəyəri eyni olmalıdır. Tərsi tələb olunmur; fərqli obyektlərin hash kodu üst-üstə düşə bilər, bunun bədəli yalnız performansdır.

Kolleksiyalar üçün əsas olan 6-cı qaydadır. equals-u override edib hashCode-u unutsanız, Object.hashCode() identity əsaslı dəyər qaytarmağa davam edir və bərabər iki obyekt əksər hallarda fərqli bucket-lərə düşür.

HashMap bu iki metoddan necə istifadə edir

HashMap əvvəlcə açarın hashCode() dəyəri ilə bucket seçir, sonra həmin bucket-dakı elementləri gəzib equals ilə düzgününü tapır. Hər iki addım uğurlu olmalıdır. Bərabər, amma fərqli hash kodlu açarla axtarış yanlış bucket-a gedir və heç nə tapmır, halbuki equals "bəli" deyərdi.

final class Money {    private final long cents;    private final String currency;    Money(long cents, String currency) {        this.cents = cents;        this.currency = currency;    }    @Override public boolean equals(Object o) {        if (this == o) return true;        if (!(o instanceof Money other)) return false;        return cents == other.cents && currency.equals(other.currency);    }    // Bu metodu unutsanız, identity əsaslı hash qalır.    @Override public int hashCode() {        return Objects.hash(cents, currency);    }}

İki metod da yerində olanda new Money(500, "EUR") başqa bir Money(500, "EUR") altında saxlanan dəyəri tapır. hashCode-u çıxarsanız, eyni axtarış çox vaxt uğursuz olur və bu nəticə proqramın işə düşməsindən asılı olaraq dəyişə bilər.

Dəyişən açar problemi

Girişdəki hekayəyə qayıdaq. Hash kodu açar əlavə ediləndə hesablanıb və xəritə elementi həmin hash-ın bucket-ına qoyub. Sonradan hashCode-da istifadə olunan sahə dəyişəndə açar artıq başqa bucket-a işarə edir, element isə köhnəsində qalır. Dəyişdirilmiş açarla axtarış yeni bucket-a baxır, heç nə tapmır. Dövrə ilə gəzmə bütün bucket-lara baxır, ona görə element orada görünür.

var key = new MutableKey("AZ", "1234567890");var map = new HashMap<MutableKey, String>();map.put(key, "Alice");key.setTaxNumber("1234567899");   // hashCode burada dəyişirmap.get(key);              // null: yanlış bucketmap.containsKey(key);      // falsemap.size();                // 1: element hələ də oradadır

Düzəlişlər bunun baş verməsinə imkan verməməkdən ibarətdir:

  • Açarları immutable edin: final sahələr, setter yoxdur.
  • Açar mütləq dəyişməlidirsə, əvvəlcə xəritədən çıxarın, dəyişin, sonra yenidən əlavə edin.
  • Təbii olaraq sabit açarlara üstünlük verin: identifikator, value object və ya string; zamanla dəyişən entity-lərdən çəkinin.

Eyni xəbərdarlıq HashSet elementlərinə də aiddir. Sıralama üçün istifadə olunan sahələr dəyişərsə, TreeMap və TreeSet elementlərində də eyni problem yaranır.

Record-lar: müqavilə hazır gəlir

Java 16-dan etibarən record bütün komponentlərə əsasən equals, hashCode və toString generasiya edir. Value object-lər üçün bu, demək olar ki, həmişə lazım olandır və bir sıra səhvləri aradan qaldırır.

record CustomerKey(String country, String taxNumber) {    CustomerKey {        Objects.requireNonNull(country);        Objects.requireNonNull(taxNumber);    }}

Record-lar səthi (shallow) immutable-dır: sahələri yenidən mənimsətmək olmur, amma List kimi komponent compact constructor-da List.copyOf ilə kopyalanmayıbsa, dəyişdirilə bilər. Dəyişən siyahı saxlayan record yenə dəyişən açardır.

compareTo ilə uyğunluq

TreeMap və TreeSet kimi sıralı kolleksiyalar iki açarın eyni olub-olmadığını equals ilə deyil, compareTo (və ya Comparator) ilə müəyyən edir. Comparable sənədi compareTo-nun equals ilə uyğun olmasını tövsiyə edir, yəni a.compareTo(b) == 0 yalnız a.equals(b) olduqda. Klassik əks nümunə BigDecimal-dır: new BigDecimal("2.0") və new BigDecimal("2.00") scale fərqli olduğu üçün equals ilə bərabər deyil, amma compareTo 0 qaytarır. Nəticədə HashSet hər ikisini saxlayır, TreeSet isə yalnız birini.

Tipinizin sıralaması bəzi sahələri nəzərə almırsa, bunu sənədləşdirin; çünki eyni tip üçün HashSet və TreeSet qarışıq istifadə olunanda cavablar fərqli çıxa bilər.

IDE generasiyası nəyi düzgün edir, nəyi etmir

IDE və Lombok tipli alətlər sadə value sinifləri üçün düzgün equals və hashCode yaradır: null ola bilən sahələr üçün Objects.equals, primitivlər üçün birbaşa müqayisə, seçilmiş bütün sahələrdən hash. Zəif tərəfi sizin verməli olduğunuz qərarlardır:

  • Hansı sahələr? Kimliyi müəyyən edən bütün sahələri və hər iki metodda eyni dəsti daxil edin.
  • İrsiyyət. Sinif iyerarxiyasında instanceof əsaslı bərabərlik simmetriyanı poza bilər: Point bir istiqamətdə ColorPoint-ə bərabərdir, digər istiqamətdə yox. final siniflərə və ya kompozisiyaya üstünlük verin.
  • JPA entity-ləri. Generasiya olunan database id-ni hashCode-a qoysanız, hash ilk saxlamadan sonra dəyişir; bu, yenə dəyişən açar problemidir. Entity üçün biznes açarı və ya sabit yanaşma şüurlu seçilməlidir.
  • Sinif daxilində massiv və kolleksiyalar. Referansla yox, məzmunla müqayisə üçün Arrays.equals və ya siyahıların məzmun müqayisəsindən istifadə edin.

Tez-tez edilən səhvlər

  • equals(Foo other) kimi tipli parametrlə yazmaq. Bu, override yox, overload-dur; HashMap onu heç vaxt çağırmır. @Override və Object parametri yazın ki, kompilyator səhvi tutsun.
  • Floating-point sahəni == ilə müqayisə etmək. Double.compare istifadə edin; record-lar və wrapper tiplər üzərində Objects.equals artıq belə davranır.
  • equals-da olmayan sahəni hashCode-a qoymaq. Bərabər obyektlər fərqli hash verə bilər.
  • hashCode-dan sabit dəyər qaytarmaq. Bu, qanunidir və düzgündür, amma bucket-lar uzun zəncirə çevrilir, axtarış xətti və ya ağac əsaslı axtarışa qədər yavaşlayır.
  • Loglarda və xəritələrdə kimlik üçün toString-ə və ya default hash-a güvənmək.

Güzəştlər

Bütün sahələri daxil etmək bərabərliyi dəqiq edir, amma hash-ı bahalaşdırır və sahələr dəyişəndə kövrəkləşdirir. Az sahə daxil etmək (məsələn, yalnız biznes id) daha ucuz və sabitdir, amma dizayn bəyanatıdır: eyni id-yə malik iki obyekt, digər sahələrindən asılı olmayaraq, eyni şeydir. Bu seçimi şüurlu edin və sinif sənədində yazın.

Yoxlama siyahısı

  • Hər iki metod birlikdə, @Override ilə yazılıb.
  • Hər ikisində eyni sahələr işlənir.
  • Hash əsaslı kolleksiyaların açarları immutable-dır.
  • Sadə value object-lər üçün record.
  • Tip həm də Comparable-dırsa, compareTo uyğunluğu sənədləşdirilib.
  • Obyekti HashSet-ə qoyub yenidən tapan test var.

Növbəti addım

Davam etmək üçün düzgün kolleksiyanın seçilməsi məqaləsində HashMap, TreeMap və LinkedHashMap-in praktik fərqlərinə baxın, yaxud bərabərlikdən asılı olan toMap toqquşmaları kimi stream tələləri haqqında oxuyun. Ümumi mənzərə üçün junior Java developer yol xəritəsinə baxa bilərsiniz.