כל קובץ C שאי פעם קימפלתם התחיל בשורה כמו #include <stdio.h>, והשורה הזו בכלל לא C. זו הוראה לתוכנית נפרדת, ה-preprocessor, שרצה ראשונה, משכתבת את טקסט המקור שלכם ומעבירה את התוצאה לקומפיילר.
הבנת החלוקה הזו לשני שלבים מסבירה הרבה מההתנהגות של C: למה קבצי header מודבקים ולא מיובאים, למה מאקרו פגום גורם לשגיאה בשורה שנראית תקינה לגמרי, ולמה אותו קובץ מקור יכול להתקמפל לתוכניות שונות במערכות שונות.
מה באמת קורה לפני הקומפילציה
קומפילציה של קובץ .c היא לא צעד אחד. בערך, מדובר בארבעה:
- Preprocessing: ביצוע כל שורה שמתחילה ב-
#, והפקת טקסט מקור מורחב אחד גדול (translation unit). - קומפילציה: הפיכת הטקסט הזה לאסמבלי, ואז לקובץ object.
- אסמבלי: הפקת קוד מכונה.
- קישור (linking): חיבור קובצי ה-object עם הספריות לקובץ הרצה.
ה-preprocessor עובד אך ורק על טקסט. הוא לא יודע מה זה משתנה, מה זה טיפוס, או אם הסוגריים המסולסלים שלכם מאוזנים. הוא רואה תווים ו-tokens, מחליף חלק מהם באחרים, וממשיך הלאה. כל מה שמוזר במאקרו נובע מהעובדה הזו.
כשהקומפיילר קורא את התוכנית הזו, אין בה GREETING בשום מקום. ה-preprocessor כבר החליף אותו בליטרל "Hello from a macro", בדיוק כאילו הקלדתם אותו בעצמכם.
משפחת ההנחיות
כל הנחיית preprocessor מתחילה ב-# כתו הראשון בשורה שאינו רווח. אין נקודה־פסיק, וההנחיה מסתיימת בסוף השורה, אלא אם ממשיכים אותה עם לוכסן הפוך.
| הנחיה | מה היא עושה |
|---|---|
#include | מדביקה את התוכן של קובץ אחר |
#define | מגדירה מאקרו (החלפת טקסט) |
#undef | מסירה הגדרה של מאקרו |
#ifdef, #ifndef | משאירות את הקוד שאחריהן רק אם מאקרו מוגדר (או לא מוגדר) |
#if, #elif, #else, #endif | משאירות קוד לפי ביטוי קבוע |
#error | עוצרת את הקומפילציה עם הודעה |
#pragma | הוראה ייחודית לקומפיילר, למשל #pragma once |
#line | משנה את מספר השורה המדווח (נדיר) |
לשלוש מהן יש עמודים משלהן: עמוד המאקרו עוסק ב-#define לעומק, עמוד הקומפילציה המותנית עוסק במשפחת #if, ועמוד קבצי ה-header מסביר איך משתמשים ב-#include כדי לבנות תוכנית מכמה קבצים.
#include: סוגריים משולשים לעומת מירכאות
ל-#include יש תפקיד אחד בלבד: היא מחליפה את השורה שלה בכל התוכן של הקובץ שצוין. גם הקובץ שנכלל עובר preprocessing, כך ששורות ה-#include שלו מתרחבות גם הן.
#include <stdio.h> /* חיפוש בתיקיות ה-include של המערכת */
#include "config.h" /* חיפוש קודם בתיקייה של הקובץ הזה */
ההבדל הוא בסדר החיפוש:
<angle brackets>מחפשים בתיקיות ה-include הסטנדרטיות של הקומפיילר:/usr/include, התיקיות של ערכת הכלים עצמה, וכל מה שמוסיפים עם-I. זה מיועד לקבצי header של ספריות."quotes"מחפשות קודם בתיקייה של הקובץ שמבצע את ההכללה, ואז חוזרות לאותה רשימה כמו הסוגריים המשולשים. זה מיועד לקבצי header שכתבתם בעצמכם.
ברוב הקומפיילרים שתי הצורות עובדות לשני סוגי ה-header, אבל למוסכמה יש משמעות: סוגריים משולשים אומרים "זה ה-header של מישהו אחר", מירכאות אומרות "זה שלי". בלבול ביניהן הוא הדרך שבה פרויקט מגיע לכלול header ישן של המערכת במקום ה-header שלו.
מכיוון שהכללה היא הדבקה של טקסט, הכללה של אותו header פעמיים מדביקה אותו פעמיים, ולכן קבצי header צריכים include guards. זה הדבר הראשון שעמוד קבצי ה-header מתקן.
#define: החלפת טקסט, לא יותר
#define NAME replacement אומר ל-preprocessor: מעכשיו, בכל מקום שבו מופיע ה-token NAME, שים במקומו replacement.
שימו לב למה שלא קורה כאן. ל-MAX_USERS אין טיפוס. הוא לא נשמר בשום מקום. אי אפשר לבדוק אותו ב-debugger. זה כלל של חיפוש והחלפה, ואחרי ה-preprocessing התוכנית מכילה ממש את printf("%s allows %d users\n", "Coddy", 100);.
זה גם אומר שההחלפה עיוורת. הקוד הבא מתקמפל, ועושה משהו מפתיע:
#define SIZE 5 + 1
int arr[SIZE]; /* תקין: int arr[5 + 1]; */
int total = SIZE * 2; /* 5 + 1 * 2 == 7, ולא 12 */
הפתרון, סוגריים סביב הכול, הוא הכלל המרכזי של עמוד המאקרו, יחד עם מאקרו שמקבלים ארגומנטים.
#define בלי טקסט החלפה מגדיר את השם כ"קיים אבל ריק". זה חסר תועלת כהחלפה, וחיוני כדגל:
#define DEBUG /* מוגדר, ומתרחב לכלום */
אחר כך הקוד יכול לשאול אם DEBUG קיים בעזרת #ifdef.
צפייה בפלט של ה-preprocessor
הדרך הטובה ביותר לפתח אינטואיציה היא להסתכל על מה שה-preprocessor באמת הפיק. gcc -E עוצר אחרי ה-preprocessing ומדפיס את התוצאה:
gcc -E hello.c
בקובץ שכולל את <stdio.h>, מדובר ב-700 עד 30,000 שורות, תלוי במערכת, וכמעט כולן תוכן ה-header עצמו. כדי לראות רק את החלק שלכם, קחו את הסוף:
gcc -E hello.c | tail -20
נסו את זה על קובץ כמו זה:
#define SQUARE(x) ((x) * (x))
#define LIMIT 10
int main(void) {
int n = SQUARE(LIMIT);
return n;
}
סוף הפלט מציג:
int main(void) {
int n = ((10) * (10));
return n;
}
כל המאקרו נעלמו, ונשאר רק הטקסט המוחלף. כשמאקרו מתנהג לא כמצופה, הפקודה הזו מסבירה לכם למה תוך שניות, וזה עדיף על ניחושים. שני כלים שימושיים נוספים: gcc -dM -E - < /dev/null מציג את כל המאקרו שהקומפיילר מגדיר מראש, ו-gcc -E -P file.c משמיט את סימוני השורות המיותרים.
למה שגיאות מצביעות על השורה הלא נכונה
מכיוון שהקומפיילר רואה טקסט מורחב, טעות בתוך מאקרו מדווחת במקום שבו המאקרו נוצל, ולא במקום שבו הוא נכתב:
#define HALF(x) (x / 2
int main(void) {
int y = HALF(8); /* השגיאה מדווחת כאן */
return 0;
}
הסוגר החסר נמצא בשורת ה-#define, אבל הקומפיילר מתלונן על השורה שמכילה את HALF(8), לעיתים קרובות עם הודעה על token לא צפוי שאין לה שום היגיון בהקשר. כששגיאה נראית בלתי אפשרית, הרחיבו את הקובץ עם gcc -E וקראו את השורה האמיתית.
קומפיילרים מודרניים עוזרים: GCC ו-clang מדפיסים הערה בסגנון "in expansion of macro" שמפנה בחזרה להגדרה. קמפלו עם -Wall -Wextra כדי שבאמת תראו את ההערות האלה.
מאקרו מוגדרים מראש
ה-preprocessor מספק בעצמו כמה מאקרו. הם שימושיים מאוד לאבחון:
__FILE__ ו-__LINE__ מתרחבים לשם הקובץ ולמספר השורה הנוכחיים, וכך מאקרו של assertion ושל לוגים מדווחים איפה משהו השתבש. (__func__ קצת שונה: זה מזהה אמיתי שהקומפיילר מספק, ולא מאקרו של preprocessor, אבל משתמשים בו באותה דרך.)
קומפיילרים גם מגדירים מראש מאקרו של פלטפורמה, כמו __linux__, _WIN32 ו-__APPLE__. קוד שצריך להשתנות בין מערכות בודק אותם עם #ifdef, וזה הנושא של קומפילציה מותנית.
מה לקחת מכאן
ה-preprocessor קטן והוא טיפש, ושני הדברים מכוונים. הוא נותן לכם שלוש יכולות: למשוך קובץ, להחליף טקסט, ולהדליק ולכבות קוד, ובלי שום בדיקת טיפוסים שתלווה אותן.
העסקה הזו היא הסיבה שהעצה המקובלת היא להעדיף קודם את יכולות השפה: השתמשו ב-const int או ב-enum במקום #define לקבועים כשאפשר, ובפונקציה אמיתית במקום מאקרו דמוי פונקציה. במקומות שבהם ה-preprocessor הוא באמת הכלי הנכון, כמו קבצי header, מתגים לניידות והגדרות בזמן קומפילציה, אין לו תחליף.
בהמשך, עמוד המאקרו מתייחס ל-#define ברצינות: ארגומנטים, כללי הסוגריים, והמלכודות שמגיעות עם החלפת טקסט בתוך קוד שלא אתם כתבתם.
שאלות נפוצות
מה זה ה-preprocessor ב-C?
שלב של עיבוד טקסט שרץ לפני הקומפילציה. הוא מבצע את השורות שמתחילות ב-#: מדביק את תוכן קבצי ה-header במקום #include, מחליף מאקרו שהוגדרו עם #define, ומוחק או משאיר קוד לפי #if/#ifdef. הקומפיילר רואה רק את התוצאה, ואף פעם לא את הקובץ המקורי שלכם.
מה ההבדל בין #include <stdio.h> לבין #include "myfile.h"?
סוגריים משולשים מחפשים בתיקיות ה-include של המערכת שהקומפיילר מכיר, ושם נמצאים קבצי ה-header של הספרייה הסטנדרטית. מירכאות מחפשות קודם בתיקייה של הקובץ הנוכחי, ורק אחר כך בנתיבי המערכת. השתמשו בסוגריים משולשים עבור קבצי header של ספריות, ובמירכאות עבור קבצי header שכתבתם בעצמכם.
איך רואים מה ה-preprocessor הפיק?
הריצו gcc -E file.c כדי לעצור אחרי שלב ה-preprocessing ולהדפיס את קוד המקור המורחב. בקובץ שכולל את <stdio.h> הפלט הוא אלפי שורות, לכן העבירו אותו בצינור: gcc -E file.c | tail -30 מציג רק את הקוד שלכם, כשכל מאקרו כבר הוחלף.
האם #include היא פקודה של C?
לא. הנחיות אינן חלק משפת C עצמה: יש להן תחביר משלהן שמבוסס על שורות, הן לא מסתיימות בנקודה־פסיק, והן כבר נעלמו כשהקומפיילר מנתח את התוכנית. בגלל זה טעות במאקרו מופיעה כשגיאה מבלבלת בשורה שנראית תקינה.