Liran Chen's Blog

.Net Internals, Development, Multithreading - and More!

May 2009 - Posts

Object.ReferenceEquals vs. The Equality Operator
לפני כמה פוסטים הבנו מה המשמעות של אופרטור ההשוואה (==) כשנעשה בו שימוש בקונטקסט של Reference Types. למעשה הגענו למסקנה שבמימוש הבסיס הוא למעשה עונה לנו על השאלה "האם 2 הרפרנסים שיש לי מצביעים לאותו אובייקט?", כלומר יש לנו כאן השוואה של כתובות בזכרון. אם הן זהות, קיבלנו true; אחרת, false.
אבל רק רגע, בנקודה הזאת אנחנו נזכרים לרגע בפונקציה ReferenceEquals שנמצאת תחת מחלקת הבסיס Object. כמו שמשתמע מהשם, גם התפקיד שלה הוא לענות בדיוק על אותה השאלה.
אז אם 2 הדרכים הללו למעשה "עושות אותו הדבר", מדוע יש צורך בהפרדה הזאת? או השאלה היותר חשובה ומתבקשת: האם ומה ההבדל ביניהן? (ספויילר: הבדל גדול).

במחשבת תחילה, אפשר לחשוב שאופרטור ההשוואה הוא פשוט סוג של "keyword" נוח שנתנו במקום שנצטרך לקרוא בכל פעם ל-ReferenceEquals. כלומר, היינו מצפים שקריאה לאופרטור ההשוואה למעשה תפנה אותנו ל-ReferenceEquals.
אז בואו נחקור את הטענה הזאת. אז פתחנו את Reflector, והלכנו לבדוק את המימוש של Object.ReferenceEquals. ואיזה פלא, אנחנו רואים את הדבר הבא:

public static bool ReferenceEquals(object objA, object objB)
{
return (objA == objB);
}
אז רגע, מה הולך כאן?נראה שהכל קורה כאן הפוך, ולמעשה ReferenceEquals הוא זה שמפעיל את אופרטור ההשוואה ולא ההפך?
אז זהו, שלא בדיוק. זה אחד המקרים שבאמת מדגים למה לא תמיד אפשר להסתמך בעיניים עצומות על התרגום של Reflector, ועדיף לפנות לקוד ה-IL המקורי. כי במקרה הזה למשל, Reflector פשוט מטעה אותנו, וכדי שנוכל להבין מה באמת קורה כאן, אנחנו צריכים לפנות לרגע לקוד ה-IL המקורי.
אז כשאנחנו פונים אליו, אנחנו מקבלים את הקוד הבא:

.method public hidebysig static bool ReferenceEquals(object objA, object objB) cil managed
{
.maxstack 8
L_0000: ldarg.0
L_0001: ldarg.1
L_0002: ceq
L_0004: ret
}
מה שמעניין אותנו כאן זה שכדי להשוות בין 2 המופעים, משתמשים בפקודה CEQ - Compare Equal. מה שהפקודה הזאת יודעת לעשות, זה להשוות בין 2 ערכים שמעבירים לה. במקרה הזה, 2 הערכים האלה הם למעשה הרפרנסים, כתובות הזכרון, של המופעים שלנו. כלומר, יש כאן ממש השוואה בין 2 חתיכות זכרון, וזה הכל.

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

// original code

object a = new object();

object b = new object();

 

bool x = a == b;


--- Generated IL ---

    L_0000: nop 
L_0001: newobj instance void [mscorlib]System.Object::.ctor()
L_0006: stloc.0
L_0007: newobj instance void [mscorlib]System.Object::.ctor()
L_000c: stloc.1
L_000d: ldloc.0
L_000e: ldloc.1
L_000f: ceq
L_0011: stloc.2
והנה שוב, אנחנו רואים שמתבצעת קריאה ל-CEQ. כך שבינתיים, אנחנו מקבלים התנהגות זהה לחלוטין בין אם מדובר באופרטור ההשוואה ו-ReferenceEquals.
הדברים מתחילים להיות מעניינים באמת ברגע שאנחנו מתחילים לדרוס את את אופרטור ההשוואה. שבנקודה הזאת אנחנו כבר יכולים להראות התנהגות שונה בין 2 האפשרויות.
אז ניצור טיפוס חדש בשם Foo ונעניק מימוש חדש לאופרטור ההשוואה שלו. לאחר מכן נקמפל את אותו הקוד מהדוגמה הקודמת ונסתכל על קוד ה-IL החדש שקיבלנו:

    L_0000: nop 
