אמרנו באחד הטיפים הקודמים שאי אפשר לעשות Constraint מהסוג
1
where T : ValueType
לפעמים אנחנו רוצים שType שנקבל יהיה דווקא value type ולא reference type
נוכל לכתוב את זה כך:
1
where T : struct
שימוש אפשרי:
1
2
3
4
5
publicstatic T Clone<T>(T source)
where T : struct
{
return source;
}
הפונקציה הזאת מקבלת כמעט כל value type ומשכפלת את הערך שלו.
למשל
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
publicstruct Person
{
publicstring Name;
publicstring LastName;
publicPerson(string name, string lastName)
{
Name = name;
LastName = lastName;
}
}
Person ziggy = new Person("Bob", "Marley");
Person bob = Clone(ziggy);
ziggy.Name = "Ziggy";
Console.WriteLine(bob.Name); // Prints Bob!
למה זה עובד? מאחר וכאשר מעבירים value type לפונקציה מועבר העתק שלו, הפונקציה מחזירה עותק של הערך שקיבלה.
באופן דומה קיים Constraint מקביל:
1
where T : class
המשמש כדי לדרוש שמחלקה תהיה reference type. (שימו לב, גם interfaceים, delegateים, Arrayים וכו’ נחשבים reference type).
למה זה טוב? כפי שציינו, כאשר מעבירים value type לפונקציה, מועבר עותק שלו. פעולה זו עשויה להיות כבדה, לכן לפעמים נרצה להכריח פונקציה לקבל רק reference types שאינם דורשים העתקה של כל האובייקט.
עוד סיבה אפשרית להשתמש בזה היא מאחר ובמידה ומועבר value type לא נוכל להשתמש במילה השמורה as, ראו גם טיפ מספר 12.
זה בעצם איזשהו טיפוס שיש לו פונקציה Add שמקבלת איבר ומחזירה את הסכום של הinstance עם האיבר שהתקבל.
איזה דברים יכולים לממש את זה?
הרבה דברים מתמטיים:
בעצם כל דבר שסגור לחיבור:
מחלקה של מספרים שלמים שתכתבו, מחלקה של מספרים ממשיים/רציונליים/מרוכבים שתממשו, מחלקה של מטריצות מסדר latex 5 \times 6 $ שתממשו, מחלקה של וקטורים ממימד 3 שתממשו, ועוד ועוד.
הכל נראה טוב ויפה, עד שנגיע לבעיה הבאה:
יש לנו שתי מחלקות
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
publicclassMatrix4x4 : IAddable
{
public IAddable Add(IAddable other)
{
// ...
}
}
publicclassRationalNumber : IAddable
{
public IAddable Add(IAddable other)
{
// ...
}
}
כעת נוכל לעשות משהו כזה:
1
2
3
4
RationalNumber rationalNumber;
Matrix4x4 matrix;
IAddable result = rationalNumber.Add(matrix); // Compiles!
אני אסביר את הבעיה:
באמת אפשר לחבר מטריצות (אחת עם השנייה), ואפשר גם לחבר מספרים רציונליים (אחד עם השני), אבל אי אפשר לחבר מספר רציונלי עם מטריצה בגודל $ 4 \times 4 $!
מאחר וכל המחלקות שלנו מממשות IAddable, אפשר לחבר אותן אחת עם השנייה.
בפועל לרוב נקבל שגיאה בזמן ריצה מאחר ובפונקציה Add המממושת בRationalNumber בוודאי תהיה הסבה של other (המשתנה שאנחנו מקבלים בפונקציה) לRationalNumber.
פתרון:
שלב 1: נהפוך את הממשק לגנרי:
1
2
3
4
public interface IAddable<T>
{
T Add(T other);
}
כעת המימושים יראו כך:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Matrix4x4 : IAddable<Matrix4x4>
{
public Matrix4x4 Add(Matrix4x4 other)
{
// ...
}
}
public class RationalNumber : IAddable<RationalNumber>
{
public RationalNumber Add(RationalNumber other)
{
// ...
}
}
והשורה השטנית
1
object result = rationalNumber.Add(matrix);
כבר לא מתקמפלת!
The best overloaded method match for ‘RationalNumber.Add(RationalNumber)’ has some invalid arguments Argument ‘1’: cannot convert from ‘Matrix4x4’ to ‘RationalNumber’
הכל טוב ויפה, לא?
לא.
למה לא?
כי נוכל למשל לעשות שטויות מהסוג הזה:
1
2
3
4
5
6
7
public class DumbAddable : IAddable<int>
{
publicintAdd(int other)
{
// ...
}
}
מה הבעיה? היינו מצפים שDumbAddable יקבל בפונקציית הAdd שלו DumbAddable ויחזיר DumbAddable, כמו שהיה בכל הדוגמאות הקודמות. בעצם הפסדנו את היתרון של "הסגירות" שהיה לנו בIAddable הלא הגנרי.
אתם יכולים להגיד שזו בעיה של המתכנת, אם הוא מחליט לעשות שטויות ולא לממש את הממשק כמו שצריך.
זה לא נכון, משתי הסיבות הבאות:
תשתית אמורה להגן כמה שניתן מהמתכנת מלעשות דברים לא נכונים.
נניח שיש לנו פונקציה פשוטה שמוצאת מקסימום של מערך נתון:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
publicstaticintMax(int[] array)
{
if (array.Length == 0)
{
thrownew ArgumentException("Array was empty",
"array");
}
else
{
int currentMax = array[0];
foreach (int element in array)
{
if (currentMax < element)
{
currentMax = element;
}
}
return currentMax;
}
}
כרגיל לשבוע, אין שום דבר מיוחד בint, והיינו רוצים שהפונקציה תעבוד גם לchar, ulong, long וכו’.
אלא שכשננסה להפוך את הפונקציה לגנרית נקבל את השגיאה הבאה בקימפול:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
publicstatic T Max<T>(T[] array)
{
if (array.Length == 0)
{
thrownew ArgumentException("Array was empty",
"array");
}
else
{
T currentMax = array[0];
foreach (T element in array)
{
if (currentMax < element)
{
currentMax = element;
}
}
return currentMax;
}
}
Operator ‘ ‘T’ does not contain a definition for ‘CompareTo’ and no extension method ‘CompareTo’ accepting a first argument of type ‘T’ could be found (are you missing a using directive or an assembly reference?)
כדי לפתור בעיות מסוג וכאלה, המציאו עוד כלי מאוד חזק לעבודה עם Generics – Generic constraints.
הפונקציה CompareTo אכן קיימת, אבל בממשק IComparable (או IComparable<T>).
היינו רוצים בעצם שרק T שמממש את IComparable יוכל להיכנס לפונקציה הזאת.
נוכל לעשות זאת כך:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
publicstatic T Max<T>(T[] array)
where T : IComparable
{
if (array.Length == 0)
{
thrownew ArgumentException("Array was empty",
"array");
}
else
{
T currentMax = array[0];
foreach (T element in array)
{
if (element.CompareTo(currentMax) > 0)
{
currentMax = element;
}
}
return currentMax;
}
}
הwhere מציין שהפונקציה יכולה לקבל רק T שמקיים את התנאים הכתובים אחריו.
נוכל לכתוב שם בין השאר איזה ממשקים אנחנו רוצים שT יממש, ומאיזה מחלקה אנחנו רוצים שהוא ירש.
על המחלקה שאנחנו רוצים שהוא ירש ממנה יש מספר הגבלות: היא לא יכולה להיות System.Array (תכתבו פשוט T[] בארגומנטים), System.Delegate, System.Enum, System.ValueType או object.
נראה הרבה מאוד דברים מגניבים שאפשר לעשות עם זה בטיפים הבאים.
int length = Count("This string's length is 26"); // length = 26
מה שיפה פה זה שאנחנו קוראים לפונקציה שיכולה לקבל כל IEnumerable<T>, ואנחנו לא צריכים לציין שום דבר על הT שלנו.
אמרתי שבהרבה מאוד מקרים אפשר לעשות את זה. מתי אי אפשר?
כשהקומפיילר לא מבין לבד מה אנחנו רוצים לשלוח לו:
1
public class DumbEnumerable : IEnumerable<int>, IEnumerable<Person>
אם ננסה לקרוא עכשיו לCount נקבל עכשיו את השגיאה הבאה בקימפול:
1
int count = Count(new DumbEnumerable());
The type arguments for method ‘Count(System.Collections.Generic.IEnumerable)’ cannot be inferred from the usage. Try specifying the type arguments explicitly.
דוגמה נוספת היא כשאנחנו מנסים לשלוח אובייקט שהוא לא מType מתאים, למשל:
1
int count = Count(123); // doesn't compile
נשים לב שלפעמים דווקא כן נרצה לציין את הסוג מפורשות.
למשל, בדוגמה הראשונה, ייתכן ולפעמים נרצה לקבל object[] לפעמים מהפונקציה (למשל אם עובדים בתשתית).
זו מחלקה די פשוטה המייצגת מימוש דבילי של רשימה, שיש לה שתי פונקציות – להוסיף איבר ולמצוא איבר במקום מסוים.
עכשיו נניח שאנחנו רוצים אותו מימוש גם עבור מחלקות אחרות, לאו דווקא int, אז מה שהיינו צריכים לעשות בימי framework 1 העליזים זה לשכפל את הקוד, ובכל מקום להחליף את int במחלקה שאנחנו מעוניינים לבנות עבורה את הCollection:
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
29
30
31
32
33
publicclassPersonCollection
{
private Person[] m_InternalArray = new Person[4];
privateint m_NumOfValues;
public Person this[int index]
{
get
{
return m_InternalArray[index];
}
set
{
m_InternalArray[index] = value;
}
}
publicvoidAdd(Person item)
{
if (m_NumOfValues == m_InternalArray.Length)
{
Person[] copyArray = new Person[m_InternalArray.Length * 2];
m_InternalArray.CopyTo(copyArray, 0);
m_InternalArray = copyArray;
}
m_InternalArray[m_NumOfValues] = item;
m_NumOfValues++;
}
}
וכך באמת היה קורה באותם הימים. כך קיבלנו למשל DataRowCollection, DataTableCollection,DataColumnCollection ושלל מחלקות אחרות מFramework 1.0 שכולן פחות או יותר עם אותה פונקציונאליות, רק על Type שונה.
אבל אז בכל גישה למחלקה היינו צריכים לעשות הסבה לint / Person / הסוג המבוקש של האוסף.
בנוסף, עבור primitive types היינו מקבלים boxing וunboxing בכל גישה (הכנסה והוצאה מobject), דבר שהיה גורע מהביצועים.
בFramework 2.0 אחד הדברים הכי חשובים שהכניסו זה Generics. הדבר מאפשר לנו לכתוב קוד גנרי ולתת למשתמש במחלקה שלנו להחליט מבחוץ מה יהיה הType.
הקוד שכתבנו קודם לכן יראה כך:
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
29
30
31
32
33
public class Collection<T>
{
private T[] m_InternalList = new T[4];
privateint m_NumOfValues;
public T this[int index]
{
get
{
return m_InternalList[index];
}
set
{
m_InternalList[index] = value;
}
}
publicvoidAdd(T item)
{
if (m_NumOfValues == m_InternalList.Length)
{
T[] copyArray = new T[m_InternalList.Length*2];
m_InternalList.CopyTo(copyArray, 0);
m_InternalList = copyArray;
}
m_InternalList[m_NumOfValues] = item;
m_NumOfValues++;
}
}
בעצם כתבנו מחלקה, שעובדת עם איזה טיפוס "T". את הT הזה קובע מי שמשתמש במחלקה מבחוץ. כדי להשתמש במחלקה שלנו הוא צריך לעשות משהו כזה:
1
2
3
4
5
6
7
8
Collection<Person> personCollection = new Collection<Person>();
// a collection of Person
Collection<int> intCollection = new Collection<int>();
// a collection of int
Collection<object> objectCollection = new Collection<object>();
// a collection of object
כך בעצם אנחנו חוסכים המון שכפול קוד ומאפשרים Reuse מיטבי של הקוד שלנו.
מה שקורה בעצם זה שבזמן ריצה נוצרת מחלקה חדשה לType (במידה ולא נוצרה קודם) שבה מוחלף בכל מקום ה"T" בטיפוס שמוזרק מבחוץ.
קצת על הsyntax: בתוך המחלקה אנחנו מציינים את הGeneric Types שלנו בסוגריים כאלה <> המופרדים בפסיקים ביניהם. אנחנו יכולים לתת כל שם שעולה על רוחנו, אבל מומלץ להשתמש בT אם יש רק Type אחד כזה בתור פרמטר, ואחרת בשם עם תחילית T.
לדוגמה, אם אנחנו רוצים ליצור מחלקה המייצגת זוג סדור, כך שהאיבר הראשון מסוג אחד, והשני מסוג אחר נוכל לעשות את זה ככה:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Pair<TFirst, TSecond>
{
private TFirst m_First;
private TSecond m_Second;
public TFirst First
{
get { return m_First; }
set { m_First = value; }
}
public TSecond Second
{
get { return m_Second; }
set { m_Second = value; }
}
}
הדבר תקף גם לגבי ממשקים. נוכל להגדיר באופן זהה Generic Types ולקבל ממשק שבו מגדירים טיפוס מבחוץ. למשל:
1
2
3
4
5
6
7
8
9
10
11
12
private interface IPair<TFirst, TSecond>
{
TFirst First
{
get;
}
TSecond Second
{
get;
}
}
בנוסף לFeature המהפכני, קיבלנו את הnamespace ששמו System.Collections.Generic בו ממשקים ומימושים עם Generics, ביניהם List, Dictionary ושאר הממשקים והטיפוסים שכבר למדנו לאהוב.
לפעמים יש לנו Property במחלקה שיש לו getter וsetter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
privateint m_MyField;
publicint MyProperty
{
get
{
// Write get code here
return m_MyField;
}
set
{
// Write set code here
m_MyField = value;
}
}
מדי פעם משיקולים כאלה ואחרים, אנחנו לא רוצים שהModifier של הgetter והsetter יהיה זהה, למשל שאחד יהיה public והשני יהיה private.
ניתן לפתור בעיה זו ע"י הוספת הModifier לפני הget/set:
1
2
3
4
5
6
7
8
9
10
11
12
13
publicint MyProperty
{
get
{
// Write getter code here
return m_MyField;
}
privateset
{
// Write setter code here
m_MyField = value;
}
}
אנחנו מקבלים Property בשם MyProperty שהgetter שלו הוא public והsetter שלו הוא private.
כמובן, נוכל להחליף את הModifierים של public וprivate בכל שני Modifierים שונים זה מזה אחרים.
יש שתי הגבלות שצריך לדעת:
לא נוכל לתת לשני הAccessorים modifierים
לא נוכל לתת Modifier שמאפשר חשיפה יותר גבוהה מהmodifier של הProperty. למשל, לא נוכל שהProperty יהיה private ושהgetter יהיה public. לעומת זאת, נוכל שהProperty יהיה protected והsetter יהיה private.
האמת שזה לא מדויק, כי בC# 2.0/3.0 הוסיפו Keywordים חדשים (למשל yield, select, from, var ועוד), ולמען תאימות לאחור, חלק מהkeywordים, ביניהם החדשים גורמים לשגיאה רק אם משתמשים בהם בcontext הנכון.
למשל אין בעיה להגדיר משתנה בשם select כל עוד זה לא בscope של שאילתת LINQ:
1
2
3
4
5
stringselect = "SELECT * FROM TABLE"; // Compiles
fromselectin Collection
selectselect;
// Doesn't compile
משום מה לפעמים נתקלים במקרים בהם כן רוצים לתת למשתנה שם שמור, למשל בדוגמה הראשונה (למרות שתמיד עדיף לתת שם אחר 😃).
לפעמים גם רוצים לתת שם שהוא CLSCompliant, כלומר יעבוד גם בשפות netיות אחרות.
יש תמיכה בזה ברמת השפה:
נוכל לכתוב את הקוד הבא:
1
int @int = 3;
תוכלו לומר "יופי, הוספת תחילית @. יכולת להוסיף גם תחילית _".
אז זהו שיש לזה משמעות מעבר: אפשר לרשום למשל את הקוד הזה:
1
2
3
int @number = 3;
Console.WriteLine(number);
// Compiles
ואת הקוד הבא:
1
2
3
int number = 3;
Console.WriteLine(@number);
// Compiles
שימו לב שבשתי הפעמים בשורה אחת יש @ ובשנייה אין.
הקומפיילר מזהה את שני המשתנים כאותו משתנה.
הוכחה נוספת לכך היא שהקוד הבא לא מתקמפל:
1
2
3
int @number = 3;
int number = 3;
// Doesn't compile
גם בשגיאות ובwarning שאנחנו מקבלים הקומפיילר מסיר את ה@
1
int @int = 3;
The variable ‘int’ is assigned but its value is never used
באופן כללי אני ממליץ שלא לתת למשתנים שמות של מילים שמורות, אבל יש אנשים שאוהבים לעשות את זה. למשל, לקרוא לערך החזר של פונקציה @return. 😕
וכשמשלבים שני דגלים מקבלים ערך שמופיעה בו "נורה" בדיוק בדגלים ששילבנו. דרך נוחה לבדוק האם "נורה" דולקת היא לעשות AND עם ה"נורה" ולבדוק שקיבלנו את ערך הנורה (או לחלופין לבדוק שקיבלנו ערך שאינו 0).
הכל נראה טוב ויפה, אך ישנה בעיה. שימו לב לקוד הבא:
1
2
3
4
5
6
7
Direction west = Direction.West;
Direction southEast = Direction.South |
Direction.East;
Console.WriteLine(west); // prints West
Console.WriteLine(southEast); //prints 6
שימו לב שהשורה השנייה מדפיסה 6, בניגוד לשורה הראשונה שמדפיסה את שם הenum.
מהסיבה הזו ועוד סיבות דומות, המציאו הAttribute ששמו FlagsAttribute.
אם נשים כעת את הAttribute הזה מעל הenum ונריץ את הקוד, נקבל:
1
2
3
4
5
6
7
Direction west = Direction.West;
Direction southEast = Direction.South |
Direction.East;
Console.WriteLine(west); // prints West
Console.WriteLine(southEast); //prints South, East
הAttribute הזה אומר לFramework שהenum הוא בעצם enum של דגלים ("נורות") ונותן לנו תמיכה מתאימה בכמה פונקציות של enumים.