אובייקטים בלתי משתנים (Immutable Objects)
אובייקטים בלתי משתנים (Immutable Objects) הם אחת מהתפיסות החשובות והחזקות ביותר בתכנות מונחה עצמים (OOP) ובתכנות מקבילי (Concurrency).
הכלל הבסיסי פשוט: מרגע שנוצר אובייקט Immutable, אי אפשר לשנות את הערכים (השדות) שלו לאורך כל מחזור החיים שלו. אם נרצה לשנות משהו, נצטרך ליצור אובייקט חדש לגמרי.
איך מייצרים Immutable Object ב Java?
כדי לוודא שאף אחד לא יוכל לשנות את מצב האובייקט אחרי בנייתו, יש להקפיד על הכללים הבאים:
- סימון המחלקה כ-final: כדי שאף מחלקה אחרת לא תוכל לרשת אותה ולשנות את ההתנהגות שלה (להוסיף שדות ניתנים לשינוי).
- הגדרת כל השדות כ-private ו final: השדות ניתן להגדרה פעם אחת בלבד (בזמן ה Constructor).
- אין מתודות setter: אסור שיהיו מתודות המאפשרות לשנות את ערכי השדות מבחוץ.
- הגנה על שדות מורכבים (Deep Copy): אם האובייקט שלנו מחזיק שדות שהם בעצמם ניתנים לשינוי (כמו Date, מערכים, או List), עלינו לוודא שאנחנו מעתיקים אותם בבנייה ושליפה (Defensive Copying), כדי שהקוד בחוץ לא יוכל לשנות אותם דרך הפניה פנימית. אם יש לך אובייקט Immutable שמחזיק הפניה לאובייקט אחר שהוא Mutable , ואנחנו לא מבצע להם Defensive Copying (עותק הגנה), האובייקט שלנו לא באמת Immutable!
ראו קוד לדוגמה כיצד עלינו להגן על עצמנו במצב כזה
public final class ImmutablePerson {
private final String name;
private final int age;
private final List<String> hobbies;
public ImmutablePerson(String name, int age, List<String> hobbies) { this.name = name; this.age = age;
//deep copy rule
this.hobbies = new ArrayList<>(hobbies);
}
public String getName()
{ return name; }
public int getAge()
{ return age; }
public List<String> getHobbies()
//new copy!
{ return new ArrayList<>(hobbies); }
}
יתרונות בשימוש Immutable Object:
- בטיחות מוחלטת ב Multithreading Thread-Safety: זהו היתרון העצום ביותר. מכיוון שהנתונים לעולם לא משתנים, מספר Threads יכולים לקרוא את אותו אובייקט בו זמנית בבטחה מלאה, ללא שום צורך במנגנוני נעילה (Locks או Synchronization). אין סיכוי ל Race Conditions.
- אמינות ומניעת באגים (Side Effects): כשאנחנו מעביר אובייקט למתודה אחרת, אנחנו יודעים בוודאות מוחלטת שהמתודה הזו לא הולכת "להרוס" או לשנות את הערכים של האובייקט מאחורי הגב שלנו.
הקשר ל-String Pooling (אופטימיזציית זיכרון)
הסיבה המרכזית לכך שמחלקת String ב Java היא Immutable היא כדי לאפשר String Pooling. מכיוון שהמחרוזת בלתי משתנה, Java יכולה לשמור מופע (Instance) אחד של המחרוזת בזיכרון (בתוך ה String Pool) ולתת למספר משתנים שונים להצביע אליו בבטחה מלאה. אם String היה משתנה, שינוי אצל משתמש אחד היה הורס את הערך לכל שאר המשתנים במערכת. ניצן לקרוא עוד על String Pooling כאן
שיפורים החל מ JAVA 14
Java Records (החל מ Java 14): אם אנחנו צריכים אובייקט שהוא נטו "מיכל נתונים" (Data Carrier), Java מציעה את ה record. רשומות הן Immutable בצורה מובנית ומוצהרת, וחוסכות לנו לכתוב את רוב הקוד (Constructors, Getters, equals, hashCode, toString). מחלקה מסוג record עושה את כל זה אוטומטית בשורה אחת, והיא Immutable (בלתי משתנה) מטבעה. יצירת הקונסטרוקטור מתבצעת אוטומטית, ה get מתקבלים אוטומטית גם, equals and hashCode גם מגיעים בצורה אוטומטית.
לדוגמה ניתן לכתוב את המחלקה הבאה:
public record UserRecord(int id, String username, String email) {}
אם נרצה להרחיב, למשל לכתוב ולידציה, או להוסיף מתודה משלנו, ניתן לעשות זאת ככה:
// 1. Define a Record in a single concise line!
public record UserRecord(int id, String username, String email) {
// Compact Constructor: Perform validations without boilerplate code
public UserRecord {
if (id <= 0) {
throw new IllegalArgumentException("ID must be positive"); }
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email address"); }
}
// Add a custom helper method public void printSummary() { System.out.println("User: " + username + " (" + email + ")");
}
}
שימוש במחלקה:
public class Main {
public static void main(String[] args) {
// Create an object (no complex new logic needed, compiler generates the constructor automatically)
UserRecord user1 = new UserRecord(1, "john_doe", "john@example.com");
// Use the automatic getters (method name is directly the field name, without the 'get' prefix!)
System.out.println(user1.username()); // Output: john_doe
System.out.println(user1.email()); // Output: john@example.com
// Use the custom method we added
user1.printSummary();
// toString() is built-in and provides a nice automatic format:
System.out.println(user1);
// Output: UserRecord[id=1, username=john_doe, email=john@example.com]
// Equality comparison (equals and hashCode are automatically implemented based on values!)
UserRecord user2 = new UserRecord(1, "john_doe", "john@example.com");
System.out.println(user1.equals(user2)); // true (because the values are identical)
}
}