L_0001: newobj instance void Demo.Foo::.ctor()
L_0006: stloc.0
L_0007: newobj instance void Demo.Foo::.ctor()
L_000c: stloc.1
L_000d: ldloc.0
L_000e: ldloc.1
L_000f: call bool Demo.Foo::op_Equality(class Demo.Foo, class Demo.Foo)
L_0014: stloc.2

הפעם אנחנו רואים שבמקום הקריאה ל-CEQ למעשה מפעילים את המימוש החדש לאופרטור ההשוואה.

מה זה אומר לגבינו?

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

public class Foo

{

    private int m_x;

 

    public override bool Equals(object obj)

    {

        Foo other = obj as Foo;

        if (other == null)

            return false;

 

        bool result = m_x.Equals(other.m_x);

 

        return result;

    }

}


בדוגמה הזאת, ביום שיחליטו להעניק מימוש חדש לאופרטור ההשוואה של Foo, כל קריאה ל-Equals תגרור הפעלה של אופרטור ההשוואה. ובמידה ובתוך המימוש החדש ישנה קריאה ל-Equals (כמו ברוב המקרים), אזי שתווצר לנו פה סדרה של קריאות אינסופיות בין Equals לאופרטור ההשוואה. כך עד שלפחות יתפוצץ ה-Call Stack ותזרק StackOverflowException.

במקום זה, הדרך הנכונה והבטוחה היא לקרוא ל-ReferenceEquals. כך שגם במידה ויעניקו מימוש חדש לאופרטור ההשוואה, בדיקת הרפרנסים תתפקד ותצליח בכל זאת. ללא קשר לאיזו לוגיקה מוזרה החליטו להכניס לאופרטור.
Visual Studio: Fast File Navigation
הנה טיפ קטן שיכול לעשות את החיים שלכם להרבה יותר קלים. כמה פעמים כבר קרה לכם שאתם מקודדים איזו פונקציה או מדבגים איזשהיא מחלקה ופתאום שמים לב שאתם צריכים לגשת לאיזה קובץ אחר שנמצא גם כן ב Solution אבל מסתתר אי שם בפרוייקט נפרד תחת 10 ספריות שדואגות להסתיר אותו. בשלב הזה מתחילה המשימה המרגיזה של להתחיל לנווט בנבכי ה-Solution Explorer ולהתחיל לחפש את הקובץ המבוקש. לאו דווקא יקח יותר מדי זמן, אבל כנראה תסכימו איתי שזה יכול סתם להציק להתחיל להתעסק עכשיו עם העכבר ולהתחיל לנווט אליו. למזלנו, יש פתרון פשוט שיעזור לנו להעלות בעוד מעט את הפרודוקטיביות ועל הדרך לשמור על פאסון ה-Power User :) מה שקורה זה שישנו אוסף פקודות טקסט ש- Visual Studio יודע לקבל והפעיל. מדובר בכל מיני פקודות Utility כאלו ואחרות, למשל לפתוח טאב, לסגור טאבים, לנווט לפה ולשם.. בנוסף לאלה, נמצאת גם הפקודה OpenFile (או בקיצור הנתמך גם כן: of). כדי להשתמש ביכולת הזאת, כל מה שעלינו לעשות זה לגשת לתיבת הטקסט של Find ולהקליד לתוכה את את הפקודה שאנחנו רוצים להפעיל, ולאחריה את הפרמטרים שהיא מקבלת. המבנה שצריך להעביר בנוי בצורה הבא:

> [command-name] [parameters]

רק יש לשים לב ולהכניס את הסימן "<" בתחילת ההקלדה כדי ש-VS יתיחס לשאר הטקסט בתור פקודה ולא בתור מחרוזת לחיפוש. אז במקרה שלנו הפקודה בה נעשה שימוש הוא of, כך שלאחריה אנחנו רק צריכים להתחיל להקליד את שם הקובץ שאנחנו רוצים לפתוח, ו-VS כבר יפתח אוטומטית את תיבת התוצאות שלו שמתוכה אנחנו כבר יכולים לבחור את הקובץ המבוקש ולפתח אותו.
לסיכום, בסופו של דבר זה נראה כך:
 
Avoid Using Culture-Specific Configuration Data

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

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

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

Write More Debuggable Code

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

למה הכוונה? הנה דוגמה קלאסית:

static void Main()

{

if (calculate(2, 4) > 4)

Console.WriteLine("Greater");

}

