מצביע תמיד מחזיק ערך כלשהו. כשעוד אין לו אובייקט להצביע עליו, לפני שמשימים לו, אחרי שמה שהוא הצביע עליו שוחרר, או כשלפונקציה אין מה להחזיר, הוא צריך ערך שאומר באופן חד משמעי "אין כאן כלום". הערך הזה הוא מצביע ה-null, ו-NULL הוא השם שכותבים.
המשמעת סביבו קטנה ומשתלמת כל הזמן: הציבו NULL במצביעים כשאין להם יעד, בדקו NULL לפני dereference, והחזירו NULL כשאין לכם תוצאה.
מה NULL באמת
NULL הוא מאקרו, שמוגדר ב-<stddef.h> ונמשך גם על ידי <stdio.h>, <stdlib.h>, <string.h> ואחרים. הוא מתרחב לקבוע מצביע null, בדרך כלל ((void*)0) או 0 פשוט.
תקן C מבטיח את התכונה שחשובה: מצביע null לא שווה למצביע לאף אובייקט או פונקציה אמיתיים. שני מצביעי null מאותו טיפוס תמיד שווים זה לזה.
רוב המערכות מדפיסות (nil) או 0x0 עבור מצביע ה-null, כלומר הכתובת אפס. זה מימוש נפוץ, לא כלל; התקן אף פעם לא מבטיח שהביטים הם אפס. מה שהוא מבטיח הוא ההשוואה, אז כתבו p == NULL, ולעולם לא משהו שתלוי בייצוג.
dereference ל-NULL קורס
הסיבה לכל בדיקת null שקיימת:
int *p = NULL;
printf("%d\n", *p); // התנהגות לא מוגדרת: כמעט בטוח קריסה
ב-Windows, ב-macOS וב-Linux, הדף הראשון של מרחב הכתובות נשאר בכוונה לא ממופה, כך שהמעבד לוכד את הגישה ומערכת ההפעלה הורגת את התוכנית. תראו:
Segmentation fault (core dumped) # Linux
zsh: segmentation fault ./program # macOS
הקריסה המיידית הזו היא יתרון. היא הופכת באג לוגי לכישלון רועש בדיוק בשורה שגרמה לו, במקום השחתה שקטה שתמצאו שלושה שבועות אחר כך. ראו את העמוד על segmentation fault כדי ללמוד לקרוא את הקריסה ולמצוא את השורה.
זו הסיבה ש"null pointer" הוא לא exception שתופסים ב-C. אין מנגנון להתאושש; יש רק בדיקה מראש.
בדקו את מה שעלול להיכשל
פונקציות שעלולות להיכשל מסמנות את זה בהחזרת NULL. הבדיקה היא לא נימוס אופציונלי: החלופה היא קריסה.
הספרייה הסטנדרטית מלאה בהן: malloc, calloc, realloc, fopen, strchr, strstr, getenv, bsearch. כל אחת מהן עונה על "אין תוצאה" עם NULL. ראו את העמוד על זיכרון דינמי לסיפור המלא של ההקצאה.
שתי צורות לבדיקה, שתיהן נפוצות:
if (p == NULL) { /* טיפול */ } // מפורש: עדיף בשביל הבהירות
if (!p) { /* טיפול */ } // תמציתי: מסתמך על כך ש-NULL נחשב שקר
מצביע null נבדק כשקר בתנאי ומצביע תקין כאמת, כך ששתיהן עובדות. הצורה המפורשת קריאה יותר כשמישהו זר סורק את הקוד שלכם בחיפוש אחרי באגים.
NULL מול 0 מול לא מאותחל
שלושה מצבים שונים שמתחילים מטשטשים ביניהם:
int *a = NULL; "points at nothing" - testable, safe to check
int *b; uninitialized - holds garbage, NOT testable
int *c = &x; points at a real object
המסוכן הוא b. הבתים שלו הם מה שנשאר במקום הזה במחסנית, שעשוי להיות אפס (ולהיראות בסדר) או כתובת שנראית סבירה מקריאה קודמת. שום בדיקה לא יכולה להבדיל בינו לבין מצביע תקין, וההתנהגות משתנה בין build של debug ל-build של release.
אתחלו כל מצביע. אם אין לכם עדיין כתובת, NULL היא הכתובת. קמפלו עם -Wall -Wextra והקומפיילר יסמן בשבילכם הרבה שימושים לא מאותחלים.
לגבי 0: בהקשר של מצביע, הקבוע השלם 0 הוא קבוע מצביע ה-null, כך ש-p = 0; הוא C תקין. העדיפו בכל זאת את NULL. הוא מבטא כוונה, וזה חשוב בקריאות variadic שבהן הקומפיילר לא יכול להמיר בשבילכם:
execl("/bin/ls", "ls", 0); // מסוכן: עלול להעביר int במקום שבו מצופה מצביע
execl("/bin/ls", "ls", (char *)NULL); // נכון
C23 מוסיפה את nullptr, מילת מפתח עם טיפוס משלה שמונעת את כל סוג העמימות הזה; NULL נשאר נכון ונייד בכל מקום.
דפוסים הגנתיים
שמרו על הכניסה של פונקציה שמקבלת מצביעים.
הציבו NULL אחרי שחרור. free לא משנה את המצביע שלכם: הוא רק משחרר את הזיכרון. המצביע הישן שנשאר הוא מצביע תלוי (dangling), והשימוש בו הוא התנהגות לא מוגדרת שלעתים קרובות לא קורסת מיד.
שתי עובדות בדוגמה הזו ששווה לזכור. free(NULL) מוגדר כפעולה שלא עושה כלום, כך שקוד ניקוי אף פעם לא צריך לבדוק אותו. והצבת p = NULL אחרי free הופכת use-after-free, שעלול להשחית נתונים בשקט, לקריסה מיידית שאפשר לדבג.
החזירו NULL כדי לומר "אין תוצאה", וכתבו את זה.
// מחזירה מצביע לאיבר המתאים, או NULL אם אין כזה
int *find(int *arr, int n, int target);
תעדו את זה בהערה מעל הפונקציה. קוראת שיודעת ש-NULL אפשרי כותבת את הבדיקה; קוראת שלא יודעת, לא תכתוב.
רשימת בדיקה מהירה
- אתחלו כל מצביע, עם כתובת אמיתית או עם
NULL. - בדקו את הערך המוחזר של
malloc,calloc,reallocו-fopenלפני שמשתמשים בו. - בדקו פרמטרים מסוג מצביע בראש כל פונקציה שעלולים לקרוא לה עם קלט גרוע.
- הציבו
NULLבמצביעים מיד אחריfree. - כתבו
NULL, לא0, בכל פעם שהכוונה למצביע. - בנו עם
-Wall -Wextra, וכשאפשר, הריצו תחת sanitizer:gcc -fsanitize=address,undefinedתופס dereference ל-null עם דוח מדויק.
שאלות נפוצות
מה זה מצביע NULL ב-C?
מצביע שמחזיק את קבוע מצביע ה-null: ערך שמובטח שלא יהיה שווה לכתובת של אף אובייקט אמיתי. זו הדרך הסטנדרטית לומר "המצביע הזה עוד לא מצביע על כלום", ו-NULL הוא המאקרו עבורו, שמוגדר ב-<stddef.h> ובעוד כמה headers.
מה קורה אם מבצעים dereference למצביע NULL ב-C?
זו התנהגות לא מוגדרת. בפועל, במחשבים שולחניים ובשרתים, זה קורס מיד עם segmentation fault, כי הכתובת אפס נשארת בכוונה לא ממופה. במערכות משובצות בלי הגנת זיכרון זה עלול במקום זאת לקרוא או להשחית משהו בשקט, וזה הרבה יותר גרוע.
האם NULL זהה ל-0 ב-C?
בהקשר של מצביעים, כן: הקבוע השלם 0 הוא קבוע מצביע ה-null, כך ש-p = 0; ו-p == NULL עובדים שניהם. אבל השתמשו ב-NULL עבור מצביעים וב-0 עבור מספרים: זה אומר לקורא למה התכוונתם, וזה שומר על קריאות variadic בטוחות, שבהן 0 חשוף עלול לעבור כ-int ולא כמצביע.
מה ההבדל בין מצביע NULL למצביע לא מאותחל?
מצביע NULL בוודאות לא מצביע על כלום, ואפשר לבדוק אותו. מצביע לא מאותחל מחזיק את הבתים שבמקרה היו בזיכרון הזה, אולי כתובת שנראית תקינה, כך שאין בדיקה שיכולה לתפוס אותו. תמיד אתחלו מצביעים ל-NULL כשאין לכם משהו טוב יותר.