לדלג לתוכן

2.3 - חישוב בזמן בנייה ומודולריות - הרצאה

לא צריך לחשב אותה טבלה בכל הפעלה

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

הדוגמה הבאה יוצרת את הטבלה באמצעות פונקציה שניתן להעריך בזמן קומפילציה:

#include <array>
#include <cstddef>
#include <cstdio>

template<std::size_t N>
constexpr std::array<unsigned, N> squares() {
    static_assert(N <= 256);
    std::array<unsigned, N> result{};
    for (std::size_t i = 0; i < N; ++i) {
        result[i] = static_cast<unsigned>(i * i);
    }
    return result;
}

constexpr auto table = squares<8>();

int main() {
    static_assert(table[3] == 9);
    for (unsigned value : table) std::printf("%u ", value);
    std::puts("");
}

הגודל N הוא non-type template parameter: פרמטר שמייצג ערך בזמן קומפילציה, ולא טיפוס. הגבלנו אותו כדי שהחשבון והטבלה יישארו קטנים וברורים בניסוי. הטיפוס שמוחזר כולל את הגודל, ולכן squares<8> ו־squares<16> מחזירות טיפוסים שונים.

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

הערכה אפשרית בזמן קומפילציה - constexpr

הפונקציה הבאה חוקית גם עם קלט שמתקבל בזמן ריצה:

constexpr unsigned twice(unsigned value) {
    return value * 2;
}

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

אפשר לבחון את ההבדל בלי להסתמך על ביצועים. קריאה שמאתחלת constexpr variable חייבת להיות constant expression, בעוד שקריאה עם משתנה runtime אינה נדרשת לכך. האופטימייזר יכול עדיין לפשט קוד שהוא מצליח להסיק עליו מידע, אבל זאת שאלה אחרת.

הערכה מיידית נדרשת - consteval

כשממשק חייב לקבל מידע זמין בזמן קומפילציה, אפשר להשתמש ב־consteval:

consteval unsigned mask(unsigned bit) {
    if (bit >= 32) throw "bit out of range";
    return 1u << bit;
}

constexpr unsigned flag = mask(3);

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

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

אתחול קבוע אינו אובייקט קבוע - constinit

הכתיבה constinit בודקת את סוג האתחול של משתנה בעל משך אחסון סטטי או thread storage duration. היא יכולה למנוע הפתעה שבה נדרש dynamic initialization לפני שהקוד שלנו מתחיל לעבוד.

הדוגמה הבאה היא תוכנית שלמה:

#include <cstdio>

constinit int requests = 0;

int main() {
    ++requests;
    std::printf("%d\n", requests);
}

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

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

ענף שלא נוצר

הכלי if constexpr בוחר ענף בהתאם לתנאי שניתן להכריע בזמן קומפילציה. בתוך template הוא מאפשר להימנע מיצירת חלק שאינו מתאים ל־T.

הדוגמה הבאה מספקת שני מסלולים לפי תכונת טיפוס:

#include <cstdio>
#include <type_traits>

template<class T>
void print_kind(const T&) {
    if constexpr (std::is_pointer_v<T>) {
        std::puts("pointer");
    } else {
        std::puts("not pointer");
    }
}

int main() {
    int value = 0;
    print_kind(value);
    print_kind(&value);
}

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

אותה הגדרה בכמה יחידות תרגום

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

משתנה גלובלי רגיל שמוגדר ב־header ונכלל בשני קובצי cpp עלול ליצור הגדרות כפולות. אפשר להשתמש בהצהרת extern ובהגדרה אחת, או ב־inline variable כשהחוזה מתאים.

הקובץ settings.hpp יכול להכיל:

#pragma once
inline unsigned request_limit = 64;

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

גם ODR violation אינו תמיד מסתכם בשגיאת linker נעימה. אם הגדרות שונות נוצרות בגלל macros או flags שונים, ההתנהגות עלולה להיות לא תקינה בלי אבחון שמסביר את כל הסיבה.

מודול קטן שאפשר לבנות

מודול מאפשר להגדיר במפורש מה מיוצא למשתמש. הקובץ arithmetic.ixx יכיל:

export module arithmetic;

export int add_small(int a, int b) {
    return a + b;
}

הקובץ main.cpp יכיל:

#include <cstdio>
import arithmetic;

int main() {
    std::printf("%d\n", add_small(2, 3));
}

ב־MSVC מודרני עם תמיכת modules ב־C++20 אפשר לבנות משורת הפקודה כך:

cl /nologo /std:c++20 /EHsc /c /interface arithmetic.ixx /ifcOutput arithmetic.ifc /Foarithmetic.obj
cl /nologo /std:c++20 /EHsc /reference arithmetic=arithmetic.ifc main.cpp arithmetic.obj /Femodule_demo.exe

ה־IFC הוא מידע ביניים שהמהדר משתמש בו לצריכת המודול. הוא אינו תחליף לקובץ האובייקט שהלינקר צריך. שתי הפקודות צריכות להשתמש בכלים ובתצורה תואמים, ולכן שומרים אותן כחלק מהוראות הבנייה.

מודולים אינם החלפת טקסט בשם חדש

ב־include המהדר מעבד תוכן טקסטואלי בהקשר ה־macros של יחידת התרגום. במודולים וב־header units יש חוזים שונים של נראות ושל בנייה מוקדמת. לכן לא מחליפים אוטומטית כל include ב־import בלי לבדוק תלויות.

אפשר למדוד זמני בנייה מלאה ובנייה אחרי שינוי קטן, וכן גודל תוצרי ביניים. שיפור בזמן compile של קובץ אחד אינו מבטיח שכל build graph השתפר. החלטת המודולריות צריכה להתאים לממשק ולמערכת הבנייה, ולא רק לתכונה החדשה בשפה.

לפרטי הכלים ראו את תיעוד reference למודולים ואת מדריך Microsoft ל־header units.