قرارداد برابری اشیا در جاوا چیست؟

«قرارداد برابری اشیا» (Object Equality Contract) بیان میکند، زمانی که دو شی با هم برابرند، کد درهمسازی (hash code) آن دو شی نیز باید با هم برابر باشد.
این قرارداد، برای تمام اشیای جاوایی مورد استفاده در مجموعههای مبتنی بر درهمسازی (مانند HashMap یا HashSet) صدق میکند و هدف اصلی آن، بهینهسازیِ کارایی هنگام کار با این مجموعهها است.
احتمالا شنیدهاید که توصیه میشود زمانی که متد ()equals را برای کلاس خود پیادهسازی میکنید، باید متد ()hashCode را هم پیادهسازی کنید. این کار، یک رویکرد عملی برای برای پایبندی به «قرارداد برابری اشیا» است. اگر میخواهید بدانید که چرا پایبندی به این قرارداد مهم است، در ادامه با ما همراه باشید.
چرا ()equals و ()hashCode را باید همزمان با هم بازنویسی کنیم؟
در یک کلام، برای پرهیز از بروز باگهای احتمالی در آینده.
اگر برای کلاسی که جدیدا ایجاد کردهاید، تنها یکی از این متدها را پیادهسازی کنید، کدتان به درستی کامپایل شده و از نظر فنی هم بدون هیچ مشکلی اجرا میشود؛ اما فقط از لحاظ فنی. از لحاظ منطقی، ممکن است درگیر باگهای زمان اجرایی شوید که تشخیصشان چندان آسان نیست.
توجه: در ادامه، منظور از مجموعه، صرفا مجموعههای مبتنی بر درهمسازی (مانند HashSet و HashMap) است.
اگر فقط متد equals را پیادهسازی کنیم، چه اتفاقی میافتد؟
تصور کنید کلاسی با نام Player ایجاد کردهاید که صرفا یک ویژگی از نوع رشته (String) برای نام بازیکن دارد. از آنجایی که منطق برنامهتان نیازمند مقایسه بازیکنها است، شما تصمیم میگیرید که متد equals را بازنویسی کنید. به طوری که دو بازیکن با نام یکسان، برابر در نظر گرفته شوند.
Player p1 = new Player("John"); Player p2 = new Player("John"); System.out.println("Same players: " + p1.equals(p2)); // prints: Same players true
سپس، همگروهیتان، بدون توجه به اینکه کلاس Player چگونه پیادهسازی شده است و اینکه آیا بر «قرارداد برابری اشیا» پایبند است یا نه، از این کلاس در یک HashSet استفاده میکند. چه باور کنید چه نکنید، این کار یک اشتباه کاملا معمول است.
Set<Player> uniquePlayers = new HashSet<>(); uniquePlayers.add(new Player("John")); uniquePlayers.add(new Player("John")); System.out.println("Unique players " + uniquePlayers.size()); // prints: Unique players 2
همانطور که میدانید، setها امکان اضافه کردن اشیای تکراری به مجموعه را نمیدهند. اما چرا در کد مثال بالا، اجازه داده شد که دو شی یکسان به مجموعه اضافه شوند؟
جواب، بهینهسازی کارایی است.
معمولا، محاسبه و مقایسه مقادیر درهمسازی دو شی، نسبت به اجرای متد ()equals بسیار سریعتر است. به همین دلیل، مجموعهها ابتدا تساوی کدهای درهمسازی را چک کرده و تنها به عنوان یک روش جایگزین از متد ()equals استفاده میکنند. «قرارداد برابری اشیا» میگوید اگر دو شی با هم برابرند، مقدار درهمسازیشان نیز با هم برابر است. پس طبق این قرارداد، اگر مقدار کدهای درهمسازی دو شی با هم برابر نباشد، این دو شی با هم برابر نیستند. یکبار که بهش فکر کنید، میبینید که کاملا واضح است.
در مثال بالا هم، چون متد ()hashCode پیادهسازی نشده است، کد درهمسازی آن دو شی، مقدار متفاوتی خواهد داشت و طبق قرارداد، سریعا نتیجه گرفته میشود که دو شی با هم برابر نیستند.
اما احتمالا یک سوال جدید در ذهنتان مطرح میشود…
اگر کد درهمسازی دو شی با هم برابر باشد، آیا آن دو شی لزوما با هم برابرند؟
همانطور که پیش از این نیز اشاره شد، مجموعه، از متد ()equals به عنوان یک روش جایگزین استفاده میکند. زیرا ممکن و کاملا قابل پذیرش است (اما پیشنهاد نمیشود) که دو شی غیر یکسان، کد درهمسازی یکسانی داشته باشند.
بیایید به کلاس Player برگردیم و مثال HashSet را این بار با پیادهسازی زیر برای متد ()hashCode در نظر بگیرید:
class Player { //... @Override public int hashCode() { return 4; } }
قطعا این بهترین پیادهسازی ممکن نیست. اما واقعیت این است که بر «قرارداد برابری اشیا» وفادار است. (لطفا از این کد در تولید برنامههایتان استفاده نکنید.)
در کمال تعجب، این پیادهسازی ضعیف، باعث میشود که HashSet موجود در مثال قبل، کاملا مطابق با انتظار ما کار کند.
Set<Player> uniquePlayers = new HashSet<>(); uniquePlayers.add(new Player("John")); uniquePlayers.add(new Player("John")); System.out.println("Unique players " + uniquePlayers.size()); // prints: Unique players 1
اما چرا؟ بیایید نگاهی دقیقتر به مثالمان بیندازیم:
- در قدم اول، ابتدا یک نمونه خالی از HashSet میسازیم.
- سپس اولین نمونه از Player را به set اضافه میکنیم. چون تا قبل از این، مجموعه خالی بوده، نیازی به چک کردن وجود داده تکراری نیست.
- بعد، سعی میکنیم دومین player را که مشابه با اولی است، به مجموعه اضافه کنیم.
- حالا set کد درهمسازی دومین player را محاسبه میکند و چک میکند که آیا از قبل عضوی با همین کد درهمسازی در مجموعه حضور دارد یا خیر.
- از آنجا که آیتمی با کد درهمسازی مشابه پیدا میکند، لازم است با استفاده از متد equals مطمئن شود که آیا صرفا یک تطابق در کدها رخ داده یا اشیا واقعا با هم برابرند.
- از آنجایی که این دو شی کد درهمسازی یکسانی دارند و طبق متد equals هم با یکدیگر مشابهند، set اجازه اضافه کردن دومین نمونه player را نمیدهد.
اگر فقط متد ()hashCode را پیادهسازی کنیم، چه اتفاقی میافتد؟
حالا شرایط برعکس آنچه پیش از این گفتهشد را در نظر بگیرید. یعنی شرایطی که فقط متد ()hashCode برای کلاس Player پیادهسازی شده است و متد ()equals پیادهسازی نشده است.
Set<Player> uniquePlayers = new HashSet<>(); uniquePlayers.add(new Player("John")); uniquePlayers.add(new Player("John")); System.out.println("Unique players " + uniquePlayers.size()); // prints: Unique players 2
مجددا، مقدار کد درهمسازی برای هر دو شی یکسان است و set لازم میبیند که تساوی آن دو را با استفاده از متد ()equals بررسی کند. از آنجایی که این متد را بازنویسی نکردیم، پیادهسازی پیشفرض مورد استفاده قرار میگیرد و خروجی false خواهد بود. به این ترتیب هر دو player به آسانی به set اضافه میشوند.
چه زمانی لازم است که متدهای ()equals و ()hashCode را پیادهکنیم؟
ممکن است این نظر اشتباه را شنیده باشید که برای تمام کلاسها باید متدهای ()equals و ()hashCode پیادهسازی شوند. اما در عمل، چنین رویکردی معنادار و عملگرایانه نیست.
مثلا، کلاسهای utility را در نظر داشته باشید. معمولا از چنین کلاسهایی، تنها یک نمونه در کل اپلیکیشن وجود دارد و در نتیجه هیچگاه دو نمونه از این کلاسها قرار نیست با هم مقایسه شوند.
زمانی باید متدهای ()equals و ()hashCode را بازنویسی کنید که کلاس شما:
- شامل دادههایی است.
- دارای state است.
- یک value object است.
- قرار است در مجموعهها مورد استفاده قرار بگیرد.
اگر کلاس شما هر یک از شروط لیست بالا را داراست، باید متدهای ()equals و ()hashCode را برایش بازنویسی کنید.
حتی اگر قصد مقایسه نمونههای کلاسی که به تازگی ایجاد کردهاید را ندارید اما کلاستان یکی از شروط لیست بالا را داراست، توصیه میشود که متدهای ()equals و ()hashCode را برایش بازنویسی کنید تا از بروز خطا در آینده جلوگیری کنید. چون دیر یا زود به آنها نیاز خواهید داشت.
جمعبندی
- طبق «قرارداد برابری اشیا»،
- اگر دو شی با هم برابرند، مقدار کد درهمسازیشان نیز باید با هم برابر باشد.
- و اگر مقدار کد درهمسازی دو شی با هم برابر نباشد، آن دو شی با هم برابر نیستند.
- هنگام اضافه کردن آیتم جدید به مجموعههای مبتنی بر درهمسازی، همیشه ابتدا کد درهمسازی آیتم جدید با آیتمهای موجود مقایسه میشود و
- اگر آیتمی با کد درهمسازی مشابه یافت نشد، آیتم جدید به سادگی به مجموعه اضافه میشود.
- اگر آیتمی با کد درهمسازی مشابه یافت شد، متد equals فراخوانی میشود. اگر نتیجه متد ()equals برابر با false باشد، آیتم جدید به مجموعه اضافه میشود. اما اگر نتیجه متد ()equals هم true باشد، آیتم جدید اضافه نمیشود.
- اگر فقط متد ()hashCode پیادهسازی شده باشد:
- هنگام اضافه کردن آیتم جدید، اگر کد درهمسازی مشابه یافت شود، به سراغ پیادهسازی پیشفرض متد ()equals میرود و در هر صورت جواب false میگیرد1 و ممکن است دو شی برابر را نابرابر در نظر بگیرد و به اشتباه آیتم تکراری به مجموعه اضافه شود.
- اگر فقط متد ()equals پیادهسازی شده باشد:
- همانطور که گفتهشد، ابتدا سراغ بررسی و مقایسه مقادیر درهمسازی میرود و برای اینکار از پیادهسازی پیشفرض متد ()hashCode استفاده میکند. در پیادهسازی پیشفرض، با هر بار اجرای برنامه، یک عدد صحیح تصادفی به عنوان کد درهمسازی داده میشود و به همین دلیل، دو شی برابر، کد درهمسازی یکسان نخواهند داشت و شباهت آن دو شی درجا رد میشود. در واقع در این حالت اصلا سراغ متد ()equalsای که بازنویسی کردهایم، نمیرود.
تا به اینجا باید فهمیده باشید که چرا همیشه متدهای ()equals و ()hashCode باید هر دو همزمان با یکدیگر بازنویسی شوند. نوشتن پیادهسازی برای این متدها باید از عادتهایتان باشد، اما قطعا نه برای تمام کلاسها. چون همانطور که بحث کردیم، این کار تنها برای کلاسهایی با شرایط معین؛ لازم و معنادار است.
- در پیادهسازی پیشفرض متد ()equals، برابری آدرس ذو شی در حافظه چک میشود. در واقع این متد تنها زمانی مقدار true بر میگرداند که هر دو شی دقیقا به یک آدرس از حافظه اشاره کنند.