DCSIMG
טעויות של מפתחים - להבין את הBCL - Bah, Humbug!

Bah, Humbug!

Wear sunscreen...

שטויות

  • Join me

בלוגים שאני קורא

טעויות של מפתחים - להבין את הBCL

הקדמה

בפוסט הקודם שלי בסדרת "טעויות של מפתחים" הסברתי את ההבדל בין הConditionalAttribute לבין #IF. את הפוסט כתבתי בעקבות בעיה שנתקלתי בה באחד הצוותים בארגון.

את הפוסט הנוכחי אני כותב גם כן אחרי ביצוע מספר Code Reviews לצוותים שונים, ובעיות נפוצות שנתקלתי בהן. את הבעיות שבחרתי להעלות כאן אני מבסס על כתבה שנכתבה לפני כשנתיים בMSDN Magazine על נושא הביצועים בBCL.

לפי ההודעות כאן בבלוגים, נראה כאילו עיקר העבודה של מפתחים בימנו זה התממשקות למסדי נתונים באמצעות LINQ או עיצוב אפליקציות תלת-מימדיות שמפעילות רובוטים ועושות פליק-פלאק באוויר. נראה כאילו הבסיס של הבסיס נשאר מעט בצד.. לעד ימשיכו ללמד אותו בקורסים והדרכות - אבל הוא זוכה לפחות פוקוס ונראה שהנושאים היותר "יוקרתיים" הם אלו שמושכים את רובנו (אני מודה שאני חוטא בחטא הטכנולוגיות החדשות לא פחות מכל אחד אחר).

אז מה בעצם יהיה כאן? בפוסט הזה אני אנסה לגעת במספר מחלקות מתוך הBCL - Base Class Libraryשאנחנו נוטים למצוא בשימוש בפרויקטים שלנו לעתים קרובות - ולא תמיד יודעים מתי נכון להשתמש בהן. אני בעיקר אגע בדברים שהשתנו בין .NET 1.1 לבין .NET 2.0 שהם בגדר Breaking Changes גם אם הם לא באמת כאלו. אנסה להוכיח מדוע הם כאלו. לפוסט הזה אני מצרף קובץ ZIP עם דוגמאות שהכנתי להרצאה דומה שלי לארגון. לא בכל הנושאים שמופיעים בקוד המקור אני אגע בפוסט הזה.. אבל מצד שני, לא אגע כאן בדברים שלא תוכלו למצוא שם (קישור בסוף ההודעה)

מה זה בדיוק הBCL?

יישור קו אחד לפני שאני אתחיל לגעת בתאכלס. הBCL זוהי הספריה של מחלקות הבסיס שמגיעות עם הCLR. אלו שני הDLLים שכולנו מכירים - System.DLL ו-mscorlib.dll. הם מכילים Namespaces דוגמת System, System.IO, System.CodeDom, System.Diagnostics, System.Text, System.Collections וכו'. אלו ישויות הבסיס בהן אנחנו משתמשים בתוכנות שלנו. הנטיה הטבעית שלנו לחשוב שמתוקף היותן ישויות בסיס הן גם היעילות ביותר לכל מטרה שגויה. ההיכרות שלנו היום עם המימוש מאחורי פונקציות הוא מינימליסטי. אם בעבר תכניתן C הכיר את מימוש הAssembly שמאחורי כל פונקצית STDIO, היום המצב לא כזה, ורובנו (או לפחות, אלו מאיתנו הבריאים בנפשם.. לצערי אני לא יכול להכליל את עצמי בקטגוריה הזאת) לא עוברים עם Reflector על המחלקות בהן אנחנו משתמשים ובודקים איך מיקרוסופט מימשו אותן.

השורה התחתונה של הפוסט הזה היא להבהיר ש"Best Practices" זה טוב ויפה - אבל דעו קודם היכן לא צריכים להשתמש בהם.

BaseType.Parse() מול BaseType.TryParse().

עם ההצגה של .NET 2.0, כולנו למדנו שמעתה יש להשתמש בTryParse בכל מקום באפליקציה, ורובנו הפסקנו להשתמש בParse. במרבית המקרים, זו אכן הדרך היעילה ביותר לטפל בניתוח קלט, אבל ישנם מספר מקרים בהם השימוש בParse עדיף על שימוש בTryParse.