static double calculate(double x, double y)

{

return Math.Pow(y, x);

}


במבט מהיר אפשר להבין כבר מה הקוד הזה עושה, אין שום בעיה עם הקריאות שלו.
אבל, מה יקרה כשנרצה לדבג אותו? נניח ואנו רוצים לדעת מהו ערך ההחזר של calculate? כדי לדעת בדיוק, נהיה חייבים לקרוא לה שנית דרך פאנל ה-Immediate, אבל מן הסתם זה לא פתרון נוח כל כך, ולא פרקטי אם מדובר בפונקציה מורכבת יותר.
הצורה הכי פשוטה ונכונה למנוע מצב כזה מגיעה משני כיוונים:
  • לא לקרוא לפונקציות בתוך משפטים מורכבים (קריאות לפונקציה, משפטי if, הכרזות על for ... למעשה כל מה שכולל בתוכו סוגריים).
  • לא לבצע חישוב מתמטי/קריאה לפונקציה בתוך קריאה ל-return.
בשני המקרים, כל קריאה לפונקציה/חישוב צריכה להיעשת לפני השימוש בערך ההחזר שלה. צריך ליצור משתנה "מאוד זמני" שיחזיק אותו ובו אפשר להשתמש בתוך הקריאה לפונקציה/return. בצורה הזאת, כשנדבג את הקוד, נוכל לבדוק את ערך ההחזר על ידי מבט מהיר ב-Watch.
 
מבחינת ביצועים? ההבדל זניח, אם בכלל קיים. הביטוי היחיד שלו יהיה בתוספת של מספר הוראות IL שתפקידן להעלות ולהוריד ערכים מה-Evaluation Stack. בפועל, לתוספת הקוד הזאת אין משמעות. גם אם תריצו את אותה הפונקציה, פעם בגרסה עם משתנים זמנים ופעם בגרסה בלי, גם אחרי עשרות מיליוני קריאות, לא תראו הבדל כלשהו בביצועים. זה יכול לנבוע מ-2 סיבות. האחת: ההבדל כל כך (אבל כל כך!) זניח עד שכדי שבאמת נראה הבדל "כלשהו" נצטרך לבצע כמה מילארדי קריאות לפונקציות, ורק אז, אולי, נוכל לקבל סט נתונים שיצביע על איזשהו הבדל בין 2 הגרסאות של הפונקציה. הסיבה השניה היא שלמרות שה-IL שחולל כאן שונה, יתכן וה-JIT מבצע אופטימיזציות נוספת על אותו הקוד ולמעשה מבטל את התוספת שקיימת בקוד ה-IL. כך שלמעשה, בפועל, אנחנו מריצים את אותו הקוד בשני המקרים.
Debugging Multithreaded Code

כל מי שאי פעם ניסה לדבג קוד שרץ תחת כמה ת'רדים בו זמנית, נתקל כנראה בתופעה המציקה שברגע שמבצעים Step Inside וכו', בדיוק כשאתם באמצע הדיבאגינג .. הדיבאגר לוקח אתכם בחזרה 200 שורות קוד לאחור, בפרוייקט אחר בכלל, חותך את חוט המחשבה שלכם בדרך, ובעצם מחזיר אתכם בחזרה ל breakpoint המקורי שהגדרתם. למה? כי פתאום ת'רד אחר בכלל הגיע לאותו ה breakpoint.

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

