שתי שאלות על כל משתנה
לכל משתנה ב-C יש שתי תכונות נפרדות, והבלבול ביניהן הוא המקור לרוב ההפתעות בעמוד הזה:
- Scope: איפה אפשר להשתמש בשם. שאלה של זמן קומפילציה.
- אורך חיים (lifetime): כמה זמן האחסון קיים. שאלה של זמן ריצה.
בדרך כלל הן נעות יחד (משתנה מקומי רגיל נראה בבלוק שלו וחי בדיוק באותו זמן), אבל static מפריד ביניהן, ולכן זו מילת המפתח שכדאי להבין כאן.
Scope של בלוק
משתנה שהוצהר בתוך סוגריים מסולסלים נראה מההצהרה שלו ועד הסוגר המסולסל הסוגר, ובשום מקום אחר.
הבלוק הפנימי רואה את outer, כי הוא מקונן בתוכו. הבלוק החיצוני לא רואה את inner. זה חל על כל זוג סוגריים מסולסלים: גופי פונקציות, גופי if, גופי לולאות, או בלוק חשוף כמו זה שלמעלה.
לולאות הופכות את זה למוחשי. מונה שהוצהר בכותרת של for שייך ללולאה:
for (int i = 0; i < 5; i++) {
/* i חי כאן */
}
/* i לא קיים כאן */
וזה בדיוק מה שרוצים: שתי לולאות באותה פונקציה יכולות להשתמש כל אחת ב-i בלי הפרעה, ואי אפשר לקרוא את המונה בטעות אחרי הלולאה. אם אתם צריכים את הערך אחר כך, למשל את האינדקס שבו חיפוש נעצר, הצהירו עליו לפני הלולאה.
לפרמטרים של פונקציה יש scope של גוף הפונקציה, ולכן הם מתנהגים כמו משתנים מקומיים שהוצהרו בראש שלה.
C99 מאפשרת הצהרה בכל מקום בבלוק, לא רק בראשו. הצהירו על משתנים במקום שבו אתם צריכים אותם לראשונה: למשתנה עם scope קצר יש פחות דרכים לטעות.
אורך חיים: אחסון אוטומטי
למשתנה מקומי רגיל יש אחסון אוטומטי: הוא נוצר כשהביצוע נכנס לבלוק שלו ונהרס כשהביצוע יוצא ממנו. הזיכרון שלו נמצא על ה-stack.
הקוד מדפיס 1 שלוש פעמים. כל קריאה מקבלת count חדש לגמרי, שמאותחל ל-0 ונזרק ביציאה. זו גם הסיבה שלכל frame של פונקציה רקורסיבית יש עותק משלו של כל משתנה מקומי.
שתי השלכות שכדאי לומר במפורש. משתנה מקומי שלא אותחל מכיל זבל, לא אפס: את הבתים שהיו על ה-stack. והחזרת מצביע למשתנה מקומי היא undefined behavior, כי האחסון נעלם ברגע שהפונקציה חוזרת:
/* באג: buf לא קיים אחרי ה-return */
char *broken(void) {
char buf[64] = "hello";
return buf;
}
משתנים מקומיים static: זיכרון בין קריאות
הוסיפו static למשתנה מקומי, ואורך החיים שלו הופך לכל משך התוכנית, בזמן שה-scope שלו נשאר בדיוק אותו דבר.
עכשיו המונה מדפיס 1, 2, 3. ה-= 0 רץ פעם אחת, לפני ש-main מתחילה, ולא בכל קריאה.
שלושה כללים למשתנים מקומיים static:
- המאתחל חייב להיות ביטוי קבוע, כי הוא מוחל בתחילת התוכנית ולא בזמן ריצה.
- בלי מאתחל, משתנה
staticמאותחל לאפס (בניגוד למשתנה מקומי אוטומטי, שמחזיק זבל). - המשתנה עדיין פרטי לפונקציה. שום קוד אחר לא יכול לפנות אליו בשמו.
הנקודה האחרונה היא כל הקסם: פונקציה יכולה לזכור משהו בין קריאות בלי לחשוף משתנה גלובלי שכל אחד יכול לשנות. השתמשו בזה למוני קריאות, לטבלאות חיפוש שנבנות בשימוש הראשון ולדגלי אתחול חד־פעמיים.
המחיר הוא שהפונקציה כבר לא ממפה קלט לפלט בצורה טהורה: אותם ארגומנטים יכולים לתת תשובות שונות. זה מקשה לבדוק אותה, והופך אותה ללא בטוחה לקריאה מכמה threads בו־זמנית בלי הגנה. השתמשו במשתנים מקומיים static בכוונה, לא מתוך נוחות.
Scope של קובץ: משתנים גלובליים
למשתנה שהוצהר מחוץ לכל הפונקציות יש scope של קובץ. הוא נראה מההצהרה שלו ועד סוף הקובץ, בכל פונקציה שמתחתיו, ואורך החיים שלו הוא כל התוכנית.
משתנים גלובליים מאותחלים לאפס כברירת מחדל, כך ש-totalOperations מתחיל ב-0 גם בלי ה-= 0.
כברירת מחדל, למשתנה גלובלי יש גם קישור חיצוני (external linkage): קבצי .c אחרים באותה תוכנית יכולים להגיע אליו אם הם מצהירים עליו כ-extern:
/* ב-stats.c */
int totalOperations = 0; /* ההגדרה: בדיוק אחת בכל התוכנית */
/* ב-main.c */
extern int totalOperations; /* הצהרה: "הוא קיים איפשהו" */
שימו את הצהרת ה-extern בקובץ header, כך שכל מי שמשתמש בו יראה את אותה הצהרה. שימו לב לאי הסימטריה: הצהרת ה-extern אומרת שהמשתנה קיים, וקובץ .c אחד בדיוק חייב להגדיר אותו בפועל.
למה משתנים גלובליים נושכים
משתנים גלובליים הם הדרך הקלה ביותר לשתף נתונים, והדרך הקלה ביותר ליצור באגים שאי אפשר לאתר. ארבע בעיות מוחשיות:
כל דבר יכול לשנות אותם. כש-totalOperations מחזיק ערך שגוי, האשם יכול להיות כל שורה בכל קובץ. עם פרמטר, החשודים הם נקודות הקריאה שאתם רואים.
אי אפשר לבדוק את הפונקציות. פונקציה שקוראת משתנה גלובלי לא ניתנת לקריאה בבידוד: צריך להכין קודם מצב גלובלי, ולזכור לאפס אותו אחר כך.
השם תופס את כל התוכנית. משתנה גלובלי בשם count או buffer יתנגש בסוף במשתנה של מישהו אחר.
המאתחלים שלהם חייבים להיות קבועים של זמן קומפילציה. ב-C, אפשר לאתחל משתנה גלובלי רק בביטוי קבוע: int limit = readConfig(); לא יתקמפל, וגם int b = a * 2; ב-scope של קובץ לא. כל ערך מחושב צריך להיות מושם בזמן ריצה על ידי פונקציית הכנה כלשהי, וכל קובץ שנוגע במשתנה הגלובלי לפני שהקריאה הזו רצה רואה 0 בשקט.
החלופות כמעט תמיד זמינות: העבירו ערכים כפרמטרים, החזירו תוצאות, וארזו מצב קשור ב-struct שמועבר במפורש. יש חריגים אמיתיים, כמו אובייקט הגדרות לכל התוכנית או handle ללוגים, וגם הם עדיפים כ-static ב-scope של קובץ עם פונקציות גישה, וזה הנושא של הפרק הבא.
static ב-scope של קובץ: פרטיות
על משתנה גלובלי או על פונקציה, static אומר משהו שונה לגמרי ממה שהוא אומר על משתנה מקומי: קישור פנימי (internal linkage). השם הופך לפרטי לקובץ ה-.c שלו, ואי אפשר להגיע אליו מאף קובץ אחר.
/* counter.c */
#include "counter.h"
static int count = 0; /* פרטי לקובץ הזה: אף קובץ אחר לא יכול לגעת בו */
static void validate(void) { /* פונקציית עזר פרטית, לא חלק מהממשק */
if (count < 0) count = 0;
}
void increment(void) { /* ציבורית: מוצהרת ב-counter.h */
count++;
validate();
}
int getCount(void) { /* ציבורית */
return count;
}
קובץ אחר יכול לקרוא ל-increment ול-getCount, אבל הוא לא רואה את count ולא יכול לקרוא ל-validate: ה-linker לא יפתור את השמות האלה. זו מערכת המודולים של C, כמה שהיא: header שמצהיר על הפונקציות הציבוריות, ו-static על כל השאר.
שני יתרונות מעשיים מעבר לסדר. לשני קבצים יכולה להיות לכל אחד static void validate(void) בלי התנגשות, מה שלא היה אפשרי אם הפונקציות היו ציבוריות. והקומפיילר יודע שלפונקציה static אין קוראים מחוץ לקובץ, ולכן הוא יכול לבצע לה inline בצורה אגרסיבית יותר, או להזהיר שהיא לא בשימוש.
כך שלמילת המפתח יש שתי משמעויות נפרדות, שנקבעות לפי המקום שבו היא מופיעה:
static על משתנה מקומי -> אורך החיים הופך לקבוע (ה-scope לא משתנה)
static ב-scope של קובץ -> הקישור הופך לפנימי (אורך החיים לא משתנה)
Shadowing
הצהרה על שם ב-scope פנימי, כשהוא כבר קיים ב-scope חיצוני, מסתירה (shadows) את השם החיצוני: בתוך ה-scope הפנימי, השם מתייחס למשתנה החדש, ואי אפשר להגיע לחיצוני.
זה חוקי, ולפעמים מכוון. לעיתים קרובות יותר זו תאונה, ויקרה: פונקציה שאמורה לעדכן משתנה גלובלי מעדכנת במקום זה משתנה מקומי עם אותו שם, והגלובלי לא משתנה אף פעם. אותו דבר קורה כשמשתנה מקומי מסתיר פרמטר, וההשמה שהתכוונתם לעשות לקלט לא מגיעה לשום מקום.
gcc -Wshadow מדווח על כל מקרה. הוא לא כלול ב--Wall, לכן הפעילו אותו במפורש:
gcc -Wall -Wextra -Wshadow program.c -o program
ההרגל שמונע את הבעיה לגמרי הוא לתת שמות לפי תפקיד ולא לפי טיפוס: totalScore ו-itemScore לא יכולים להסתיר זה את זה, בזמן ששני משתנים שנקראים שניהם n בסוף יסתירו.
טבלת עזר מהירה
הוצהר בתוך בלוק: scope של בלוק, אורך חיים אוטומטי, זבל אם לא אותחל
פרמטר של פונקציה: scope של גוף הפונקציה, עותק של הארגומנט
static בתוך פונקציה: scope של בלוק, אורך חיים של כל התוכנית, מאותחל לאפס, נשמר בין קריאות
הוצהר מחוץ לכל הפונקציות: scope של קובץ, אורך חיים של התוכנית, מאותחל לאפס, נראה לקבצים אחרים
static מחוץ לכל הפונקציות: scope של קובץ, אורך חיים של התוכנית, פרטי לקובץ ה-.c הזה
הצהרת extern: מתייחסת למשתנה שמוגדר בקובץ אחר
שאלות נפוצות
מה זה scope ב-C?
האזור בקוד שבו שם מסוים נראה. משתנה שהוצהר בתוך בלוק (זוג סוגריים מסולסלים) נראה רק בבלוק הזה, וזה scope של בלוק. משתנה שהוצהר מחוץ לכל הפונקציות נמצא ב-scope של קובץ, ונראה מההצהרה שלו ועד סוף הקובץ.
מה static עושה למשתנה מקומי ב-C?
הוא משנה את אורך החיים של המשתנה בלי לשנות את ה-scope שלו. משתנה מקומי static נוצר פעם אחת, מאותחל פעם אחת ושומר על הערך שלו בין קריאות, אבל הוא עדיין נראה רק בתוך הפונקציה שלו. זו הדרך לתת לפונקציה זיכרון בלי להשתמש במשתנה גלובלי.
מה ההבדל בין static לגלובלי ב-C?
משתנה גלובלי רגיל נראה לכל קובץ בתוכנית, וקבצים אחרים יכולים להגיע אליו עם extern. משתנה או פונקציה static ב-scope של קובץ פרטיים לקובץ .c האחד הזה, וקבצים אחרים לא יכולים לקשר אליהם. static ב-scope של קובץ עוסק בהסתרה, ו-static על משתנה מקומי עוסק באורך חיים.
למה משתנים גלובליים גרועים ב-C?
כל פונקציה יכולה לשנות אותם, כך שבאג יכול להיגרם מכל מקום בתוכנית. אי אפשר לבדוק בנפרד פונקציות שקוראות אותם. והשם תופס את כל התוכנית ומזמין התנגשויות. העבירו ערכים כפרמטרים והחזירו תוצאות במקום, ובמקום שבו באמת צריך מצב משותף, הפכו אותו ל-static ב-scope של קובץ, כך שרק קובץ אחד יכול לגעת בו.