222. Virtual method calls in constructors

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

טעות נפוצה שיכולה להיווצר משימוש בפונקציות וירטואליות, היא קריאה להן בConstructor.

למה הכוונה?

הנה דוגמה קלאסית (ושכיחה!): נניח שיש לנו איזושהי פונקציה שמאתחלת לנו את אחד הMemberים של המחלקה. למשל, פונקציה שמאתחלת את הLogger של המחלקה שלנו:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Animal
{
private ILogger mLog;
public Animal()
{
this.mLog = GetLogger();
}
private ILogger GetLogger()
{
return new FileLogger();
}
}

כעת, אנחנו מעוניינים שמי שיורש מהמחלקה שלנו יוכל להחליף את האתחול של הLogger בפונקציה משלו.

הדבר הראשון שעולה לנו לראש היא להפוך את הפונקציהGetLogger לprotected וvirtual:

1
2
3
4
protected virtual ILogger GetLogger()
{
return new FileLogger();
}

יופי, עכשיו כל אחד יכול לדרוס את הפונקציה הזאת ולעשות איתה מה שהוא רוצה, למשל:

1
2
3
4
5
6
7
public class Dog : Animal
{
protected override ILogger GetLogger()
{
return new BoneLogger();
}
}

נראה שהפתרון טוב, אלא שיש פה בעיה.

מה הבעיה? נניח שלמחלקה היורשת יש איזשהו פרמטר משלה, והלוגר מאותחל לפיו:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Dog : Animal
{
private string mBark;
public Dog(string bark)
{
mBark = bark;
}
protected override ILogger GetLogger()
{
return new BarkLogger(mBark);
}
}

נראה תקין, נכון?

למרות זאת, מי שינסה להריץ את הקוד הזה, יגלה שאנחנו שולחים לBarkLogger את הערך null תמיד.


טוב, אז מה קורה פה?

כזכור, כאשר מתבצעת קריאה לConstructor של המחלקה שלנו, קודם מתבצעת הקריאה לאבות, והקריאה למחלקה שלנו מתבצעת אחרונה:

למשל, אם יש לנו את הירושה הבאה:

1
Labrador : Dog : Animal : object

אז קודם כל יקרא הConstructor של object, אחר כך הConstructor של Animal, אז הConstructor של Dog, ולבסוף הConstructor של Labrador.

עכשיו, עצם העובדה שקראנו לפונקציה וירטואלית בConstructor של Animal, גורם לכך שנכנסנו אליה, לפני שבכלל נכנסנו לConstructor של Dog, ולכן הMemberים של Dog אינם עדיין מאותחלים, ולכן תמיד נקבל בmBark את הערך הדיפולטי.


מה אפשר לעשות?

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

האופציה השנייה היא לפעפע את הפרמטרים כלפי מעלה. למשל, במקרה שלנו, לדאוג לכך שהConstructor של Animal יקבל ILogger בConstructor. זה יראה בערך כך:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Animal
{
private ILogger mLog;
public Animal(ILogger logger)
{
this.mLog = logger;
}
public Animal() : this(new FileLogger())
{
}
}
public class Dog : Animal
{
private string mBark;
public Dog(string bark): base(GetLogger(bark))
{
mBark = bark;
}
private static ILoggerGetLogger(string bark)
{
return new BarkLogger(bark);
}
}

הפתרון מונע קריאה לפונקציות וירטואליות בConstructor, אבל הוא יותר מסובך, אנחנו קוראים לפונקציה (או Constructor אחר) בקריאה לbase. (ראו גם טיפ מספר 88)

בד"כ זה לא כל כך פשוט להעביר את הפרמטרים למעלה.

האופציה השלישית היא ליצור פונקצית Init, שתדאג לאתחל את האובייקט:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Animal
{
private ILogger mLog;
public Animal()
{
}
protected virtual void Init()
{
this.mLog = GetLogger();
}
protected virtual ILogger GetLogger()
{
return new FileLogger();
}
}

ואז לאתחל את האובייקטים שלנו עם איזשהו Factory:

1
2
3
4
public interface IAnimalFactory
{
TAnimal GetAnimal<TAnimal>() where TAnimal : Animal, new();
}

עם מימוש כזה:

1
2
3
4
5
6
7
8
9
private class AnimalFactory : IAnimalFactory
{
public TAnimal GetAnimal<TAnimal>() where TAnimal: Animal,new()
{
TAnimal animal = new TAnimal();
animal.Init();
return animal;
}
}

כאן AnimalFactory היא מחלקה פנימית של Animal, אז היא יכולה לגשת לפונקציה הprotected ששמה Init.

ואז יש איזשהו Property שחושף את הFactory הזה, שאנחנו יכולים להשתמש בו:

1
Dog dog = Animal.Factory.GetAnimal<Dog>();

אמנם אין יותר קריאות וירטואליות בConstructor, ואין סכנה שניגש לMemberים של המחלקה לפני שאותחלו, אבל יש פה את החסרונות הבאים: כל היצירות של אובייקטים חייבות לעבור דרך הFactory הנ"ל. על כן, גם לכל המחלקות היורשות חייבת להיות אותה חתימה של Constructor (במקרה שלנו – Constructor דיפולטי). כך שהפתרון מסרבל את השימוש במחלקה שלנו.

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


מאוד חשוב לשים לב לקריאה לפונקציות וירטואליות בConstructor. הReSharper מזהיר על כך.

בנוסף לדוגמאות שכתבתי, אתם עשויים להיתקל בבעיות כאלה כאשר תיצרו Mockים לאובייקטים שלכם לUnit Testים.

יום וירטואלי (אבל שלא נקרא מהConstructor) טוב!

שתף