משתנה שמחזיק כתובת
כל משתנה חי איפשהו בזיכרון, במיקום ממוספר שנקרא הכתובת שלו. ברוב הזמן לא אכפת לכם איפה: פשוט משתמשים בשם של המשתנה. מצביע הופך את זה: זה משתנה שהערך שלו הוא כתובת. במקום להחזיק 42, הוא מחזיק את "המקום שבו 42 שמור".
העקיפות הזו היא מה שהופך מצביעים לחזקים. פונקציות יכולות לשנות דרכם משתנה של מי שקרא להן, מבני נתונים כמו רשימות מקושרות משרשרים איתם צמתים, ו(כמו שתראו בזיכרון דינמי) הם הדרך להגיע לזיכרון שמקצים בזמן ריצה.
ה-& ב-&score הוא אופרטור הכתובת (address-of): הוא מפיק את המיקום של score. ה-* ב-*p הוא אופרטור הגישה (dereference): הוא עוקב אחרי הכתובת בחזרה אל הערך שנמצא שם.
שני האופרטורים: & ו-*
הדבר המבלבל ביותר למתחילים הוא ש-* אומר שני דברים שונים, לפי המקום שבו הוא נמצא. הקפידו להבחין ביניהם:
int* p; // הצהרה: "p הוא מצביע ל-int"
p = &x; // & = כתובת של: שומרים את הכתובת של x ב-p
int y = *p; // * = גישה: קוראים את הערך ש-p מצביע עליו
*p = 99; // גישה בצד ההשמה: כותבים דרך המצביע
בהצהרה, * הוא חלק מהטיפוס. בביטוי, * מבצע פעולה. ברגע שמצביע מוגדר, גישה דרכו נותנת לכם גישת קריאה וכתיבה מלאה למשתנה המקורי:
שימו לב שלא נגעתם ב-health בשמו אחרי השורה הראשונה, ובכל זאת הערך שלו המשיך להשתנות. זו כל הנקודה: hp הוא כינוי לאותו אחסון. הריווח (int* p, int *p, int*p) הוא קוסמטי וזהה מבחינת הקומפיילר, והמדריך הזה משתמש ב-int* p.
nullptr: הצבעה על כלום
מצביע שלא מצביע לשום מקום צריך לקבל את הערך nullptr (C++11). זו דרך ברורה ובטוחה מבחינת טיפוסים לומר "אין יעד עדיין", והיא נותנת לכם משהו לבדוק מולו לפני הגישה.
העדיפו nullptr על פני המאקרו הישן NULL או 0 חשוף. מכיוון של-nullptr יש טיפוס מצביע אמיתי, הוא אף פעם לא מתפרש בטעות כמספר השלם 0 בבחירת העמסה, באג עדין שהסגנון הישן יכול היה לגרום.
מלכודת: גישה דרך null. קריאה או כתיבה דרך מצביע null (או לא מאותחל) היא התנהגות לא מוגדרת, ובדרך כלל קריסה מיידית:
int* p = nullptr;
cout << *p; // קריסה: גישה דרך null היא התנהגות לא מוגדרת
הגנו תמיד עם if (p) (או if (p != nullptr)) לפני שאתם ניגשים דרך משהו שעלול להיות null.
מצביעים ומערכים
השם של מערך מתנוון למצביע לאיבר הראשון שלו, ולכן מצביעים ומערכים שזורים זה בזה לעומק. הוספת 1 למצביע לא מוסיפה בית אחד: היא מקדמת באיבר אחד, וזה מה שגורם לאריתמטיקת מצביעים לעבוד:
p[i] ו-*(p + i) הם ממש אותו ביטוי, והשקילות הזו היא הסיבה שמערכים מתחילים מאינדקס אפס. הבאג הקלאסי כאן הוא מעבר אל מעבר לסוף: nums + 4 הוא סמן תקין של "אחד אחרי הסוף" להשוואה, אבל גישה דרך *(nums + 4) קוראת מחוץ לגבולות. שגיאות של אחד-יותר-מדי עם מצביעים הן גורם מוביל לקריסות ולהשחתה שקטה של נתונים, ולכן הקפידו על תנאי העצירה.
const ומצביעים
const יכול לחול על מה שהמצביע מצביע עליו, על המצביע עצמו, או על שניהם. קראו את ההצהרה מהסוף להתחלה כדי לפענח אותה:
const int* p; // מצביע ל-const int: אי אפשר לשנות את *p, אפשר להפנות את p למקום אחר
int* const p = &x; // מצביע const ל-int: אפשר לשנות את *p, אי אפשר להפנות את p למקום אחר
const int* const p = &x; // שניהם נעולים
זה חשוב כל הזמן בקוד אמיתי. פונקציה שמבטיחה לא לשנות את הנתונים שלכם מקבלת מצביע ל-const:
סימון היעד כ-const מתעד את הכוונה ומאפשר לקומפיילר לעצור כתיבות בטעות: בטיחות בחינם, בלי שום עלות בזמן ריצה.
המלכודת הגדולה: מצביעים תלויים
מצביע תלוי (dangling pointer) מצביע על זיכרון שכבר לא מחזיק את הערך שציפיתם לו: המשתנה יצא מהטווח, או שהזיכרון שוחרר. גישה דרכו היא התנהגות לא מוגדרת, והחלק המרושע הוא שלעיתים קרובות זה נראה שזה עובד, עד שזה לא.
int* makeBad() {
int local = 5;
return &local; // באג: local מת כשהפונקציה חוזרת
} // המצביע שהוחזר תלוי עכשיו
הכתובת עדיין מספר תקין, אבל היא מצביעה על משבצת במחסנית שכבר נלקחה בחזרה: קריאה ממנה נותנת זבל או קריסה. אותו דבר קורה אם שומרים מצביע לאובייקט ב-heap שעבר delete, או לאיבר של vector שמאוחר יותר מקצה את עצמו מחדש.
שלושה כללים שומרים עליכם:
- לעולם אל תחזירו את הכתובת של משתנה מקומי. החזירו לפי ערך, או תנו למי שקורא להחזיק באחסון.
- הציבו
nullptrבמצביע אחרי שמה שהוא מצביע עליו נעלם, ובדקו לפני שימוש. - לניהול בעלות ואורך חיים, השתמשו במצביעים חכמים במקום
new/deleteגולמיים: הם משחררים זיכרון אוטומטית ומצמצמים את כל סוג הבאגים הזה.
הבא בתור: הפניות מול מצביעים
מצביעים הם לא הדרך היחידה להתייחס בעקיפין למשתנה אחר. ב-C++ יש גם הפניות (references), שמרגישות דומות אבל לא יכולות להיות null, לא יכולות להיות מופנות מחדש ומשתמשות בתחביר נקי יותר. בהמשך נשים אותן זו לצד זו בעמוד הפניות מול מצביעים, כדי שתדעו בדיוק באיזה כלי להשתמש, ולמה רוב ה-C++ המודרנית מעדיפה הפניות כשאפשר.
שאלות נפוצות
מהו מצביע ב-C++?
מצביע הוא משתנה ששומר את כתובת הזיכרון של ערך אחר ולא את הערך עצמו. מצהירים עליו עם * (למשל int* p), לוקחים כתובת עם האופרטור & (p = &x), וקוראים או כותבים את הערך שהוא מצביע עליו על ידי גישה דרכו (dereference) עם *p.
מה ההבדל בין & ל-* במצביעים ב-C++?
בהקשר של מצביעים, & הוא אופרטור הכתובת: &x נותן לכם את הכתובת של x. ל-* יש שני תפקידים: בהצהרה (int* p) הוא מסמן שהמשתנה הוא מצביע, ובביטוי (*p) הוא ניגש דרך המצביע כדי להגיע לערך ששמור בכתובת הזו.
מהו nullptr ב-C++ ולמה להשתמש בו במקום NULL?
nullptr הוא ליטרל מצביע null בטוח מבחינת טיפוסים, שנוסף ב-C++11. משמעותו "לא מצביע על כלום". העדיפו אותו על פני NULL הישן או 0, כי nullptr הוא טיפוס מצביע אמיתי, ולכן הוא אף פעם לא מתבלבל עם מספר שלם בבחירת העמסה. בדקו תמיד if (p) לפני גישה דרכו: גישה דרך מצביע null היא התנהגות לא מוגדרת.