לדלג לתוכן

2.2 - אילוצים וממשקים גנריים - הרצאה

השגיאה מופיעה רחוק מהממשק

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

נרצה להציג את הדרישה בגבול הציבורי. ב־C++20 אפשר להגדיר concept שמתאר תנאים שהמהדר מסוגל לבדוק בזמן קומפילציה:

#include <concepts>
#include <cstdio>
#include <string>

template<class T>
concept ClosedAdd = requires(const T& a, const T& b) {
    { a + b } -> std::same_as<T>;
};

template<ClosedAdd T>
T combine(const T& a, const T& b) {
    return a + b;
}

int main() {
    std::printf("%d\n", combine(4, 5));
    auto text = combine(std::string{"ab"}, std::string{"cd"});
    std::puts(text.c_str());
}

ה־requires expression אינו מבצע את החיבור בזמן ריצה. הוא בודק שהביטוי תקין עבור T ושסוג התוצאה מתאים לדרישה. הקריאה התקינה של combine עדיין מבצעת את החיבור בזמן הריצה הרגיל שלה.

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

למה short יכול להידחות

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

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

אפשר להפריד בין טיפוס הארגומנטים לטיפוס התוצאה. אפשר גם להגדיר פונקציה שמקבלת המרה מפורשת לתוצאה. בכל אפשרות יש לבדוק loss of precision ו־overflow בנפרד, מפני ש־concept אינו בודק את כל הערכים שיופיעו בעתיד.

תחביר אינו מתמטיקה

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

אם אלגוריתם מניח שהפעולה קומוטטיבית, חוקיות הביטוי a+b אינה מוכיחה ש־a+b שווה ל־b+a. גם הוספת ביטוי השוואה בתוך requires אינה בהכרח בודקת את האמת של ההשוואה עבור ערכי runtime.

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

דרישה מורכבת ודרישה פשוטה

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

#include <concepts>
#include <cstddef>

template<class T>
concept HasSize = requires(const T& value) {
    { value.size() } -> std::convertible_to<std::size_t>;
};

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

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

בחירה בין overloadים

נניח שרוצים מימוש כללי לכל אובייקט בעל size ומימוש מיוחד למי שיש לו גם empty. נכתוב concept שני שמרחיב את הראשון:

#include <concepts>
#include <cstddef>
#include <cstdio>
#include <vector>

template<class T>
concept HasSize = requires(const T& v) {
    { v.size() } -> std::convertible_to<std::size_t>;
};

template<class T>
concept SizedAndEmpty = HasSize<T> && requires(const T& v) {
    { v.empty() } -> std::convertible_to<bool>;
};

template<HasSize T>
void describe(const T&) {
    std::puts("size");
}

template<SizedAndEmpty T>
void describe(const T&) {
    std::puts("size and empty");
}

int main() {
    describe(std::vector<int>{1, 2});
}

השימוש המפורש ב־HasSize בתוך הconcept השני עוזר לבטא את הקשר בין האילוצים לפי כללי normalization ו־subsumption. לא מספיק שכל שני תנאים ייראו לנו שקולים לוגית; כללי בחירת overload אינם מנוע הוכחה מתמטית כללי.

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

חבילות פרמטרים

תבנית יכולה לקבל מספר משתנה של טיפוסים או ערכים. הדוגמה הבאה משתמשת ב־fold expression כדי לסכום קבוצה לא ריקה של מספרים שלמים:

#include <concepts>
#include <cstdio>

template<std::integral... T>
requires (sizeof...(T) > 0)
auto sum(T... values) {
    return (values + ...);
}

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

האילוץ על גודל החבילה מונע קבוצה ריקה שאין לה איבר התחלתי ב־fold הזה. החיבור עדיין כפוף לכללי promotions ו־overflow של הטיפוסים. הדוגמה משתמשת בערכים קטנים כדי להפריד בין לימוד התחביר לבין חוזה חשבון בטוח.

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

קריאת קוד מלפני concepts

בספריות קיימות תיתקלו ב־enable_if, ב־void_t וב־detection idiom. המנגנונים האלה משתמשים בכשל החלפה כדי להוציא מועמד מבחירת overload כאשר תנאי מסוים אינו מתקיים.

צריך להבדיל בין כשל בהקשר שבו SFINAE חל לבין שגיאה שמתרחשת בתוך גוף שכבר נבחר. לא כל הודעת template הופכת אוטומטית למועמד שנפסל בשקט. המעבר ל־concepts צריך לשמר את החוזה, ולא רק לשנות את התחביר.

בדיקה של ספרייה גנרית

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

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