#C

למה string בשפת #C הוא Immutable?

לא מזמן שאלתי בקבוצת המפתחים שלנו חידה קטנה:

string a = "Hello";
string b = "Hello";

Console.WriteLine(ReferenceEquals(a, b));

מה יודפס?

התשובה היא True. אבל למה? הרי לא כתבנו b = a. יצרנו שני משתנים נפרדים, ושניהם קיבלו את אותו הטקסט.

אחת התשובות שעלו בקבוצה היתה ש־string אינו ממש אובייקט רגיל, וש־#C מתייחסת אליו קצת כמו אל int. זו השערה מובנת, כי הרבה פעמים אנחנו משתמשים במחרוזות כאילו הן ערכים פשוטים. אבל string הוא Reference Type. אם רוצים לבדוק מה קורה עם int, אפשר לנסות:

int a = 4;
int b = 4;

Console.WriteLine(ReferenceEquals(a, b)); // False

כאן התוצאה היא False. ‏ReferenceEquals מקבלת אובייקטים, ולכן כל אחד מהמספרים עובר boxing לאובייקט נפרד לצורך הקריאה. העובדה שהערכים שווים לא הופכת אותם לאותו אובייקט. התיעוד של Microsoft מסביר את ההבדל הזה.

אז מה מיוחד בשתי המחרוזות מהחידה?

אותה מילה, אותו אובייקט

ל־.NET יש מנגנון בשם String Interning. הוא מאפשר למחרוזות זהות לחלוק מופע אחד במקום להחזיק כמה מופעים עם אותו תוכן. בדוגמה הראשונה, שני המקומות שבהם מופיע ה־literal "Hello" יכולים להשתמש באותו אובייקט.

אפשר לראות את הגבול של הרעיון אם משנים מעט את החידה:

string suffix = "World";

string a = "Hello" + suffix;
string b = "Hello" + suffix;

Console.WriteLine(a == b);                // True
Console.WriteLine(ReferenceEquals(a, b)); // False

התוכן זהה, ולכן == מחזיר True. אבל החיבור מתבצע בזמן ריצה בכל אחת מהשורות, והתוצאה היא שני אובייקטים שונים. ReferenceEquals לא שואלת אם כתוב בהם אותו דבר. היא שואלת אם מדובר באותו אובייקט.

עכשיו נשנה רק מילה אחת:

const string suffix = "World";

string a = "Hello" + suffix;
string b = "Hello" + suffix;

Console.WriteLine(ReferenceEquals(a, b)); // True

הפעם suffix הוא קבוע שידוע בזמן קומפילציה. הקומפיילר יכול לחשב את "Hello" + suffix מראש ולהשתמש באותו ערך קבוע בשתי השורות. כאן אין שני חיבורים נפרדים שמתבצעים בזמן ריצה. התיעוד של #C מתאר את חיבור הקבועים בזמן קומפילציה.

חשוב להפריד בין שני השלבים: הקומפיילר מחשב את הערך מראש; המחרוזת כאובייקט קיימת בזיכרון בזמן ריצת התוכנית. אין צורך לדמיין אובייקט שמחכה ב־heap מאז שהקוד קומפל.

אפשר לבקש Interning גם במפורש

אפשר ליצור שתי מחרוזות בזמן ריצה ואז להעביר אותן דרך string.Intern:

string first = new string(new[] { 'H', 'i' });
string second = new string(new[] { 'H', 'i' });

Console.WriteLine(ReferenceEquals(first, second)); // False

first = string.Intern(first);
second = string.Intern(second);

Console.WriteLine(ReferenceEquals(first, second)); // True

Intern מחזירה את המופע המשותף של אותו ערך מתוך ה־intern pool. זה כלי שקיים, אבל לא סיבה להפעיל אותו על כל מחרוזת בתוכנית. גם ניהול המאגר עולה משהו, ומחרוזות שנשמרות בו עלולות להישאר בזיכרון זמן רב. לפני שמוסיפים interning ידני בגלל ביצועים, צריך למדוד אם הוא בכלל מועיל במקרה המסוים. תיעוד String.Intern.

כולם יודעים ש־string הוא Immutable, אבל כשמבינים את מנגנון ה־interning מבינים טוב יותר אחת הסיבות לכך: מחרוזות זהות יכולות לחלוק את אותו אובייקט בבטחה, מפני שאף אחד מהמשתמשים בו לא יכול לשנות את תוכנו.

מה היה קורה אם היה אפשר לשנות אותו?

נחזור לשני משתנים שמצביעים לאותה מחרוזת:

string a = "Hello";
string b = a;

נניח לרגע שהיה מותר לעשות את הדבר הבא:

b[0] = 'J'; // קוד היפותטי: הוא לא מתקמפל

אם המחרוזת היתה Mutable, גם a היה מציג עכשיו "Jello". שינינו דרך b אובייקט שגם a משתמש בו. במקרה של מחרוזת שעוברת בין מתודות רבות, או של מחרוזת משותפת מתוך ה־intern pool, שינוי כזה היה עלול להפתיע הרבה יותר משני משתנים בדוגמה קטנה.

לכן אפשר לשנות את המשתנה, אבל לא את תוכן האובייקט:

string name = "Hello";
name = "Jello";

name מצביע כעת על מחרוזת אחרת. "Hello" לא הפכה ל־"Jello".

זה חשוב גם בלי Interning

מחרוזות משמשות לעיתים קרובות כמפתחות של Dictionary:

var users = new Dictionary<string, User>();
users["hernan"] = user;

המילון נעזר ב־hash של המפתח כדי לדעת היכן לשמור אותו ואיפה לחפש אותו. אילו היה אפשר לשנות את תוכנו של אותו מפתח אחרי ההכנסה, החיפוש היה עלול להסתמך על hash אחר ולא למצוא את מה ששמרנו. הכלל של Dictionary הוא שמפתח לא ישתנה באופן שמשפיע על ה־hash שלו בזמן שהוא נמצא במילון. מחרוזת מקיימת את הכלל הזה באופן טבעי כי התוכן שלה קבוע. תיעוד Dictionary.

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

ויש גם מחיר

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

string result = "";

for (int i = 0; i < 10000; i++)
{
    result += i;
}

למקרה כזה נועד StringBuilder. בונים בעזרתו את הטקסט במאגר שניתן לשינוי, ובסוף מפיקים string:

var builder = new StringBuilder();

for (int i = 0; i < 10000; i++)
{
    builder.Append(i);
}

string result = builder.ToString();

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

בסופו של דבר, string הוא אובייקט, אבל אנחנו רוצים להתייחס לתוכן שלו כאל ערך יציב. אפשר להעביר אותו הלאה, להשתמש בו כמפתח ואפילו לשתף מופע אחד בין כמה מקומות. כל זה הרבה יותר פשוט כשאף אחד מהם לא יכול להפוך את "Hello" ל־"Jello" מאחורי גבם של האחרים.

בעיניי זו התובנה היפה בחידה: ReferenceEquals הראה לנו ששני משתנים יכולים לחלוק מחרוזת אחת. ‏Immutability היא אחת הסיבות שבגללן השיתוף הזה בטוח.

כתיבת תגובה

האימייל לא יוצג באתר. שדות החובה מסומנים *