מערך הוא בלוק בגודל קבוע של איברים מאותו טיפוס, שמסודרים אחד אחרי השני בזיכרון. הסידור הרציף הזה הוא כל הסיפור: בגללו גישה לפי אינדקס היא מיידית, בגללו מערכים ומצביעים קשורים כל כך, ובגללו C יכולה לתת לכם את הכתובת של איבר אפס ולתת לכם למצוא את השאר בחשבון.
הצהרה על מערך
טיפוס האיברים, שם וגודל בסוגריים מרובעים:
int scores[5]; // 5 ints
double prices[100]; // 100 doubles
char initials[3]; // 3 chars
הגודל הוא מספר האיברים, והוא חייב להיות ידוע במקום שבו המערך מוצהר, בדרך כלל ערך מילולי או קבוע שהוגדר עם #define. הזיכרון נשמר מיד; שום דבר לא מוקצה מאוחר יותר.
מערך מקומי שזה עתה הוצהר מכיל זבל, לא אפסים:
השורה השנייה עשויה להדפיס אפסים, עשויה להדפיס מספרים ענקיים, ועשויה להשתנות בין הרצות או בין גרסת debug לגרסת release. קריאת הערכים האלה היא התנהגות לא מוגדרת; אתחלו לפני שאתם קוראים.
אתחול
כמה צורות, כולן שימושיות:
את הניב {0} כדאי לזכור: כל איבר שלא רשמתם מאותחל לאפס, כך שאפס יחיד מאפס את כל המערך. מאתחלים ממוענים ([4] = 99) מאפשרים לקבוע מיקומים מסוימים ולהשאיר את השאר באפס, וזה נוח לטבלאות חיפוש שרובן ריקות.
דבר אחד שאי אפשר לעשות הוא להשים ערך למערך אחרי שהוא נוצר:
int a[3] = {1, 2, 3};
int b[3];
b = a; // error: an array is not assignable
memcpy(b, a, sizeof a); // this is how you copy one
האינדקס מתחיל מאפס
האיבר הראשון הוא arr[0] והאחרון הוא arr[n-1]. אין arr[n].
הריצו את הלולאה האחרונה והסתכלו על הכתובות: כל אחת נמצאת בדיוק sizeof(int) בתים אחרי הקודמת. האיברים באמת מסודרים ברצף:
index 0 1 2 3 4
+--------+--------+--------+--------+--------+
value | 88 | 95 | 75 | 60 | 100 |
+--------+--------+--------+--------+--------+
offset +0 +4 +8 +12 +16 bytes
^
&scores[0], which is also what `scores` decays to
אינדקס שמתחיל מאפס אינו בחירה שרירותית: הוא הופך את האינדקס להיסט מההתחלה, וזה בדיוק מה שחשבון הכתובות צריך. scores[i] מוגדר כ-*(scores + i); ראו מצביעים ומערכים.
מציאת האורך
C לא שומרת את אורך המערך בשום מקום בזמן ריצה, אבל הקומפיילר יודע אותו, ו-sizeof נותן לכם גישה אליו:
sizeof(arr) / sizeof(arr[0]) הוא הניב המקובל. חלוקה ב-arr[0] במקום ב-sizeof(double) קבוע בקוד פירושה שהשורה ממשיכה לעבוד גם אם תשנו את טיפוס האיברים.
המלכודת: זה עובד רק במקום שבו המערך הוצהר. העבירו את המערך לפונקציה, והפרמטר הוא מצביע, כך ש-sizeof מודד את המצביע במקום, בדרך כלל 8 בתים, ומקבלים "אורך" של 1 או 2. זו הסיבה שכל פונקציה ב-C שמקבלת מערך מקבלת גם מספר איברים:
מעבר על מערך בלולאה
הצורה המקובלת היא לולאת for מ-0 כל עוד i < n:
שימו לב ל-i < n, לא i <= n. עם n איברים האינדקסים החוקיים הם 0 עד n - 1, כך ש-<= מריץ סיבוב נוסף וקורא מעבר לסוף. אתחול hottest ו-coldest מ-temps[0] במקום מ-0 הוא ההרגל השני שכדאי להעתיק: מקסימום שמתחיל מאפס נשבר בשקט על נתונים שכולם שליליים.
חריגה מהגבולות היא התנהגות לא מוגדרת
זה החלק ב-C שמפתיע אנשים שמגיעים משפות אחרות. אין בדיקת גבולות. לא בזמן קומפילציה ולא בזמן ריצה. arr[10] על מערך של חמישה איברים אינו שגיאה: זה חישוב כתובת שהקומפיילר מבצע בלי להעיר דבר.
int arr[5] = {1, 2, 3, 4, 5};
arr[7] = 99; // writes 8 bytes past the end of the array
int x = arr[-1]; // reads before the start
מה שקורה אחר כך הוא התנהגות לא מוגדרת, והתסמינים שלה מגוונים באופן לא מועיל:
- זה נראה עובד, כי הזיכרון שנפגע לא היה בשימוש;
- משתנה אחר משתנה בלי סיבה נראית לעין;
- התוכנית קורסת עם segmentation fault, אולי הרבה יותר מאוחר;
- ההתנהגות שונה בין
-O0ל--O2.
"נראה עובד" הוא המקרה המסוכן, כי פירושו שהבאג מגיע לייצור. שלוש הגנות:
- כתבו לולאות עם
i < n. רוב החריגות הן טעויות של אחד בתנאי הלולאה. - בדקו אינדקסים שמגיעים מקלט.
- בנו עם sanitizer בזמן הפיתוח.
gcc -fsanitize=address -g prog.cהופך את רוב החריגות לדיווח מיידי שמציין את הקובץ, השורה והמערך המעורב.
מערכים של טיפוסים אחרים
אותו תחביר עובד לכל טיפוס איברים, כולל מבנים (structs):
השורה char word[6] = "hello"; שווה עצירה: מחרוזת ב-C היא פשוט מערך של char שהבית המשמעותי האחרון שלו הוא '\0'. התו המסיים הזה הוא הסיבה שהמערך צריך שישה תאים עבור חמש אותיות.
מערכים באורך משתנה, בקצרה
C99 מתירה מערך שהגודל שלו הוא ערך בזמן ריצה:
int n = get_count();
int buffer[n]; // a variable-length array
שתי אזהרות. הגודל קבוע ברגע שהמערך נוצר, כך ש-VLA אינו רשימה שיכולה לגדול. והזיכרון מגיע מהמחסנית, כך ש-n גדול או כזה שתוקף שולט בו עלול לגרום לה לגלוש ולהקריס את התוכנית, ולכן פרויקטים רבים, כולל ליבת Linux, אוסרים על VLA לחלוטין. בנוסף, ב-C11 התמיכה בהם היא אופציונלית למימושים.
כשהגודל באמת לא ידוע עד זמן הריצה, הקצו זיכרון במקום:
שימו לב ש-arr[i] נקרא בדיוק אותו דבר בין אם arr הוא מערך ובין אם הוא מצביע לזיכרון שהוקצה: לתחביר האינדקס זה לא משנה. כל הפרטים בזיכרון דינמי.
שאלות נפוצות
איך מצהירים על מערך ב-C?
כותבים את טיפוס האיברים, שם וגודל בסוגריים מרובעים: int scores[5]; שומר מקום לחמישה int. הגודל חייב להיות קבוע שידוע במקום שבו המערך מוצהר (למעט מערכים באורך משתנה), והאיברים מתחילים לא מאותחלים אלא אם נותנים להם ערכים.
איך מוצאים את האורך של מערך ב-C?
sizeof(arr) / sizeof(arr[0]): הגודל הכולל בבתים חלקי הגודל של איבר אחד. זה עובד רק בטווח שבו המערך הוצהר: ברגע שהמערך מועבר לפונקציה הוא הופך למצביע, ו-sizeof מודד את המצביע במקום.
מה קורה אם חורגים מסוף מערך ב-C?
שום דבר לא עוצר אתכם. C לא בודקת גבולות, כך ש-arr[10] על מערך של 5 איברים קורא או כותב את מה שנמצא בזיכרון שם. זו התנהגות לא מוגדרת: היא עלולה להדפיס זבל, להשחית משתנה אחר או לקרוס, ואולי גם להיראות תקינה עד שיום אחד היא לא.
איך מאתחלים את כל איברי המערך לאפס ב-C?
int arr[100] = {0};: כל איבר שלא רשמתם מאותחל לאפס, כך שאפס אחד מאפס את כל המערך. גם int arr[100] = {}; עובד ב-C23. בלי שום מאתחל, התוכן של מערך מקומי הוא זבל לא מוגדר.