JavaRush /בלוג Java /Random-HE /Equals וחוזי hashCode או מה שזה לא יהיה
Aleksandr Zimin
רָמָה
Санкт-Петербург

Equals וחוזי hashCode או מה שזה לא יהיה

פורסם בקבוצה
הרוב המכריע של מתכנתי ג'אווה, כמובן, יודעים ששיטות equalsקשורות hashCodeזו לזו, ושמומלץ לעקוף את שתי השיטות הללו במחלקות שלהן באופן עקבי. מספר מעט קטן יותר יודע מדוע זה כך ואיזה השלכות עצובות יכולות להתרחש אם הכלל הזה יופר. אני מציע לשקול את הרעיון של שיטות אלו, לחזור על מטרתן ולהבין מדוע הן כל כך קשורות. כתבתי לעצמי את המאמר הזה, כמו הקודם על טעינת שיעורים, על מנת לחשוף סוף סוף את כל פרטי הנושא ולא לחזור יותר למקורות צד שלישי. לכן, אשמח לביקורת בונה, כי אם יש פערים איפשהו, צריך לבטל אותם. המאמר, למרבה הצער, התברר כארוך למדי.

שווה לכללי ביטול

נדרשת שיטה equals()ב-Java כדי לאשר או להכחיש את העובדה ששני אובייקטים מאותו מקור שווים מבחינה לוגית . כלומר, כאשר משווים בין שני אובייקטים, המתכנת צריך להבין האם השדות המשמעותיים שלהם שווים . אין צורך שכל השדות יהיו זהים, שכן השיטה equals()מרמזת על שוויון לוגי . אבל לפעמים אין צורך מיוחד להשתמש בשיטה זו. כמו שאומרים, הדרך הקלה ביותר להימנע מבעיות בשימוש במנגנון מסוים היא לא להשתמש בו. כמו כן, יש לציין שברגע שאתה מפר חוזה, equalsאתה מאבד שליטה על ההבנה כיצד חפצים ומבנים אחרים יתקשרו עם האובייקט שלך. ובהמשך למצוא את הגורם לשגיאה יהיה קשה מאוד.

מתי לא לעקוף שיטה זו

  • כאשר כל מופע של מחלקה הוא ייחודי.
  • במידה רבה יותר, זה תקף לאותן מחלקות שמספקות התנהגות ספציפית ולא מתוכננות לעבוד עם נתונים. כגון, למשל, כמו הכיתה Thread. עבורם equals, היישום של השיטה שמספקת הכיתה Objectהוא די והותר. דוגמה נוספת היא מחלקות enum ( Enum).
  • כאשר למעשה המחלקה אינה נדרשת לקבוע את השוויון של המקרים שלה.
  • לדוגמה, עבור מחלקה java.util.Randomאין צורך בכלל להשוות מופעים של המחלקה זה לזה, ולקבוע אם הם יכולים להחזיר את אותו רצף של מספרים אקראיים. פשוט כי האופי של המעמד הזה אפילו לא מרמז על התנהגות כזו.
  • כאשר לכיתה שאתה מרחיב כבר יש יישום משלה של השיטה equalsוההתנהגות של יישום זה מתאימה לך.
  • לדוגמה, עבור מחלקות Set, List, Mapהיישום equalsהוא ב AbstractSet, AbstractListובהתאמה AbstractMap.
  • ולבסוף, אין צורך לעקוף equalsכאשר היקף הכיתה שלך הוא privateאו package-privateואתה בטוח שהשיטה הזו לעולם לא תיקרא.

שווה חוזה

