Professional Documents
Culture Documents
C Pointers
C Pointers
medium.com/@danielfonkaz
תמונת זכרון אפשרית של בלוק (אוסף בתים) החל מהבית בכתובת .5הערך בתוך כל בית שרירותי,
אבל הכתובות של הבתים בבלוק בהכרח תהינה עוקבות.
מהו משתנה?
משתנה הוא שם וטיפוס שהמתכנת קובע עבור בלוק של בתים .כאשר מוגדר משתנה
בתוכנית ,מוקצה עבורו בלוק של בתים החל מכתובת מסוימת בזכרון .כתובת ההחחלה
אינה בשליטת המתכנת ,אבל מובטח שהבתים יוקצו ברצף -כלומר כבלוק.
שם -כיצד בוחר המתכנת לכנות את המשתנה.
טיפוס -כמה בתים יתפוס המשתנה ,ומה סוג הערכים שיאוחסנו בו.
הערה :1גודל הטיפוסים בבתים ומכאן טווח הערכים שלהם תלוי בסביבה בה עוברת
התוכנית קומפילציה .מוצגת כאן האפשרות הנפוצה שלרוב נלמדת .
הערה :0הערה 1נכונה לכל הטיפוסים למעט char, float, double :אשר גודלם קבוע בכל
הסביבות -כי הוא מוגדר בתקן השפה.
לשם חידוד ההבנה ,נציג כעת תוכנית לדוגמה ולאחריה יוצג תרשים זכרון המחשב
המתאר אותה:
)(int main
{
;int i
;char c
;short s = 30
;float f = 1.25f
;char c1
;return 0
}
תרשים תמונת הזכרון המתאים לתוכנית לעיל ,בשיטת ( little-endianראו הערה ,)1בהנחה
שהמשתנים מוקצים ברצף בזכרון החל מהכתובת ( 10ראו הערה .)2שמות המשתנים בראש הציור,
החצים מסמנים את הבתים שמוקצים לכל משתנה ,כתובת כל בית מופיע מתחתיו ,וערכו של המשתנה
מופיע מתחת לחץ המתאים.
ניתן להבחין שהמשתנים מוקצים ברצף בתוכנית ,כאשר לכל משתנה מוקצים מספר בתים
בהתאם לטיפוס שלו.
הערה חשובה :1קיימות שתי שיטות ניהול זכרון :בשיטת little-endianעבור כל משתנה
נשמר בכתובת הנמוכה הבית הכי פחות משמעותי ( - )least significant byteהבתים
נשמרים על פי משמעותם בסדר עולה ,ואילו בשיטת big-endianעבור כל משתנה נשמר
בכתובת הנמוכה הבית הכי משמעותי ( - )most significant byteהבתים נשמרים על פי
משמעותם בסדר יורד .במדריך זה נעבוד עם שיטת ניהול הזכרון הכי נפוצה ,שהיא little-
.endian
© דניאל פונכץ 0202
medium.com/@danielfonkaz
הערה חשובה :0לא מובטח שהמשתנים בתוכנית יוקצו החל מכתובת מסוימת ,בפרט .10
בנוסף ,לא מובטח שהמשתנים בתוכנית יוקצו ברצף בזכרון (כלומר משתנה אחד יופיע
בזכרון מיד לאחר משתנה שני).
מה שכן ניתן לדעת בוודאות על תמונת הזכרון ,מתואר להלן:
לכל משתנה ,הבתים המרכיבים אותו מכילים יחד את ערכו s .למשל מורכב משני .I
בתים ,ולכן שני הבתים יחד יכילו את הערך .22בנוסף ,כל הבתים של משתנה
מסוים יופיעו בהכרח ברצף בזכרון .למשל i ,מורכב מ 4-בתים ,ואם הבית הראשון
מופיע בכתובת , 10שלושת הבתים הנותרים יופיעו בהכרח בכתובות , 14 ,12
.15
הערכים מיוצגים בתוך הבתים בצורה בינארית ,כאשר כל בית מכיל 1ביט. .II
במאמר זה לא נדון ביצוג הבינארי ,ששמור לדיון אחר.
המשתנים שאינם מאותחלים בערך מסוים ,נותרים 'בלתי מאותחלים' וערכם .III
מסומן בתרשים בסימן שאלה .בפועל ,המשתנים מאותחלים ב'-ערך זבל' -הערך
שהיה במקרה בזכרון כאשר נטענה התוכנית והוקצו הבתים עבור המשתנים.
ערך זה אינו ידוע ולכן נתייחס למשתנה כ 'בלתי מאותחל' עד אשר יאותחל.
אם שואלים מה כתובתו של כל בית התשובה ברורה -הכתובת מצוינת מתחת .IV
לבית .אבל מה אם נשאל מה כתובתו של משתנה כלשהו? אם המשתנה מורכב
מבית בודד התשובה היא פשוט כתובתו של הבית הבודד .אבל אם המשתנה
מורכב מכמה בתים ,כתובתו של המשתנה היא הכתובת של הבית הראשון שלו -
זה עם הכתובת הנמוכה ביותר .בדוגמה זו ,כתובתו של המשתנה iהיא ,10
כתובתו של sהיא ,11וכתובתו של c1היא .02
;return 0
}
הגדרת מצביעים
מצביע הוא משתנה שמכיל כתובת .כעקרון מצביע יכול להכיל כל כתובת שהיא .עם זאת,
לרוב הוא יכיל את כתובתו של:
משתנה מטיפוס פשוט .int, double, char - .I
משתנה מטיפוס מבנה כלשהו .struct - .II
האיבר הראשון במערך מטיפוס כלשהו .. int[], double[], char[] - .III
כאמור מצביע הוא משתנה שמכיל כתובת ,שהיא מספר שלם .לכן ניתן היה עקרונית
להשתמש במשתנה מטיפוס מספר שלם (כמו )intעם טווח גדול מספיק על מנת שישמש
כמצביע .בפועל ,לא משתמשים בטיפוס מספר שלם ,אלא קיימים טיפוסים שונים של
מצביעים .כל המצביעים מכילים כתובת ,אך הם נבדלים ביניהם בסוג הכתובת שהם
עשוים להכיל.
להלן מספר דוגמאות:
- int* aמשתנה מצביע שמכיל את כתובתו של מספר שלם מסוג .int -
- char* cמשתנה שמכיל את כתובתו של מספר שלם מסוג .char -
- struct student* ptrמשתנה שמכיל את כתובתו של מבנה בשם .student -
שימו לב לכוכבית — היא מגדירה שהמשתנה הוא מצביע לטיפוס ,ולא משתנה רגיל
מהטיפוס:
- int* aמשתנה שמכיל את הכתובת של מספר שלם ,לעומת:
0202 © דניאל פונכץ
medium.com/@danielfonkaz
struct student {
int id;
int grade;
};
int main() {
// nonpointer variables
int a = 10, b;
struct student stud = { 100000001, 95 };
char c;
// pointer variables
int* ptr1 = &a;
struct student* ptr2 = &stud;
char* ptr3 = &c;
double* ptr4 = NULL; // initialized to NULL - points to "nothing"
return 0;
}
שימוש במצביעים
מצביעים מאפשרים גישה לערכם של המשתנים שעליהם הם מצביעים .ניתן להשתמש
במצביעים לשתי מטרות:
שינוי ערך של המשתנה המוצבע (גישה לכתיבה). -
קריאת ערך המשתנה המוצבע (גישה לקריאה). -
מבצעים זאת תוך שימוש באופרטור * operator* -שמשמעותו :קח את הכתובת שמכיל
המצביע ,גש לכתובת זו בזכרון ,ועשה שימוש בערך שיש שם.
נדגים זאת בתוכנית הבאה:
>#include <stdio.h
{ )(int main
;int a = 5
;} 'char arr[3] = { 'a', 'b', 'c
;]char* c_ptr = &arr[1
;int* i_ptr = &a
;return 0
}
)(int main
{
;int num = 5
;)resetNum(num
;)printf("%d", num
;return 0
}
מה יהיה פלט התוכנית? במבט ראשון ניתן אולי לחשוב שהפלט יהיה .2הפונקציה
מקבלת כפרמטר מספר שלם ,ומציבה בו — 2ולכן ערכו של המשתנה בפונקציה main
יעודכן להיות 2וההדפסה בהתאם .אך לא כך הדבר — ערכו של המשתנה בפונקציה
mainנותר כשהיה ,כלומר num = 5ומכאן יודפס בהתאם .5
הסיבה לכך היא שפונקציה מקבלת ערך — ולא משתנה .הערך שמועבר לפונקציה מוצב
בפרמטר ,שהוא משתנה מקומי חדש שנוצר בפונקציה -ומכאן מכונה העתק.
;) - resetNum(numבשורה זו מועבר הערך של המשתנה שמוגדר בפונקציה ,main
כלומר .5
) - void resetNum(int aבכניסה לפונקציה נוצר משתנה חדש -הפרמטר ,והוא מקבל
את הערך שהועבר בקריאה לפונקציה ,במקרה זה .5
; - a = 0בשורה זו משתנה ערכו של הפרמטר להיות .2ערכו של המשתנה שמוגדר
בפונקציה mainנותר ללא שינוי.
כעת נתבונן בדוגמת קוד נוספת ,בה עולה אותה הבעיה ,הפעם בשימוש ב:struct-
>#include <stdio.h
© דניאל פונכץ 0202
medium.com/@danielfonkaz
>#include <string.h
>#include <stdio.h
#define MAX_NAME_SIZE 64
{ struct student
;]char name[MAX_NAME_SIZE
;unsigned int grade
;}
)(int main
{
struct student stud = { "", 0 }; // initialized
;)readStudent(stud
;)printf("Name: %s, grade: %d", stud.name, stud.grade
;return 0
}
מה יהיה פלט התוכנית? על פניו בפונקציה mainמאותחל מבנה עם ערכי ברירת מחדל
(מחרוזת ריקה בשם ו 2-בציון) .המבנה נשלח כפרמטר לפונקציה שקולטת מהמשתמש
את פרטי הסטודנט ומכניסה אותם למבנה .מכאן צפוי אולי שפלט התוכנית יהיה הפרטים
שהזין המשתמש למבנה — אך עם זאת מודפסים ערכי האיתחול — מחרוזת ריקה ו-
.0
הסיבה ל כך ,בדומה לדוגמת הקוד הקודמת ,היא שבכניסה לפונקציה נוצר משתנה חדש
— הפרמטר ,struct student studשלתוכו מועתק הערך שהועבר כפרמטר ,כלומר
המבנה .הפונקציה קולטת את הקלט שהזין המשתמש לתוך הפרמטר ,ואילו המשתנה
בפונקציה mainנותר ללא שינוי.
>#include <stdio.h
0202 © דניאל פונכץ
medium.com/@danielfonkaz
int main()
{
int num = 5;
resetNum(&num);
printf("%d", num);
return 0;
}
#include <stdio.h>
#include <string.h>
#include <stdio.h>
#define MAX_NAME_SIZE 64
struct student {
char name[MAX_NAME_SIZE];
unsigned int grade;
};
int main()
{
struct student stud = { "", 0 }; // initiailized
readStudent(&stud);
printf("Name: %s, grade: %d", stud.name, stud.grade);
© דניאל פונכץ 0202
medium.com/@danielfonkaz
;return 0
}
;return arr
}
)(int main
{
;int* arr
;int size
;)arr = read_array_from_user(&size
;)free(arr
;return 0
}
בדוגמה זו מוצגת פונקציה הקולטת מערך שלמים מהמשתמש .על הפונקציה להחזיר שני
ערכים — את המערך עצמו ואת גודלו .לכן היא עושה שימוש במשתנה פלט — המערך
מוחזר בהוראת החזרה כרגיל ,וגודל המערך מוחזר תוך שימוש במשתנה הפלט .ניתן
היה גם לממש אחרת — שהמערך יוחזר במשתנה הפלט ,ואילו גודלו יוחזר בהוראת
החזרה — או לחלופין להחזיר את שני הערכים בשני משתני פלט ,ולא להשתמש בערך
החזרה (טיפוס ההחזרה יהיה .)void
>#include <stdio.h
#define MAX_NAME_SIZE 64
{ struct student
;]char name[MAX_NAME_SIZE
;unsigned int grade
;}
)(int main
{
;} struct student stud = { "Yossi", 92
;)printStudent(stud
;return 0
}
לכאורה קטע הקוד תקין .במקרה זה על הפונקציה להדפיס את נתוני הסטודנט ,והיא
איננה מעונינת לשנות את ערכי המשתנה שמוגדר מחוצה לה ,בפונקציה — mainומכאן
אין בעיה שבפונקציה מוגדר הפרמטר studכערך ( struct studentכלומר ,לא כמצביע),
ונוצר משתנה חדש בכניסה לפונקציה אליו מועתקים ערכי המבנה שהועבר מהפונקציה
הראשית — כלומר נוצר העתק.
ברמת הפלט אכן אין בעיה בקוד הנ’ל ,אך קיימת בו בעיה של יעילות — בכל פעם
שקוראים לפונקציה ומעבירים לה כפרמטר מבנה ,הוא מועתק למשתנה ההעתק שנוצר
— וזה עולה בזמן ריצה.
כאשר מדובר במבנים בעלי משקל קטן יחסית ,כמו struct studentבמקרה הנ’ל (כ21-
בתים) — תקורה זו אינה מאוד משמעותית .אך אם מדובר במבנים גדולים יותר ,או
לחלוטין מתבצעות קריאות רבות לפונקציה בפרק זמן קצר ,תקורת זמן הריצה עשויה
להיות משמעותית.
כדי להמנע מתקורה זו ,נהוג שפונקציות מקבלות פרמטר מסוג מבנה שגודלו מעל לזה
של משתנה פרימיטיבי (כלומר ,מעל לכ 1-בתים) כמצביע ולא כערך – במקרה זה
* .struct studentבאופן זה אומנם נוצר משתנה חדש — אך הוא מסוג מצביע ,ששוקל
מעט יחסית ( 1בתים ברוב הסביבות) .כלומר ,רק הכתובת מועתקת ,וכך התקורה קטנה
משמעותית .אם הפונקציה אינה מתכוונת לשנות את המבנה המוצבע ,היא תקבל אותו
כמצביע .const
© דניאל פונכץ 0202
medium.com/@danielfonkaz
להלן קטע הקוד מעודכן כך שיעשה שימוש במצביע בכדי להמנע מהעתקה של
המבנה:
>#include <stdio.h
>#include <string.h
>#include <stdio.h
#define MAX_NAME_SIZE 64
{ struct student
;]char name[MAX_NAME_SIZE
;unsigned int grade
;}
)(int main
{
;} struct student stud = { "Yossi", 92
;)printStudent(&stud
;return 0
}
פלט הקוד זהה לזה של התוכנית לעיל ,אך נחסכת העתקה יקרה יחסית.
)(int main
{
;]int arr[3
;)short* sr = (short*)malloc(sizeof(short) * 3
;)free(sr
;return 0
}
מבחינת הקצאת הזכרון,קיימים כמה הבדלים בין מערך מוקצה מקומית למערך מוקצה
דינאמית:
הזכרון של מערך מקומי מוקצה על מחסנית זמן הריצה ,ואילו הזכרון של -
מערך דינאמי מוקצה בערימה.
על פי כללי התחביר בשפת ,Cמערך מקומי חייב להיות מוקצה בגודל קבוע -
ואילו מערך דינאמי יכול להיות מוקצה בגודל קבוע או שאינו קבוע — למשל
גודל שתלוי בערכו של משתנה.
הקצאת הזכרון של מערך דינאמי עשויה להכשל ,ואילו הקצאה של מערך -
מקומי מובטח שתצליח (ליתר דיוק ,מובטח שהקצאה מקומית תצליח אם
נותר די מקום במחסנית .אחרת ,התוכנית תיפול .מאחר שלרוב מקצים
מערכים מקומיים קטנים מתעלמים בדרך כלל מאפשרות זו).
© דניאל פונכץ 0202
medium.com/@danielfonkaz
הן בהקצאה מקומית והן בהקצאה דינאמית ,המשתנה שמייצג את המערך מכיל את
הכתובת של האיבר הראשון .מכאן ,ניתן לומר שבשני המקרים טיפוס משתנה המערך
הוא מצביע* (ראו הערה בהמשך) ,אשר מצביע לאיבר הראשון במערך.
עם זאת ,קיימים מספר הבדלים חשובים בין מצביע למערך מקומי* לבין מצביע למערך
דינאמי:
מצביע למערך מקומי הוא מקובע — לא ניתן לשנות את מיקום הצבעתו לאחר -
היצירה ,הוא לעד יצביע לאיבר הראשון במערך .לעומת זאת ,מצביע למערך
דינאמי ניתן לשינוי במקום הצבעתו — ניתן לשנות את הכתובת אותה הוא
מכיל כך שהצבעתו תשתנה.
— sizeofאופרטור זה מחזיר את הגודל בבתים של משתנה .אם יופעל על -
מצביע למערך מקומי יתקבל הגודל הכולל של המערך בבתים — בדוגמה
לעיל למשל היה מתקבל ( 10שלושה מספרים שלמים במערך ,כל אחד בגודל
4בתים) .לעומת זאת ,אם יופעל על מצביע למערך דינאמי יתקבל הגודל של
המצביע עצמו — 1ברוב הסביבות ,ללא תלות בגודל המערך.
(*) ליתר דיוק ,במקרה של מערך מקומי ,טיפוס המשתנה אינו מצביע ,אלא טיפוס מערך,
אשר ברוב ההקשרים עובר המרה אוטומטית למצביע ,ולכן מתנהג בפועל כמו מצביע.
במקרים בהם לא מתבצעת המרה אוטומטית כאמור ,מערך מקומי מתנהג בצורה שונה
מעט ממצביע ,כפי שמתואר בנקודות לעיל .לשם הפשטות נתייחס גם למשתנה מערך
מקומי כמצביע לאיבר הראשון במערך.
)(int main
{
;]int arr[3
;)int* d_arr = (int*)malloc(sizeof(int) * 3
;)free(d_arr
;return 0
}
בקטע הקוד ניגשים ומשנים אבר במערך (גישת כתיבה) וכן קוראים את ערכו של אבר
במערך ומדפיסים אותו .הן במערך המקומי והן במערך הדינאמי אין לכאורה שימוש
בהיותם מצביעים — נעשה שימוש באופרטור סוגריים מרובעים לצורך גישה לאיברי
המערכים.
את הת וכנית לעיל היה ניתן לכתוב גם תוך שימוש באריתמטיקה של מצביעים —
ובעובדה שמשתנה מערך הוא למעשה מצביע לאיבר הראשון במערך אותו הוא מייצג:
>#include <stdlib.h
>#include <stdio.h
)(int main
{
;]int arr[3
;)int* d_arr = (int*)malloc(sizeof(int) * 3
;)free(d_arr
;return 0
}
; - *(arr + 0) = 7בשורה זו נלקח ערכו של משתנה המערך ,כלומר כתובת -
האיבר הראשון במערך ,נוסף לו הערך ,2מה שמותיר אותו ללא שינוי .לאחר
© דניאל פונכץ 0202
medium.com/@danielfonkaz
מכן ניגשים לערך בכתובת זו ,כלומר לאיבר הראשון במערך ,ומשנים את
ערכו ל.1-
; - *(d_arr + 1) = 4באופן דומה גם בשורה זו נלקח ערכו של משתנה המערך, -
כלומר כתובת האיבר הראשון ,אבל מוסף לו — 1כלומר דילוג אחד קדימה.
גודל כל דילוג הוא לצורך הענין 4בתים ,מאחר ומדובר במערך של מספרים
שלמים .לכן ,נוסף בפועל לכתובת האיבר הראשון הערך ,4והכתובת
החדשה המתקבלת היא כתובת האיבר השני במערך — אליו ניגשים ומשנים
את ערכו ל.4-
;)) - printf(“%d %d\n”, *(arr + 0), *(d_arr + 1באופן דומה לשורות לעיל, -
גם כאן ניגשים לאיבר ה 0-ולאיבר ה 1-במערכים המתאימים — אך בשורה
זו מודפס ערכם ,ללא שינויו.
אז מה ההבדל בין תחביר סוגריים מרובעים לבין תחביר אריתמטיקה של מצביעים?
למעשה אין הבדל .כאשר ניגשים לאיברי המערך בצורה ה”רגילה” (ללא שימוש
במצביעים) מאחורי הקלעים נעשה למעשה שימוש במצביע לאיבר הראשון (שהוא
כאמור משתנה המערך) כדי לגשת לאיבר המתאים .כלומר ,סוגריים מרובעים הם
למעשה “סוכר תחבירי” שנועד לאפשר עבודה נוחה יותר ,ומתורגמים בפועל על ידי
הקומפיילר לתחביר אריתמטיקה של מצביעים.
arr[0] = 7מתורגם ל← *(arr + 0) = 7 -
arr[1] = 7מתורגם ל← *(arr + 1) = 7 -
ובאופן כללי,
] arr[iמתורגם ל← )*(arr + i -
ש תי צורות הכתיבה שקולות ולכן למתכנת החופש לעבוד בכל אחת מהן .ככלל ,מומלץ
לעבוד בתחביר סוגריים מרובעים כאשר עובדים עם מצביעים המייצגים מערכים,
ובתחביר אריתמטיקה של מצביעים כאשר עובדים עם מצביעים שאינם מייצגים
מערכים ,מאחר וצורת עבודה זו קריאה יותר ופחות מסורבלת .עם זאת ,במקרים
מסוימים (למשל מערכים גנריים) כן מקובל לעבוד עם מערכים בצורת אריתמטיקה של
מצביעים.