الوراثة وتعدّد الأشكال

تجاوز equals و hashCode

15 دقيقة الدرس 4 من 14

تجاوز equals و hashCode

في الدرس الثالث تعلّمت كيفية تجاوز التوابع لكي يستبدل الصنف الفرعي سلوك الصنف الأب. اثنان من التوابع التي يحتاج كل صنف حقيقي تقريبًا إلى تجاوزها هما equals وhashCode، وكلاهما موروث من Object. فهم سبب الحاجة إلى تجاوزهما — والقواعد التي يجب اتباعها عند ذلك — معرفة أساسية في Java.

المساواة المرجعية مقابل المساواة القيمية

المعامل == في Java يتحقق من المساواة المرجعية: هل يشير المتغيران إلى نفس الكائن في الذاكرة؟ وهذا نادرًا ما يكون المقصود عند مقارنة كائنَين يمثّلان نفس المفهوم.

String a = new String("hello"); String b = new String("hello"); System.out.println(a == b); // false — كائنان مختلفان في الذاكرة System.out.println(a.equals(b)); // true — نفس الأحرف

التابع equals، حين يُتجاوز بالشكل الصحيح، يُعبّر عن المساواة القيمية: هل يمثّل الكائنان نفس الشيء، حتى لو كانا نسختَين مختلفتَين؟

التابع equals الافتراضي من Object

إذا لم تتجاوز equals، تستخدم Java التطبيق الافتراضي من Object الذي يُطبّق == ببساطة. وبذلك، لا يساوي أي نسختان مستقلتان بعضهما مهما تطابقت حقولهما.

public class Point { int x; int y; public Point(int x, int y) { this.x = x; this.y = y; } } Point p1 = new Point(3, 4); Point p2 = new Point(3, 4); System.out.println(p1.equals(p2)); // false — يُستخدم equals الافتراضي من Object

هذا نادرًا ما يكون الإجابة الصحيحة للكائنات النطاقية. ينبغي أن تُعدّ نقطتان في الموضع (3, 4) متساويتَين.

تجاوز equals بالطريقة الصحيحة

يشترط عقد equals (من توثيق Java) خمس خصائص: الانعكاسية (x.equals(x) دائمًا true)، التناظر (x.equals(y) يستلزم y.equals(x)التعدّي، الاتساق (الاستدعاءات المتكررة تُعيد نفس النتيجة)، وأن x.equals(null) يُعيد دائمًا false.

public class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object obj) { if (this == obj) return true; // نفس المرجع — متساويان بالضرورة if (obj == null) return false; // لا يساوي null أبدًا if (getClass() != obj.getClass()) return false; // أنواع مختلفة Point other = (Point) obj; // تحويل آمن الآن return this.x == other.x && this.y == other.y; } } Point p1 = new Point(3, 4); Point p2 = new Point(3, 4); System.out.println(p1.equals(p2)); // true
لماذا نستخدم getClass() بدلًا من instanceof؟ استخدام instanceof قد يكسر التناظر عند التعامل مع الأصناف الفرعية: قد يكون p1.equals(coloredPoint) صحيحًا بينما coloredPoint.equals(p1) خاطئًا. يضمن getClass() الاتساق في الاتجاهين. بالنسبة للـ records والأصناف المختومة القيمية، يكون instanceof مقبولًا؛ أما في تسلسلات الإرث العادية فيُفضَّل getClass().

لماذا يجب تجاوز hashCode أيضًا

تعتمد مجموعات Java — HashMap وHashSet وHashtable — على hashCode لوضع الكائنات في حاويات والعثور عليها. القاعدة صارمة:

  • إذا كان a.equals(b) يُعيد true، فيجب أن يكون a.hashCode() مساويًا لـ b.hashCode().
  • إذا كان a.hashCode() != b.hashCode()، فيجب أن يكون a.equals(b) خاطئًا (لا يمكن أن يكونا متساويَين).
  • يُسمح لكائنَين غير متساويَين بامتلاك نفس رمز الهاش (تصادم)، لكن التصادمات الأقل تعني أداءً أفضل.

إذا تجاوزت equals دون تجاوز hashCode، قد يمتلك كائنان متساويان منطقيًا رمزَي هاش مختلفَين، فيعاملهما HashSet كإدخالَين منفصلَين.

// دون تجاوز hashCode Set<Point> set = new HashSet<>(); set.add(new Point(3, 4)); System.out.println(set.contains(new Point(3, 4))); // false! خطأ — hashCode خاطئ

تجاوز hashCode

أسلوب مباشر ومقاوم للتصادمات هو استخدام Objects.hash()، الذي يحسب هاشًا مدمجًا لجميع الحقول المستخدمة في equals:

import java.util.Objects; @Override public int hashCode() { return Objects.hash(x, y); // نفس الحقول الواردة في equals }

مع وجود التابعَين معًا:

Set<Point> set = new HashSet<>(); set.add(new Point(3, 4)); System.out.println(set.contains(new Point(3, 4))); // true — صحيح

مثال متكامل

import java.util.Objects; public class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; Point other = (Point) obj; return x == other.x && y == other.y; } @Override public int hashCode() { return Objects.hash(x, y); } @Override public String toString() { return "Point(" + x + ", " + y + ")"; } }
اختصار حديث — records: تولّد records في Java 16+ التوابع equals وhashCode وtoString الصحيحة تلقائيًا. record Point(int x, int y) {} يمنحك الثلاثة مجانًا. للأصناف العادية لا تزال بحاجة إلى كتابتها يدويًا أو توليدها بواسطة بيئة التطوير.
لا تبنِ hashCode على حقول قابلة للتغيير. إذا أضفت كائنًا إلى HashSet ثم غيّرت حقلًا يدخل في حسابه، لن تتمكن المجموعة من إيجاده بعد ذلك — سيضيع الكائن في الحاوية الخاطئة. اجعل الحقول المستخدمة في equals وhashCode غير قابلة للتغيير (أو على الأقل ثابتة) كلما أمكن ذلك.

الخلاصة

  • == يتحقق من المساواة المرجعية؛ equals يتحقق من المساواة القيمية.
  • التابع equals الافتراضي من Object هو مجرد == — تجاوزه للكائنات النطاقية.
  • اتبع عقد equals الخماسي: الانعكاسية، التناظر، التعدّي، الاتساق، وعدم المساواة مع null.
  • تجاوز hashCode دائمًا عند تجاوز equals — يجب أن يتفقا.
  • استخدم Objects.hash(field1, field2, ...) لتطبيق hashCode نظيف وموثوق.