Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Studies

מערכים, מצביעים, ותמונת הזכרון

ראינו בהרצאה קודמת כיצד פונקציות מרכיבות את תמונת הזכרון על מחסנית קריאות - כל פונקציה הנקראת (גם main) מקצה על מחסנית הקריאות את הארגומנטים שהיא מקבלת והמשתנים הלוקאליים שהיא מגדירה. כל הקצאה כזו נקראת stack frame. כעת נרצה לבחון כיצד האירוע הזה נראה כשמשתמשים במערכים.

נבחן למשל את התוכנית הבאה:

int main() {
    int x = 78;
    char letters[4] = {'a', 'b', 'c', 'd'};
    float pi = 3.14;
}

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

כלומר, מערכים סטאטיים תמיד מוגדרים בצורה רציפה בזכרון.

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

#define N 2
int main() {
    char x = 'x';
    int matrix[N][N] = {
        {1, 2},
        {3, 4}
    };
}

מחסנית הקריאות תראה כך:

הקומפיילר מאפשר לנו “לראות” בקוד את המערך הרב-מימדי בתוך מערך של מערכים כפי שנוח לנו, אבל בפועל בזכרון מקצה את האיברים ברצף זה אחר זה.

מערכים סטאטיים ומצביעים

לאחר שהכרנו מהם מצביעים ומהם מערכים סטאטיים, נקשר ביניהם. מסתבר מערכים סטאטיים הם קרובים מאוד למצביעים, עם מספר הבדלים חשובים.

מסתבר כי

ראינו שכאשר אנו מגדירים מערך סטאטי כמו int numbers[5], הקומפיילר מקצה 5 משתני int זה אחר זה על המחסנית באופן רציף בזכרון. כשאנחנו כותבים פקודה כמו numbers[3] = 11, כיצד הקומפיילר יודע היכן נמצא האיבר במקום ה-3?

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

#include <stdio.h>
int main(void) {
    int numbers[5] = {0};
    int *ptr = &numbers[0];
    printf("%d", ptr == numbers);
}

הסיבה שקיימת שקילות מסוימת בין מערכים למצביעים נקראת array decay into pointer, או דעיכת מערך למצביע. כלומר, הקומפיילר אוטומטית ממיר מערכים למצביעים במקרים מסוימים, ובמקרים אחרים לא.

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

#include <stdio.h>
int main(void) {
    int arr[5] = {0};
    int *ptr = arr;
    printf("%zu\n", sizeof(arr));
    printf("%zu\n", sizeof(ptr));
}

ננתח:

  1. ההדפסה הראשונה הדפיסה 20. הסיבה לכך היא שהקצנו 5 איברי int, שגודלם הוא 4 בתים - סה״כ 5 * 4 = 20.
  2. ההדפסה השנייה הדפיסה 8 - הסיבה לכך היא שגודל משתנה * int הוא 8 בתים.

כלומר, במקרה זה המערך לא מבצע דעיכה למצביע. הנה מקרה אחר בו הוא כן:

int numbers[5] = {0};
int *ptr = numbers;
numbers[3] == *(ptr + 3);

כשאנחנו משתמשים בסינטקס numbers[i], אנחנו מבקשים מהקומפיילר לבצע את הפעולות הבאה:

  1. התחל בכתובת בזכרון ש-numbers מכיל
  2. התקדם בזכרון i איברים, או i בתים כפול sizeof טיפוס המערך
  3. בצע dereference * לאיבר הגעת אליו

Note

syntactic sugar הוא סינטקס שמציעה עבורנו שפת תכנות על מנת לפשט ולקצר כתיבה של פעולות מורכבות יותר. כתיבת אריתמטיקת מצביעים בתור arr[i] == *(arr + i) היא דוגמא לקונספט. צורת הכתיבה arr[i] השקולה לאריתמטיקת מצביעים היא דוגמא ל-syntactic sugar - סינטקס בשפת תכנות שמטרתו לפשט ולקצר פעולות מורכבות יותר.

מערכים ופונקציות

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

int fn1(int *arr);
int fn2(int arr[]);
int fn3(int arr[3]);

למעשה אין שום משמעות לכתיבה עם [] בהעברה לפונקציה - המערך כך או כך דועך למצביע ללא שום מידע על המערך עצמו אליו הוא מצביע. לכן, בדרך כלל כאשר מעבירים מערך לפונקציה נעביר גם את אורכו:

int calc_avg(int *arr, int len) {
    int sum = 0;
    for (int i = 0; i < len; i++) {
        sum += arr[i];
    }
    return sum / len;
}