בעת עקיפת שיטה, equalsעל המפתח לציית לכללים הבסיסיים המוגדרים במפרט שפת Java.
  • רפלקסיביות
  • עבור כל ערך נתון x, הביטוי x.equals(x)חייב לחזור true.
    נתון - פירוש כזה שx != null
  • סִימֶטרִיָה
  • עבור כל ערך נתון xו- y, x.equals(y)צריך להחזיר trueרק אם הוא y.equals(x)חוזר true.
  • טרנזיטיביות
  • עבור כל ערך נתון , xואם yמחזיר zומחזיר x.equals(y), חייב trueלהחזיר את הערך . y.equals(z)truex.equals(z)true
  • עֲקֵבִיוּת
  • עבור כל ערך נתון, xוהקריאה yהחוזרת x.equals(y)תחזיר את הערך של הקריאה הקודמת לשיטה זו, בתנאי שהשדות ששימשו להשוואה בין שני האובייקטים לא השתנו בין קריאות.
  • השוואה null
  • עבור כל ערך נתון xהשיחה x.equals(null)חייבת להחזיר false.

שווה הפרת חוזה

מחלקות רבות, כגון אלו מ-Java Collections Framework, תלויות ביישום השיטה equals(), אז אל תזניח אותה, מכיוון הפרת החוזה בשיטה זו עלולה להוביל להפעלה לא רציונלית של הבקשה, ובמקרה זה יהיה די קשה למצוא את הסיבה. על פי עקרון הרפלקסיביות , כל אובייקט חייב להיות שווה ערך לעצמו. אם העיקרון הזה מופר, כאשר נוסיף אובייקט לאוסף ולאחר מכן נחפש אותו בשיטה, contains()לא נוכל למצוא את האובייקט שזה עתה הוספנו לאוסף. תנאי הסימטריה קובע שכל שני אובייקטים חייבים להיות שווים ללא קשר לסדר ההשוואה ביניהם. לדוגמה, אם יש לך מחלקה המכילה רק שדה אחד מסוג מחרוזת, יהיה זה לא נכון להשוות equalsשדה זה למחרוזת בשיטה. כי במקרה של השוואה הפוכה, השיטה תמיד תחזיר את הערך false.
// Нарушение симметричности
public class SomeStringify {
    private String s;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o instanceof SomeStringify) {
            return s.equals(((SomeStringify) o).s);
        }
        // нарушение симметричности, классы разного происхождения
        if (o instanceof String) {
            return s.equals(o);
        }
        return false;
    }
}
//Правильное определение метода equals
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    return o instanceof SomeStringify &&
            ((SomeStringify) o).s.equals(s);
}
מתנאי המעבר נובע שאם כל שניים מתוך שלושה אובייקטים שווים, אז במקרה זה כל השלושה חייבים להיות שווים. ניתן להפר עיקרון זה בקלות כאשר יש צורך להרחיב מחלקה בסיסית מסוימת על ידי הוספת רכיב משמעותי אליו . לדוגמה, לכיתה Pointעם קואורדינטות xואתה yצריך להוסיף את צבע הנקודה על ידי הרחבתה. כדי לעשות זאת, תצטרך להכריז על מחלקה ColorPointעם השדה המתאים color. לפיכך, אם במחלקה המורחבת נקרא equalsלשיטת האב, ובאב נניח שרק קואורדינטות xומשווים y, אז שתי נקודות בצבעים שונים אך עם אותן קואורדינטות ייחשבו שוות, וזה לא נכון. במקרה זה, יש צורך ללמד את הכיתה הנגזרת להבחין בין צבעים. כדי לעשות זאת, אתה יכול להשתמש בשתי שיטות. אבל האחד יפר את כלל הסימטריה , והשני - טרנזיטיביות .
// Первый способ, нарушая симметричность
// Метод переопределен в классе ColorPoint
@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
}
במקרה זה, השיחה point.equals(colorPoint)תחזיר את הערך true, וההשוואה colorPoint.equals(point)תחזיר false, כי מצפה לאובייקט מהמעמד "שלו". לפיכך, כלל הסימטריה מופר. השיטה השנייה כוללת בדיקה "עיוורת" במקרה שאין נתונים לגבי צבע הנקודה, כלומר יש לנו את המחלקה Point. לחלופין, בדוק את הצבע אם מידע עליו זמין, כלומר, השווה אובייקט של המחלקה ColorPoint.
// Метод переопределен в классе ColorPoint
@Override
public boolean equals(Object o) {
    if (!(o instanceof Point)) return false;

    // Слепая проверка
    if (!(o instanceof ColorPoint))
        return super.equals(o);

    // Полная проверка, включая цвет точки
    return super.equals(o) && ((ColorPoint) o).color == color;
}
עיקרון הטרנזיטיביות מופר כאן כדלקמן. נניח שיש הגדרה של האובייקטים הבאים:
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
לפיכך, למרות השוויון p1.equals(p2)והוא מרוצה p2.equals(p3), p1.equals(p3)זה יחזיר את הערך false. יחד עם זאת, השיטה השנייה, לדעתי, נראית פחות אטרקטיבית, כי במקרים מסוימים, האלגוריתם עלול להיות מסונוור ולא לבצע את ההשוואה במלואה, וייתכן שאינך יודע על כך. קצת שירה באופן כללי, למיטב הבנתי, אין פתרון קונקרטי לבעיה הזו. יש דעה של מחבר סמכותי אחד בשם קיי הורסטמן שאפשר להחליף את השימוש באופרטור instanceofבקריאה לשיטה getClass()שמחזירה את המחלקה של האובייקט ולפני שמתחילים להשוות בין האובייקטים עצמם, לוודא שהם מאותו סוג , ואל תשימו לב לעובדת מוצאם המשותף. לפיכך , כללי הסימטריה והטרנזיטיביות יתקיימו. אך במקביל, מעברו השני של המתרס עומד סופר נוסף, מכובד לא פחות בחוגים רחבים, יהושע בלוך, שסבור כי גישה זו מפרה את עקרון ההחלפה של ברברה ליסקוב. עיקרון זה קובע כי "קוד קורא חייב להתייחס למחלקה בסיס באותו אופן כמו מחלקות המשנה שלו מבלי לדעת זאת . " ובפתרון שהציע הורסטמן, עיקרון זה מופר בבירור, שכן הוא תלוי ביישום. בקיצור, ברור שהעניין אפל. יש לציין גם שהורסטמן מבהיר את הכלל ליישום גישתו וכותב באנגלית פשוטה שצריך להחליט על אסטרטגיה בעת תכנון שיעורים, ואם בדיקת השוויון תתבצע רק על ידי מחלקת העל, תוכל לעשות זאת על ידי ביצוע המבצע . אחרת, כאשר הסמנטיקה של הסימון משתנה בהתאם למחלקה הנגזרת ויש להזיז את יישום השיטה למטה בהיררכיה, עליך להשתמש בשיטה . יהושע בלוך, בתורו, מציע לנטוש את הירושה ולהשתמש בהרכב אובייקט על ידי הכללת מחלקה במחלקה ומתן שיטת גישה לקבלת מידע ספציפי על הנקודה. זה ימנע מהפרת כל הכללים, אבל, לדעתי, זה יקשה על הבנת הקוד. האפשרות השלישית היא להשתמש ביצירה האוטומטית של שיטת equals באמצעות ה-IDE. Idea, אגב, משחזר את דור הורסטמן, ומאפשר לך לבחור אסטרטגיה ליישום שיטה במחלקת על או בצאצאיה. לבסוף, כלל העקביות הבא קובע שגם אם האובייקטים לא משתנים, הקריאה אליהם שוב חייבת להחזיר את אותו ערך כמו קודם. הכלל הסופי הוא שאף אובייקט לא צריך להיות שווה ל . הכל ברור כאן - זו אי ודאות, האם האובייקט שווה לאי ודאות? לא ברור, כלומר . instanceofgetClass()ColorPointPointasPoint()xyx.equals(y)nullnullfalse

