Menu

Union ב-C: זיכרון משותף, union מול struct ו-tagged union

איך union ב-C שומר כמה טיפוסים באותם בתים: למה הגודל שלו הוא של האיבר הגדול ביותר, למה צריך לקרוא את האיבר שנכתב אחרון, ואיך tagged union (enum ועוד union) הופך אותו לבטוח.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

struct אומר "את כל אלה, ביחד". union אומר "בדיוק אחד מאלה, בכל רגע". האיברים מונחים זה על גבי זה באותה כתובת, ולכן ה-union גדול רק כמו האיבר הגדול ביותר שלו, וכתיבה לאיבר אחד הורסת את האחרים.

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

הצהרה ושימוש ב-union

התחביר זהה לחלוטין לזה של struct; רק מילת המפתח משתנה.

ניגשים לאיברים עם . (או עם -> דרך מצביע), בדיוק כמו ב-struct. ההבדל הוא שרק האיבר שנכתב אחרון מחזיק ערך בעל משמעות. אחרי v.f = 3.5f, קריאה של v.i לא נותנת 3: היא נותנת את המספר השלם שתבנית הביטים של 3.5f יוצרת במקרה.

גודל: האיבר הגדול ביותר מנצח

השוו את פריסת הזיכרון של struct ושל union עם אותם איברים:

במכונה טיפוסית ה-struct תופס 24 בתים (4 ל-int, 8 ל-double, 1 ל-char, ועוד ריפוד) בעוד שה-union תופס 8: הגודל של ה-double שלו, מעוגל לצורך יישור. שלוש הכתובות שמודפסות זהות, וזה כל הסיפור של union בשורת פלט אחת.

שימו לב להמרות ל-void * עבור %p. printf מצפה בדיוק לזה עבור %p; העברה של טיפוס מצביע אחר היא התנהגות לא מוגדרת, גם אם בדרך כלל זה נראה עובד. ראו format specifiers.

אתחול של union

אתחול בסוגריים מסולסלים בלי designator מאתחל את האיבר הראשון:

הצורה עם ה-designator היא זו שכדאי להשתמש בה. {42} תלוי בשקט בסדר האיברים, כך ששינוי הסדר בהצהרה בהמשך משנה איזה איבר מאותחל: באג מגעיל באמת, כי שום דבר בקוד לא נראה אחרת.

הבעיה האמיתית: איזה איבר חי?

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

המספר שמודפס גדול ומוזר: תבנית הביטים של 1.0f כשקוראים אותה כ-int. שום דבר לא קרס, שום דבר לא הזהיר, והתוכנית שגויה בשקט. ה-union עשה בדיוק את מה שהבטיח; הטעות הייתה שלנו, ששכחנו איזה איבר חי.

הפתרון: tagged union

הפתרון הסטנדרטי הוא לצרף ל-union enum שמתעד את האיבר החי, ולעטוף את שניהם ב-struct. השילוב הזה נקרא tagged union (או discriminated union), וכך כדאי לכתוב בעצם כל union בקוד של אפליקציה.

כל קריאה עוברת עכשיו דרך ה-switch על kind, כך שאי אפשר לקרוא איבר שאף פעם לא נכתב, כל עוד כל כתיבה מעדכנת גם את התגית. עטיפת הכתיבות בפונקציות בנייה קטנות (value_from_int, value_from_string) היא הדרך המקובלת להפוך את זה לבלתי אפשרי לשכוח.

החיסכון בזיכרון אמיתי: כל Value כאן עולה 24 בתים של מטען ועוד התגית, במקום 4 + 4 + 24 של struct שמחזיק את שלושתם. כשיש מאה אלף כאלה, זה משנה.

קימפול עם -Wall מוסיף רשת ביטחון שנייה: אם בהמשך תוסיפו VAL_BOOL ל-enum ותשכחו case בשבילו, GCC יזהיר על ערך enum שלא טופל.

Unions אנונימיים

C11 מתיר איבר union בלי שם בתוך struct, ומעלה את האיברים שלו למרחב השמות של ה-struct החיצוני:

מכיוון שה-union עצמו בלי שם, כותבים s->circle.r במקום s->as.circle.r. קצר יותר לקריאה, במחיר הסתרת העובדה שיש כאן union בכלל, וזה בסדר כשהתגית נמצאת ממש לידו.

איפה unions באמת מצדיקים את עצמם

ארבעה שימושים חוזרים:

  • ערכים משתנים (variants). מפרשים, parsers של JSON ושל קובצי הגדרות ותורי הודעות, כולם נושאים ערכים שהטיפוס שלהם נקבע בזמן ריצה. tagged union הוא הייצוג הקנוני.
  • רשומות חסכוניות בזיכרון. כשב-struct יש כמה שדות שמוציאים זה את זה ויש לכם מיליונים כאלה, הנחתם זה על זה היא חיסכון ישיר.
  • פריסות של פרוטוקולים וחומרה. חבילה שהמטען שלה תלוי בבית של כותרת ממופה באופן טבעי ל-tagged union, וכך גם אוגרים של התקנים.
  • בחינת בתים. הנחת ערך על גבי מערך unsigned char[] מאפשרת לראות את הבתים הבודדים שלו, למשל כדי לקבוע את ה-endianness:

זה type punning: קריאה מכוונת של בתים כאילו הם מטיפוס אחר. קריאה דרך union בצורה כזו מותרת במפורש ב-C כתוצאה מוגדרת מימוש (בניגוד להמרת מצביעים בין טיפוסים לא קשורים, ששוברת את חוקי ה-aliasing), ובחינת בתים כ-unsigned char תמיד בטוחה. פירוש מחדש של int כ-float הוא סיפור אחר: התוצאה תלויה לגמרי בייצוג של הפלטפורמה שלכם, אז השאירו את זה מחוץ לקוד נייד.

טעויות נפוצות

  • קריאת איבר שלא כתבתם אליו. הסכנה המרכזית. השתמשו בתגית.
  • הנחה ש-union ממיר. הוא לא. u.i = 3; float f = u.f; מפרש ביטים מחדש; int i = 3; float f = i; ממיר. ראו type casting.
  • הכנסת מצביע ל-union ואיבוד המעקב אחריו. אם ענף אחד מחזיק char * שהקציתם, דריסת ה-union באיבר אחר גורמת לדליפה: לא נשאר שום דבר שמצביע על ה-buffer. שחררו לפני שמחליפים ענף.
  • ציפייה שהקומפיילר יבדוק. הוא לא יבדוק. unions הם אחד המאפיינים הבודדים ב-C שבהם השפה לא מציעה שום עזרה מעבר לגודל וליישור; התגית היא מעקה הבטיחות היחיד שלכם.

שאלות נפוצות

מה זה union ב-C?

union הוא טיפוס שכל האיברים שלו חולקים את אותו זיכרון. כתיבה לאיבר אחד דורסת את האחרים, כך ש-union מחזיק בדיוק איבר אחד בכל רגע. מצהירים עליו כמו על struct, אבל עם מילת המפתח union: union Value { int i; float f; };.

מה ההבדל בין union ל-struct ב-C?

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

מה הגודל של union ב-C?

מספיק גדול בשביל האיבר הגדול ביותר, מעוגל כלפי מעלה לצורך יישור. union של int (4 בתים) ו-double (8 בתים) תופס 8 בתים, לא 12. sizeof הוא הדרך לבדוק בפלטפורמה שלכם.

מה קורה כשקוראים איבר של union שלא כתבתם אליו?

מפרשים מחדש את אותם בתים כטיפוס אחר. כתיבה של u.i = 1 ואז קריאה של u.f לא ממירה: היא קוראת את תבנית הביטים של המספר השלם כ-float, ומקבלים מספר חסר משמעות. ב-C התקני זה לכל היותר לא מפורט, ובגלל זה קיים דפוס ה-tagged union: שומרים לצדו תגית שאומרת איזה איבר חי.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל