לדלג לתוכן

1.2 - אתחול והסקת טיפוסים - הרצאה

למה סוגריים משנים תוכנית

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

#include <cstdio>
#include <vector>

int main() {
    std::vector<int> first(10, 0);
    std::vector<int> second{10, 0};
    std::printf("first=%zu second=%zu\n",
                first.size(), second.size());
}

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

הרשימה {10, 0} אינה תיאור מילולי של כמות וערך. היא שתי רשומות שהמכולה יכולה להעתיק לתוכה. כשיש בנאי מתאים של initializer_list, כללי בחירת הבנאי נותנים לו עדיפות בתהליך של list initialization. כדאי לזכור את הדוגמה הזאת כששינוי סגנוני כביכול משנה גודל של buffer.

מתי מקבלים אפס

נתחיל בטיפוס פשוט. הקטע הבא מיועד להופיע בתוך main, והוא אינו מדפיס את המשתנה שאינו מאותחל:

int uninitialized;
int zero{};
int direct(7);
int listed{7};

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

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

אפשר לראות זאת בתוכנית מלאה נוספת:

#include <cstdio>

struct Settings {
    int retries = 3;
    bool verbose = false;
};

int main() {
    Settings defaults{};
    Settings chosen{5, true};
    std::printf("%d %d\n", defaults.retries, defaults.verbose);
    std::printf("%d %d\n", chosen.retries, chosen.verbose);
}

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

המרה מאבדת מידע

נבחן את שתי ההצהרות הבאות בתוך פונקציה:

int allowed = 3.75;
// int rejected{3.75};

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

אפשר גם להכריח המרה במפורש:

int chosen = static_cast<int>(3.75);

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

בחירת פונקציה והמרה משתמעת

הדוגמה הבאה מציגה את הקשר בין טיפוס הארגומנט לפונקציה שנבחרת:

#include <cstdio>

void report(int) { std::puts("int"); }
void report(double) { std::puts("double"); }

int main() {
    report(4);
    report(4.0);
    short small = 4;
    report(small);
}

הקריאה השלישית נעזרת ב־integer promotion מ־short ל־int. אין כאן החלטה בזמן ריצה על סמך הערך ארבע, אלא בחירה לפי הטיפוסים וכללי overload resolution. שינוי טיפוס המשתנה יכול לשנות את הפונקציה שנבחרת גם אם הערך נשאר זהה.

כדי למנוע המרה אוטומטית דרך בנאי, אפשר להגדיר אותו כ־explicit. הקטע הבא הוא דוגמה קטנה לטיפוס שמייצג כמות bytes:

struct ByteCount {
    unsigned value;
    explicit ByteCount(unsigned n) : value(n) {}
};

void reserve_bytes(ByteCount);

void example() {
    reserve_bytes(ByteCount{32});
    // reserve_bytes(32);
}

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

הסקת טיפוס אינה הסקת כוונה

נבדוק איך auto מתנהג מול reference:

#include <cstdio>
#include <type_traits>

int main() {
    int source = 10;
    const int& view = source;
    auto copy = view;
    auto& alias = source;
    const auto& readonly = source;

    static_assert(std::is_same_v<decltype(copy), int>);
    static_assert(std::is_same_v<decltype(alias), int&>);
    static_assert(std::is_same_v<decltype(readonly), const int&>);

    copy = 20;
    alias = 30;
    std::printf("%d %d %d\n", source, copy, readonly);
}

הפלט הצפוי הוא שלושים, עשרים ושלושים. המשתנה copy הוא אובייקט נפרד; ה־reference וה־const העליון של הביטוי המקורי אינם הופכים אותו אוטומטית ל־reference קבוע. המשתנה alias נקשר למקור, ואילו readonly מספק גישה לקריאה אליו.

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

סוגריים שמשנים גם הסקת טיפוס

ההבדל בין decltype(name) לבין decltype((name)) חשוב כשבונים wrapper:

int value = 7;
decltype(value) a = value;
decltype((value)) b = value;

המשתנה a הוא int, ואילו b הוא int&. במקרה הראשון יש טיפול מיוחד בשם של ישות; במקרה השני נבדקת קטגוריית הביטוי, והביטוי הוא lvalue. הכתיבה decltype(auto) מבקשת להשתמש בכללי decltype, ולכן הוספת סוגריים יכולה לשנות return value להחזרת reference.

הסכנה אינה רק האטה או העתקה מיותרת. החזרת reference לאובייקט מקומי יכולה להשאיר את הקורא עם גישה לאובייקט שחייו כבר הסתיימו. בשיעור הבא נעבור משאלת הטיפוס לשאלת החיים שלו.

לקריאה נוספת אפשר להיעזר בתיעוד האתחול של Microsoft ובתיעוד הסקת טיפוסים עם auto.