היתרון העיקרי של TryParse זו העובדה שהוא פשוט לא זורק Exceptions. במידה והניתוח נכשל, הוא מחזיר false. המשמעות מאחורי הקלעים אדירה. טיפול בExceptions זו אחת הפעילויות גוזלות המשאבים הנוראיות ביותר שקיימות. היצירה הראשונית של הException דורשת מלבד ייצור אובייקט, גם טיפול מאחורי הקלעים בקונטקסט, תחקור וכו'. התהליך הזה מאוד יקר - ולכן כבר מהימים הראשונים של הCLR קיבלנו Best Practice שלם שמתעסק רק בטיפול בExceptions. השורה התחתונה - אל תשתמשו בExceptions בזרימה הרגילה של האפליקציה. לעולם אל תצפו לException (מעבר לטיפול הרגיל בו). מנעו את הException מבעוד מועד.

מאידך, ישנם מספר מקרים בהם נעדיף לקבל את הException מהParse. זאת לדוגמא כאשר אנחנו מעוניינים להכשיל את האפליקציה במידה והוקש רצף שגוי. במקרים בהם אנחנו רוצים לחלחל את הבעיה למעלה אנחנו נרצה שהParse יהרוג אותנו. אבל התירוץ המרכזי לשימוש בParse במקום בTryParse יישאר הבידול בשגיאה. באמצעות בלוקים של Catch, אפשר לטפל באופן שונה בכל מצב של קלט שגוי. כמובן שאת הבדיקות אפשר לעשות גם קודם לכן ולמנוע מההתחלה את הקריאה לTryParse, אבל זה עשוי לסבך את הקוד ובסופו של דבר אף עשוי להיות פחות יעיל מזריקת הException והטיפול בו.

Parse יודע לזרוק ארבעה סוגים של Exceptions:

  1. OverflowException - במקרה שהערך המספר שהוזן חורג מהMinValue או מהMaxValue של הType לו מנסים לבצע Parse. לדוגמא, נסיון להכניס 32768 לתוך Int16 יזרוק את הException הזה.
  2. FormatException - במקרה שאנחנו מנסים לבצע Parse למספר, אבל.. הקלט הוא בכלל אותיות.
  3. ArgumentNullException - במקרה שאנחנו מעבירים משתנה שלא תוחל.
  4. ArgumentException - במקרה שאחד המשתנים אינו תואם את מה שהParse מצפה לקבל. את זה ניתן לדוגמא לראות במקרה שלParse שלEnums.

ArrayList עדיף לפעמים על List<>

מאז שמיקרוסופט הביאה לחיינו את הGenerics, נראה שפסק השימוש בCollections דוגמת ArrayList. זה כמובן בצדק, היות וGenerics עדיפים הן באיתור שגיאות בשלב הקומפילציה והן בביצועים בזמן הריצה, אבל ישנם מקרים בהם אנחנו עשויים להמשיך להעדיף את השימוש בArrayList הישן והטוב.

עבור ValueTypes, אין ויכוח שהשימוש בGenerics יהיה עדיף. השמירה נעשית בדיוק כמו מערך רגיל אחד אחרי השני בזכרון, והשיפור בביצועים עשוי להגיע לסדרי גודל. מאידך.. עבור ReferenceTypes, המצב עשוי להיות שונה. אין ספק שהטיפול בשליפה הבודדת, בהכנסה וכו' יהיה מהיר יותר. תהליך הBoxing והUnboxing נחסך. מאידך, תהליכים של מיון ובדיקת קיום של איבר יהיו איטיים יותר.

במקרה כזה, אפשר לראות שArrayList.Sort יהיה מהיר בסדר גודל מהשימוש בList<>.Sort. דבר דומה נראה בשימוש בContains.

ההבדל בין SortedList<> וSortedDiictionary<>

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

הSystem.Collections.Generic מכיל מימושים ג'נריקסיים לCollections שכולנו מכירים. Queue<>, Stack<>, LinkedList<> וכו'.

