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ə:
- Reflexive:
x.equals(x)həmişə doğrudur. - Symmetric:
x.equals(y)yalnız və yalnızy.equals(x)doğru olduqda doğrudur. - Transitive:
x.equals(y)vəy.equals(z)doğrudursa,x.equals(z)də doğrudur. - Consistent: müqayisədə iştirak edən sahələr dəyişmədikcə təkrar çağırışlar eyni cavabı verir.
- Null-safe:
x.equals(null)falseqaytarır və istisna atmır. - 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ırDüzəlişlər bunun baş verməsinə imkan verməməkdən ibarətdir:
- Açarları immutable edin:
finalsahə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:Pointbir istiqamətdəColorPoint-ə bərabərdir, digər istiqamətdə yox.finalsiniflə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.equalsvə 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;HashMaponu heç vaxt çağırmır.@OverridevəObjectparametri yazın ki, kompilyator səhvi tutsun.- Floating-point sahəni
==ilə müqayisə etmək.Double.compareistifadə edin; record-lar və wrapper tiplər üzərindəObjects.equalsartıq belə davranır. equals-da olmayan sahənihashCode-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ə,
@Overrideilə 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,compareTouyğ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.