C++/העמסת אופרטורים: הבדלים בין גרסאות בדף

מתוך ויקיספר, אוסף הספרים והמדריכים החופשי
< C++
תוכן שנמחק תוכן שנוסף
Crazy Ivan (שיחה | תרומות)
מאין תקציר עריכה
SagiLotan (שיחה | תרומות)
שורה 6: שורה 6:
מבחינת המהדר אופרטור הוא פונקציה שכדי לקרוא לה נשתמש בתחביר שונה מזה של קריאה לפונקציה רגילה. להלן חלק מהגדרת מבנה המייצג ווקטור מתמטי דו מימדי ופונקציית האופרטור שמחבר שני ווקטורים נתונים:
מבחינת המהדר אופרטור הוא פונקציה שכדי לקרוא לה נשתמש בתחביר שונה מזה של קריאה לפונקציה רגילה. להלן חלק מהגדרת מבנה המייצג ווקטור מתמטי דו מימדי ופונקציית האופרטור שמחבר שני ווקטורים נתונים:
<div style="direction: ltr;"><source lang="cpp">
<div style="direction: ltr;"><source lang="cpp">
struct Vector2D
struct VectorR2
{
{
double x, y;
double x, y;
שורה 12: שורה 12:
};
};


inline Vector2D operator + (const Vector2D &a, const Vector2D &b)
inline VectorR2 operator + (const VectorR2 &a, const VectorR2 &b)
{
{
Vector2D res;
VectorR2 res;
res.x = a.x + b.x;
res.x = a.x + b.x;
res.y = a.y + b.y;
res.y = a.y + b.y;

גרסה מ־13:26, 16 באוגוסט 2014

כל ביטוי מורכב מפעולות ופרמטרים איתם מתבצעות הפעולות, פעולות אלו נקראות אופרטורים ואילו הפרמטרים איתם עובדים האופרטורים נקראים אופרנדים. בC++ ישנם אופרטורים רבים שחלקם מקבלים כפרמטרים טיפוסים ומרחבי שמות (כמו :: ו-sizeof), פעולתם מתבצעת רק בעת הידור התוכנית, ואילו האחרים מקבלים אובייקטים, אופרטורים אלה עובדים עם האוביקטיים בזמן ריצה. כאשר אנו מגדירים מחלקות אנו מוסיפים טיפוסים חדשים לשפה, אבל עד כה ביצענו איתם פעולות רק ע"י קריאות לפונקציות המחלקה או פונקציות רגילות. כדי להגדיר טיפוס המשתלב בשפה בדומה לטיפוסים המובנים נגדיר עבורו אופרטורים. דבר זה נקרא העמסת אופרטורים כיוון שאנו מעמיסים אופרטורים נוספים לאופרטורים הקיימים. המטרה העיקרית בהעמסת אופרטורים היא נוחות וקריאות הקוד, הרי הקוד a = b + c קריא יותר למתכנת מאשר a.set(add(b, c)). שימו לב: בשני הביטויים האלה אנו לא יודעים מהי באמת התוצאה כי לא ידועים לנו לא הטיפוסים של a, b, c ולא המשמעות של =, +, add, set עבור טיפוסים אלה.

הגדרת פונקציית האופרטור

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

struct VectorR2
{
    double x, y;
    // ...
};

inline VectorR2 operator + (const VectorR2 &a, const VectorR2 &b)
{
    VectorR2 res;
    res.x = a.x + b.x;
    res.y = a.y + b.y;
    return res;
}

נסביר את הכתוב:

  • הגדרנו טיפוס זה כמבנה ולא כמחלקה כיוון שאין לנו צורך להסתיר את משתניו, הרי לא קיימים ערכים בלתי חוקיים עבור רכיבי הווקטור ודווקא נרצה לתת אפשרות לגשת למשתנים אלו: cout << vec.x << ',' << vec.y;.
  • שם פונקציית האופרטור מורכב מהמילה operator השמורה וסימן האופרטור עצמו, במקרה זה +.
  • פונקציית האופרטור מקבלת מספר פרמטרים לפי מספר האופרנדים, במקרה זה שני פרמטרים. אלה משתני יחוס קבועים, הם יתיחסו אל האופרנדים של האופרטור במקום הקריאה. הגדרנו את הפרמטרים כמשתני יחוס כי גודל האובייקט Vector2D גדול יחסית, אולם ניתן גם לתת לפרמטרים את טיפוס את אופרנד עצמו, לא בהכרח משתנה מיוחס.
  • פונקציית האופרטור מחזירה אובייקט Vector2D שבו נמצאת התוצאה של חיבור שני הווקטורים. לא נוכל להחזיר משתנה יחוס כיוון שאנו מחזירים אובייקט לוקלי.
  • פונקציית האופרטור מוגדרת כ-inline כי היא קצרה ומחזירה אובייקט גדול יחסית. נגדיר את רוב האופרטורים הפשוטים כ-inline.

עכשיו נוכל להשתמש באופרטור שהגדרנו עבור טיפוס הווקטור, כאילו ש-Vector2D הוא טיפוס מובנה:

Vector2D foo = {10, 20};
Vector2D bar = {5, 0};
Vector2D klop = foo + bar;

לאחר ביצוע קטע זה במשתנה klop ימצא הערך {15.0, 20.0}. שימו לב שאנחנו לא חייבים להגדיר את האופרטור = ואת הבנאי המעתיק כי משמעותם כברירת מחדל מתאימה לטיפוס זה.

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

struct Vector2D
{
    // ...

    Vector2D& operator += (const Vector2D &b)
    {
        x += b.x;
        y += b.y;
        return *this;
    }
};

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

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

Vector2D foo = {1, 1};
Vector2D bar = {5, -5};
foo = (bar += foo);

תחילה יתבצע הביטוי (bar += foo), ערך משתנה bar יהפוך ל-{6.0, -4.0}, ביטוי זה יחזיר משתנה יחוס ל-bar אשר ישמש לנו כאופרנד לפעולת ההשמה. כשתתבצע פעולת ההשמה, יועתק ערכו החדש של bar למשתנה foo.

הערה: אין צורך בסוגריים בביטוי זה כיוון שפעולות ההשמה מתבצעות מימין לשמאל (ולא משמאל לימין כמו פעולות חשבוניות אחרון).

הערה: כאשר משמעות הביטוים a = a @ x ו-a @= x זהה, מקובל לממש את אופרטור ה@ באמצעות אנלוג ההשמה שלו @=. הדבר נכון גם לאופרטורים אחרים שמימושיהם דומים, כמו אופרטורי ההשוואה.

קריאה לפונקציית האופרטור

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

Vector2D klop = operator + (foo, bar);
foo.operator = (bar.operator += (foo));

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

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

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

פרטי האופרטורים הניתנים להעמסה

בשפת C++ ישנם אופרטורים אוּנָרִיים (בעלי אופרנד אחד), בִינָרִיים (בעלי שני אופרנדים), טֵרְנָרִיים (בעלי שלושה אופרנדים) ואפילו אופרטור קריאה לפונקציה () שמספר האופרנדים בו משתנה. כנאמר למעלה, חלק מהאופרטורים אנו יכולים להגדיר כחברי המחלקה, חלק כפונקציות סטטיות, וחלק אין באפשרותינו להעמיס.

  • אופרטורים בינאריים הניתנים להעמסה גם כפונקציות המחלקה וגם כפונקציות סטטיות. אם נגדיר אותם כפונקציות סטטיות אז יהיו להן שני פרמטרים, אם נגדיר אותן כחברי מחלקה יהיה להן פרמטר אחד:
+   -   *   /   %   &   |   ^   ,
<<  >>  <=  >=  ==  &&  ||  !=
  • אופרטורים בינאריים הניתנים להעמסה רק בתוך המחלקה, לפונקציות אופרטורים אלה יהיה פרמטר אחד תמיד:
=   +=  -=  *=  /=  %=  &=  |=  ^=  <<= >>= []  ->*
  • אופרטורים אונריים הניתנים להעמסה מחוץ למחלקה, לפונקציות אופרטורים אלה יהיה פרמטר אחד כשהם מחוץ למחלקה ולא יהיו להן פרמטרים כלל כשהם בתוך המחלקה:
~   !   -   +   *
  • אופרטורים אונריים הניתנים להעמסה רק בתוך המחלקה, לפונקציות אופרטורים אלה אף פעם לא יהיו פרמטרים:
->  ++  --  &

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

בחלק זה נפרט על אופרטורים מסויימים בנפרד.

פעולות קלט ופלט

הספרייה התקנית של C++ (שמה STL) מגדירה את המחלקות istream ו-ostream שמהוות את מחלקות האב (למד הורשה בהמשך) למחלקות רבות אחרות שמאפשרות קלט ופלט. מחלקות לעבודה עם קבצים, למשל, נגזרות ממחלקות אלה. גם טיפוסי האובייקטים cout ו-cin, איתם כבר עבדנו, הם ostream ו-istream בהתאמה. כדי לקלוט ולפלוט משתנים מטיפוסים מובנים אנו משתמשים באופרטורים << ו->> שהם אופרטורים בינאריים המוגדרים בספריית STL. באופן דומה נוכל להעמיס אופרטורים אלה כדי לקלוט מ-istream ולפלוט ל-ostream את הטיפוס שאנו הגדרנו. אופרטור כזה יקבל כאופרנד ראשון את ה-stream ממנו אנו קולטים או אליו אנו פולטים, וכאופרנד שני את האובייקט עצמו. לדוגמה:

ostream& operator << (ostream& s, const Vector2D &v)
{
    s << "(" << v.x << ", " << v.y << ")";
    return s;
}

אנו החזרנו את s כערך כדי שנוכל לשרשר את הפלט בעתיד. עכשיו נוכל להשתמש באופרטור זה:

Vector2D pos = {12, 32};
cout << "pos = " << pos << endl;

וכתוצאה יודפס: pos = (12.0, 32.0).

הוספה והפחתה

האופרטורים ++ ו-- קיימים בשתי גרסות:

  • הנכתבים לפני האופרנד (prefix) - עבור הטיפוסים המובנים הם תחילה משנים (מוסיפים או מפחיתים) את האופרנד ולאחר מכן מחזירים את ערכו החדש.
  • הנכתבים אחרי האופרנד (postfix) - התוצאה שתוחזר תהיה הערך הישן של האופרנד.

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

דוגמה:

struct X
{
    int val;

    X& operator ++ () // ++Prefix
    {
        ++val;
        return *this;
    }

    X operator ++ (int) // Postfix++
    {
        X tmp = *this;
        ++val;
        return tmp;
    }
};

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

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

פעולות למצביעים ולמערכים

באמצעות העמסת האופרטורים האופיניים לעבודה עם מערכים ומצביעים (* & [] ->) נוכל להגדיר טיפוסים אשר מתנהגים בדומה למצביעים ומערכים אך מבצעים פעולות נוספות כלשהן. בין השאר נוכל להוסיף בדיקה לתקינות המצביעים או מצביעים מופשטים עבור אוספים של אובייקטים (iterators).

אופרטורים אלה מועמסים בדומה לשאר האופרטורים שצוינו, אמנם נפרט:

  • אופרטור האינדקסציה [] מקבל כפרמטר את האינדקס שיכול להיות כל טיפוס שהוא (למשל מחרוזת, או טיפוס המייצג "טווח" של אינדקסים). כדי שנוכל להשים ערך באמצעות אחד אופרטורי ההשמה, אופרטור זה צריך להחזיר ייחוס לאיזשהו אובייקט.
  • את האופרטור * (אונארי) נעמיס בדומה ל-[] למעט ההבדל היחידי שהוא אונרי ולא מקבל פרמטרים.
  • האופרטור -> הינו אופרטור מבלבל. הסיבה לכך היא שהוא נראה כמו אופרטור בינארי, אומנם אנו מגדירים אותו כאילו שהוא אונרי. אופרטור זה זהה לאופרטור * מלבד התחביר במקום הקריאה.
  • אופרטור & (אונארי) מוגדר כברירת מחדל לכל מחלקה, ומשמעותו הרגילה היא "כתובת האובייקט בזיכרון", אומנם ניתן לשנות משמעות ברירת מחדל זו על ידי העמסה.

להלן דוגמה של מצביע "חכם" אשר בודק את נכונותו בכל רגע בו אנו מנסים לקרוא או לכתוב למערך אליו הוא מצביע:

class CheckedPtr
{
    X *begin, *cur, *end; // מצביעים להתחלה לאמצע ולסוף של האזור

    void check(X *p) const
    {
        if(p < begin || p >= end) 
            // חריגה (למד בהמשך), יצאנו מגבולות המערך
            throw "Out of range"; 
    }

public:
    CheckedPtr(X *arr, size_t size, int ind) :
        begin(arr), end(arr+size), cur(arr+ind) { }

    CheckedPtr& operator ++ ()
    {
        cur++;
        return *this;
    }

    CheckedPtr& operator -- ()    { /* ... */ }
    CheckedPtr& operator += (int) { /* ... */ }
    // עוד אופרטורים לשינוי המצביע

    X& operator [] (int i) const
    {
        X *ptr = cur + i;
        check(ptr);
        return *ptr;
    }

    X& operator * () const
    {
        check(cur);
        return *cur;
    }

    X& operator -> () const { /* בדיוק כמו אופרטור הכוכבית */ }
};

הנה דוגמה לשימוש במצביע זה, נניח ש-X הוא טיפוס מוגדר מראש (בתוכנית אמיתית עדיף לשכתב מחלקה זו לתבנית):

X arr[100];
CheckedPtr p(arr, 100, 0);

(*p).print();       // מדפיס את arr[0]
p++;
p->print();         // מדפיס את arr[1]
p[99].print();      // שגיאה בזמן ריצה, מנסה להדפיס את arr[100]
p[-1].print();      // מדפיס את arr[0]

אופרטור קריאה לפונקציה

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

class randGen {
    int num;

public:
    randGen(int seed) : num(seed) {}

    static const int max = 0x80000000; // קטע זה יעבוד כאשר שלם הוא 32 סיביות ומעלה
	
    int operator () () {
        // מחזיר מספר פסאודו אקראי בין 0 לערך המקסימלי
        num *= 1664525;
        num += 1013904223;
        return num & ~max;
    }

    int operator () (int x, int y) {
        // מחזיר מספר: x <= num < y
        return int(double((*this)())/max*(y-x)) + x;
    }
};

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

העמסנו את האופרטור () פעמיים: ללא פרמטרים לקבלת מספר אקראי כלשהו בין 0 ל-max ועם שני פרמטרים לקבלת ממספר אקראי בין x ל-y. עתה נוכל להשתמש בטיפוס זה, נגדיר שני אובייקטים ליצירת שתי סדרות: האחת דטרמיניסטית (עם כל הפעלה של התוכנית היא תהיה זהה) והשנייה פסאודו אקראית המאותחלת באמצעות הטיימר של המחשב:

randGen detSeq(346); // הסדרה תמיד זהה
randGen randSeq(static_cast<int>(time(0))); // הסדרה תמיד שונה

for(int i = 0; i < 5; i++)
    cout << detSeq(0, 100) << ", ";
cout << endl;

for(int i = 0; i < 5; i++)
    cout << randSeq(0, 100) << ", ";
cout << endl;

תוכנית זו תדפיס בשורה הראשונה תמיד: 74, 32, 45, 95, 96, ואילו אנו לא יודעים מראש מה יופיע בשורה השנייה.

העמסת new ו-delete

האופרטורים new ו-delete המוגדרים כברירת מחדל, מקצים זיכרון מאיזושהי ערימה הניתנת על ידי המערכת. לרוב זוהי אותה הערימה בה משתמשות הפונקציות ממשפחת malloc ב-C. בתוכנות גדולות נרצה לעיתים להעמיס את האופרטורים new ו-delete, בעיקר כדי לשפר את הביצועים של התוכנה על ידי שימוש ב-pools או על ידי הקצאת זיכרון בשיטה אחרת כלשהי (מעירמה המיוחד שלנו למשל).

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

void* operator new (size_t s)
void operator delete (void* p);
void* operator new[] (size_t s);
void operator delete[] (void* p);

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

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

void* operator new (size_t s, const char* str);
void operator delete (void* p, const char* str);

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

class T { /* ... */ }; 

// האופרטורים הגלובליים התקניים
T *stdNew = new T;
delete stdNew;

// האופרטורים שלנו	
T *myNew = new("1") T;
myNew->~T(); // יש לקרוא למפרק
operator delete(myNew, "2"); // פינוי זיכרון

void *myNew2 = operator new(8, "3"); 
// זיכרון לא מאותחל - אין בנאים ואין מפרקים
operator delete(myNew2, "4");

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


הפרק הקודם:
הורשה
העמסת אופרטורים הפרק הבא:
המרות