בין הCollections שהוא מכיר לנו אלו שני מימושים של רשימות ממויינות. מי שקרא על ההבדל בין SortedList וSortedDictionary, סביר שיבחר להשתמש בSortedDictionary בגלל שהוא יעיל יותר בהכל (תמיד הוא O של log n. גם בהכנסה וגם בשליפה). או שאם הוא לא קרא, הוא ישתמש בSortedList בגלל שהוא נראה פשוט יותר לשימוש לכאורה.

כמו כל דבר, חשוב להכיר את היתרונות והחסרונות של כל אחד מהם. את היתרון של SortedDictionary במימוש העץ מאחורי הקלעים רובנו מכירים. לכן, רובנו נבחר להשתמש בו ובצדק.. אבל חשוב להכיר כמה מהחסרונות שלו, שעשויים לעלות בביצועים - ולגרום לSortedList (שבו השליפה גם כן מתבצעת בO של log n) להיות העדיף.

כשאנחנו באים לממש, חשוב שנזכר שהSortedDictionary מתבסס מאחורי הקלעים על עץ. העץ הזה מורכב מפריטים (Nodes). כל פריט כזה הוא ReferenceType. המשמעות היא שהם ממלאים את הHeap תמיד, גם במקרה שאנחנו מאחסנים מאחורי הקלעים סתם ValueType. בנוסף, העובדה שהם נשמרים כרשימה מקושרת מאחורי הקלעים (קרי - אחד מצביע על הבא בתור, והם לא בהכרח יושבים אחד ליד השני בזכרון), עשויה לגרור מצב שבו לא כל הDictionary נטען לCache, מה שעשוי לגרור "דפדוף" או לפעמים אפילו Page Faults. שני אלו מאוד יקרים בביצועים.

השורה התחתונה היא שאם עיקר העבודה היא דחיפה לתוך הרשימה הממוינת - הSortedDictionary מצויין. אבל אם עיקר העבודה זו השליפה אח"כ, כנראה שSortedList יהיה עדיף.

ההבדל בין UTC וזמן לוקאלי

בהמשך לבעית הIntellisensing שדנתי בה בסעיף הקודם, גם אוביקט הDateTime סובל מבעיה דומה. כאשר אנחנו מעוניינים לקבל את הזמן הנוכחי, כנראה שנבחר באפשרות הכי בולטת - Now. מה שאיננו יודעים זה שמאחורי הקלעים נוצר לנו אובייקט DateTime שמחושב לפי הזמן המקומי של המחשב. המשמעות היא שעניינים של לוקאליזציה נלקחים בחשבון וגוררים פגיעה בביצועים.

אם נסתכל על DateTime, נראה שהוא מממש גם את UtcNow. הProperty הזה יחזיר לנו גם כן את הזמן הנוכחי, אלא שכאן מדובר בזמן בפורמט בינלאומי. הפורמט הזה לא מתחשב בתופעות כמו DST או היסטי GMT, ולכן מהיר בסדר גודל מDateTime.Now. יתרון נוסף שנקבל ככה על הדרך בשימוש בDateTime.UtcNow יהיה העובדה שמאחורי הקלעים הזמן שלנו תמיד יהיה מסונכרון. כך לדוגמא אם יש לנו מערכת שמקבלת קלט ממספר מחשבים.. לא תהיה לנו בעיה לסנכרן בינהם. למעשה, זו הצורה הנכונה לעבוד. ההמרה לזמן מקומי צריכה לקרות רק כאשר באים להציג את הזמן למשתמש - או כאשר מעבירים אותו לדו"ח או מערכת אחרת שמצפה לקבל זמן בפורמט מקומי.

דע מתי להשתמש בStringBuilder.Append, ומתי String.Concat עדיף.

זה נכון שStringBuilder נחשב כמענה היעיל ביותר לחיבור של מחרוזות. הסיבה לכך היא שמחרוזת היא בלתי ניתנת לשינוי. המשמעות היא שכל תוספת למחרוזת גוררת יצירת מחרוזת חדשה. כבר דנתי בסעיפים אחרים במשמעות של הרבה ReferenceType Objects. עיקר הבעיה נעוץ בגודל הHeap שמוקצה לאפליקציה. בנוסף, העובדה שהמחרוזות נוצרות מחדש בכל פעם גוררת זמני תגובה גבוהים יחסית. את זה בא לפתור הStringBuilder שמתייחס למחרוזת כרשימת תווים (או רשימת מחרוזות) ומחבר אותם.. כך מאחורי הקלעים לא נוצרות מחרוזות. רק ברגע שמבקשים לקבל את האובייקט כמחרוזת (StringBuilder.ToString) מתבצע החיבור בפועל ונוצרת מחרוזת.

