אחד השיעורים הראשונים שכל מפתח דוט-נט לומד לשנן, הוא ש"בכל מקרה בו
רוצים לחבר הרבה מחרוזות אחת לשניה, אסור בתכלית האיסור להשתמש באופרטור
+, ובמקום זה להשתמש במחלקה StringBuilder". אז אחרי הדקלום המושלם הזה,
אתה שואל "אוקיי, אבל למה בעצם?", והתשובה היא בדרך כלל "בגלל שמחרוזות הן
Immutable, זה אומר ששימוש באופרטור+ יביא להקצאות מיותרות של מחרוזות
שיהווה כל מיני 'שלבי ביניים' עד שנגיע למחרוזת הסופית".
כפי שאני רואה
את זה, הדקלום הזה יותר מטעה מאשר מה שהוא תורם. StringBuilder באמת יותר
טוב מאופרטור+? אם כן, אז מבחינת מה? ביצועים? ניצול זכרון? בפועל .. לא
הרבה מזה באמת מחזיק מים, תכף גם נראה למה.
קודם כל נסתכל על
האופרטור +. באופן בסיסי, מה שהוא עושה זה לקחת 2 מחרוזות קיימות, ליצור
אחת חדשה ואז להעתיק לתוכה את התוכן של המחרוזות הישנות. כלומר, באופן
תיאורטי, קיים כאן חסרון מהותי של הקצאות זכרון רבות אך לא נחוצות. עכשיו,
אני אומר "באופן תיאורטי" בגלל שבפועל, זה שאנחנו משתמשים באופרטור +, זה
לא באמת אומר שהקוד המקומפל ישתמש בו גם כן. מה שקורה, זה שאחת
מהאופטימיזציות שהקומפיילר מבצע על הקוד, הוא לזהות מקומות בהן מחברים 5 מחרוזות ומעלה בעזרת האופרטור +, ואז למעשה להחליף את הקריאה לאופרטור בקריאה ל-String.Concat.
לדוגמה, הקוד הזה:
string str2 = 1.ToString() + 2.ToString() + 3.ToString() + 4.ToString() + 5.ToString();
יתקמפל לקוד הזה:
string[] CS$0$0000 = new string[] { 1, 2, 3, 4, 5 }; // edit: removed ToString
string text1 = string.Concat(CS$0$0000);
לגבי String.Concat, אין סיבה לחשוש מהקצאות זכרון מיותרות. כל
מה שהוא עושה זה לחשוב מה יהיה גודל המחרוזת הסופית, להקצאות את באפר היעד
עם קריאה ל-AllocateFastString, ואז פשוט להעתיק את תכני המחרוזות הישנות
לבאפר היעד עם wstrcpy (העתקת בתים יעילה דרך שימוש ב-unsafe code).
אחד
השימושים הנפוצים בבניית מחרוזות, הוא כתיבת לוגים. ובמידה והאפליקציה
שלכם כותבת הרבה ללוג, אז יתכן שהיא מקדישה חלק לא קטן מזמן הריצה שלה
לטובת בניית מחרוזות שיגיעו בסופו של דבר ללוג. הסיטואציה יכולה מעט
להשתנות על פי ה-Logging Framework שאתם עובדים איתה, אבל אני אקח כדוגמה
את log4net כרגע. מה שקורה ב-log4net זה שיש 2 דרכים לכתוב ללוג. האחד
"לוג רגיל" (Debug/Info/Warn..), והשני "לוג מפורמט"
(DebugFormat/InfoFormat/WarnFormat..). ההבדל היחיד בין 2 הדרכים האלו
הוא שהראשון מקבל מחרוזת קבועה ללא פרמטרים, בעוד השני מקבל מחרוזת
מפורמטת, ובנוסף הפרמטרים שצריכים להכנס אליה (שכל הסיפור הזה בסופו של
דבר מועבר ל-String.Format). עכשיו זה.. בעייתי.
מה שבעייתי כאן זה
שבכל פעם שאנחנו רוצים להכניס איזשהו פרמטר לתוך המחרוזת שלנו, סביר להניח
שאוטומטית נשתמש בגרסאת ה-Format של הכתיבה ללוג. מה שבעיה בזה, זה אומר
שעבור כל כתיבה ללוג, אנחנו למעשה מפעילים את String.Format.
מתחת לפני
השטח, String.Format משתמש ב-StringBuilder, שבהשוואה ל-String.Concat,
נמצא הרחק מאחור בכל הנוגע לביצועים ויעילות. אומנם היינו יכולים בקלות
פשוט לכתוב ללוג עם הגרסה הלא-מפורמטת (שלא משתמשת ב-String.Format),
ובמקום זה לפנות ל-String.Concat בעצמנו, אבל בפועל מאחר ו-log4net חושף
לנו את 2 ה-overload'ים האלה, ברוב המוחלט של המקרים, אנשים פשוט יעדיפו
לפנות לגרסאת ה-Format. פשוט בגלל שזה נגיש, ושזה שם.
בשביל להדגים את הבדלי הביצועים בין האפשרויות השונות, נשתמש בבנצ'מרק הבא:
while(true)
{
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < 500000; i++)
{
// 960ms
string str1 = string.Format("{0}, {1}, {2}, {3}, {4}", 1, 2, 3, 4, 5);
// 665ms
string str2 = string.Concat(1, ", ", 2, ", ",3, ", ", 4, ", ", 5);
// 566ms
string str3 = string.Join(", ", new string[] { 1, 2, 3, 4, 5 });
}
Console.WriteLine(sw.ElapsedMilliseconds);
}
במקרה הזה עשיתי שימוש בלוג פשטני למדי, אבל גם כאן אפשר להבחין בהבדלים
ניכרים בין הפונקציות השונות. כשמשווים את Format ל-Concat אנחנו מקבלים
שיפור של 31%, בעוד שאם נשווה ל-Join (שאמנם הוא לא תמיד אופציה ללוגים
סטנדרטים), נקבל שיפור של 62% (המימוש של Join אומנם לא פונה ישירות
ל-Concat, אבל עובד בצורה דומה ויעילה עם קריאה ל-FastAllocateString
ועבודה עם המחלקה UnSafeCharBuffer לבניית המחרוזת החדשה).
אחרי שראינו את ההבדל בזמן הריצה, מה לגבי הבדלים בהקצאות הזכרון? הפעלתי
בלולאה של 10 איטרציות את Format ואת Concat כשברקע CLRProfiler ניטר את
התהליך, ואלו התוצאות שהתקבלו: גרסאת ה-Format גרסה להקצאה של סה"כ 69,320
בתים. במהלך הריצה נוצרו 816 מופעים של מחרוזות שתפסו סה"כ 35,308 בתים
(שאר ההקצאות הלכו בעיקר על יצירת מערכי Object'ים, מערכי Int'ים ומערכי
Char'ים).
לעומת זאת, אותה תוכנית עם שימוש ב-Concat גרמה להקצאה של 53,822 בתים
(15,498 בתים פחות), ויצירה של 714 מופעים של מחרוזות (102 מופעים פחות),
שתפסו סה"כ 20,810 בתים. אם כן, מתברר שגם מבחינת ניהול הזכרון, השימוש
ב-Concat כדאי יותר.
עוד פרט שכדאי לשים לב אליו, הוא שאלמלא ציינתם במפורש ל-StringBuilder
לאיזה גודל הוא אמור כנראה להגיע (פרמטר ה-capacity בבנאי), הוא יווצר עם
באפר בגודל של 16 בתים. כך שברגע שתחרגו ממנו, הוא יאלץ להקצות את המקום
מחדש. במקרה של String.Format, הפריימוורק כבר דואג לאתחל אותו עם
ה-capacity המדוייק שהוא יצטרך, מה שאומר שבמידה ובקוד שלכם אתם עובדים
ישירות עם StringBuilder, בלי לעזור לו לשער לאיזה גודל הוא הולך להגיע,
אתם צפויים לקבל עוד הרבה יותר הקצאות זכרון ממה שרואים בדוגמה הזאת.
דבר נוסף שצריך לשים לב אליו הוא שהפוסט הזה עוסק בהשוואה בין Concat ל-Format (או למעשה בין Concat להפעלה בודדת על StringBuilder), בסיטואציות אחרות, בהן קיימת דרישה לבניית מחרוזת גדולה "לאורך זמן" (לולאות, בניה מתמשכת וכו'), שימוש במחלקה StringBuilder (עם קריאה ל-Append) יהיה עדיף מאחר ולא נצטרך להקצות מחרוזות חדשות בכל איטרציה של הלולאה וכו'.. (כמו שהיינו נדרשים לעשות במידה והיינו עובדים בלעדית עם String.Concat).
כמובן שעבור לוגים קצרים שנכתבים "פעם ב..", להבדל הביצועים הזה אין באמת
משמעות. ושעצם זה שהשימוש ב-Format נותן לנו קוד קצת יותר קריא מ-Concat
(לפחות תלוי בסיטואציה), ההעדפה הברורה היא לטובת שימוש ב-Format. אבל,
במידה וקיימים חלקים באפליקציה שלכם שבהם אתם כותבים בצורה אינטנסיבית
ללוג, הבדלי הביצועים האלה יכולים להיות משמעתיים עבורכם. כי אם תבדקו את
הקוד שלכם בעזרת Profiler מתאים, תוכלו לגלות שאתם שורפים Cycle'ים על
פרסור ובניית מחרוזות, בעוד ששימוש ב-Concat (או אפילו Join אם מתאפשר
לכם), יכול לתת לכם Boost משמעותי ביעילות, במידה ותבחרו להשקיע את 5
הדקות בשביל לעדכן את הקוד המתאים.