השבוע הוגשה טיוטת הצעה חדשה למימוש Generics בשפת Go.
מה זה Generics ואיזה צורך זה פותר?
כבר שנים רבות שהשפה סובלת מחוסר היכולת להגדיר פונקציות או מבנים אחרים שאינם מתבססים על טיפוסים קונקרטיים אלא על טיפוסים המופיעים בצורה פרמטרית. כך, למשל, הפונקציות למציאת מינימום ומקסימום – ()math.Max
ו-()math.Min
– מוגדרות בספריה הסטנדרטית אך ורק עבור הטיפוס float64
, ולא עבור אף טיפוס אחר. גם מי שירצה לייצר לעצמו ספרית Min-Max שבה פונקציות כמו ()mymath.Min
או ()mymath.Max
עבור טיפוסים שונים, לא יוכל לעשות זאת: מצד אחד, השפה מחייבת הגדרה ברורה של הטיפוסים כחלק מהגדרת הפונקציה, כך שלא ניתן להגדיר את הפונקציה בעזרת פרמטרים היכולים לקבל טיפוסים שונים. מצד שני, השפה אינה מאפשרת העמסת פונקציות (function overloading), ולכן לא ניתן לכתוב שתי פונקציות בעלות שם זהה, אשר לכל אחת מהן פרמטרים מטיפוסים שונים.
בעבודה עם משתנים מטיפוסים מורכבים יותר, אשר הגישה אליהם אינה באמצעות אופרטורים אלא באמצעות פונקציות, Golang מאפשרת כרגע גמישות מסויימת. פונקציות יכולות לקבל ממשקים (interfaces) כטיפוסי ארגומנטים. בצורה זו, פונקציה יכולה לקבל כארגומנט טיפוסים מסויימים של אינטרפייס. למשל, פונקציה יכולה לקבל כארגומנט משתנה מטיפוס Averager
, אשר מחייב מימוש של הפונקציה ()Average
. לפונקציה כזו ניתן להעביר כל טיפוס שהוא, כל עוד הוא מימש את האינטרפייס הזה. כך, הפונקציה יכולה לקרוא ל-()Average
, מבלי לדעת מראש לאיזה טיפוס קונקרטי היא מתייחסת, ולפיכך – לאיזו פונקציה בדיוק היא קוראת. זהו המימוש הסטנדרטי של פולימורפיזם בשפת Go. ה"מכנה המשותף הנמוך ביותר" הוא הגדרת פרמטר מסוג {}interface
, אשר אינו מגדיר כל פונקציה ספציפית, ולכן מתאים לכל טיפוס – אם כי, כמובן, אינו מאפשר יותר מדי.
איך זה עובד
על פי ההצעה החדשה, ניתן יהיה לכתוב, למשל, פונקציה בעלת חתימה כזו:
// Stringify accepts a slice of any type that
// implements the Stringer interface:
func Stringify[T Stringer](s1, s2 []T) (ret []string) {
...
}
אשר מקבלת פרמטר-טיפוס אחד (T
) שיכול להיות כל טיפוס המממש את Stringer
, ושני ארגומנטים, s1
ו-s2
, אשר שניהם צריכים להיות מאותו הטיפוס T
. ניתן גם להגדיר טיפוסים מורכבים בצורה פרמטרית, כמו למשל:
// Vector is a name for a slice of any element type.
type Vector[T any] []T
...
var vi Vector[int]
var vf Vector[float64]
משעשע לראות שגם כאן, כמו בהרבה עניינים סינטקטיים אחרים, נראה ש-Go בחרה בכוונה בצורה שונה מזו של C++ ובנות-הדודה שלה. הסוגריים המשולשים <T>
הם סימן ההיכר הבלתי מעורער של generics או templates בשפות אלו, ואילו ההצעה הזו בחרה דווקא בסוגריים מרובעים. יחד עם זאת, ההצעה מסבירה את הסיבות הפרקטיות לבחירה זו, למי שמתעניין (רמז: אילוץ על צורת הקומפילציה).
לא רק מבחינה צורנית, אלא גם במהות, Go שונה מאוד מהשפות הללו, גם אם תתקבל ההצעה. שפות מתקמפלות אחרות, כמו C++ או ג'אווה, מציעות יכולות חזקות מאוד בתחום ה-Generics, ובעניין הזה, Go נמצאת הרחק מאחור. החסרון העיקרי בשפות מתקמפלות הוא שיש צורך לייצר מראש את התוצר הבינארי המתאים לכל פלטפורמה. מן הצד השני, היתרון ששפות אלו מסוגלות להעניק הוא קוד יעיל יותר, הן בגודלו והן במהירותו. אך לשם כך, עליהן לאפשר ניצול של יכולות אלה.
האם אכן ההצעה הקיימת תהפוך את Golang ליעילה יותר?
כנראה שכן, אך נראה שבצורה מוגבלת למדי.
מהם, אם כן, היתרונות של ההצעה הנוכחית ביחס ליכולות הקיימות של השפה?
היתרונות העיקריים של ההצעה
כאמור, מדובר במימוש מצומצם יחסית של עקרון ה-Generics ביחס לשפות אחרות, אך בכל זאת, הוא יעניק למפתחים יתרונות רבים על פני המצב הנוכחי.
העברת שגיאות מזמן-ריצה לזמן-קומפילציה
מציאת שגיאות בזמן קומפילציה מאפשרת התקדמות הרבה יותר מהירה בפיתוח הקוד מאשר מצב בו אותן השגיאות מתגלות אך ורק בזמן הריצה (וכמובן, לא בהכרח בריצה הראשונה, או בכל ריצה שהיא, אלא רק בסיטואציות מסויימות). באופן עקרוני, שימוש נכון באינטרפייסים אמור לאפשר זאת במרבית המקרים. יחד עם זאת, הרבה קוד ב-Golang נכתב בצורה גנרית מאוד, ורק לקראת קריאה לפונקצייה רלוונטית, נבדק בזמן ריצה הטיפוס הדינאמי שלו והוא עובר המרה לטיפוס הסטטי המתאים. שימוש ב-Generics יכול לשנות את סגנון הכתיבה כך שיהיה מראש מדויק יותר.
פולימורפיזם שלא באמצעות פונקציות
לפונקצית Golang אשר צריכה לטפל בצורה פולימורפית בישויות אליהן היא ניגשת שלא באמצעות מתודות או פונקציות המוגדרות על האינטרפייס (אלא למשל באמצעות אופרטורים), אין כיום דרך להימלט מהעברת אינטרפייס גנרי והמרה לטיפוס המקורי בתוך הפונקציה. גישה זו היא – א. מסורבלת, ב. פוגעת בעקרון ה-Open/Close, ג. משפיעה על הביצועים, ו-ד. חמור מכל: מעבירה את השגיאה לזמן הריצה. האופציה האחרת היא לוותר על הפולימורפיזם ולכתוב מספר רב של פונקציות. כך, למשל, ראינו שהפונקציות min
/max
אינן מוגדרות באופן פולימורפי. עקרונית, כתיבה של אותה הפונקציה באמצעות Generics תהיה הרבה יותר אלגנטית וברורה ותגלה שגיאות אפשריות כבר בזמן הקומפילציה.
הגדרת אילוצים בין פרמטרים פולימורפיים
פונקצית Golang אשר מקבלת כיום שני פרמטרים פולימורפיים a
ו-b
(או יותר), יכולה אך ורק להגדיר את האינטרפייס אליו הם משתייכים. היא אינה יכולה להגדיר קשר או אילוץ כלשהו בין הטיפוסים הללו – על כל פנים, לא בזמן קומפילציה. למשל, לחייב את a
ו-b
להיות מאותו טיפוס, או לחייב את a
להיות פונקציה המקבלת שלושה פרמטרים מטיפוס b
. שימוש ב-Generics יאפשר זאת בקלות רבה.
שימוש באופן ישיר ב-Type גנרי
פונקציה המקבלת ארגומנט "פולימורפי" דרך פרמטר פורמלי המוגדר כאינטרפייס אינה יכולה להתייחס באופן ישיר לטיפוס של הארגומנט. למשל, לייצר משתנה מקומי מהטיפוס המדובר. באמצעות Generics, ניתן לעשות זאת באופן פשוט ואלגנטי.
הגדרת טיפוסים מורכבים באמצעות פרמטרים
הגדרות ה-Generics המוצעות מאפשרות גם לייצר טיפוסים המוגדרים באופן "גנרי" באמצעות פרמטרים. למשל, מבנה המכיל שני slices של טיפוסים מספריים כלשהם (בין אם שניהם חייבים להיות מאוטו הטיפוס T
או שמדובר בשני טיפוסים שונים, T
ו-S
). באמצעות הטיפוס הגנרי הזה ניתן לייצר טיפוסים קונקרטיים המגדירים את הטיפוס/ים של שני ה-slices, ולהשתמש בהם כמו כל מבנה אחר בשפה.
חסכון בזמן ומקום בזכות גישה ישירה ולא עקיפה
גישה פולימורפית באמצעות Interface נעשית תמיד בצורה של גישה "עקיפה": האינטרפייס מחזיק מצביע למידע ה"אמיתי", וגישה לאינטרפייס פונה באמצעות המצביע הזה אל המידע. כך, למשל, אינטרפייס המסתיר מאחוריו int8
, יצרוך הרבה יותר מבית אחד בזכרון, וכל גישה אליו תחייב פנייה כפולה – קודם כל גישה לכתובת בה נמצא האינטרפייס, ומשם, באמצעות המצביע שהוא מחזיק, גישה למידע עצמו. כל עוד מדובר במשתנים ספורים זה אולי פחות קריטי; אך כאשר מדובר למשל ב-slice המכיל איברים רבים מטיפוס כזה, גם הנפח וגם זמן הגישה יכולים להתארך מאוד. כשמוסיפים לזה Garbage Collector שצריך לעדכן כתובות כל כמה רגעים – זה יכול להפוך לצוואר בקבוק ממשי. המעבר לשימוש ב-Generics על פי ההצעה הזו יחסוך הרבה מאוד מקום וזמן ריצה במקרים מהסוג הזה.
מה ההצעה יכולה לשפר, במימושים מסויימים?
ההצעה מדברת על ה-syntax שיאפשר את השימוש ב-Generics. היא לא מגדירה מראש את המימוש. בניגוד לשפה כמו C++, בה השאיפה היא תמיד לייצר קוד קומפקטי ויעיל ככל האפשר, Golang מובלת בידי עקרונות נוספים.
כך, למשל, function templates ב-C++ מתקמפלות לכל אחד מהמימושים שלהן בנפרד. נניח, לדוגמא, פונקצית טמפלייט ב-C++ בשם ()makePairs
אשר מקבלת שני וקטורים של טיפוסים שונים, T
ו-S
, ומחזירה וקטור של זוגות המורכבים מהאיברים המתאימים בשני הוקטורים. אם הפונקציה הזו נקראה עם שני וקטורים המחזיקים int
s, הקומפיילר ייצר פונקציה מתאימה. אם בנוסף הפונקציה נקראה עם שני וקטורים המחזיקים double
s, הקומפיילר ייצר עותק נפרד לחלוטין של הפונקציה עבור שני וקטורים של double
. אם במקום נוסף הפונקציה נקראה כך ש-T
הוא int
ו-S
הוא double
, הקומפיילר ייצר עותק שלישי של הפונקציה. כיוון שהפונקציה שתבוצע בפועל נקבעת בזמן הקומפילציה/לינקייג', אין צורך לבצע שום בדיקה בזמן ריצה, והפונקציה תתבצע במהירות הגבוהה ביותר ועם האופטימיזציות הטובות ביותר (לפעמים עלול לקרות דווקא ההיפך, בשל "ניפוח קוד" – Code Bloat – אך זה נושא לפוסט אחר). מצד שני, כל מי שקימפל פרוייקט רציני ב-C++ יודע שעל מנת למקסם את יעילות הקוד בזמן ריצה, משלמים ב"שעות נוספות" בזמן הקומפילציה. מימוש (instantiation) של פונקציות טמפלייט הוא דוגמא לכך.
בשפת Go, לעומת זאת, ישנו דגש גם על זמן הקומפילציה; כאשר ישנה התנגשות בין יעילות הקוד לבין זמן הקומפילציה, לא תהיה לראשון עדיפות אוטומטית על השני, אלא ייעשה נסיון למצוא פשרה שתאזן בין השניים. לפיכך, למרות שההצעה הנוכחית אינה מגדירה בהכרח את צורת המימוש של ה-Generics, היא מניחה שמימוש סביר כנראה לא יכלול ייצור עותק נפרד של הפונקציה עבור כל טיפוס שהיא מקבלת, אלא פונקציה אחת המסוגלת להתמודד עם כל טיפוס שיועבר אליה.
מה ההצעה הנוכחית אינה מאפשרת?
פונקציה גנרית מעל טיפוס גנרי
מלבד יעילות הקוד ויעילות זמן הקומפילציה, עקרון נוסף המנחה את Golang הוא יעילות הקידוד – או, במילים אחרות, שמירה על פשטות השפה ככל האפשר. העקרון הזה נשמר גם כאשר הוא מגיע במחיר של קוד מסורבל יותר או ויתור על יכולות שונות; התפיסה היא שמוטב לשמור על סינטקס פשוט גם אם הוא גורם לקוד ארוך, על פני כתיבת קוד קצר במחיר של סינטקס מורכב. זו אחת הסיבות ש-Go נחשבת לשפה שניתן ללמוד מהר יחסית.
כך, למשל, בשפת C++ ניתן לכתוב class-template אשר מקבל טיפוס גנרי T
כלשהו, ובתוכו לכתוב member function template אשר מקבלת טיפוס גנרי S
כלשהו; בסופו של דבר, הפונקציה שתיווצר ותופעל תהיה בהתאם לשני הטיפוסים. היכולת הזו מאפשרת בצורה אלגנטית מגוון רחב של אפשרויות. מצד שני, אוסף האפשרויות הבלתי-מוגבלות של שימוש במבנים שונים של טמפלייטים הופך את השליטה בהם ללא-טריוויאלית עבור מפתחים חדשים. ההצעה הנוכחית של Golang, לפיכך, אינה מאפשרת שימוש דו-שלבי מהסוג הזה:
או שהפונקציה מקבלת פרמטרים שהם כולם טיפוסים קונקרטיים,
או שהפונקציה פועלת על טיפוס גנרי אבל היא לא מגדירה type parameters נוספים.
זה יכול לגרום סרבול של קוד, כמו יצירה של טיפוס גנרי אך ורק לצורך מימוש פונקציה שכזו, אך זה שומר על סינטקס פשוט יותר.
ספציאליזציה
דבר נוסף שההצעה הנוכחית אינה מאפשרת היא כתיבת קוד שונה עבור טיפוסים ספציפיים – מה שידוע בשפות כמו C++ כ-Specialization. יש בזה הגיון כאשר מדובר בשפה שאינה מאפשרת אפילו function overloading פשוט, כך שכל קריאה לפונקציה בשם מסויים מבהירה בדיוק באיזו פונקציה מדובר. יחד עם זאת, חוסר האפשרות לספשיאליזציה מצמצם מאוד את יכולת השימוש בכלי הזה.
ניזכר, למשל, בדוגמא הראשונה שהבאנו – ()math.Min
ו-()math.Max
. כיום הן פועלות אך ורק על טיפוס float64
. יהיה מפתה, אם כן, לכתוב אותן מחדש, כך שיוכלו לפעול על כל שני ארגומנטים מספריים מטיפוס זהה. נניח, קוד כזה (בהתאם לכללי ההצעה החדשה לפרמטרים גנריים):
// Assume that constraints.Ordered includes
// all the types on which "less then" operator works
func Min[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
לכאורה, זהו פתרון אלגנטי ומתבקש אשר ירחיב את פונקציות המינימום ומקסימום לארגומנטים מכל טיפוס רלוונטי.
אלא שהוא לא יעבוד כמצופה!
הפונקציות הקיימות לחישוב מינימום ומקסימום, אשר מקבלות שני ארגומנטים מטיפוס float64
, צריכות להתחשב במצבים מיוחדים אשר אינם רלוונטיים לארגומנטים מטיפוסים שלמים. למשל, מה קורה במקרה של אינסוף, NaN
, או שני ארגומנטים שאחד מהם הוא "0 חיובי" והשני הוא "0 שלילי". כיוון שלא ניתן לכתוב מקרה פרטי של פונקציה גנרית, לא ניתן יהיה לפתור זאת בצורה אלגנטית. דרך אחת לפתור זאת, למשל, היא באמצעות כתיבת פונקציות בעלות שמות שונים – ()MinInt
גנרית לכל הטיפוסים השלמים, ופונקציות אחרות (גנריות או לא) לטיפוסים מסוגים אחרים. דרך אחרת לפתור זאת יכולה להיות כתיבת פונקציה גנרית אחת שבתוכה ישנה בדיקה של ה-type. לא ממש התגלמות היעילות, האלגנטיות והדיזיין. בכל מקרה, פתרונות מסוגים אלו אינם פולימורפיים ומוסיפים תקורה – או לתהליך הקידוד או לביצועים.
דרישות "משתמעות" – Duck Typing
אחת הגישות הקיימות לכתיבת קוד גנרי מניחה שאין כל הגבלה פורמלית על הטיפוסים שהקוד (פונקציה או טיפוס) הגנרי רשאי לקבל. המגבלה היחידה היא משתמעת – implicit interface (מכונה גם Duck-Typing, כינוי שמגיע דווקא מעולמות הפייתון): כאשר הקומפיילר קורא את הקוד ומחליף את הטיפוס הגנרי (נניח, T
) בטיפוס קונקרטי (נניח, MyAwesomeCar
או int64
), על הקוד להתקמפל בצורה תקינה. המשמעות ניתנת לו אך ורק לאחר קומפילציה קונקרטית. יתרה מזו:
לא ניתן לדעת האם קריאה לקוד גנרי מסויים עם ארגומנט-טיפוס כלשהי היא חוקית או לא, ללא מעבר על הקוד כולו.
דרך זו אמנם מאפשרת חופש מסויים לשימוש יצירתי ורב-מימדי באותה פיסת קוד, אך היא פוגעת מאוד בהנדסת התוכנה. מי שירצה לדעת האם ניתן לקרוא לפונקציה עם הטיפוס MyAwesomeCar
, לא יוכל לדעת האם הדבר אפשרי או לא, עד שלא ינסה לקמפל את הקוד. וגם אז, אם תתקבל שגיאה – יהיה קשה מאד להבין כיצד ניתן להתגבר עליה. מי שכבר מחזיק בידו קוד שבו פונקציה גנרית מקבלת טיפוסי MyAwesomeCar
, יתקשה מאוד לדעת אילו שינויים ב-MyAwesomeCar
יעברו ללא תקלה ואילו יגרמו לקוד להפסיק להתקמפל.
זוהי הסיבה שההצעה המדוברת דורשת כי קוד גנרי יגדיר במפורש ("explicit interface") מה נדרש מטיפוסים אשר אותם הוא מקבל: ההצעה מתייחסת לזה בשם Constraints. ההגדרה יכולה להיעשות באחת משתי דרכים. הדרך הראשונה היא "פתוחה": הקוד מגדיר interface שקובע אילו פונקציות חייבות להיות מוגדרות על כל טיפוס שמועבר. כל טיפוס שהוא יכול להתאים, כל עוד הוא תומך באינטרפייס. הדרך השניה, שנועדה לצורך פעולות גנריות שאינן פונקציות (כמו האופרטור >
שראינו בדוגמת ה-()Min
), היא "סגורה": ה-interface מגדיר ה-Constraint באמצעות רשימה סופית של כל הטיפוסים המותרים.
מטא-פרוגרמינג
הקוד הגנרי המוצע יכול לשמש אך ורק כתבנית קבועה ליצירת קוד בשפת Go. הוא בכוונה תחילה אינו מורכב דיו, ברמה שתאפשר כתיבת לוגיקה באמצעות הכללים הגנריים עצמם (ואשר תתבצע בזמן הקומפילציה) – מה שידוע כ-meta-programming. זוהי כמובן החלטה הגיונית מאוד בהתייחס לדרישות של השפה שתיארנו קודם לכן: זמן קומפילציה מהיר ופשטות סינטקטית של השפה.
מורכבויות נוספות
עולם התכנות הגנרי הוא מורכב מאוד, ומציע בשפות שונות אפשרויות רבות ושונות. הגדרת הטיפוסים הגנריים המותרים והיחסים ביניהם בצורות כאלו ואחרות; הגדרת פרמטרים שאינם טיפוסים; הגדרה של מספר פרמטרים משתנה; שימוש בקוד גנרי בתוך קוד גנרי אחר; אלו רק מקצת האפשרויות הנוספות שניתן למצוא בשפות כמו C++, ג'אווה ו-Rust. ההצעה הזו, נאמנה לפילוסופיה של Golang, בכוונה אינה מפתחת את היכולות הגנריות בצורה כזו, ומשאירה אותן בסיסיות למדי. יתירה מכך, אחת מהנחות היסוד של המציעים היא כי מעט מאוד קוד גנרי ייכתב בפועל. מרבית השימוש בסינטקס המוצע תהיה לטובת קריאה לקוד זה.
סיכום
המחסור במכניזם של Generics בשפת Go הוא נושא טעון ומדובר כבר זמן רב. אין זו הפעם הראשונה שבה נעשה נסיון להכניס מנגנון שכזה, אולם עד כה, הנסיונות לא צלחו. ההצעה המדוברת מנסה להתגבר על הקשיים ולהציע מכניזם בסיסי למדי. אם תתקבל והסינטקס יהפוך לחלק מהשפה, ניתן יהיה בהמשך לראות כיצד משתמשים בו, אילו פתרונות הוא מספק ואילו קשיים עדיין דורשים טיפול, ואולי, בהמשך הדרך, לעבות את החלק הזה של השפה בהתאם לכך.