לדלג לתוכן

2.1 - תבניות והפקת קוד - הרצאה

אותו אלגוריתם ושני טיפוסים

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

התוכנית הבאה מציגה את המקרה הפשוט:

#include <cstdio>

template<class T>
T smaller(T first, T second) {
    return second < first ? second : first;
}

int main() {
    std::printf("%d\n", smaller(7, 3));
    std::printf("%.1f\n", smaller(7.5, 3.5));
}

הפרמטר T אינו משתנה שמקבל ערך בזמן ריצה. בקריאה הראשונה המהדר מסיק T שהוא int, ובשנייה T שהוא double. הוא בודק את גוף התבנית בהקשר של הטיפוס שנבחר ומייצר את הקוד הדרוש.

הפעולה נקראת instantiation. בשונה מפונקציה רגילה שמקבלת void pointer, כאן גוף הפונקציה יודע מה הטיפוס ויכול לבדוק את חוקיות הפעולות לפני הרצה. אין צורך בקוד runtime שמנחש איזה סוג נתון הגיע.

הסקה אינה אוסף המרות חופשי

הקריאה הבאה אינה חלק מהתוכנית התקינה:

// auto mixed = smaller(7, 3.5);

הארגומנט הראשון מציע T שהוא int, והשני מציע double. לפי החתימה הזאת צריך אותו T לשני הפרמטרים, וההסקה אינה בוחרת אוטומטית את הטיפוס שנראה לנו נוח.

אפשר לבקש טיפוס מפורש:

auto mixed = smaller<double>(7, 3.5);

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

גוף התבנית הוא חוזה סמוי

מה באמת נדרש מ־T כדי ש־smaller תפעל? צריך שהביטוי second less-than first יהיה חוקי ושאפשר יהיה להחזיר את אחד הערכים לפי חוזה ההעתקה או ההעברה של הטיפוס.

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

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

מתי נדרש לראות את ההגדרה

בעיה נפוצה מופיעה כשמפרידים declaration ל־header והגדרה לקובץ cpp כמו בפונקציה רגילה. נבדוק שלושה קבצים קטנים.

הקובץ twice.hpp מכיל רק הצהרה:

#pragma once

template<class T>
T twice(T value);

הקובץ twice.cpp מכיל את ההגדרה:

#include "twice.hpp"

template<class T>
T twice(T value) {
    return value + value;
}

הקובץ main.cpp משתמש בתבנית:

#include "twice.hpp"
#include <cstdio>

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

בבנייה רגילה של שני קובצי cpp עלולה להופיע שגיאת linker עבור המימוש של twice. יחידת התרגום של main רואה declaration אבל לא גוף שממנו אפשר לייצר את המימוש. יחידת התרגום האחרת רואה גוף, אך אינה בהכרח מייצרת התמחות שלא התבקשה בה.

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

יצירה מפורשת

אפשר להשאיר את ההגדרה ב־twice.cpp ולהוסיף בסופו:

template int twice<int>(int);

ההצהרה מבקשת לייצר את ההתמחות המסוימת ביחידת התרגום הזאת. הקורא עדיין צריך declaration מתאים. אם ינסה להשתמש ב־double בלי שההגדרה נגישה ובלי explicit instantiation עבור double, שוב חסר מימוש מתאים.

הדוגמה משתמשת בערכים קטנים כדי להימנע מ־signed overflow. הגנריות אינה משנה את כללי החשבון של int. גם כאן צריך להחליט אם הפונקציה דורשת קלט שאפשר להכפיל או אמורה להחזיר שגיאה במקרה של חריגה.

התמחות של מחלקה

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

#include <cstdio>

template<class T>
struct Kind {
    static const char* name() { return "value"; }
};

template<class T>
struct Kind<T*> {
    static const char* name() { return "pointer"; }
};

template<>
struct Kind<bool> {
    static const char* name() { return "boolean"; }
};

int main() {
    std::puts(Kind<int>::name());
    std::puts(Kind<int*>::name());
    std::puts(Kind<bool>::name());
}

הגרסה עבור pointer היא partial specialization, מפני שעדיין יש בה פרמטר T. הגרסה עבור bool היא full specialization של טיפוס מסוים. גוף של התמחות אינו ממשיך אוטומטית את הגוף הכללי; צריך להגדיר בו את הממשק שרוצים לספק.

בתבניות פונקציה אין partial specialization באותו אופן. לעיתים משתמשים ב־overload או ב־helper של מחלקה. חשוב להפריד בין המנגנונים, מפני שכללי הבחירה שלהם אינם זהים.

שמות שתלויים בטיפוס

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

#include <vector>

template<class Container>
typename Container::value_type default_element() {
    return typename Container::value_type{};
}

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

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

בדיקה של הקוד שנוצר

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

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