בסיטואציות כאלה, אני עובד בצורה מעט שונה.
ברגע שאני נכנס ל breakpoint, כל מה שאני עושה זה לגשת לחלון ה Threads, ומסמן את כל הת'רדים הקיימים, מלבד הת'רד שאני נמצא בו כרגע (מסומן ע"י החץ הצהוב). לאחר מכן כל מה שנשאר הוא להקליק על הלחצן הימני ולהעביר את כל הת'רדים הנבחרים למצב Freeze. וסיימנו.
כעת, ניתן להמשיך לדבג את התוכנית כאילו היינו מדבגים ת'רד אחד בלבד. היתרון הנוסף הוא שאנחנו יודעים שאף אחד מהת'רדים האחרים לא עובד בזמן שאנחנו מדבגים, כך שאין לנו מה לדאוג שעצם זה שאנחנו מבזבזים זמן בלדבג קוד, שאר החלקים בתוכנית ישפעו מכך (או לפחות מצמצמים את ההשפעה במידה ועובדים מול מספר תהליכים נפרדים).
הדבר היחיד שצריך לזכור, הוא לפני שמסיימים לדבג ורוצים להמשיך להריץ את התוכנית (ע"ע F5), אנחנו צריכים לזכור להעיר את כל הת'רדים. נעשה זאת בדיוק כמו שהקפאנו אותם, רק שבמקום Freeze נקליק על Thaw. כעת, ניתן ללחוץ על F5 ולהמשיך בריצה כרגיל.

Know When to Use The "as" Keyword

ב-#C יש 2 דרכים לביצוע cast בין טיפוסים.
הראשונה היא הדרך הסטנדרטית והמוכרת:


class Person { }

class Program

{

static void Main()

{

object foo = new Person();

Person bar = (Person)foo;

}

}


ולעומתה, השימוש במילת המפתח "as":

object foo = new Person();

Person bar = foo as Person;


ההבדלים
  • ניסיון לבצע קאסט "סטנדרטי" על טיפוסים שאין ביניהם קשר של הורשה/מימוש (למשל ניסיון לבצע קאסט בין מחלקת Car לבין Apple), יוביל לזריקת שגיאה מסוג InvalidCastException.
  • לעומת זאת, ניסיון לבצע קאסט בלתי אפשרי באמצעות שימוש במילת המפתח "as" לא יגרום לזריקת שגיאה. בסך הכל מה שיקרה זה שהפעולה תחזיר null, והתוכנית תמשיך בביצוע כרגיל.
  • אם רוצים להיות מעט פדנטים, אז מבחינת ביצועים, "as" הינה מעט פחות מהירה מאשר שימוש בקאסט רגיל.
הבעיה
למתכנתים רבים יש נטייה להשתמש ב-"as" במקומות לא נכונים.
מהו מקום "לא נכון"? כל מקום בו הטיפוס שאנו מבצעים עליו את הקאסט (foo) חייב להיות מהטיפוס שעליו אנו מנסים לבצע את הקאסט (Person). שלמעשה, מדובר בערך ב-95% מהמקרים.
הרי נניח ופעולת הקאסט נכשלה. בשימוש ב-as, המשתנה שלנו יתאתחל ל-null והתוכנית תמשיך לרוץ כרגיל. רק "מאוחר יותר", בפעם הראשונה שמישהו ינסה לגשת למשתנה הזה, תתעופף שגיאת NullReferenceException שכנראה לא תגיד לנו שום דבר שלא קשור לקאסט שנכשל לפני 200 שורות קוד.
לעומת זאת, אם היינו משתמשים בקאסט הרגיל, כבר על ההתחלה היתה נזרקת לנו שגיאת InvalidCastException שהיתה מספקת לנו את כל המידע הדרוש להבנה מלאה למה שהוביל לשגיאה.
האלטרנטיבה (והשימוש הנכון) ב-as הוא לבדוק האם הקאסט הצליח על ידי השוואה ל-null. אבל מן הסתם, לא נרצה לכתוב את הקוד המעייף הזה בכל פעם שאנו מבצעים קאסט כזה או אחר.

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



public override bool Equals(object obj)

{

Person other = obj as Person; // use 'as' to cast obj to Person

bool result = Equals(other); // call IEquatable<Person>

 

return result;

}


Arithmetic Overflow Checking

שאלה: האם לתוכניות שרצות בדוט-נט יש בדיקה אוטומטית עבור Arithmetic Overflows?
קחו לדוגמה את התוכנית הבאה, מה לדעתכם יהיה הפלט? או שבכלל תזרק שגיאה?

class Program

{

static void Main(string[] args)

{

int foo = 1;

int bar = int.MaxValue + foo;

Console.WriteLine(foo);

}

}


אם הניחוש שלכם היה 2147483648- (שווה ערך ל int.MinValue), צדקתם.
זאת טעות נפוצה לחשוב שכברירת מחדל הסביבה תבדוק כל הזמן האם קיימת אי התאמה בין גודל הערך שאותו אנחנו מנסים להכניס למשתנה. רוב המפתחים מצפים שעל פעולה כמו בדוגמה למעלה תזרק אוטומטית שגיאה. אבל, לא.
הסיבה שאנחנו מקבלים את הערך "המוזר" הזה, הוא שהתוצאה של החישוב שלנו חורגת מהגודל ש-int יכול להכיל. כלומר, בסביבה של 32 ביט, גודלו של int הוא 4 בתים. לכן, המספר החיובי הכי גדול שהוא יכול להכיל הוא 2147483647 (הכוונה כאן היא כאמור ל-signed int). ברגע שנרצה להחזיק ערך גדול יותר, נהיה חייב להקצות עוד בתים. במקרה שלנו נצטרך להשתמש ב-long (שמחזיק 8 בתים).
אז מה שקורה בפועל זה שהבתים הנוספים שאמורים להשתמש בהם כדי להחזיק את הערך החדש, למעשה "מקוצצים" (truncated), וכך אנחנו מקבלים ערך חדש ומזובל (הביט האחרון ב-signed types מייצג את סימון המינוס, ומכאן המספר השלילי).

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

איך ניתן להתמודד עם הבעיה?
בדיקות Arithmetic Overflows בדוט נט זקוקות להוראות ספציפיות דרך ה-CIL. כלומר, זהו לא תפקידו של ה-CLR לפקח כל הזמן על כל פעולה קטנה שאנחנו עושים ולבדוק האם אנחנו גולשים בטעות מהגודל המותר. במקום זה, בכל פעם שאנחנו רוצים לבצע בדיקת Overflow הקוד המקופל חייב להכיל הוראה "תבדוק מה הולך כאן". רק במצב כזה תבוצע בדיקה על הערך ובמידה וה-CLR רואה שהולכת לקרות כאן גלישה, הוא יזרוק שגיאת OverflowException.
כדי לבצע את בדיקת החוקיות הזאת, אפשר לנקוט באחת משתי דרכים. האחת, שימוש במילת המפתח Checked כדי לתחום אזורים בקוד בהם אנחנו מעוניינים לבצע בדיקות Overflow. השניה, עדכון סוויץ' שעובר לקומפיילר בזמן קימפול הפרוייקט ולמעשה גורם לו לבצע בדיקת Overflow בכל מקום בקוד. כברירת מחדל האפשרות הזאת מבוטלת, אפשר לאפשר אותה דרך אפשרויות הפרוייקט, תפריט Build, דרך Advanced ואז לסמן את האפשרות Check for arithmetic overflow/underflow.


לכל בדיקות ה-Overflows האלו יכולה להיות השפעה לרעה על ביצועי התוכנית שלכם. לכן צריך לחשוב פעמיים לפני שהולכים ומפעילים את הבדיקות אוטומטית על כל הפרוייקט. לכן אפשר גם לשקול לאפשר את הבדיקות המלאות רק בתצורת ה-Debug של התוכנית, אבל לא ב-Release. אבל במצב כזה שוב פעם חזרנו לבעיה ש-Overflows יכולים להתקיים בלי שנדע מזה בצורה ברורה.
כאן נכנסת לתמונה מילת המפתח checked. אנחנו יכולים לתחום בעזרתה קטעי קוד "שמועדים לפורענות". מקומות שאנחנו מזהים ויודעים להגיד ששם קיימת סבירות .. סבירה, שיגרם Overflow. בצורה הזאת אנחנו נמנעים מלעשות בדיקות על מקומות חסרי טעם שסתם יבזבזו Cycle'ים של המעבד.
הנה מה שקורה לאותו הקוד ממקודם, לאחר שתחמנו את הפעולה הלא חוקית ב-checked:


רק לרקורד, קיימת גם מילת המפתח unchecked שעושה בדיוק ההפך. במידה והגדרתם שבדיקות Overflow יבוצעו אוטומטית עבור הפרוייקט, תוכלו לתחום אזורי קוד בהם אתם לא רוצים שהבדיקה תבוצע (יכול להיות שימוש למשל אם דורסים את GetHashCode ולא רוצים שהחישוב יעיף שגיאה).

נקודות נוספות
  • בדיקות Overflow מבוצעת אך ורק למספרים אינטגרלים. כלומר, אם למשל תנסו להכניס ערך גדול מדי ל-int - תקבלו שגיאה. אבל, אם תנסו להכניס ערך גדול מדי ל-double, לא תקבלו שום שגיאה, אלא רק ערכים שגויים.
  • לכל הבדיקות האלה קיימת השלכה מסויימת על הביצועים. כדאי לחשוב היטב איפה ומתי אנחנו באמת רוצים לעשות את הבדיקה הזאת, אם בכלל.
  • הקומפיילר ידע להזהיר אתכם על Overflows במידה והם ממש Hardcoded ו-"נראים לעין". זאת הסיבה שבדוגמה שנתנתי הייתי צריך להקצות את foo שיחזיק את הערך "1". במידה וממש הייתי רושם בקוד int32.MaxValue + 1 הקומפיילר היה מוציא שגיאת קומפילציה.
  • במידה ואתם כותבים קוד שאתם יודעים מראש שיכול לגרום ל-Overflow במידה והוא מקבל כקלט ערכים "גדולים מדי", אז אפשרות נוספות היא לעשות קצת Defensive Programming ולבדוק את חוקיות הקלט שמגיע אליכם. במידה והוא לא חוקי, תזרקו כבר שגיאה משלכם שתתריעו על הבעיה.
  • קידוד נעים :)
Harnessing the Power of AppDomains in UnitTests

אחת הבעיות שיכולות להתעורר במהלך בדיקות UnitTests היא שינוי מצב ה-State בין הבדיקות.
למה הכוונה? יתכנו מצבים בהם הרצה של Test אחד יכולה להשפיע על מהלך הבדיקה של Test אחר. אם במידה וקיים משתנה סטטי כלשהו באפליקציה שלכם, וב-Test הראשון איתחלתם את ערכו ל-10 למשל, אזי ברגע שתפעיל את ה-Test השני, הערך של אותו משתנה סטאטי עדיין יהיה 10. ובמידה והבדיקה תערב בצורה כלשהיא את אותו משתנה סטאטי, יתכן ולוגיקת הבדיקה תפגע.

איך ניתן להמנע ממצב כזה?
אם ניקח לדוגמה את nUnit, אנחנו יכולים להשתמש ב-Attribute'ים המתאימים בשביל ליצור פונקציות שיבוצעו לפני/אחרי כל קריאה ל-Test.
לכן, הדרך "המהירה ביותר" היא פשוט לכתוב פונקציה כזאת שתאתחל את כל המשתנים הסטאטים בתוכנית. אבל מן הסתם, זהו פתרון לא מומלץ, ולא נכון.
הוא לא מומלץ בגלל שבכל פעם שתוסיפו משתנה סטאטי חדש לתוכנית שלכם, תצטרכו לעדכן את ה-UnitTests ... משימה כמעט בלתי אפשרית.
ועוד יותר בעייתי מזה: זה לא יעבוד. כלומר, זה יעבוד ... אבל לא תמיד. קחו למשל מצב בו יש לך מחלקה עם בנאי סטאטי כלשהו. אותו בנאי סטאטי יקרא אך ורק פעם אחת, והפעם הזאת תהיה ב-Test הראשון. מהנקודה הזאת והלאה, לא יהיה ניתן להריץ מחדש את אותו בנאי סטאטי במהלך החיים של ה-AppDomain הנוכחי.

הפתרון המתאים למצב הזה, יהיה ליצור AppDomain נפרד שישמש אותנו להרצת הבדיקות.
לפני כל הרצה של Test ניצור אותו מחדש, וכך למעשה נשיג "תנאי מעבדה" נקיים, ללא כל עקבות של Test'ים אחרים שהרצנו בעבר. בצורה כזאת, שום Test לא יצור איזשהו State שישאר גם ל-Test'ים הבאים.


ביצוע קוד על AppDomain נפרד

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

// your class should implement MarshalByRefObject for

// allowing it to be accessed via a different AppDomain

public class Program : MarshalByRefObject

{

public void PrintAppDomainName()

{

Console.WriteLine(AppDomain.CurrentDomain.FriendlyName);

}

static void Main()

{

Assembly entryAssembly = Assembly.GetEntryAssembly();

// create the new AppDomain

AppDomain myDomain = AppDomain.CreateDomain("MyDomain");

// create an instance on the newly created AppDomain

Program remoteObject = (Program)myDomain.CreateInstanceAndUnwrap

(entryAssembly.FullName, typeof(Program).FullName);

remoteObject.PrintAppDomainName();

Program localObject = new Program();

localObject.PrintAppDomainName();

// remember to unload the AppDomain when finished

AppDomain.Unload(myDomain);

// Output:

// MyDomain

// ConsoleApplication2.vshost.exe

}

}


נשים לב למספר אלמנטים עיקריים בקוד:
  • Program : MarshalByRefObject
    כברירת מחדל, מרחב הגישה הרחב ביותר עבור מופעים של אובייקטים, הוא ה-AppDomain. כלומר, אם ב-Process שלי קיימים מספר AppDomain'ים נפרדים, כל AppDomain יכול לגשת רק לאובייקטים שנמצאים בתוך אותו AppDomain.
    על מנת לאפשר גישה לאובייקט שנמצא מחוץ לגבולות ה-AppDomain, נצטרך שהמחלקה תירש מ-MarshalByRefObject. בלי לפרט בדיוק מה ואיך היא עושה, נסכם שבמקרה שלנו היא נותנת למחלקה שלנו את הפונקציונליות שתאפשר לנו לגשת למופעים שלה בין AppDomain'ים שונים.

  • AppDomain myDomain = AppDomain.CreateDomain("MyDomain")
    די מובן מאליו. כאן אנחנו יוצרים AppDomain חדש עם השם "MyDomain". לאחר ביצוע השורה הזאת, יהיו לנו ב-Process שני AppDomain'ים נפרדים. הראשון הוא ה"ברירת מחדל", אותו AppDomain שנטען לנו אוטומטית ברגע שהפעלנו את התוכנית, והשני הוא אותו "MyDomain" שיצרנו עכשיו.

  • Program remoteObject = (Program)myDomain.CreateInstanceAndUnwrap

    (entryAssembly.FullName, typeof(Program).FullName)

    הנה החלק המעניין האמיתי. כאן אנחנו למעשה פונים ל-AppDomain החדש שלנו, ואומרים לו ליצור מופע של הטיפוס Program. המופע החדש שנוצר נמצא מחוץ לגבולות ה-AppDomain שעליו אנו רצים כרגע, לכן מדובר למעשה ב"מופע מרוחק".
    מהרגע שיצרנו אותו, כל פונקציה שנפעילו עליו, תתבצע תחת ה-Context של ה-AppDomain החדש.


  • AppDomain.Unload(myDomain)
    לאחר שסיימנו לעבוד עם MyDomain, אפשר לעשות לו Unload. מה שלמעשה ישחרר את המשאבים בהם הוא עשה שימוש. לאחר הנקודה הזאת, לא יהיה ניתן להשתמש כבר ב-remoteObject. ולמעשה כל ניסיון פניה אליו תגרום ליציאה של התוכנית.
אז ראינו עכשיו איך אפשר לטעון AppDomain חדש, ולגרום לקוד שלנו להתבצע עליו.
מה שנשאר עכשיו, זה ליישם את הרעיון הזה ב-UnitTests שלנו.
בשביל לעשות את זה, נצטרך "להזריק קוד" לתוך ה-AppDomain הנפרד. בשביל לעשות את זה, ניצור מופע של מחלקת הבדיקות שלנו על ה-AppDomain הנפרד, נחלץ מופע של MethodInfo עבור הפונקציה שאנחנו רוצים להריץ, ונשתמש בפונקציית ה-Invoke שלה בשביל לגרום לה להתבצע בתוך הקונטקסט של המופע המרוחק שלנו.

התוצאה הסופית יכולה להתתבסס על הקוד הבא:

public class MyUnitTests : MarshalByRefObject

{

private const string TESTING_APPDOMAIN_NAME = "Testing AppDomain";

private static AppDomain m_testingAppDomain;

private static MyUnitTests m_remoteTestBed;

public void MyTest()

{

if (AppDomain.CurrentDomain.FriendlyName != TESTING_APPDOMAIN_NAME)

{

MethodBase curMethodInfo = MethodInfo.GetCurrentMethod();

curMethodInfo.Invoke(m_remoteTestBed, null);

return;

}

Console.WriteLine("Running on AppDomain: {0}", AppDomain.CurrentDomain.FriendlyName);

}

static void Main()

{

MyUnitTests local = new MyUnitTests();

local.SetUp();

// note that we are calling the method on the main AppDomain, just like

// what would happen if we call it from nUnit.

local.MyTest();

local.Teardown();

}

public void SetUp()

{

Assembly entryAssembly = Assembly.GetEntryAssembly();

m_testingAppDomain = AppDomain.CreateDomain(TESTING_APPDOMAIN_NAME);

m_remoteTestBed = (MyUnitTests)m_testingAppDomain.CreateInstanceAndUnwrap

(entryAssembly.FullName, typeof(MyUnitTests).FullName);

}

public void Teardown()

{

AppDomain.Unload(m_testingAppDomain);

m_testingAppDomain = null;

}

}

Configuration Models as Spaghetti Code Killers
נתחיל מכך שקוד ספגטי הוא רע. אני חושב שכולנו מסכימים על כך. אני גם חושב שלפחות רובנו עושים מאמץ להמנע כמה שיותר מכתיבת קוד מסורבלת שכוללת כל מיני תלויות מוזרות בקטעי קוד שונים.
אבל יותר מכל, יש מקום אחד שיכול להיות נורא קל לפספס את זה. אני מדבר על כל פונקציות האיתחול. כמה פעמים כבר יצא לכם לכתוב איזו פונקציית InitMyself שמקבלת כפרמטר XmlElement שמכיל את הקונפיגורציה של המחלקה שלכם? מה שקורה בתוך הפונקציה זה שאתם מפרסרים את ה-XML, מייצאים ממנו ערכים מסויים, שאותם אתם מציבים בתוך data member'ים שונים בתוך המחלקה. וכמובן שבתוך הבנאי תהיה קריאה ל-InitMyself.
התבנית הזאת נראית בדרך כלל ככה:

class Customer

{

string m_name;

public Customer(XmlElement config)

{

Init(config);

}

private void Init(XmlElement config)

{

m_name = config.GetAttribute("Name");

}

}


והנה, כמעט בלי ששמנו לב, יש לנו קוד ספגטי.
יש כמה בעיות עם צורת הכתיבה הזאת. כשבראשונה היא שיש לי פונקציה, שמכירה את קובץ הקונפיגורציה שלי, ומבצעת השמות לתוך המשתנים הפרטיים שלי. מה יקרה אם בעתיד קובץ הקונפיגורציה הזה הולך להשתנות? או אולי נצטרך לתת תמיכה לקונפיגורציה שלא מגיעה בפורמט XML, כמו קובץ טקסט או בינארית.
מה נעשה במצב כזה? סביר להניח שנמצא את עצמנו כותבים כל מיני Overload'ים מוזרים, שכל אחד מהם מקבל סוג שונה של קונפיגורציה. ושוב, כל אחד מהם יתחיל לעשות השמות למשתנים הפרטיים שלי. לא צריך לעבור הרבה זמן עד שחצי מקוד המחלקה שלי יטפל רק באיתחול שלה.

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

אז ניצור מחלקה חדשה שתקרא CustomerConfiguration, והיא תכיל בתוכה את כל הפונקצליות של "תקרא קונפיגורציה/תכתוב קונפיגורציה". בתוך המחלקה שלי יהיה לי מופע בודד של המחלקה הזאת, והיא למעשה תחליף את השדות שאיתחלת מקודם. כך שאם נחזור לדוגמה ממקודם, זה אומר שהשדה m_customerName יעבור לתוך ה Configuration Model.
זה צריך להיראות משהו כזה:


class Customer

{

CustomerConfiguration m_config;

public Customer(XmlElement config)

{

m_config = new CustomerConfiguration(config);

}

}

class CustomerConfiguration

{

string m_name;

public CustomerConfiguration(XmlElement config)

{

m_name = config.GetAttribute("Name");

}

}


עכשיו בכל פעם שנרצה לגשת ל customerName, נעשה זאת דרך ה Configuration Model.

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

class Customer

{

ICustomerConfiguration m_customerConfig;

public Customer(XmlElement config)

{

m_config = new CustomerXmlConfiguration(config);

Console.WriteLine("Welcome {0}", m_customerConfig.Name);

}

}

interface ICustomerConfiguration

{

string Name { get;set;}

}

class CustomerXmlConfiguration : ICustomerConfiguration

{

string m_name;

public CustomerXmlConfiguration(XmlElement config)

{

m_name = config.GetAttribute("Name");

}

public string Name

{

get { return m_name; }

set { m_name = value; }

}

}

Dangerous Rarely Executed Code

כולנו מכירים את אזהרת הקומפיילר השימושית "Unreachable code detected". מדובר באזהרה נוחה ושימושית, שבאה להזהיר אותנו מפני קטעי קוד שלעולם לא יבוצעו. רק מה, שיש מקרה הרבה יותר מסוכן מקוד שלעולם לא יתבצע, ועליו לא נקבל שום אזהרה. המקרה של "קוד שלעיתים נדירות מתבצע" הוא אלמנט מאוד מסוכן בכתיבת קוד. מדובר בקטעי קוד קטנים, שלא משנה כמה פעמים תריצו עליהם Unit Test'ים, עדיין לא תבדקו 100% מהקוד שלכם. קחו למשל את הסיטואציה הבאה:

            string userName = xmlNode.GetAttribute("UserName");

 

            if (userName != null)

            {

                ContinueWork();

            }

            else

            {

                // rarely executed code.

                logger.WarnFormat("UserName was not supplied. {0}", myVar, anotherVar);

                ContinueWork();

            }

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