זיג (שפת תכנות)
תיאור > טיפול בזיכרון

אחת מהסיבות העיקריות לבאגים בתכנות בשפת C היא מערכת ניהול הזיכרון שמתבססת על malloc. בעת השימוש ב־malloc, מוקצים בלוקים של זיכרון ומוחזרים פויינטרים לאותו מיקום בזיכרון. אין שום מנגנון שיבטיח שהזיכרון ישוחרר כשהתוכנה סיימה להשתמש בו, והדבר יכול להוביל לדליפת זיכרון.
בעיה נפוצה אף יותר, ויש שיקראו לה השגיאה הגרועה ביותר בתכנות, היא שגיאת null pointer, בה מצביע אינו מצביע לכתובת חוקית בזיכרון בגלל כשל של ה־malloc בהקצאת הזיכרון, או בגלל בעיה אחרת.
בשפות תכנות רבות בעיות אלו בניהול זיכרון נפתרות באמצעות מנגנון איסוף זבל (GC) שבודק אם יש בתוכנה מצביעים לזיכרון שהוקצה, ומסיר בלוקים של זיכרון שנותרו ללא מצביעים שמצביעים אליהם. למרות שהמנגנון הזה מפחית מאוד, ואפילו יכול למנוע לחלוטין שגיאות זיכרון, מנגנוני איסוף זבל הם בדרך כלל איטיים יחסית לניהול זיכרון ידני, והביצועים שלהם לא צפויים. לפיכך הם אינם מתאימים לתכנות מערכות.
פתרון מוכר אחר לבעיות זיכרון הוא ספירה אוטומטית של התייחסויות (Automatic Reference Counting, ARC) שמממשת את אותו עיקרון של בדיקת מצביעים והסרת בלוקים של זיכרון, אך עושה זאת בזמן הקצאת הזיכרון על ידי מעקב אחרי מספר המצביעים המצביעים לאותו חלק בזיכרון. בטכניקה הזאת אין צורך בסריקות המצביעים שגוזלות זמן, אך במקומן נוסף זמן ריצה לפעולת הקצאת הזיכרון ושחרורו.
זיג נועדה לספק ביצועים זהים או טובים יותר משפת C, כך ששימוש באיסוף זבל או ב־ARC לא ישרת אותה היטב. לכן, נכון לשנת 2022, היא משתמשת בפתרון שידוע בשם "טיפוסים אופציונליים" (Optional types) או פויינטרים חכמים. במנגנון הזה, במקום לאפשר למצביע להצביע לכלום או לאפס, טיפוס מצביע רגיל יכול להצביע רק למקום חוקי בזיכרון, ובמקביל קיים טיפוס מיוחד שיכול להצביע גם הצבעה חוקית וגם הצבעה לא חוקית (הצבעה לאפס בשפת C). מבנה פויינטר כזה דומה ל־struct שמכיל מצביע ובנוסף לו אינדיקטור שמציין אם המצביע חוקי, כך שהאי-חוקיות היא מפורשת ואינה פרשנות של כתובת ההצבעה. בטכניקה של טיפוסים אופציונליים ניהול החוקיות של הפויינטר נעשית אוטומטית על ידי השפה ללא התערבות של המתכנת, כך שפויינטר שהקצאת הזיכרון אליו נכשלה יהיה לא חוקי, ולפיכך לא ניתן יהיה להשתמש בו בטעות כאילו הוא מחזיק זיכרון חוקי.
היתרונות במנגנון שכזה הוא שאין כל תוספת עומס על החישוב, או שהתוספת זניחה. אמנם המהדר צריך לייצר את הקוד שמעביר את הטיפוסים האופציונליים למקומות בהם מתבצע שינוי בערכי המצביעים, מה שלא קורה עם מצביעים פשוטים, אבל קוד שכזה הוא בדיוק מה שיאפשר להגדיר כבר בזמן ההידור היכן יכולות להתרחש בעיות זיכרון ללא צורך לעסוק בעניין בזמן ריצה. למשל ב־C ניתן ליצור ולהשתמש במצביע שמצביע לכתובת 0, ואז לקבל שגיאות בזמן ריצה, אולם שפה עם טיפוסים אופציונליים יכולה לבדוק בזמן הידור שכל הניסיונות להשתמש במצביעים נעשים כשהמצביע במצב תקין ולמנוע את בעיות הזיכרון עוד לפני שמגיע שלב הריצה. הטכניקה אמנם לא פותרת את כל בעיות ניהול הזיכרון, אבל אם אכן מתרחשת שגיאת זיכרון בזמן ריצה, הטכניקה מאפשרת לזהות בקלות רבה יותר היכן הן קרו ולמה.
בנוסף, ניהול הזיכרון בזיג נעשה באמצעות structs שמשמשים בביצוע ההקצאה ומתארים את פעולת ההקצאה, ולא באמצעות קריאה ל־libc. למשל ב־C פונקציה שמייצרת מחרוזת שמכילה שרשור של עותקים של מחרוזת אחרת כנראה תיראה כך:
const char* repeat(const char* original, size_t times);
בקוד הזה הפונקציה תבדוק את הגודל של original ואז תקצה את הגודל הזה times פעמים בשביל המחרוזת שהיא צפויה לבנות. מי שקורא לפונקציה הזאת לא יראה את ה־malloc שהיא מבצעת, ואם הוא לא ישחרר את הזיכרון לאחר שיסיים להשתמש בו, תהיה לו דליפת זיכרון. בזיג ניתן להשתמש בפונקציה שנראית כך בשביל בניית המחרוזת:
fn repeat(allocator: *std.mem.Allocator, original: []const u8, times: usize) std.mem.Allocator.Error![]const u8;
בקוד הזה המשתנה allocator מקבל struct שמתאר איזה קוד אמור לבצע את ההקצאה, והפונקציה repeat יכולה להחזיר את המחרוזת, או לחלופין שגיאת הקצאה, כפי שמאפשר לה סימן הקריאה שמסמן טיפוס אופציונלי. העברת ה־allocator לתוך הפונקציה משמעה שהקצאת הזיכרון אינה נסתרת. מי שקרא לפונקציה repeat יודע שהייתה הקצאת זיכרון וגם יודע מי מבצע אותה ואיך. הספרייה הסטנדרטית של זיג לא מבצעת הקצאות, ולמתכנת יש גם יכולת לשנות את ה־struct שמועבר ל־allocator, ובכך לשנות את דרך ההקצאה, ואפילו להגדיר שיטת הקצאה חדשה. גמישות כזו מאפשרת, למשל, הגדרת שיטות הקצאה שונות לאובייקטים שונים, מבלי לעבור דרך מנגנוני ההקצאה של מערכת ההפעלה שמשתמשים בדפים שלמים מן הזיכרון.
הטיפוסים אופציונליים הם דוגמה טובה למנגנונים פשוטים וגנריים בשפה שהם גם שימושיים מאוד. ואכן, אם נסתכל על הטיפוסים האופציונליים, נראה שהם נותנים יכולות שהן הרבה מעבר לטיפול ב־null pointers. הם יכולים לשמש במקרים בהם נדרש טיפוס שמסוגל להיות במצב "אין ערך". דוגמה: נניח שיש לנו פונקציה בשם countNumberOfUsers שמחזירה מספר שלם, ויש לנו משתנה מסוג integer בשם theCountedUsers שמקבל את התוצאה של הפונקציה. בשפות רבות יוגדר מספר קסם שכאשר הוא מופיע כערך ב־theCountedUsers משמעו שעדיין לא קראו לפונקציה, ובמקרים רבים מספר הקסם הזה יהיה 0. בזיג, לעומת זאת, המקרה הזה ימומש על ידי
var theCountedUsers: ?i32 = null
שמכניס למשתנה ערך שחד משמעית אומר "לא קראו עדיין לפונקציה".
לזיג יש גם יכולת כללית יותר שמסייעת בניהול בעיות זיכרון: הקונספט של defer, שמאפשר לסמן קוד ככזה שיתבצע בסוף פונקציה ללא קשר למה שהתרחש בתוכה, וללא קשר לשאלה אם בזמן ריצה היא הסתיימה בצורה תקינה או הופסקה בשל שגיאה. דוגמה ידועה יחסית לשימוש ביכולת הזו היא כאשר פונקציה מקצה זיכרון שאינו נדרש עם סיום פעולתה. אם נשחרר את הזיכרון בסוף הפונקציה, ייתכן שהפונקציה תיכשל בשלב כלשהו בריצה שלה והזיכרון לא ישתחרר ויישאר תפוס ללא סיבה, וכך תתרחש דליפת זיכרון. בזיג ניתן במקרה כזה להעביר לבלוק של ה־defer את השחרור של אותו זיכרון. כך הזיכרון ישתחרר גם כאשר הפונקציה לא הצליחה לסיים את פעולתה.
מאחר שניהול הזיכרון בזיג נמנע מהקצאות נסתרות, ההקצאות לא מנוהלות על ידי השפה ישירות. הגישה ל־heap בזיג מתבצעת בעזרת הספרייה הסטנדרטית בצורה מפורשת.