אלגוריתם כללי לקביעת שווים

  1. בדוק אם יש שוויון בין הפניות לאובייקט thisופרמטרים של שיטה o.
    if (this == o) return true;
  2. בדוק אם הקישור מוגדר o, כלומר האם הוא null.
    אם בעתיד, בעת השוואת סוגי אובייקטים, ישמש האופרטור instanceof, ניתן לדלג על פריט זה, שכן פרמטר זה מחזיר falseבמקרה זה null instanceof Object.
  3. השווה סוגי אובייקטים thisבאמצעות oאופרטור instanceofאו שיטה getClass(), בהנחיית התיאור שלמעלה והאינטואיציה שלך.
  4. אם שיטה equalsנדחקת בתת-מחלקה, הקפד לבצע שיחהsuper.equals(o)
  5. המר את סוג הפרמטר oלמחלקה הנדרשת.
  6. בצע השוואה של כל שדות האובייקט המשמעותיים:
    • עבור טיפוסים פרימיטיביים (למעט floatו- double), באמצעות האופרטור==
    • לשדות התייחסות אתה צריך לקרוא לשיטה שלהםequals
    • עבור מערכים, אתה יכול להשתמש באיטרציה מחזורית או בשיטהArrays.equals()
    • עבור סוגים floatויש doubleצורך להשתמש בשיטות השוואה של מחלקות העטיפה המתאימות Float.compare()וDouble.compare()
  7. ולבסוף, ענה על שלוש שאלות: האם השיטה המיושמת סימטרית ? טרנזיטיבי ? מוסכם ? שני העקרונות האחרים ( רפלקסיביות וודאות ) מבוצעים בדרך כלל באופן אוטומטי .

