המשך לפעם הקודמת ולתגובה שנשלחה בקשר אליה, אני רוצה להרחיב קצת על התגובה.
כעקרון אפשר לחשב שורש של מספר בשיטות שראינו בפעם הקודמת, אבל החסרון של השיטות הוא יעילות זמן הריצה שלהן: יעילות זמן הריצה שלהן הוא $ O\left(\sqrt{n}\right) $.
בכדי לשפר זאת ניתן לשים לב לנקודה הבאה:
הסדרה $ f(n) = n^2 $ היא סדרה עולה. נדמיין אותה כמו מערך אינסופי שבמקום ה$ n $ שלו נמצא הערך $ n^2 $.
כעת, אנחנו מעוניינים למצוא את התא הכי גדול שעבורו מתקיים $ f(n) \ge m $. (לאיזשהו m נתון)
מאחר ומדובר בסדרה עולה, המערך הוא מערך ממוין, ולכן אנחנו יכולים להפעיל אלגוריתמי חיפוש, כגון חיפוש בינארי. למשל משהו כזה:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
publicstaticintSqrt(int n)
{
int left = 1;
int right = n;
int currentIndex = (1 + n) / 2;
while (!(currentIndex * currentIndex <= n &&
(currentIndex + 1) * (currentIndex + 1) > n))
{
if (currentIndex * currentIndex > n)
{
currentIndex = (currentIndex + left)/2;
}
else
{
currentIndex = (currentIndex + right)/2;
}
}
return currentIndex;
}
זו שיטה נחמדה ואנחנו מקבלים פה יעילות חבל על הזמן של $ O \left(\log{n} \right) $.
למרבה הצער יש פה הרבה פעולות כפל, כך שלממש את זה באסמבלי זה לא להיט.
מה שנוכל לעשות במקום זה את הטריק הבא: יש מספר שכן נחמד להכפיל בהם באסמבלי: אלה חזקות של 2, מאחר ואפשר להשתמש באופרטורים של shl וshr בשביל ביצוע פעולות איתם.
אז איך נממש את האלגוריתם? נשים לב ש
$ (a + b)^2 = a^2 + 2ab + b^2 $
ולכן
$ (a + 2^m)^2 = a^2 + 2^{m + 1} \cdot a + 2^{2m} $
כעת נתחיל בחיפוש החזקה הגבוהה ביותר של 2 כך שהריבוע שלה קטן גדול מהמספר:
1
2
3
4
5
6
7
8
9
10
11
int right = 1;
int rightSquared = 1;
while (rightSquared <= n)
{
right *= 2;
rightSquared *= 4;
}
int current = right / 2;
int currentSquared = rightSquared / 4;
כעת ננסה להוסיף למספר חזקות של 2 כל עוד זה משאיר את הריבוע שלו קטן מn:
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
34
35
36
37
38
39
publicstaticintSqrt(int n)
{
int right = 1;
int rightSquared = 1;
while (rightSquared <= n)
{
right *= 2;
rightSquared *= 4;
}
int current = right / 2;
int currentSquared = rightSquared / 4;
int currentSuccesorSquared = currentSquared + 2*current + 1;
קצת הסבר על מה שקורה כאן: אנחנו מוצאים את החזקה הגבוהה ביותר של 2 שהריבוע שלה קטן או שווה למספר שאנחנו מחפשים את השורש שלו.
לאחר מכן אנחנו מנסים להגדיל את הריבוע של המספר ע"י הוספת חזקות קטנות יותר של 2, אבל רק במידה והריבוע שעדיין יוצא קטן או שווה לn. זה מתבצע באמצעות נוסחת הכפל המקוצר במשתנה additionSquared.
מה שחשוב לשים לב אליו זה שאין פה כפל בדברים שאינם חזקות של 2 (currentPower היא חזקה של 2 וגם 2 היא חזקה של 2) ולכן פוטנציאלית ניתן לכתוב את האלגוריתם הזה באסמבלי בלי יותר מדי סיבוכים, אבל זה כבר פחות כיף מהאלגוריתם שהצגתי פעם שעברה…
המשך שבוע מושרש לטובה.
הערה: יש ערך די מעניין על חישוב inverse square root עבור ערכי float (וייצוג floating point בכלליות) בויקיפדיה (כולל הקוד המקורי מ-Quake שחישב אותו 😃) אם מתעסקים בחישובי שורש.
נניח ואנחנו רוצים לחשב את הערך השלם של שורש של מספר טבעי. הדרך הכי פשוטה לעשות דבר כזה היא כזאת:
1
2
3
4
5
6
7
8
9
10
11
publicstaticintSqrt(int n)
{
int currentNumber = 0;
while (currentNumber * currentNumber <= n)
{
currentNumber++;
}
return (currentNumber - 1);
}
זו פונקציה פשוטה. הבעיה העיקרית כאן היא שאם נרצה לכתוב את זה בשפה שהיא יותר low-level, למשל Assembly יהיה יותר מסובך לממש את זה, זאת מאחר ויש פה פעולה של כפל.
במקום זאת, ניתן לפתור את הבעיה בצורה הבאה: נשים לב ש
בהמשך לפעם הקודמת, אם מבקשים מאיתנו לכתוב קוד שמחשב את המספר הפיבונאצ’י הnי, בד”כ נכתוב משהו כזה:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
publicstaticintFibonacci(int n)
{
int previous = 0;
int current = 1;
for (int i = 0; i < n; i++)
{
int temp = current;
current = current + previous;
previous = temp;
}
return previous;
}
זה מחשב את המספר הפיבונאצ’י הnי בסיבוכיות $ O \left(n\right) $.
מסתבר שגם כאן אפשר לחשב בצורה יותר מהירה: יהי $ \varphi $ מספר המקיים $ \varphi^2 = \varphi + 1 $ (למשוואה זו קוראים משוואת חתך הזהב ולאיבר החיובי המקיים משוואה זו קוראים חתך הזהב)
אזי מסתבר כי $ \varphi^n = F_n \cdot \varphi + F_{n - 1} $ כאשר $ F_n $ הוא מספר הפיבונאצ’י הn. (הוכחה פשוטה באינדוקציה)
אפשר לחשב בצורה דומה למה שראינו פעם שעברה את הפיבונאצ’י הn ע"י הסחבות עם ארבע זוגות של מספרים (הנוכחי והקודם של התוצאה והנוכחי והקודם של החזקה הנוכחית של 2).
אבל מאחר ויותר נוח לעבוד כמו בני אדם עם חזקה של מספר בודד, אפשר ליצור struct שמייצג זוג מספרי פיבונאצ’י:
שמתי לב שהרבה פעמים אנשים (כולל אני) משתמשים במושג Strongly typed/Weakly typed בשביל לתאר גישה בטוחה לאובייקטים בזמן קימפול.
זה שגוי להשתמש במושגים האלה, והנה הסבר מעמיק ל4 סוגים של שפות תכנות:
Statically typed language – שפה בה הטיפוסים ידועים מראש בזמן קימפול. רוב השפות מסוג זה אוכפות את התנאי הזה ע”י כך שנצטרך לציין את הטיפוס של המשתנים שלנו לפני שנוכל להשתמש בהם בפעם הראשונה. C#, Java, C ועוד שפות הן שפות שהן Statically typed.
Dynamically typed language – שפה שבה הטיפוסים מפוענחים בזמן ריצה. ההפך מStatically typed. למשל Python, Ruby וjs הן שפות שהן Dynamically typed, מאחר ומפוענח הטיפוס של המשתנה בפעם הראשונה שנכנס אליו ערך.
Strongly typed language – שפה שבה הטיפוסים נאכפים. למשל, אם יש לנו משתנה מסוג int, לא נוכל להתייחס אליו כString (כלומר להכניס אותו למשתנה של string או לקרוא לפונקציות של string) בלי לבצע איזושהי הסבה. Python, C#, Ruby, Java וכו’ הן שפות שהן Strongly typed!
Weakly typed language – שפה ניתן להתעלם מהטיפוסים. ההפך משפות Strongly typed. למשל, VBScript היא Weakly typed – בVbScript למשל ניתן לשרשר את המחרוזת ‘12’ ואת הInteger 3 כדי לקבל את המחרוזת ‘123’. לאחר מכן ניתן להתייחס למחרוזת כמספר 123 ללא הסבה מפורשת.
למשל, C# היא שפה שהיא Statically typed וStrongly typed. (החל מC# 4.0 היא גם Dynamically typed)
Python היא שפה שהיא Dynamically typed וStrongly typed.
כולנו מכירים את הממשק ICollection אותו מממשים טיפוסים רבים בFramework, ביניהם List וT[].
המימושים נחמדים, אבל לפעמים אנחנו רוצים לבצע איזושהי פעולה בעת שינוי של האוסף. יש מספר דוגמאות, אחת מהן היא וואלידציה – אנחנו מעוניינים להכניס איבר לCollection רק אם הוא תקין.
עוד דוגמא היא שינוי האובייקט שנכנס לרשימה – למשל אם יש לנו אוסף של חיות ששייכות למישהו, אנחנו יכולים לשנות את הבעלים של החיה להיות הבעלים המתאימים.
דוגמא נוספת היא הקפצת אירוע – אנחנו מעוניינים להקפיץ אירוע כשהרשימה משתנה בתנאים מסוימים (למשל, כשנכנס איבר לא תקין, להקפיץ על כך אירוע)
אפשר לעטוף את List בICollection ולהוסיף לו את הפונקציונאליות שמעניינת אותנו, אבל יש דרך יותר פשוטה לבצע דבר כזה בFramework:
קיים הטיפוס Collection העוטף List, אבל בנוסף גם מאפשר לנו להתערב במה שקורה כאשר נוסף איבר, מסירים איבר, משתנה איבר במקום מסוים, או מרוקנים את האוסף:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyCollection<T> : Collection<T>
{
protectedoverridevoidClearItems()
{
// ...
}
protectedoverridevoidInsertItem(int index, T item)
{
// ...
}
protectedoverridevoidRemoveItem(int index)
{
// ...
}
protectedoverridevoidSetItem(int index, T item)
{
// ...
}
}
במימושנו נוכל לקרוא לbase, ואז תקרה הפעולה הרגילה, אך נוכל להוסיף פונקציונאליות משלנו, למשל:
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
public class Club : Collection<Person>
{
privatereadonlyint mMaxPeopleAllowed;
publicClub(int maxPeopleAllowed)
{
mMaxPeopleAllowed = maxPeopleAllowed;
}
protectedoverridevoidInsertItem(int index, Person item)
{
if (item.Age < 21)
{
thrownew TooYoungException("Entrance is allowed only for people that are at least 21.");
}
if (this.Count == mMaxPeopleAllowed)
{
thrownew ClubFullException("The number of allowed people in the club is limited to " +
שימו לב שהדוגמה היא להמחשה, ולא היינו רוצים לראות פונקציות ארוכות כאלה בקוד שלנו, אבל אפשר להשתמש בScopeים הפנימיים גם בפונקציות קצרות לקוד ברור יותר (כשזה מתאים).
Syntax של Object Initializer שהוצג בC# 3.0 הוא מאוד נוח ומאפשר לאתחל אובייקט בצורה יותר ברורה מקודם. (ראו גם טיפ מספר 87, 88)
הבעיה היא שאפשר להשתמש בו רק במקרה שאנחנו מאתחלים אובייקט דרך Constructor – אם אנחנו מאתחלים אובייקט בצורה אחרת, למשל איזשהו Factory, לא נוכל להשתמש בSyntax כזה לאתחול האובייקט.
אפשר כמובן להתווכח אם עד כמה זה נכון לעשות משהו כזה, אבל נראה לי שזה צורך יחסית נפוץ.
מה שאנחנו יכולים לעשות זה משהו כזה:
1
2
3
4
5
6
IPerson person = Factory.CreateInstance<IPerson>();
{
var p = person;
p.Name = "Arik Einstein";
p.Age = 73;
}
שימו לב שהמשתנה p הוא משתנה שנמצא בתוך Scope פנימי, ולכן אנחנו יכולים לעשות מספר אתחולים כאלה:
1
2
3
4
5
6
7
8
9
10
11
12
13
IPerson person = Factory.CreateInstance<IPerson>();
בפעם הקודמת אמרתי שכדאי ליצור רמת אבסטרקציה בעיצוב שלנו, ולדאוג לאנקפסולציה, כך שהעיצוב יופרד מהמימוש.
עם זאת, זה לא אומר שצריך להמציא את הגלגל מחדש. למה הכוונה?
נניח שאנחנו מעוניינים שהממשק שלנו יחשוף פונקציות שמאפשרות את כל היכולות הבאות:
הוספת תלמיד
הסרת תלמיד
בדיקה האם תלמיד נמצא
אפשרות לקבל את כל התלמידים
אפשר ליצור ממשק שמאפשר את כל האופציות האלה, אבל כבר יש ממשק בשם ICollection שנועד לתת מענה כזה.
לכן אם אנחנו רוצים את כל הפונקציונאליות הזאת, כנראה מתאים שהממשק שלנו יכיל או יממש ICollection. כמובן, אם אנחנו רוצים פונקציונאליות יותר מנוונת (למשל רק הוספת תלמיד ואפשרות לקבל את כל התלמידים), יכול להיות שיותר מתאים ליצור פונקציות מתאימות בממשק שלנו (ולא לרשת משום דבר).
בכל מקרה, יש יתרון בלהשתמש בממשקים שכבר קיימים בFramework כאשר זה מתאים. בין השאר, לFramework יש הרבה פונקציות שיודעות להשתמש בממשקים אלה. בנוסף, אלה ממשקים סטנדרטיים, ולכן מתכנת שבא להשתמש או לקרוא את הקוד שלנו, אמור להכיר אותם כבר.
עם זאת, כאשר זה לא מתאים, כדאי לא להשתמש בכוח. למשל, אנחנו יכולים כמעט תמיד לממש IDictionary, אבל זה בד”כ לא מתאים. ירושה מIDictionary תכריח את המתכנתים שבאים לממש את הממשק לממש כ30 פונקציות, וכך למעשה הם יוותרו על מימושו.
אז בהמשך לפעם הקודמת, אני אציע עכשיו איזשהו פתרון לבעיה שנתקלנו בה.
קודם כל, ניצור ממשקים שמייצגים את הישויות שלנו ואת הקשרים ביניהם:
בית ספר מכיל שכבות שמכילות כיתות שמכילות מחנכת ותלמידים.
הפעולות שכתבתי שאפשר לעשות הן:
לגלות איזה שכבות יש בבית ספר
כיתות יש בשכבה מסוימת
איזה תלמידים יש בכיתה מסוימת
מי המחנכת של כיתה מסוימת
להוסיף כיתה לשכבה
להוסיף תלמיד לכיתה
אז הממשקים שלנו יראו כך:
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
publicinterfaceISchool
{
IEnumerable<IGradeYear> GradeYears { get; }
}
publicinterfaceIGradeYear
{
string Name { get; }
IEnumerable<IGrade> Grades { get; }
voidAddGrade(IGrade grade);
}
publicinterfaceIGrade
{
string Name { get; }
IEnumerable<IStudent> Students { get; }
ITeacher Teacher { get; }
voidAddStudent(IStudent student);
}
publicinterfaceIStudent
{
string Name { get; }
}
publicinterfaceITeacher
{
string Name { get; }
}
כמובן אפשר להתווכח על זה, יכול להיות שלא צריך שלכל דבר יהיה ממשק, יכול להיות שמספיק לנו לשמור את השמות של התלמידים והמורה, ואנחנו לא צריכים ממשק שעוטף string, ויש עוד אלף דרכים לעצב את הפתרון לבעיה הזאת.
אבל מה שאני מנסה להעביר זה שיש פה אנקפסולציה – אין לי מושג מקריאת הממשקים האלה שמסתתר פה מבנה נתונים של Dictionary. יכול להיות שבמימוש באמת נרצה להחזיק איזשהו Dictionary באופן דומה לפעם הקודמת, אבל זה פרט מימושי.
אז מה הרווחנו:
אבסטרקציה – אפשר לממש את הממשקים האלה בכל דרך שבא לנו
בהירות – יותר ברור איך להשתמש בממשקים האלה מבטיפוס Dictionary<string, Dictionary<string, List<string>>>
שליטה – אנחנו יכולים לשלוט בפעולות שמבצעים על המבנה נתונים שלנו – אם בפעם הקודמת יכלו להכניס לנו זבל למבנה נתונים, עכשיו אנחנו יכולים לבצע וואלידציה ברמת הפונקציות של הממשק
הכמסה – אנחנו חושפים רק פעולות שאנחנו מעוניינים שיבצעו על המבנה נתונים. אם למשל בפעם הקודמת יכלו לאפס לנו את המבנה נתונים, אנחנו כבר לא חושפים פונקציות כאלה.
אני רוצה להרחיב עוד טיפה על בהירות, כי יש לזה יתרון משמעותי. נניח שאנחנו מעוניינים למצוא את כל התלמידים בתיכון שהשם שלהם מתחיל באות S. אז במימוש הישן היינו כותבים פונקציה כזאת:
foreach (IGradeYear gradeYear in school.GradeYears)
{
foreach (IGrade grade in gradeYear.Grades)
{
foreach (IStudent student in grade.Students)
{
if (student.Name.StartsWith("S"))
{
result.Add(student);
}
}
}
}
return result;
}
לדעתי זה יותר קריא בפעם הראשונה, ויותר ברור.
אז מה אני רוצה?
יותר אבסטרקציה בין הממשק למימוש – לדעתי אם בממשק מסוים (במובן של API) חושפים פונקציה שמקבלת או מחזירה איזשהו IDictionary, או שיש לה איזשהו Property שהוא IDictionary – כנראה חסרה פה אבסטרקציה.
באופן דומה – אם יש פונקציה שמחזירה או מקבלת מימוש קונקרטי של ממשק מסוים, למשל List<string> או List<MyClass>, כנראה חסרה פה אנקפוסלציה. לא צריך להשתגע וליצור ממשק לכל דבר, אבל גם לא להתעצל ולהשקיע באבסטרקציה. (ראו גם טיפ מספר 81)
תחשבו גם על זה שאם יש לכם פונקציה שמקבלת IDictionary כלשהו, אבל תכלס עשיתם את זה כדי שתוכלו לגשת לDictionary במקום מסוים (או לחלופין לרוץ עליו), אז אתם מונעים מאנשים אחרים להשתמש בפונקציה שלכם, כי הם צריכים לממש IDictionary, כשכל מה שהייתם צריכים מהם זה לממש שתי פונקציות.