משתני ארגומנטים ומימוש printf - variadic functions¶
הקדמה¶
בפרק 4.1 פגשנו את printf, ומאז השתמשנו בה כמעט בכל תוכנית. שמנו לב למשהו מוזר? כל פונקציה שכתבנו עד עכשיו מקבלת מספר קבוע של פרמטרים - strlen מקבלת מחרוזת אחת, memcpy מקבלת שלושה ארגומנטים. אבל printf מקבלת כמה שבא לה:
אותה פונקציה בדיוק, פעם עם ארגומנט אחד ופעם עם שלושה. איך זה עובד?
פונקציה כזו נקראת variadic function - פונקציה עם מספר משתנה של ארגומנטים. בהרצאה הזו נלמד איך לכתוב פונקציות כאלה בעצמנו, ובסוף נכתוב מימוש משלנו ל-printf. זו אחת הפעמים הבודדות שבהן נפתח קופסה שחורה שהשתמשנו בה מהשיעור הראשון.
החתימה - שלוש הנקודות¶
כך נראית ההצהרה האמיתית של printf:
שלוש הנקודות (...) נקראות ellipsis, והן אומרות לקומפיילר "אחרי הפרמטר הזה יכולים לבוא עוד ארגומנטים, ואני לא מתחייב כמה או מאיזה טיפוס".
יש כלל אחד חשוב: חייב להיות לפחות פרמטר רגיל אחד לפני ה-.... אי אפשר לכתוב void f(...). תכף נראה למה - הפרמטר הזה הוא נקודת העיגון שממנה מתחילים לקרוא את השאר.
ה stdarg.h - הכלים לעבודה עם הארגומנטים¶
כדי לגשת לארגומנטים שהועברו ב-... צריך את קובץ הכותרת:
הוא נותן לנו טיפוס אחד ושלושה מאקרו:
- ה
va_list- הטיפוס של המשתנה שעוקב אחרי המיקום שלנו ברשימת הארגומנטים - ה
va_start(ap, last)- מאתחל אתap, כאשרlastהוא שם הפרמטר האחרון שיש לו שם (זה שלפני ה-...) - ה
va_arg(ap, type)- מחזיר את הארגומנט הבא, ומקדם אתapלזה שאחריו - ה
va_end(ap)- מסיים את העבודה ומשחרר את מה שצריך
בואו נראה פונקציה שמחברת מספרים:
#include <stdarg.h>
int sum_ints(int count, ...) {
va_list ap;
va_start(ap, count);
int total = 0;
for (int i = 0; i < count; i++) {
total += va_arg(ap, int);
}
va_end(ap);
return total;
}
והשימוש:
שימו לב ל-va_arg(ap, int). אנחנו אומרים לו במפורש "תביא לי int". הוא לא יודע לבד מה יושב שם - אנחנו מספרים לו.
החוזה - מי סופר את הארגומנטים?¶
זה החלק הכי חשוב בהרצאה, ואם תזכרו ממנה דבר אחד שיהיה זה.
בשפת C אין שום דרך לשאול כמה ארגומנטים הועברו לפונקציה, ומאיזה טיפוס. המידע הזה פשוט לא נשמר בשום מקום בזמן ריצה. אין argc נסתר, אין טבלת טיפוסים.
לכן, כל פונקציה variadic חייבת להסכים עם הקורא שלה על חוזה - דרך כלשהי לדעת מתי לעצור. בפועל יש שלוש שיטות מקובלות:
- פרמטר שסופר - כמו
sum_intsלמעלה, שמקבלתcountמפורש - ערך עצירה (sentinel) - הרשימה מסתיימת בערך מוסכם, בדרך כלל
NULL. כך עובדתexeclשנפגוש בפרק 5 - מחרוזת הפורמט - הדרך של
printf. מספר ה-%במחרוזת ומה שכתוב אחריהם הם שאומרים לפונקציה כמה ארגומנטים לקרוא ומאיזה טיפוס
והנה הנקודה: va_arg הוא עיוור. הוא לא בודק כלום. אם תבקשו ממנו ארגומנט שלא הועבר - הוא יקרא מהזיכרון בכל מקרה, ויחזיר לכם זבל. אם תבקשו int במקום שבו הועבר char* - הוא יחזיר לכם את הכתובת כמספר, בלי להתלונן.
הקוד הזה מתקמפל (עם אזהרה מ-gcc), רץ, ומדפיס ערכים אקראיים מהזיכרון. אף אחד לא עצר אותו, כי אין מי שיעצור.
תזכרו את המשפט הזה - נחזור אליו בפרק 7.6. כשמשתמש זדוני שולט במחרוזת הפורמט, הוא בעצם שולט בחוזה עצמו, ויכול לגרום ל-
printfלקרוא כמה ערכים שהוא רוצה מהמחסנית.
פירוק מחרוזת הפורמט¶
עכשיו שאנחנו מבינים את החוזה, נכתוב printf משלנו. השלב הראשון הוא לעבור על מחרוזת הפורמט תו-תו: כל תו רגיל מודפס כמו שהוא, וכשנתקלים ב-% מסתכלים על התו הבא כדי להחליט מה לעשות.
void my_printf(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
for (const char *p = fmt; *p != '\0'; p++) {
if (*p != '%') {
putchar(*p);
continue;
}
p++; // move past the '%' to the conversion character
switch (*p) {
case 'd': /* handle int */ break;
case 's': /* handle string */ break;
case 'c': /* handle char */ break;
case '%': putchar('%'); break;
}
}
va_end(ap);
}
השלד הזה הוא כל הרעיון. מה שנשאר הוא למלא את המקרים.
המרת מספר למחרוזת¶
בשביל %d צריך להמיר int לרצף תווים. בפרק 4.5 השתמשנו ב-sprintf בשביל זה, אבל עכשיו אנחנו כותבים את printf - אסור לנו להיעזר בו. נכתוב את ההמרה בעצמנו:
void print_number(int n, int base) {
char digits[] = "0123456789abcdef";
char buf[32];
int i = 0;
if (n < 0) {
putchar('-');
n = -n;
}
if (n == 0) {
putchar('0');
return;
}
// the digits come out in reverse order
while (n > 0) {
buf[i++] = digits[n % base];
n /= base;
}
// so we print them backwards
while (i > 0) {
putchar(buf[--i]);
}
}
חילוק חוזר בבסיס מייצר את הספרות מהאחרונה לראשונה, ולכן אוספים אותן למאגר ומדפיסים בסדר הפוך. שימו לב שהפרמטר base נותן לנו גם %d (בסיס 10) וגם %x (בסיס 16) באותו קוד.
מימוש מלא¶
נחבר את הכל:
#include <stdio.h>
#include <stdarg.h>
void my_printf(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
for (const char *p = fmt; *p != '\0'; p++) {
if (*p != '%') {
putchar(*p);
continue;
}
p++;
switch (*p) {
case 'd':
print_number(va_arg(ap, int), 10);
break;
case 'x':
print_number(va_arg(ap, int), 16);
break;
case 'c':
// char is promoted to int when passed through '...'
putchar(va_arg(ap, int));
break;
case 's': {
const char *s = va_arg(ap, const char *);
while (*s) {
putchar(*s++);
}
break;
}
case '%':
putchar('%');
break;
default:
// unknown conversion - print it as-is
putchar('%');
putchar(*p);
break;
}
}
va_end(ap);
}
שורה אחת כאן דורשת הסבר: case 'c' קורא va_arg(ap, int) ולא va_arg(ap, char). הסיבה היא שכשארגומנט עובר דרך ..., טיפוסים קטנים מ-int מקודמים אוטומטית ל-int (וגם float מקודם ל-double). זה נקרא default argument promotions. לכתוב va_arg(ap, char) זו שגיאה - הערך פשוט לא נמצא שם בגודל הזה. נחזור לזה לעומק בפרק 10.
משפחת ה-v - להעביר את הארגומנטים הלאה¶
נניח שאנחנו רוצים פונקציית לוג שמוסיפה תחילית לכל הודעה:
void log_msg(const char *level, const char *fmt, ...) {
printf("[%s] ", level);
printf(fmt, /* ??? */); // how do we forward the arguments?
}
כאן נתקעים. אי אפשר "לפתוח" את ה-... ולהעביר אותו הלאה לפונקציה אחרת - אין סינטקס כזה בשפה.
הפתרון הוא שלכל פונקציה variadic בספרייה יש בת-זוג שמקבלת va_list מוכן במקום .... השם שלה מתחיל ב-v:
| הפונקציה הרגילה | הגרסה שמקבלת va_list |
|---|---|
printf |
vprintf |
fprintf |
vfprintf |
sprintf |
vsprintf |
snprintf |
vsnprintf |
עכשיו הפונקציה שלנו עובדת:
#include <stdarg.h>
#include <stdio.h>
void log_msg(const char *level, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
printf("[%s] ", level);
vprintf(fmt, ap);
printf("\n");
va_end(ap);
}
והשימוש נראה בדיוק כמו printf:
זה הדפוס שתראו בכל ספרייה רצינית שכתובה ב-C, וגם בקרנל של לינוקס. בכל פעם שאתם רוצים לעטוף את printf במשהו משלכם - זו הדרך.
אזהרה חשובה: אחרי שהעברתם
va_listלפונקציה כמוvprintf, אסור להשתמש בו שוב. הוא כבר "נצרך". אם צריך לעבור על הארגומנטים פעמיים, ישva_copyשמייצר עותק.
למה אסור שהמשתמש ישלוט בפורמט¶
עכשיו, עם ההבנה של החוזה, אפשר להסביר סוף-סוף למה הקוד הזה מסוכן:
char user_input[100];
fgets(user_input, sizeof(user_input), stdin);
printf(user_input); // dangerous!
printf("%s", user_input); // safe
בשורה הראשונה, מה שהמשתמש הקליד הופך להיות מחרוזת הפורמט. אם הוא יקליד %x %x %x, אז my_printf שלנו (וגם ה-printf האמיתי) יקרא שלושה ארגומנטים שאף אחד לא העביר - כלומר יקרא ערכים אקראיים מהמחסנית ויציג אותם למשתמש.
בשורה השנייה מחרוזת הפורמט קבועה ובשליטתנו, והקלט של המשתמש הוא רק נתון רגיל. אין שום דבר שהמשתמש יכול להקליד כדי לשנות את זה.
זו חולשת format string, ונרחיב עליה בפרק 7.6. עכשיו אתם כבר יודעים בדיוק מה קורה שם מתחת - זה לא קסם, זה va_arg שממשיך לקרוא כי אין מי שיגיד לו לעצור.
סיכום¶
- פונקציה variadic מקבלת מספר משתנה של ארגומנטים, ומסומנת ב-
...בחתימה - חייב להיות לפחות פרמטר אחד עם שם לפני ה-
... - ה
stdarg.hנותן אתva_list,va_start,va_arg,va_end - ה
va_argלא בודק כלום - הוא קורא את מה שביקשתם, בטיפוס שביקשתם - ב-C אין דרך לדעת כמה ארגומנטים הועברו, ולכן חייב להיות חוזה: מונה, sentinel, או מחרוזת פורמט
- ה
printfהיא בסך הכל לולאה על מחרוזת הפורמט עםswitchעל התו שאחרי ה-% - טיפוסים קטנים מקודמים ל-
intו-floatמקודם ל-doubleכשהם עוברים ב-... - אי אפשר להעביר
...הלאה - בשביל זה יש את משפחת ה-v(vprintf,vsnprintf) - לעולם אל תעבירו קלט משתמש כמחרוזת פורמט