עם זאת, אין זה אומר שכל חיבור של מחרוזות צריך להיות ממומש באמצעות StringBuilder. יש לזכור שStringBuilder הוא אובייקט כבד יחסית. ליצירה שלו יש משמעויות. האובייקט מתחיל להחזיר את עלות הייצור שלו רק אחרי מספר מסויים של חיבור מחרוזות באורכים מסויימים. כך לדוגמא, בשביל לחבר ביחד 3 מחרוזות בנות 30 תווים, עדיף יהיה להשתמש בString.Concat.

ואם במחרוזות עסקינן... תכירו את String.CompareOrdinal

שוב בעיית Intellisense - הString.Compare. מפתחים רבים משתמשים בפונקציה הזאת לצורך השוואה של מחרוזות, בלי להיות מודעים לoverhead שהיא טומנת בחובה. כברירת מחדל, הפונקציה הזאת משווה מחרוזות תוך שהיא בודקת את הרקע שלהן (הCulture של המחרוזת).

מאידך, הפונקציה CompareOrdinal עושה את מה שרובנו מצפים - משווה את הערכים המספריים של המחרוזות ובהתאם לזה מחזירה ערכים בהתאם להשוואה (גדול מ, קטן מ וזהות). הפונקציה הזאת היא המימוש של השוואת מחרוזות שאנחנו מכירים משפת C ודומיה.

ועם זאת, String.Compare עדיין מכילה לא מעט Overloads שעשויים להתגלות כשימושיים (כמו התעלמות מרישיות וכו'..), ולכן - שווה לא לזנוח אותה. אבל במקרה שכל המטרה היא להשוות טקסט פשוט, כנראה שCompareOrdinal תעשה את העבודה, ובסדר גודל פחות מבחינת זמן ריצה.

קריאת תוכנו המלא של קובץ

אני אסיים עם נושא שהרבה לא מכירים. מפתחי .NET מגרסאותיה הראשונות רגילים בגישה לקבצים לפתוח Handle ולעבור על הקובץ עד שהסמן מגיע לEOF. לעתים, אכן זו האפשרות היעילה יותר, אך במידה והמטרה היא לקרוא את כל תוכנו של הקובץ, ה.NET בגרסה 2.0 ומעלה מציעה לנו שלוש פונקציות שכדאי להכיר:

  1. ReadAllText - קוראת קובץ ASCII מלא לתוך מחרוזת.
  2. ReadAllBytes - קורת קובץ בינארי מלא לתוך באפר.
  3. ReadAllLines - איטרטור על שורות בקובץ. מאפשר קריאת שורה שורה.

היתרון הכי גדול של השלוש הוא העובדה שהן מסתירות בתוכן את כל הטיפול בשגיאות. הן יודעות לשחרר את הHandle בסוף השימוש וחוסכות לנו המון דאגה מיותרת.

עם זאת, היעילות של ReadAllLines נתונה בספק, וישנם מקרים בהם תעדיפו להמשיך להשתמש בFile.ReadLine ולעבור עליו עד שתגיעו לEOF.

לסיכום

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

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

קישור לקוד המקור של הפוסט הזה.

תוכן התגובה

Maxim כתב/ה:

אהבתי. כמו שאומרים: "כל מילה זהב...".

אתה צודק לגבי השימוש generic collections, מאז שקיבלנו אותם דוט-נט 2, די הזנחנו את ArrayList ויתר דברים שעדיין אפשר להשתמש לצרכים פשוטים שלא דורשים ביצועים מהירים או חוכמה גדולה (ללא התחשבות ב-boxing/unboxing). כדי להמנע מבעיות תאימות ב-webservice אני מעדיף להשתמש ב-ArrayList, לעומתו List<T> מתורגם למערך של אובייקטים בממשק ההתאמה. לגבי sorted... למיניהם גם מסכים, יש קונטיינרים גנריים ויש קונטיינרים שהם special למבנה נתונים ספציפי כמו "מילון מחרוזות" ועוד. בחירת מבנה נתונים משפיע רבות על הביצועיים ויש לעשות אותה בהתאם לשכיחות סוג הפעולות כמו הכנסה, שליפה ומחיקה (מה עושים יותר או מה צריך לבצע במהירות גבוהה יותר ובתדירות גבוהה יותר).

לגבי ReadAll... למיניהם צריך להזהר, עדיף להשתמש רק לקבצים קטנים (יחסית) כדי לבצע קריאה באופן מהיר ולשחרר את הקובץ. עוד שגיאה נפוצה של המון מתכנתים היא לקרוא קובץ שורה אחרי שורה ותוך כדי לבצע עיבוד מידע, המון פעמים קריאה של קובץ יכולה לקחת מספר מילישניות אבל העיבודים עושה באמצע הקריאה מעריכים את זמן הקריאה וקובץ "תפוס" יותר ממה שנחוץ; (א) קרא את הקובץ למקום זמני, (ב) שחרר אותו (את המשאב) ו-(ג) בצע עיבוד כמה זמן שיידרש.

# January 1, 2008 12:12 AM

Doron Ben-David כתב/ה:

בהחלט! החזקת Handle פתוח היא פעולה יקרה ברמה של מערכת ההפעלה. לכן, כמובן עדיף לשחרר את הHandle כמה שיותר מוקדם. מצד שני, גם שמירה של Buffer ומעבר עליו אח"כ יקר.. ולכן, כמו כל דבר - הפתרון צריך להבחן ברמת הפרויקט והבעיה אותה באים לפתור.

# January 1, 2008 12:20 AM

עדי כתב/ה:

אני לא מסכים בנוגע לArrayList

עדיין יש לך את העלות של Casting

עשיתי בדיקה קטנה, והביצועים במיון היו דומים, עם יתרון קל מאוד לList

# January 1, 2008 2:44 PM

Doron Ben-David כתב/ה:

שים לב למה שכתבתי - לא מדובר כאן על תהליך השליפה עצמו או תהליך ההכנסה. מדובר אך ורק על שתי פעולות-

מיון ובדיקת קיום של איבר.

שתי הפעולות האלו מתבצעות על מפתח הHash של האובייקט ואינן מחייבות Boxing (או במקרה של שליפה Unboxing) לתוך object. לכן, רק במקרים של Sortים מרובים על המערך או בדיקות Contains מרובות - רק באלו יהיה יעיל יותר להשתמש בArrayList.

צרפתי בקוד המצורף למאמר דוגמא שמוכיחה זאת עבור 10,000 איטרציות.

# January 1, 2008 2:52 PM

Moshe L כתב/ה:

מעניין !

במיוחד הנושא של UTC - אף פעם לא חשבתי איזו משמעות יכולה להיות לבחירה בין האובייקטים שם.

והאמת שלרוב יצא לי להשתמש בכלל ב-System.Collections.Specialized.NameValueCollection, שמהיר (אאל"ט, לא עשיתי בדיקה ראויה לשמה כך שזו רק תחושה) משמעותית בכל מה שמדובר ב-Name ו-Value ששניהם מחרוזת.

# January 1, 2008 8:12 PM

Doron Ben-David כתב/ה:

הNameValueCollection הוא כמו Hashtable (מלבד מגבלות על unique key).

מהסיבה הזאת, הHashtable הרבה יותר מהיר...

# January 1, 2008 8:44 PM

Bah, Humbug! כתב/ה:

אני מזהיר מראש.. הפוסט הזה הולך לעסוק בי, דורון . הפוסט הזה הולך לעסוק באלטר-אגו שלי, הבלוג הזה. הפוסט

# May 18, 2008 11:06 AM
שלח תגובה

(שדה חובה)  

(שדה חובה)  

(אופציונלי)

(שדה חובה) 

Please add 8 and 1 and type the answer here:


Enter the numbers above: