פרמטרים וארגומנטים
שתי מילים שמשתמשים בהן לסירוגין, אבל כדאי להפריד ביניהן:
- פרמטר הוא המשתנה בהגדרה של הפונקציה:
int nב-int square(int n). - ארגומנט הוא הערך שמספקים במקום הקריאה: ה-
4ב-square(4).
קריאה לפונקציה יוצרת את הפרמטרים שלה כמשתנים מקומיים חדשים ומעתיקה לתוכם את הארגומנטים. ההעתקה הזו היא כל הנושא של העמוד הזה.
C מעבירה by value, תמיד
זה הכלל, ואין לו ב-C אף חריג: פונקציה מקבלת עותקים של הארגומנטים שלה. השמה לפרמטר משנה רק את העותק.
value הוא עדיין 42. הפונקציה קיבלה עותק בשם x, איפסה את העותק, והעותק נעלם כשהפונקציה חזרה. שינוי שם הפרמטר ל-value לא משנה כלום: השם לא מחבר ביניהם; אלה שני משתנים שונים.
אותו דבר נכון לכל טיפוס: int, double, char, מצביע, ואפילו struct שלם (שמועתק שדה אחרי שדה). אם אתם מגיעים מ-Python, Java או JavaScript, שימו לב שבשפות האלה מעבירים הפניות לאובייקטים by value, ולכן שינוי אובייקט בתוך פונקציה נראה גם מחוץ לה. ב-C זה לא כך: היא מעתיקה את האובייקט עצמו.
העברה by value היא יתרון, לא מגבלה. פונקציה לא יכולה להשחית בטעות את הנתונים של הקוראת, וזה הופך את הקוד להרבה יותר קל להבנה.
הכישלון הקלאסי: swap
ההדגמה הקלאסית היא פונקציה שמנסה להחליף בין שני ערכים:
הלוגיקה נכונה: היא באמת מחליפה בין a ל-b. אבל a ו-b הם עותקים, כך שכל העבודה נזרקת ברגע שהפונקציה חוזרת.
הדמיית העברה by reference עם מצביעים
כדי לאפשר לפונקציה לשנות משתנה של הקוראת, תנו לה את הכתובת של המשתנה במקום את הערך שלו. הכתובת עצמה מועברת by value, כלומר המצביע מועתק, אבל העותק עדיין מצביע על האובייקט המקורי, ולכן כתיבה דרכו מגיעה לזיכרון של הקוראת.
שלושה פריטי תחביר נושאים את כל הרעיון:
int *aברשימת הפרמטרים מצהיר עלaבתור "מצביע ל-int".&xבמקום הקריאה מפיק את הכתובת שלx.*aבתוך הפונקציה פירושו "ה-intש-aמצביע עליו": אפשר לקרוא אותו ולהשים לו.
לזה מתכוונים מתכנתי C כשהם אומרים "pass by reference", וכדאי לדייק: זו עדיין העברה by value. המצביע מועתק. השמה ל-a עצמו (a = NULL;) תשנה רק את העותק; השמה ל-*a משנה את המשתנה של הקוראת. העמוד על מצביעים מכסה את המודל במלואו.
המחיר הוא שמקום הקריאה כבר לא אומר לכם אם משתנה יכול להשתנות, וזו בדיוק הסיבה ש-& נדרש. swap(x, y) לא יתקמפל; צריך לכתוב swap(&x, &y), וה-& הזה הוא הסימן הגלוי שהקריאה עשויה לשנות את x.
החזרת כמה תוצאות
return מפיק ערך אחד, ולכן פלטים נוספים עוברים דרך פרמטרים מסוג מצביע. הצורה הנפוצה היא קוד סטטוס בתור ערך ההחזרה, והתוצאות האמיתיות נכתבות דרך מצביעים:
שימו לב ש-q ו-r שומרים על הערכים הקודמים שלהם כשהקריאה נכשלת: הפונקציה חזרה לפני שכתבה משהו. זה החוזה שהקוראת צריכה לכבד, ולכן בדיקת הסטטוס באה לפני השימוש בפלטים.
החלופה היא להחזיר struct שמחזיק את שני השדות, מה שחוסך מצביעים לגמרי ולעתים קרובות הוא העיצוב הנחמד יותר כשהערכים באמת שייכים זה לזה.
מערכים הם סיפור אחר
מערכים לא מצייתים לכלל ההעתקה, וזה מכשיל כמעט את כולם. כשמעבירים מערך, הוא דועך למצביע לאיבר הראשון שלו, כך שהפונקציה מקבלת כתובת, לא עותק של הנתונים.
המערך של הקוראת באמת השתנה, בלי & בשום מקום, כי data כבר הוא כתובת בהקשר הזה. שלוש השלכות:
int a[] ו-int *a אומרים בדיוק אותו דבר ברשימת פרמטרים. גם int a[100]; הגודל פשוט מתעלמים ממנו. הכתיבה int a[] מתעדת את הכוונה, אבל אל תקראו אותה כ"מערך מועתק".
sizeof בתוך הפונקציה שגוי. זה הבאג:
ב-main, sizeof(data) הוא 20 בתים והאורך יוצא 5. בתוך הפונקציה sizeof(a) הוא הגודל של מצביע, 8 במערכת של 64 ביט, ולכן ה"אורך" הוא 2. הגודל של המערך פשוט לא זמין שם. תמיד העבירו את האורך כפרמטר נפרד. קומפיילרים מודרניים מזהירים על זה עם -Wall.
השתמשו ב-const כשאין כוונה לכתוב. const int a[] אומר שהפונקציה רק קוראת, והקומפיילר אוכף את זה והקורא יכול לסמוך על כך:
int sum(const int a[], int n); /* מבטיחה לא לשנות את המערך */
גם מחרוזות הן מערכים, אז אותו דבר חל עליהן: void greet(const char *name) היא החתימה הסטנדרטית לפונקציה שקוראת מחרוזת בלי לשנות אותה.
ומה עם העלות?
העברת struct גדול by value מעתיקה כל בית שלו בכל קריאה. עבור struct עם קומץ שדות זה בסדר, ולעתים קרובות אפילו מהיר יותר מהעקיפות שמצביע מוסיף. עבור struct גדול, העבירו מצביע, וסמנו אותו const אם הפונקציה רק קוראת:
struct Config { char name[64]; int flags[32]; double weights[128]; };
void applyConfig(const struct Config *cfg); /* בלי העתקה של 512 בתים בכל קריאה */
const struct Config *cfg פירושו "מצביע ל-Config שלא אשנה", וכך מקבלים את היעילות של מצביע עם הבטיחות של עותק.
טעויות נפוצות
- לצפות שפרמטר רגיל ישנה את המשתנה של הקוראת. הוא לא יכול. העבירו מצביע.
- לשכוח
&במקום הקריאה.swap(x, y)כש-swapמצפה למצביעים היא שגיאת טיפוס: הקומפיילר תופס אותה, אבל רק אם יש אב טיפוס בטווח. - לשכוח
*בתוך הפונקציה.a = b;על שני פרמטרים מסוג מצביע מחליף את העותקים המקומיים, לא את הערכים. מה שרציתם הוא*a = *b;. - להשתמש ב-
sizeofעל פרמטר מסוג מערך. תמיד שגוי. העבירו את האורך. - להחזיר מצביע למשתנה מקומי. המשתנה המקומי מת כשהפונקציה חוזרת, כך שהקוראת מחזיקה מצביע תלוי (dangling pointer), וזו התנהגות לא מוגדרת. החזירו by value, כתבו לתוך buffer שהקוראת סיפקה, או הקצו עם
malloc. - לא לבדוק אם פרמטר מסוג מצביע הוא
NULL. פונקציה שמבצעת dereference לכל מה שמעבירים לה תקרוס על ארגומנט null.
שאלות נפוצות
האם יש ב-C העברה by reference?
לא. ב-C יש מצב אחד בלבד: העברה by value. פונקציה תמיד מקבלת עותק של כל ארגומנט, כך שהשמה לפרמטר אף פעם לא נוגעת במשתנה של הקוראת. מה שנראה כמו העברה by reference הוא בעצם העברה של מצביע by value: המצביע מועתק, אבל הוא עדיין מצביע על האובייקט של הקוראת, ולכן הפונקציה יכולה לשנות אותו דרך המצביע.
איך מעבירים מערך לפונקציה ב-C?
כתבו את הפרמטר בתור int a[] או int *a, שתי הצורות זהות במשמעותן, והעבירו את האורך כפרמטר נפרד: int sum(const int a[], int n). המערך לא מועתק; הפונקציה מקבלת מצביע לאיבר הראשון שלו, כך ששינויים ב-a[i] נראים גם אצל הקוראת.
למה sizeof לא עובד על פרמטר מסוג מערך ב-C?
כי הפרמטר הוא בעצם מצביע. sizeof(a) בתוך הפונקציה נותן את הגודל של מצביע (בדרך כלל 8 בתים), לא את הגודל של המערך, ולכן sizeof(a)/sizeof(a[0]) נותן משהו כמו 2 במקום מספר האיברים. תמיד העבירו את האורך במפורש.
איך פונקציה ב-C יכולה להחזיר יותר מערך אחד?
העבירו מצביעים עבור התוצאות הנוספות וכתבו דרכם: int divide(int a, int b, int *quotient, int *remainder) מחזירה דגל הצלחה וממלאת את שני הפלטים. החלופה היא להחזיר struct שאורז את הערכים יחד.