כללים לעקוף HashCode

Hash הוא מספר שנוצר מאובייקט שמתאר את מצבו בנקודת זמן כלשהי. מספר זה משמש ב-Java בעיקר בטבלאות Hash כגון HashMap. במקרה זה, יש ליישם את פונקציית הגיבוב של השגת מספר המבוסס על אובייקט באופן שתבטיח חלוקה שווה יחסית של אלמנטים על פני טבלת הגיבוב. וגם כדי למזער את הסבירות להתנגשויות כאשר הפונקציה מחזירה את אותו ערך עבור מפתחות שונים.

חוזה hashCode

כדי ליישם פונקציית Hash, מפרט השפה מגדיר את הכללים הבאים:
  • קריאה למתודה hashCodeפעם אחת או יותר על אותו אובייקט חייבת להחזיר את אותו ערך hash, בתנאי ששדות האובייקט המעורבים בחישוב הערך לא השתנו.
  • קריאה למתודה hashCodeעל שני אובייקטים צריכה תמיד להחזיר את אותו מספר אם האובייקטים שווים (קריאה למתודה equalsעל אובייקטים אלו מחזירה true).
  • קריאה למתודה hashCodeעל שני אובייקטים לא שווים חייבת להחזיר ערכי hash שונים. למרות שדרישה זו אינה מחייבת, יש לקחת בחשבון שליישום שלה תהיה השפעה חיובית על הביצועים של טבלאות גיבוב.

יש לעקוף את שיטות equals ו-hashCode יחד

