לדלג לתוכן

משתני ארגומנטים ומימוש printf - variadic functions

הקדמה

בפרק 4.1 פגשנו את printf, ומאז השתמשנו בה כמעט בכל תוכנית. שמנו לב למשהו מוזר? כל פונקציה שכתבנו עד עכשיו מקבלת מספר קבוע של פרמטרים - strlen מקבלת מחרוזת אחת, memcpy מקבלת שלושה ארגומנטים. אבל printf מקבלת כמה שבא לה:

printf("hello\n");
printf("%d\n", 42);
printf("%s is %d years old\n", name, age);

אותה פונקציה בדיוק, פעם עם ארגומנט אחד ופעם עם שלושה. איך זה עובד?

פונקציה כזו נקראת variadic function - פונקציה עם מספר משתנה של ארגומנטים. בהרצאה הזו נלמד איך לכתוב פונקציות כאלה בעצמנו, ובסוף נכתוב מימוש משלנו ל-printf. זו אחת הפעמים הבודדות שבהן נפתח קופסה שחורה שהשתמשנו בה מהשיעור הראשון.

החתימה - שלוש הנקודות

כך נראית ההצהרה האמיתית של printf:

int printf(const char *format, ...);

שלוש הנקודות (...) נקראות ellipsis, והן אומרות לקומפיילר "אחרי הפרמטר הזה יכולים לבוא עוד ארגומנטים, ואני לא מתחייב כמה או מאיזה טיפוס".

יש כלל אחד חשוב: חייב להיות לפחות פרמטר רגיל אחד לפני ה-.... אי אפשר לכתוב void f(...). תכף נראה למה - הפרמטר הזה הוא נקודת העיגון שממנה מתחילים לקרוא את השאר.

ה stdarg.h - הכלים לעבודה עם הארגומנטים

כדי לגשת לארגומנטים שהועברו ב-... צריך את קובץ הכותרת:

#include <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;
}

והשימוש:

printf("%d\n", sum_ints(3, 10, 20, 30));  // 60
printf("%d\n", sum_ints(5, 1, 2, 3, 4, 5)); // 15

שימו לב ל-va_arg(ap, int). אנחנו אומרים לו במפורש "תביא לי int". הוא לא יודע לבד מה יושב שם - אנחנו מספרים לו.

החוזה - מי סופר את הארגומנטים?

זה החלק הכי חשוב בהרצאה, ואם תזכרו ממנה דבר אחד שיהיה זה.

בשפת C אין שום דרך לשאול כמה ארגומנטים הועברו לפונקציה, ומאיזה טיפוס. המידע הזה פשוט לא נשמר בשום מקום בזמן ריצה. אין argc נסתר, אין טבלת טיפוסים.

לכן, כל פונקציה variadic חייבת להסכים עם הקורא שלה על חוזה - דרך כלשהי לדעת מתי לעצור. בפועל יש שלוש שיטות מקובלות:

  1. פרמטר שסופר - כמו sum_ints למעלה, שמקבלת count מפורש
  2. ערך עצירה (sentinel) - הרשימה מסתיימת בערך מוסכם, בדרך כלל NULL. כך עובדת execl שנפגוש בפרק 5
  3. מחרוזת הפורמט - הדרך של printf. מספר ה-% במחרוזת ומה שכתוב אחריהם הם שאומרים לפונקציה כמה ארגומנטים לקרוא ומאיזה טיפוס

והנה הנקודה: va_arg הוא עיוור. הוא לא בודק כלום. אם תבקשו ממנו ארגומנט שלא הועבר - הוא יקרא מהזיכרון בכל מקרה, ויחזיר לכם זבל. אם תבקשו int במקום שבו הועבר char* - הוא יחזיר לכם את הכתובת כמספר, בלי להתלונן.

printf("%d %d %d\n", 42);  // only one argument was passed - the rest is garbage

הקוד הזה מתקמפל (עם אזהרה מ-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:

log_msg("INFO", "server started on port %d", 8080);
log_msg("ERROR", "cannot open %s", filename);

זה הדפוס שתראו בכל ספרייה רצינית שכתובה ב-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)
  • לעולם אל תעבירו קלט משתמש כמחרוזת פורמט