בהתבסס על החוזים המתוארים לעיל, יוצא שכאשר אתה מחליף את השיטה בקוד שלך equals, עליך תמיד לעקוף את השיטה hashCode. מכיוון שלמעשה שני מופעים של מחלקה שונים מכיוון שהם נמצאים באזורי זיכרון שונים, יש להשוות אותם לפי כמה קריטריונים לוגיים. בהתאם לכך, שני אובייקטים שווים מבחינה לוגית חייבים להחזיר את אותו ערך hash. מה קורה אם רק אחת מהשיטות הללו נדחית?
  1. equalsכן hashCodeלא

    נניח שהגדרנו נכון שיטה equalsבכיתה שלנו, והחלטנו hashCodeלהשאיר את השיטה כפי שהיא בכיתה Object. ואז מנקודת המבט של השיטה equalsשני האובייקטים יהיו שווים מבחינה לוגית, בעוד שמנקודת המבט של השיטה hashCodeלא יהיה להם שום דבר במשותף. וכך, על ידי הצבת חפץ בטבלת גיבוב, אנו מסתכנים בכך שלא יחזירו אותו באמצעות מפתח.
    לדוגמה, כך:

    Map<Point, String> m = new HashMap<>();
    m.put(new Point(1, 1),Point A);
    // pointName == null
    String pointName = m.get(new Point(1, 1));

    ברור שהאובייקט שמציבים והאובייקט שמחפשים הם שני אובייקטים שונים, למרות שהם שווים מבחינה לוגית. אבל, בגלל יש להם ערכי חשיש שונים בגלל שהפרנו את החוזה, אנחנו יכולים לומר שאיבדנו את החפץ שלנו איפשהו בטן טבלת הגיבוב.

  2. hashCodeכן equalsלא.

    מה קורה אם אנו עוקפים את השיטה hashCodeונורשים equalsאת יישום השיטה מהמחלקה Object. כפי שאתה יודע, equalsשיטת ברירת המחדל פשוט משווה מצביעים לאובייקטים, וקובעת אם הם מתייחסים לאותו אובייקט. נניח hashCodeשכתבנו את השיטה לפי כל הקנונים, כלומר, יצרנו אותה באמצעות ה-IDE, והיא תחזיר את אותם ערכי hash עבור אובייקטים זהים מבחינה לוגית. ברור שבכך כבר הגדרנו מנגנון כלשהו להשוואה בין שני אובייקטים.

    לכן, הדוגמה מהפסקה הקודמת צריכה להתבצע באופן תיאורטי. אבל עדיין לא נוכל למצוא את החפץ שלנו בטבלת ה-hash. אמנם נהיה קרובים לכך, כי לכל הפחות נמצא סל שולחן חשיש שבתוכה ישכב החפץ.

    כדי לחפש בהצלחה אובייקט בטבלת גיבוב, בנוסף להשוואת ערכי ה-hash של המפתח, נעשה שימוש גם בקביעת השוויון הלוגי של המפתח עם האובייקט המחפש. כלומר, equalsאין דרך לעשות בלי לעקוף את השיטה.

אלגוריתם כללי לקביעת hashCode

כאן, נראה לי, אתה לא צריך לדאוג יותר מדי וליצור את השיטה ב-IDE האהוב עליך. מכיוון שכל ההזזות האלה של ביטים ימינה ושמאלה בחיפוש אחר יחס הזהב, כלומר התפלגות נורמלית - זה עבור בחורים עקשנים לחלוטין. באופן אישי, אני בספק אם אני יכול לעשות טוב יותר ומהיר יותר מאותו רעיון.

במקום מסקנה

לפיכך, אנו רואים שלשיטות equalsתפקיד hashCodeמוגדר היטב בשפת ג'אווה ונועדו להשיג את השוויון הלוגי המאפיין שני אובייקטים. במקרה של השיטה, equalsיש לזה קשר ישיר להשוואת אובייקטים, במקרה של hashCodeעקיף, כאשר יש צורך, נניח, לקבוע את מיקומו המשוער של אובייקט בטבלאות גיבוב או מבני נתונים דומים על מנת לאפשר להגביר את מהירות החיפוש אחר חפץ. בנוסף לחוזים , equalsישנה hashCodeדרישה נוספת הקשורה להשוואת חפצים. זוהי העקביות של שיטת compareToממשק Comparableעם equals. דרישה זו מחייבת את היזם לחזור תמיד x.equals(y) == trueכאשר x.compareTo(y) == 0. כלומר, אנו רואים שההשוואה הלוגית של שני אובייקטים לא צריכה לסתור בשום מקום באפליקציה וצריכה להיות תמיד עקבית.

מקורות

Java יעיל, מהדורה שנייה. יהושע בלוך. תרגום חופשי של ספר טוב מאוד. Java, ספריית מקצוענים. כרך 1. יסודות. קיי הורסטמן. קצת פחות תיאוריה ויותר פרקטיקה. אבל הכל לא מנותח בפירוט רב כמו זה של בלוך. למרות שיש השקפה על אותו שווה(). מבני נתונים בתמונות. HashMap מאמר שימושי ביותר על מכשיר HashMap ב-Java. במקום להסתכל על המקורות.
הערות
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION