C++/גרסה להדפסה
נמצאה תבנית הקוראת לעצמה: תבנית:C++
ספר זה ילמד אתכם, צעד אחר צעד, את שפת התכנות C++.
שפת C++ הינה שפת תכנות מרובת-פרדיגמות, המבוססת על התחביר של שפת C, ומהווה מעין קבוצת-על שלה, במובן שכמעט כל קוד תקני בשפת C הוא קוד תקני בשפת C++.
למי הספר מיועד ?
עריכההספר מיועד לכל מי שמעוניין ללמוד תכנות מונחה עצמים בשפת C++, לא משנה באיזה גיל הוא.
ידע נדרש
עריכהדרוש ידע בסיסי בעבודה עם מחשב (בהנחה שאתם גולשים באינטרנט יש לכם ידע זה). כמובן רצוי ידע מעמיק יותר מכיוון שמתכנתים צריכים לדעת את אופן פעולתו של המחשב כדי לכתוב תוכניות עבורו. ידע באנגלית יהיה גם הוא לעזר היות שרוב החומר בנושא הקיים בעולם הוא בשפה זו וגם התוכניות נכתבות באותיות לטיניות.
יש לוודא תחילה שאתם מבינים מהי שפת תכנות וכיצד קוד הנכתב בה מומר לתכנית באמצעות מהדר ומקשר.
קישורים חיצוניים
עריכהמבוא
נמצאה תבנית הקוראת לעצמה: תבנית:C++
הצורך בשפת תכנות
עריכהמחשב שומר נתונים ביחידות הנקראות סיביות, כל יחידה כזו יכולה להיות דלוקה או כבויה. נהוג לסמן בכתיב 1 כדלוקה ו-0 ככבויה. מעבדו של המחשב יודע לקרוא מזיכרון המחשב פקודות ולבצען. כל פקודת מעבד מיוצגת על ידי אוסף של סיביות. בתחילת ימי המחשב, היה על מתכנתים להחזיק בידם דף המפרט את הפקודות שיודע לבצע המעבד ואת אוסף הסיביות שצריך המעבד לקרוא בכדי לבצע כל פקודה. אז היה המתכנת מעביר לזיכרון המחשב את רצף הסיביות שמייצג את הפקודות שהוא רוצה שהמעבד יבצע, אחת אחרי השניה. תהליך זה הוא תהליך מסורבל אשר מקשה על מלאכת התכנות. בכדי להקל על מתכנתים לכתוב תוכניות באו לעולם שפות התכנות.
שפת תכנות
עריכהשפת תכנות היא ייצוג טקסטואלי של הוראות למחשב. בעזרת שפת התכנות, יכול המתכנת ליצור קובץ מלל המתאר את הפעולות שהוא מעוניין שהמחשב יבצע. את קובץ המלל מעבירים כקלט לתוכנה הנקראת מהדר (בלעז compiler) אשר מתרגמת אותו לשפת מכונה, אוסף של סיביות אשר כל קבוצה שלהן מהווה פקודת מעבד, אשר המעבד מסוגל לטעון מהזיכרון ולבצע.
שפת התכנות הראשונה הייתה שפת סף (בלעז Assembly). בשפה זו כל פקודת מעבד מיוצגת על ידי מילה אחת והפרמטרים לפקודה. פרמטרים הינם נתונים אשר בהם משתמשת הפקודה בזמן ביצוע. למשל פקודה יכולה להיות "חיבור" הפרמטרים הם "2" ו-"4". כך למעשה, מלבד העובדה שהפקודות מיוצגות בצורה מילולית ולא בעזרת סיביות, התוכנית בזיכרון המחשב ובשפת סף היו זהות.
מאז פיתחו שפות תכנות מורכבות יותר אשר בהן המתכנת רשם פקודה אחת, יותר מובנת לאדם, והתרגום הוא פקודות מעבד רבות אשר ביחד מביאות לתוצאה שמתכנת מצפה לה כאשר הוא רושם פקודה זו. דור חדש זה של שפות תכנות חסך למתכנתים עבודה רבה. היו מספר התפתחויות נוספות בשפות התכנות אשר בשלב לימוד זה מוקדם מדי להיכנס אליהן. אחת משפות התכנות שהתפתחו היא שפת C++.
על C++
עריכהשפת C++ הומצאה כהרחבה לשפת C הפופולרית על ידי בְּיַאנֵה סְטְרוֹוסְטְרוּפ באמצעות הוספת מחלקות, פונקציות וירטואליות, חריגות, העמסה ועוד מספר תכונות ההופכות אותה לשפה מונחית-עצמים. כהרחבה, כמעט כל תוכנית C תוכל לעבור הידור במהדר C++. היתרון הגדול במימוש זה הוא שניתן להשתמש ולשלב קטעי קוד אשר נכתבו ב-C לקוד אשר נכתב ב-C++.
כיוון ש-C++ שומרת על תאימות עם C, ניתן לתכנת בה גם בסגנון פרוצדורלי וגם בסגנון מונחה עצמים. בנוסף, התבניות בשפה מאפשרות תכנות גנרי. כך יוצא ש-C++ היא שפה בעלת שלוש פרדיגמות תכנות שונות.
אז למה C ?
עריכהיש מספר סיבות שניתן להעלות על הדעת להסתפק ב-C.
- ביצועים - קיימת דעה ש-C++ בעלת ביצועים נמוכים משל C.
- הביצועים של C++ נמוכים משל C, כיוון שהיא מונחית-עצמים, אולם לרוב הבדל זה זניח. המתכנת צריך לדעת אילו כלים של השפה מאטים את התוכנית ולהמנע משימוש מופרז בהם (למשל חריגות ו-dynamic_cast).
- לעתים קרובות, מהירות הפיתוח חשובה יותר ממהירות התוכנה, וכאן יש ל-C++ יתרון.
- פשטות - הכלים המתקדמים של C++ מסובכים יותר מהכלים הבסיסיים של C. לכן קל יותר ללמוד לתכנת בשפת C.
על אף שכלים רבים של C++ דורשים יותר הבנה מהמתכנת, השימוש בכלים אלה מפשט את התוכנית:
- חלוקה לוגית ברורה של הקוד (מחלקות ומרחבי שמות עוזרים בעניין זה).
- כתיבת קוד גנרי, פעם אחת (תבניות).
- טיפול נוח בשגיאות שצצות במהלך התוכנית (חריגות).
- שימוש חוזר בקוד קיים (ירושה).
- שימוש ב-STL (הספריה התקנית), מקל על כתיבת הקוד ולרוב מקצר אותו.
מבוא לתכנות מונחה עצמים
עריכהכאשר הניח אלן טיורינג את יסודות התכנות, הוא תכנן את העניינים באופן מתמטי, שיהיה אפשרי. אכן, כל פעולה ניתן לבצע בעזרת לולאות ותנאים, ומידע המסודר באמצעות משתנים ומערכים. אולם, במשך הזמן התגלו בעיות של תכנון בעזרת הכלים האלו. המיזמים נעשו מורכבים יותר ויותר, ונוצר צורך בכלים מתקדמים יותר, ואף שאינם נצרכים באופן מתמטי, הרי שהם נצרכים כדי שהתכניתן יחשוב ויעבוד בצורה מסודרת. המצאת הפונקציה היתה המהלך הראשון בנושא, והיא בעצם חילקה את התוכנית לחתיכות קטנות שניתן לסמן V לגבי הצלחת כל אחת מהן. ובכל זאת, עדיין נתקלו מיזמים בקשיים גדולים, שגיאות לוגיות שלא חשבו עליהן מראש, ופעמים רבות המיזם ביקש עוד זמן ועוד כסף ולבסוף התמוטט והלקוח נותר בלא כספו ובלא המוצר שהזמין.
בכנס של תכניתנים שדן בבעיה זו, הועלה הרעיון הבא: לדמות את התכנות לחיים האמיתיים. בחיים אנחנו עובדים עם עצמים, לא עם פונקציות. עצם יכול להיות כל דבר: מחיפושית ועד חללית.
לעצם כזה יש תכונות, ויש פעולות שהוא יכול לבצע. גם בתוך עצמים ישנם עצמים פנימיים שונים. זהו ה-class: עצם המכיל משתנים, מערכים, והחידוש: גם פונקציות !
כאשר יצרנו אותו, יצרנו תבנית. נוכל לאחר מכן ליצור כמה מופעים ממנו שנרצה.
למשל, נוכל ליצור עצם המייצג מכונית. יהיו לו משתנים שיכילו את הצבע, את הדגם, את נפח המנוע ועוד תכונות נוספות. תהיינה לו גם פונקציות: פונקציה בשם start בשביל להתניע, פונקציה בשם go בשביל לנסוע, פונקציה בשם fuel בשביל לתדלק וכן הלאה.
נוכל ליצור "מופעים" רבים של המכונית הזאת, בדיוק כשם שאנחנו יוצרים משתנים מכל סוג אחר. נוכל ליצור מכוניות שונות ולהגדיר לכל אחד את תכונותיו.
לרכב יש גם תהליכים ונתונים פנימיים שרק הטכנאי מטפל בהם. הלקוח אינו אמור להתעסק איתם, ועל כן ניתן להסתיר אותם מעיניו, ולתת לו להתעסק רק במה שהוחצן. למשל, אם בעת ההתנעה צריכים להפסיק לרגע את הרדיו כדי שלא ישרף, לא ניתן ללקוח לשנות זאת. ניתן לו רק את הפונקציה start, והוא יבחר האם להשתמש בה, על כל השלכותיה.
בנוסף, תוכננה גם האפשרות לבנות עצמי-על. כשם שאנחנו יכולים לבנות עצם מסוג מכונית וליצור מגוון רחב של מכוניות בעזרתו, כך נוכל ליצור עצם-על שממנו נוכל ליצור עצמים מדויקים יותר.
למשל, נוכל ליצור עצם-על מסוג כלי רכב וממנו ניצור עצם מסוג מכונית, עצם מסוג משאית, עצם מסוג קורקינט. גם הם עדיין תבניות, אבל הן נבנו מתוך התבנית הכללית יותר של כלי הרכב. למשל, בתבנית הכללית יהיו גלגלים, אבל ניתן יהיה לשנות את כמותם. לעומת זאת בכל אחד מהמימושים תהיה הגדרה מדויקת של כמה הם, ולא ניתן יהיה לשנות. בעצם כלי הרכב תהיה הגדרה של נסיעה, אבל לא של התנעה, למקרה שהמשתמש ייצור קורקינט.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
שלום עולם!
נמצאה תבנית הקוראת לעצמה: תבנית:C++ נתחיל מהצגת תכנית שלום עולם!, כנהוג להתחיל ספרים על שפות תכנות:
#include <iostream>
using namespace std;
int main()
{
std::cout << "Hello, world!\n";
return 0;
}
הסבר
עריכהתיקון לקוד שמופיע למעלה: על מנת שהתכנית תיעצר ותראה את הפלט באמצעי פלט הטקסט, צריך להוסיף לפני ה- ";return 0" שורה שעוצרת את התכנית כדי שנוכל לראות את הכתוב. שורות שניתן להוסיף לקוד הן: ";()cin.get" או ";("system("Pause" במקרה של השורה השנייה שמוצעת, יופיע המשפט "Press any key to continue..."
זהו קוד מקור של תכנית פשוטה, שכל מטרתה היא לפלוט באיזשהו אמצעי פלט את הטקסט "Hello, world!"
.
נסביר את הקוד בפרטים:
- הפקודה
#include
היא פקודה לקדם־מעבד (pre-processor), כך הן כל הפקודות המתחילות בסולמית. קדם־מעבד הוא חלק מהמהדר, והוא מורץ בתחילת תהליך ההידור. פקודת ה־include מורה לקדם־מעבד להדביק את תוכן הקובץ ששמו נכתב אחרי הפקודה, לתוך המקום בו נכתבה הפקודה עצמה. משתמשים בפקודה זו על מנת להכליל את ההצהרות של הספרייה בה אנו רוצים להשתמש, במקרה שלנו "iostream".
- iostream היא חלק מהספרייה התקנית של C++ האחראית על קלט ופלט (Input/Output Stream). כל הספריות התקניות יבואו בסוגריים משולשים לאחר פקודת ה־include, זאת כדי שהמהדר ימצא את הקובץ לבד. במקרה של הספריה התקנית של C++, לאו דווקא קיים קובץ כזה על הדיסק הקשיח שלכם, דבר זה תלוי בסביבת הפיתוח בה אתם עובדים.
- השורה
int main()
פותחת את הגדרת פונקציית main, זוהי השורה שממנה תתחיל להתבצע התוכנית. הסוגריים המסולסלים פותחים וסוגרים בלוק של פקודות שתתבצענה בזמן הרצת התוכנית, כל השורות הנמצאות מחוץ לסוגריים אלה, משמשות אותנו רק בזמן ההידור. את הפרטים על פונקציות תלמדו בהמשך.
- הסימן >> משמעותו "פלוט את מה שמימין לתוך מה שמשמאל". במקרה שלנו אנו פולטים את המחרוזת (טקסט) "Hello, world!" לתוך ה־stream ששמו std::cout. שימו לב שמשמעות סימן זה יכולה להשתנות ממקום למקום בהתאם למה שנכתב מימין ומשמאל (ראו העמסת אופרטורים).
- השם std::cout מורכב משני חלקים: הראשון (std) מציין את מרחב השם של הספריה התקנית, ואילו החלק השני (cout) הוא שם של עצם הנמצא בתוך מרחב שם זה. בקצרה ניתן לסכם זאת כך: אנו פולטים לתוך עצם cout התקני.
- העצם cout מוגדר בספרייה iostream, ולשם השימוש בו הכללנו אותה בשורה הראשונה. כאשר אנו פולטים לתוך העצם cout, הפלט נשלח לאיזשהו אמצעי פלט במערכת. ברוב המערכות אמצעי פלט זה הוא המסך, כברירת מחדל. אם תריצו תוכנית זו, כנראה גם במערכת שלכם תראו את המילים "Hello, world!" מוצגות בחלון/מסך טקסט (ידוע בשם קונסולה).
- את המחרוזות (טקסט) ב־C++ נכניס לתוך גרשיים על מנת להבדיל משאר הקוד הניתן לביצוע. שימו לב שהגרשיים לא יופיעו בפלט. שני הסימנים האחרונים במחרוזת n\ מייצגים תו אחד הנקרא "ירידת שורה" (Line Feed או New Line). מקור התו הזה, ועוד תווים שונים אחרים, הוא בתחילת הסטוריית המדפסות שלא נספר עליה כאן. במקרה שלנו אנו מורידים את סמן הפלט לשורה חדשה, כך שכל הפלט הבא (שאיננו כאן) יופיע בשורה הבאה.
- מטעמי נוחות ותאימות למערכות אחרות, נהוג לפעמים להשתמש בכתיב std::endl לירידת שורה (ראה דוגמה למטה). כתיב זה כמעט זהה בפעולתו לזה שבתוכנית המוצגת כאן.
- ההוראה
return 0;
מחזירה את הערך 0 למערכת, ערך זה מסמל סיום תוכנית מוצלח. כל ערך השונה מאפס מסמל שגיאה בזמן ריצת התוכנית. הוראה זו הכרחית בכל הפונקציות שמחזירות ערך, חוץ מפונקציית main, אומנם בשל האחידות רבים כותבים אותה גם כאן.
- כל הוראה ב־C++, מלבד הוראות הבקרה, תסתיים בנקודה־פסיק ולא בסוף שורה (כמו בחלק מהשפות), כך נוכל לפרק הוראות ארוכות למספר שורות.
בנייה והרצה
עריכהכדי ללמוד לתכנת יש להריץ את התוכניות שנכתבו (על אף שבמקרים מסויימים כתיבת קוד היא אך ורק פעילות תיאורטית). לצורך זה תצטרכו להדר אותן באמצעות מהדר וכאשר אתם תכתבו תוכניות גדולות שתתפוסנה יותר מקובץ אחד תצטרכו גם לקשר באמצעות מקשר.
ניפוי השגיאות הוא חלק בלתי נפרד מפיתוח תוכנות גדולות. העדר אפשרות הרצת התוכנית תחת מנפה שגיאות, במערכות זמן אמת לדוגמה, מקשה על פיתוח תוכנות אלה. מנפה שגיאות טוב יכול לתרום לכם להבנה של אופן פעולת התוכנית; הוא מאפשר להריץ קטעים שמתבצעים במשך כמיקרון השנייה, שורה אחר שורה.
בהתאם למערכת שבה אתם עובדים תצטרכו ללמוד להשתמש בכלים הקיימים בה.
פרק זה לוקה בחסר. אתם מוזמנים לתרום לוויקיספר ולהשלים אותו. ראו פירוט בדף השיחה.
g++ בלינוקס ו-Cygwin
עריכהכדאי לדעת: מהדר ה־g++ הוא מהדר C++ נפוץ למגוון פלטפורמות. |
השימוש במהדר g++ זהה כמעט לחלוטין לשימוש במהדר ה־gcc (כאשר במקום gcc כותבים g++), ומי שכבר למד להשתמש בו יכול לדלג על חלק זה.
כדי להדר תוכנית משורת הפקודה, כותבים את השורה הבאה:
g++ myprogram.cpp
הפקודה תיצור קובץ הרצה בשם a.out
, או תדפיס הודעות שגיאה מתאימות.
כדי להדר כמה קבצים ביחד, כותבים אותם בשורה, בזה אחר זה:
g++ prog1.cpp prog2.cpp prog3.cpp
הפקודה תיצור קובץ הרצה המורכב מכל הקבצים יחד. בצורה זו, ניתן ליצור תוכנית מכמה קבצים שונים.
כדי לשלוט בשם קובץ ההרצה הנוצר, נשתמש בדגל -o
, בצורה הבאה:
g++ myprog.cpp -o Program
אם נרצה רק להדר קובץ, אך עדיין לא ליצור ממנו קובץ הפעלה, נשתמש בדגל -c
:
g++ -c myprogram.cpp
פקודה זו תיצור קובץ בשם myprogram.o
, שאינו קובץ הרצה, אך ניתן לקשר אותו עם קבצים אחרים. נניח, למשל, שהתוכנית שלנו כוללת את הקבצים prog1.cpp, prog2.cpp ו-main.cpp, אז ניתן להדר אותה בעזרת רצף הפקודות:
g++ -c prog1.cpp
כפי שראיתם, ניתן להדר קבצי .o וקבצי .cpp יחד. הסיבה היא שפעולת הקישור - יצירת התוכנית הכוללת, ופעולת ההידור, הן שתי פעולות נפרדות.
דגל נוסף שכדאי להכיר הוא
g++ -c prog2.cpp
g++ main.cpp prog1.o prog2.o -o Program-Wall
. דגל זה מורה למהדר לכתוב כל הודעת אזהרה אפשרית, ובכך מסייע רבות בזיהוי בעיות אפשריות. לכן, כדי להדר את התוכנית שכתבנו, בהנחה ששמרנו אותה בקובץ hello.cpp, נשתמש בפקודה הבאה:
g++ -Wall hello.cpp -o Hello
כתוצאה מכך, יווצר קובץ הרצה בשם Hello בתיקיה בה אנו עובדים. ניתן להריץ את הקובץ ולקבל את פלט התוכנית.
סביבת פיתוח בחלונות
עריכהכדאי לדעת: ברוב סביבות הפיתוח בחלונות, הרצת תוכנית כזו תפתח חלון אשר ייסגר מיד לאחר סיום התכנית, דבר שעלול להקשות על קריאת הפלט. אם הדבר אכן קורה, אפשר להוסיף שתי שורות לקוד, שיראה עתה כך:#include <iostream>
#include <stdio.h>
int main()
{
std::cout << "Hello, world!\n";
getchar();
return 0;
}
לאחר הוספת שורות אלו, החלון יישאר פתוח עד שתקישו על מקש כלשהו. כך תוכלו לראות את הפלט לפני שהחלון ייסגר. |
אם אתם משתמש ב־Microsoft Visual Studio, תוכלו לפעול לפי הצעדים הבאים. על אף שקיימים הבדלים בין גרסות שונות, הם אינם משמעותיים. בגרסה 2005 ניתן לעשות זאת כך:
- מיזם חדש – כנסו לתפריט File → New → Project.... מצאו תחת קטגוריה "Visual C++" את סוג המיזם "Win32 Console Application" ובחרו אותו. הזינו שם למיזם ואת המסלול בו תיווצר התיקיה של המיזם. ניתן להוריד את הסימון "Create Directory for Solution", על מנת שתיווצר תיקייה אחת פחות. לחצו על OK. באשף שיפתח, עברו ל־Application Settings או לחצו על Next. בחלון זה סמנו את "Empty project" וסיימו עם לחיצת Finish. כעת נוצרה לכם תיקייה במסלול שהזנתם עם שם המיזם ובה קבצים של המיזם; כרגע הוא ריק.
- יצירת קובץ C++ חדש – כדי ליצור קובץ C++ חדש, כנסו לתפריט Project → Add New Item.... מהחלון שנפתח בחרו את סוג הקובץ "C++ File" והזינו את שם הקובץ (לדוגמה main.cpp), מותר להזין את שם הקובץ גם ללא הסיומת, היא תתווסף אוטומטית.
- הוספת קובץ C++ קיים – כדי להוסיף קובץ C++ קיים, רצוי תחילה להעתיקו לתיקיית המיזם. לאחר מכן יש לבחור מהתפריט Project → Add Existing Item.... בחלון שיופיע, יש לבחור את הקבצים שברצונכם להוסיף.
- עריכת קוד – על מנת לערוך את אחד מקבצי המיזם, יש ללחוץ עליו פעמיים בחלון "Solution Explorer".
- הידור והרצה – כדי לבנות את המיזם (להדר ולקשר), יש לבחור את Build → Build Solution. אם ההידור יעבור בהצלחה יווצר קובץ הרצה בתיקיית Debug בתוך תיקיית המיזם. כדי להריץ תחת מנפה שגיאות יש לבחור את Debug → Start Debugging. אם יהיו שגיאות בזמן ההידור, הן תופענה בחלון Output או Task List. לחיצה כפולה על שגיאה תביא אתכם לשורה בה הייתה השגיאה.
לא צוינו כאן קיצורי המקשים, מכיוון שהם יכולים להשתנות. כמו כן תוכלו לשנות את ההגדרות (אם הן עדיין לא כאלה) כך שהמיזם יהודר אוטומטית בכל פעם כשאתם מריצים (לרוב על ידי מקש F5). במקרה זה, כדאי להגדיר כך שלא תורץ הגרסה האחרונה שהודרה במקרה שהיו שגיאות הידור בגרסה חדשה.
כברירת מחדל, מהודרת גרסת Debug של המיזם. בגרסה זו, המהדר אינו עושה ייעול; בנוסף, הוא משאיר מידע נוסף בקובץ ההרצה החיוני למנפה שגיאות, דבר המגדיל מעט את קובץ ההרצה. כאשר תרצו להדר גרסה סופית של המיזם (למשל, כדי להפיצו), תוכלו לבחור ב־Release במקום Debug (בתוך תיבה על סרגל הכלים הראשי).
כאשר תרצו להקטין את נפח המיזם (למשל, כשתגבוהו או תשלחוהו במייל), תוכלו לנקותו מתפריט Build → Clean Solution או למחוק ידנית את תיקיות ה־Debug וה־Release ואת הקבצים עם הסיומות: ncb, suo, user, aps (תחילה יש לסגור את סביבת הפיתוח).
Dev C++
עריכהDev C++ היא תוכנת קוד פתוח לפיתוח ב C\C++. ניתן להוריד אותה מכאן.
בגרסה 4.9.9.2 ניתן לפעול כך:
- מיזם חדש - כנסו לתפריט File → New → Project. מצאו תחת הכרטיסייה "Basic", את סוג המיזם "Empty Project" ובחרו אותו. בחרו "C++ Project", ואף רצוי לסמנו כשפת ברירת המחדל ע"י סימון התיבה "Make Default Language". לחצו Ok לאישור. נפתח לכם חלון בו תוכלו לבחור היכן לשמור את המיזם ואת שמו. בחרו תיקייה ולחצו Save לסיום. כעת נוצר לכם קובץ מיזם בעל סיומת .dev בתיקייה בה בחרתם לשמור את המיזם.
- יצירת קובץ C++ חדש - ליצירת קובץ C++ חדש בחרו בתפריט Project → New file. כעת נפתח לכם דף בו תוכלו לכתוב את התוכנית שלכם.
- הוספת קובץ C++ קיים – כדי להוסיף קובץ C++ קיים, רצוי תחילה להעתיקו לתיקיית המיזם. לאחר מכן יש לבחור מהתפריט Project → Add to Project. בחלון שיופיע, יש לבחור את הקובץ שברצונך להוסיף.
- עריכת קובץ - על מנת לערוך אחד מקבצי המיזם, לחצו עליו לחיצה כפולה בכרטיסייה "Project" שבצד שמאל.
- הידור והרצה - על מנת להדר ולהריץ את התוכנית, בחרו בתפריט Execute→ Compile & run. בהרצה הראשונה תוכלו לבחור את שם קובץ ההרצה ואת מיקום שמירתו. על מנת להריץ בלבד מבלי להדר את כל התוכנית שוב, בחרו בתפריט Execute → run. הרצה בלבד תוכל להתבצע רק לאחר שהתוכנית הודרה לפחות פעם אחת. אם ההידור עבר בהצלחה, נוצר לכם בתיקיית המיזם קובץ בשם שבחרתם עם סיומת .exe אותו תוכל להריץ ישירות מהמחשב. אם ישנן שגיאות, הן תופענה לכם בחלון בתחתית התוכנה. לחיצה כפולה על שגיאה תביא אתכם לשורה בה הייתה השגיאה.
לא צוינו כאן קיצורי המקשים, מכיוון שהם יכולים להשתנות בגרסאות הבאות, לכשיצאו כאלה.
סביבות פיתוח אחרות
עריכההשתמשו בסביבת הפיתוח שלכם בכדי להדר את הקוד. אם ההידור יעבור בהצלחה, יווצר קובץ מתאים בעל סיומת .exe אשר יהיה ניתן להריצו.
פרק זה לוקה בחסר. אתם מוזמנים לתרום לוויקיספר ולהשלים אותו. ראו פירוט בדף השיחה.
סגנון הקוד בהמשך הספר
עריכהusing namespace
כאשר כותבים ב++C יש לציין את שם הספרייה בה משתמשים לפני שימוש בכל אחת מהמתודות שלה. כתיבת הקידומת std:: לפני כל פנייה לכלים מהספרייה התקנית, לא נוחה. בתוכניות קטנות בהן אין בעייה של עומס שמות, משתמשים לרוב במשפט using namespace std
. משפט זה מציין למהדר שאנחנו נפנה לשמות של הספרייה התקנית ללא הקידומת std::. ברוב קטעי הקוד בספר לא נוסיף קידומת זו:
#include <iostream>
using namespace std;
int main()
{
cout << "Hello, world!" << endl;
return 0;
}
זו דוגמה לדברים שנאמרו לעיל: אין כאן תוספת ה־std::, והשתמשנו ב־endl. שימו לב כיצד שרשרנו את הפלט באמצעות מספר אופרטורי ה־>>.
כמו כן, לרוב לא נכתוב תוכניות שלמות, אלא רק קטעי קוד קצרים:
cout << "Here is a \" quote." << endl;
כדי להדר קטעים אלה תצטרכו להוסיף אותם לתוך פונקציית main ולהכליל את הספריות המתאימות.
הזחות
דבר סגנוני נוסף הוא ההזחות שבקוד. הזחה היא הוספת רווחים בתחילת השורה, לרוב משתמשים בטאבים לצורך זה. גודל הטאב הנפוץ בקרב מתכנתי C++, הינו 4 רווחים, כמו כן מתכנתים אחרים יכולים להעדיף גם אורך אחר, לדוגמה 2 רווחים מקובלים בתכנות בסביבות פיתוח טקסטואליות. ב־C++ אין שום משמעות לרווחים בעיני המהדר אבל היתרון בהוספת רווחים במקום זה או אחר הוא שיפור הקריאות של הקוד. מוסכמה גורפת בקרב המתכנתים (גם בשפות אחרות) היא שקוד הנמצא בתוך בלוק פקודות (בין סוגריים מסולסלים ב־C++) יוזח ימינה מעט יותר מהבלוק בו הוא נמצא. לגבי הימצאות הסוגריים עצמם, רווחים בתוך ביטויים או רווחים אחרי משפטי בקרה – אין כללים.
הערות
עריכההערה בקוד מקור של שפת תכנות היא קטע מהקוד שהמהדר מתעלם ממנו. הערות נכתבות בעיקר כדי להקל על קריאת הקוד על ידי אדם. במקרים נדירים הערות משמשות עבור תוכנות אוטומטיות לכתיבת קוד, לדוגמה אשפים. כמו כן בעת כתיבת הקוד וניפוי השגיאות נוח "לכבות" מספר שורות שלא תתבצענה, במקרה כזה מכניסים אותן לתוך הערה כך שהמהדר יתעלם משורות אלה.
ב־C++ קיימות שתי סוגי הערות:
- אלה הנמשכות עד לסוף השורה. מתחילים את ההערה ב: // (שני קווים נטויים).
- אלה הנפרסות על מספר שורות. מתחילים אותן בכל מקום ב: /* (קו נטוי וכוכבית) ומסיימים ב: */ (כוכבית וקו נטוי).
לדוגמה:
/* This code demonstrates
the type of comments in
C++ */
#include <iostream> // For std::cout
using namespace /* A comment in the middle of a line */ std;
// Here the program starts execution.
int main()
{
cout << "Hello, world!\n";
return 0;
}
בספר זה נשתמש בהערות מהסוג: /* ... */ ו־// ... כדי לסמן בקוד שבמקום זה נמצא קוד שלא נכתב. זאת כדי לא לחזור על אותו קוד משני שכבר נזכר למעלה פעמים נוספות וגם כאשר במקום זה נוכל לכתוב כל קוד שהוא.
קישורים חיצוניים
עריכה- אוסף כללים סגנוניים לכתיבת קוד עם טענות בעד ונגד: http://c2.com/cgi/wiki?SelfDocumentingCode.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
תרגילים
הפיכת התכנית Hello World לאישית יותר
עריכהלאחר שכתבתם תכנית פשוטה המדפיסה את המחרוזת "Hello, world!", כתבו תכנית חדשה שתדפיס את המחרוזת עם שמכם בצורה הבאה: "Hello World, <name>!".
#include <iostream>
int main ()
{
std::cout << "Hello World, <name>!" << endl;
return 0;
}
הדפסת מספר מחרוזות
עריכהכתבו תכנית שתדפיס את המחרוזות "Look At Me!", "I'm another program" ו־"I'm a C++ program!".
#include <iostream>
int main ()
{
std::cout << "Look at me!" << endl;
std::cout << "I'm another program." << endl;
std::cout << "I'm a C++ Program!\n" << endl;
return 0;
}
משתנים
נמצאה תבנית הקוראת לעצמה: תבנית:C++ משתנה הוא הישות הבסיסית איתה עובדת התוכנית, הוא מרחב בזיכרון המכיל נתונים. כל הנתונים במחשבים מוצגים באמצעות סיביות (במחשבים בינאריים אלה אפסים ואחדות). הסיביות מקובצות לבתים, כאשר ברוב המחשבים הבית מורכב מ-8 סיביות. נפח הזיכרון נמדד בבתים, לכל בית בזיכרון המחשב ישנה כתובת יחודית משלו - זו הסיבה שהתוכנית יכולה לעבוד אך ורק עם בתים וקבוצות שלהם. כדי לשנות רק סיביות מסויימות מתוך הבית, יש להשתמש בפעולות מיוחדות.
טיפוסים
עריכהכנאמר, כל משתנה מוצג בזכרון המחשב בתור סדרת אפסים ואחדות. אמנם בעת כתיבת התוכנית חשוב לנו להגדיר את סוג הנתונים שנשמרים במשתנה. סוג המשתנה נקרא גם טיפוס. טיפוסים בסיסיים שמוגדרים בתקן של השפה עצמה, נקראים טיפוסים מובנים. להלן רשימת הטיפוסים המובנים בשפת C++:
שם הטיפוס | גודל[1] | טווח ערכים מינימלי[2] |
---|---|---|
char | 8 ביטים לפחות | 0 ~ 127 |
signed char | שווה ל-char | -128 ~ 127 |
unsigned char | שווה ל-char | 0 ~ 255 |
short signed short short int signed short int |
16 ביטים לפחות גדול או שווה ל-char |
-32768 ~ 32767 |
unsigned short unsigned short int |
שווה ל-short | 0 ~ 65535 |
int signed int signed |
גדול או שווה ל-short | -32768 ~ 32767 |
unsigned unsigned int |
שווה ל-int | 0 ~ 65535 |
long signed long long int signed long int |
32 ביטים לפחות גדול או שווה ל-int |
-2147483648 ~ 2147483647 |
unsigned long unsigned long int |
שווה ל-long | 0 ~ 4294967295 |
float | גדול או שווה ל-char | לא מוגדר |
double | גדול או שווה ל-float | לא מוגדר |
long double | גדול או שווה ל-double | לא מוגדר |
bool | גדול או שווה ל-char קטן או שווה ל-long |
true או false |
wchar_t | גדול או שווה ל-char קטן או שווה ל-long |
הערות
עריכה[1] התקן לא מגדיר את הגודל המדוייק של הטיפוסים, אלא רק את גודלם המזערי והיחסי. כדי לקבל את טווח הערכים של טיפוס מסויים, יש להשתמש בתבנית numeric_limits שבספריה <limits> התקנית.
[2] טווחי הערכים המובאים בטבלה הינם הערכים שניתנים לייצוג בכל המערכות ללא תלות במערכת. למשל משתנה ה-char יכול להכיל לפחות 256 ערכים שונים. 128 הערכים הנוספים יכולים להיות או בין 127 ל-256 או בין -128 ל-0. שימו לב שהטווח המובא בטבלה עבור משתנים עם סימן שווה עבור ערכים שליליים וחיוביים על אף שברוב המחשבים יש ערך אחד יותר בשליליים. הסיבה היא שברוב המערכות הערכים השליליים מיוצגים בשיטת המשלים ל-2 הנפוצה, אבל קיימות גם שיטות אחרות על אף שהן כמעט ולא בשימוש.
טיפוסים תוויים
עריכההטיפוס char עשוי להכיל תו אחד ויחיד. כיוון ש-char לבדו יכול להיות עם סימן או בלעדיו, בהתאם למערכת, טווח ערכים המובטח הוא בין 0 ל-127. לטווח זה נכנסים כל תווי ה-ASCII. כאשר אתם משתמשים במשתנה תווי זה לשמירת ערכים מספריים, כיתבו האם אתם מתכוונים ל-signed או-unsigned.
wchar_t זהה ל-char מלבד שהוא גדול מספיק כדי להכיל את כל התווים האפשריים במערכת.
טיפוסים שלמים
עריכהכל טיפוסי ה-int יכולים להכיל אך ורק מספרים שלמים. ה-int הרגיל הוא הטיפוס השלם המתאים ביותר למכונה שלכם, למשל במחשבי 16 ביט גודלו 16 ביט ואילו במחשבי 32 ביט גודלו 32 ביט. יש להעדיף את ה-int הרגיל על פני שאר הטיפוסים השלמים, אלא אם כן עליכם לחסוך בזיכרון או להרחיב את טווח הערכים.
נקודה צפה
עריכהfloat, double ו-long double שייכים לקטגוריה זו. נקודה צפה היא פורמט המאפשר לשמור מספרים ממשיים (שברים) באמצעות מנטיסה ומעריך. שיטה זו מאפשרת לעבוד עם מספרים גדולים מאוד וקטנים מאוד (בערך המוחלט), כאשר בקירבת האפס הדיוק גדול יותר מאשר רחוק ממנו. לדוגמה נוכל לרשום את המרחק עד לאלפא קנטאורי בצורה: 4.129e+16 (מטרים) ואילו את גודל אטום הליום בצורה: 3.1e-11 (מטרים).
כמו במקרה של הטיפוסים השלמים, גם כאן יש להעדיף את השימוש ב-double אלא אם כן יש צורך לחסוך בזיכרון או להעלות את דיוק החישובים.
טיפוס בוליאני
עריכהbool הוא טיפוס בוליאני, כלומר הוא יכול לקבל את הערכים true (אמת) או false (שקר).
sizeof
עריכההאופרטור (פעולה) sizeof מחזירה את גודל הטיפוס הנרשם בסוגריים:
cout << "sizeof(int) == " << sizeof(int) << endl;
הטיפוס של הערך שמוחזר על ידי sizeof הוא size_t. טיפוס זה לא נמצא בטבלה שלמעלה, אלא הוא אחד מהטיפוסים השלמים שמובאים שם, תלוי במערכת. שימו לב שהגודל נמדד ב-char-ים, כלומר "כמה משתני char נכנסים למשתנה int". על מנת לקבל את הגודל בסיביות יש להשתמש ב-numeric_limits.
הצהרת משתנים
עריכהב-C++ יש להצהיר על משתנה לפני השימוש הראשון בו. ניתן להצהיר משתנה כמעט בכל מקום בתוכנית, כולל תנאים לוגיים ולולאות. הצהרת המשתנה תראה כך:
int variable;
כאשר נוכל להחליף את int בטיפוס כלשהו ואת variable בשם כלשהו. בדוגמה זו הצהרנו על משתנה בשם variable מטיפוס שלם.
ניתן להצהיר באותה שורה על מספר משתנים מאותו טיפוס ואף לאתחל אותם:
double alphaCentauri = 4.129e+16, radiusOfHelium = 3.1e-11;
לאתחל משתנה זה לשמור ערך בתוך המשתנה ברגע שהוא נוצר. מומלץ לאתחל משתנים ככל היותר מוקדם, רצוי בנקודת ההצהרה. אם נקרא מהמשתנה לפני שנאתחל אותו בערך הגיוני, הערך שיוחזר יהיה "אקראי" והתוכנית עלולה לתפקד באופן לא צפוי ואף לקרוס.
שמות
עריכהנוכל לתת למשתנים כל שם שנרצה שיתאים לכללים הבאים:
- השם יתחיל באחת האותיות הלטיניות (a-z, A-Z) או בקו תחתון (_).
- השם לא יתחיל בספרה (0-9) על מנת שהמהדר יוכל להבדיל בין שמות למספרים.
- שאר התווים בשם יהיו אותיות לטיניות, ספרות או קוים תחתונים.
- אסור להשתמש במילים שמורות בתור שמות.
C++ מבדילה בין תווים גדולים לבין תווים קטנים, כך ש-Var, var, vAr ו-vaR אלה ארבעה שמות שונים.
בכל שפת תכנות, גם ב-C++, נהוג לכנות לכל משתנה לפי תפקידו. זו אינה חובה כלל אך הדבר מקל לקרוא, להבין ולכתוב את הקוד.
פלט וקלט
עריכהכבר ראינו כיצד ניתן לשלוח מחרוזת או ערך מסויים אל תוך אובייקט ה-std::cout שידפיס את הטקסט על המסך או באמצעי פלט אחר במערכת. באותה דרך נוכל לפלוט גם את ערכו של משתנה:
cout << "The distance to Alpha Centauri is " << alphaCentauri << " meters.\n";
הטקסט שבין הגרשיים יודפס כמו שהוא ואילו הטקסט שלא בגרשיים (alphaCentauri) יחושב ויומר לרצף תווים שיודפסו. כתוצאה יופיע בפלט הטקסט:
The distance to Alpha Centauri is 4.129e+016 meters.
נוכל גם לקלוט ערך לתוך משתנה. לצורך זה משמש אובייקט ה-std::cin ואופרטור הקלט << (קלוט מ-). אובייקט cin זה, בדומה ל-cout, מקבל את הקלט מאיזשהו אמצעי קלט במערכת, לרוב אמצעי זה, כברירת מחדל, הוא המקלדת. לדוגמה נוכל להדפיס בקשה מהמשתמש להזין את גילו ולאחר מכן לקלוט את אשר יקליד לתוך משתנה שלם:
cout << "Enter your age:\n";
int age;
cin >> age;
לאחר ביצוע שורות אלה יימצא בתוך משתנה age המספר שהקליד המשתמש. במקרה והוא יקליד משהו לא מספרי ניתן יהיה לזהות זאת באמצעות בדיקת המצב של האובייקט cin, אומנם בדוגמות שבספר זה נניח לצורך הפשטות את תקינות הקלט.
כנאמר כבר בספר זה, cout לאו דווקא מדפיס את הפלט למסך ו-cin לאו דווקא קולט את הקלט מהמקלדת. לדוגמה אם נריץ את התוכנית בשם ourProg.exe משורת הפקודה תחת Linux או Microsoft Windows בצורה הבאה:
cat input.txt | ourProg.exe > output.txt
(ב-Microsoft Windows יש להשתמש בפקודה type במקום הפקודה cat), אזי כל הקלט מ-cin ייקרא מתוך קובץ בשם input.txt מבלי לחכות עד שהמשתמש יקליד אותו במקלדת, ואילו כל הפלט ל-cout יישמר בקובץ בשם output.txt מבלי להופיע על המסך.
ישנם פרמטרים רבים אצל האובייקטים cout ו-cin (הם מופעי המחלקות ostream ו-istream) שמאפשרים לשלוט במראה הפלט ובאופן הקלט. אנו לא נעסוק בהם במסגרת ספר זה מהסיבה שהם חלק מנושא גדול בפני עצמו: הספרייה התקנית של C++.
ביטויים
עריכה"ביטוי" בתכנות דומה ל-"ביטוי" במתמטיקה. בשפות תכנות ישנם כללים ברורים לכתיבת ביטויים, כך שאם נחרוג מכללים אלה המהדר לא יבין את הביטוי ויתן הודעת שגיאה.
כל ביטוי מורכב מפעולות ופרמטרים איתם מתבצעות הפעולות, פעולות אלו נקראות אופרטורים ואילו הפרמטרים איתם עובדים האופרטורים נקראים אופרנדים. האופרטורים מתחלקים ל-3 קטגוריות עיקריות הבאות:
- יונאריים - כאלה שעובדים עם אופרנד אחד. לדוגמה "מינוס":
-x
(הופך את הסימן של המשתנה x). - בינאריים - עובדים עם שני אופרנדים. לדוגמה "פחות":
x - y
(מוצא את ההפרש של x ו-y). - טרנאריים - עובדים עם שלושה אופרנדים. ב-C++ יש אחד כזה, והוא אופרטור ההתניה.
האופרנדים יכולים להיות קבועים (מספרים ומחרוזות) או משתנים שהגדרנו. לדוגמה נוכל לחשב כמה אטומי הליום יש להציב בשורה כדי להגיע עד לאלפא קנטאורי:
double alphaCentauri = 4.129e+16, radiusOfHelium = 3.1e-11;
alphaCentauri / radiusOfHelium;
הביטוי בשורה השנייה מורכב מאופרטור בינארי "חילוק" ושני אופרנדים שהם המשתנים שהגדרנו אותם שורה למעלה.
הביטוי שרשמנו כאן לא שימושי במיוחד, הוא מחשב את המנה אך לא שומר אותה בשום מקום. נוכל לשמור את התוצאה של החישוב במשתנה אחר. תחילה נברר מה אמור להיות הטיפוס של משתנה זה.
טיפוס הביטוי
עריכהכל ביטוי מחזיר איזשהי תוצאה, לכל ערך ב-C++ יש טיפוס. הכללים למציאת טיפוס זה די הגיוניים ואינטואיטיביים. הכלל הבסיסי הוא: לקדם את הטיפוסים של האופרנדים אל טיפוס שיוכל להכיל את כל הערכים של הטיפוסים המקוריים ללא איבוד מידע. אם המהדר לא מוצא טיפוס שיתאים אז הוא יתן הודעת שגיאה ונצטרך לעשות המרה בעצמנו.
למשל כאשר אנחנו מחברים שני מספרים שלמים מטיפוס unsigned short, התוצאה תהיה גם היא unsigned short (יתכן שהמהדר ירחיב אותה ל-unsigned int). אך אם נחבר מספר שלם עם נקודה צפה אז התוצאה תהיה נקודה צפה (כי double לרוב יכול להכיל את כל הערכים של int, אבל אף פעם לא להיפך). כאשר נחבר signed ו-unsigned נקבל שגיאה.
לדוגמה, נציב את התוצאה של החילוק מהדוגמה הקודמת למשתנה חדש:
double lengthOfChain = alphaCentauri / radiusOfHelium;
אופרטורים
עריכהנסכם בקצרה את האופרטורים הבסיסיים הקיימים ב-C++.
פעולות חשבוניות
עריכהב-C++ נרשום את ארבעת פעולות החשבון הבסיסיות באמצעות הסימנים הרגילים שלהם. כמו כן נוכל לחשב את שארית החילוק השלם (מודולו) באמצעות הסימן "אחוז":
5 + 6; // הסכום הוא 11
10 - 7; // ההפרש הוא 3
3 * 4; // המכפלה היא 12
13 / 3; // המנה היא 4
13 % 3; // השארית היא 1
שימו לב שלא קיים אופרטור "להעלות בחזקה". כמו כן פעולת החילוק מחזירה תוצאה שלמה כאשר האופרנדים מטיפוס שלם.
פעולות השוואה
עריכהב-C++ נוכל להשוות שני ערכים בינהם ולקבל תשובה מטיפוס בוליאני (true או false) על השאלה האם האחד גדול מהשני או האם הם שווים או לא. לביצוע השוואה כזו נשתמש בסימנים הבאים:
5 < 10; // 5 קטן מ-10? תשובה true
10 <= 10; // 10 קטן או שווה ל-10? תשובה true
10 > 10; // 10 גדול מ-10? תשובה false
10 >= a; // 10 גדול או שווה למשתנה a? תשובה תחושב בהתאם למשתנה a
a == b; // המשתנה a שווה למשתנה b?
a != b; // המשתנה a שונה ממשתנה b?
התוצאה של כל השוואה כזאת היא בוליאנית ונוכל לשמור אותה במשתנה מטיפוס בוליאני, אבל לרוב משתמשים בתוצאה זו בתנאים ולולאות שתלמדו עליהם בהמשך.
חשוב לא להתבלבל בין השוואה, '==', לבין השמה, סימן '=' אחד. הדבר גורם לבעיות נסתרות בבדיקת תנאים.
פעולות בוליאניות
עריכהפעולות בוליאניות, לעיתים נקראות גם פעולות לוגיות. פעולות אלה מקבלות אופרנדים מטיפוס לוגי (bool) ומחזירות תוצאה בהתאם שגם היא מטיפוס זה. בדרך כלל משתמשים בפעולות אלה כדי לחבר תוצאה של כמה השוואות פשוטות ולהרכיב משפט לוגי מורכב. לדוגמה נוכל לבדוק האם המשתנה a נמצא בין 10 ל-20 כולל באמצעות בדיקת שתי טענות פשוטות יותר: "a גדול או שווה ל-10" וגם "a קטן או שווה ל-20". שימו לב שהשתמשנו במילה "וגם" שמשמעותה היא לאחד את תוצאות שני הביטויים ולהחליט האם הביטוי כולו נכון. להלן הפעולות הבוליאניות הקיימות ב-C++:
- קוניוקציה (וגם, AND) – תסומן בסימן && (שני אמפרסנדים) ותחזיר true אך ורק אם שני האופרנדים שווים ל-true. אם אחד האופרנדים שקרי (שווה ל-false) אז כל הביטוי יהפוך לשקרי. כאשר התוכנית תחשב תוצאה של ביטוי המשתמש באופרטור זה היא תחשב את האופרנדים לפי הסדר שבו הם נרשמו על אף שאין לזה השפעה ישירה על תוצאת הביטוי. במקרה והאופרנד הראשון יהיה יתברר כשקרי התוכנית לא תמשיך לחישוב האופרנד הבא כי תוצאת הביטוי כבר ידועה.
- דיסיונקציה (או, OR) – תסומן בסימן || (שני קווים אנכיים) ותחזיר true אם לפחות אחד משני האופרנדים שווה ל-true. יוחזר false רק כאשר שני האופרנדים מקבלים את הערך false. בדומה לאופרטור && האופרנדים יחושבו לפי סדר כתיבתם וכאשר הראשון יקבל ערך true השני לא יחושב אלה תוחזר התוצאה הכוללת true.
- שלילה (לא, NOT) – תסומן בסימן ! (סימן קריאה) ותחזיר את הפכו של האופרנד היחיד שלה. במילים אחרות: אם תוצאת האופרנד היא true, יוחזר false; אם תוצאת האופרנד היא false, יוחזר true.
לדוגמה:
false || true; // תוצאה true
a == b && a != b; // תוצאה false
!(a > 10 && a < 20); // a לא בין 10 ו-20
a <= 10 || a >= 20; // אותו דבר, a לא בין 10 ו-20
כדאי לדעת: את פעולת ה-XOR הלוגי ניתן לבצע באמצעות אופרטור ההשוואה != (שונה). |
פעולות על סיביות
עריכהכנאמר בתחילת הפרק, לתוכניות אין גישה ישירה לסיביות נפרדות מהן מורכבים המשתנים, אך ניתן לגשת אליהם באמצעות פעולות מיוחדות.
השמות
עריכהמלבד ההשמה הרגילה אותה הכרנו למעלה קיימים קיצורים לצירופים מהתבנית a = a ■ b כאשר a הוא משתנה מטיפוס מובנה, b הוא ביטוי כלשהו (גם הוא מטיפוס מובנה) ו-■ הוא אחת מפעולות החשבוניות או פעולה על סיביות. נוכל לקצר בצורה הבאה: a ■= b. לדוגמה:
a = 10; // שומרים בתוך משתנה a את המספר 10
a += 3; // מוסיפים לערך של a את המספר 3, התוצאה 13
a -= 4; // עכשיו בתוך a נמצא המספר 9
a *= 4;
a /= 3;
a %= 5;
b &= 0; // מאפסים את b בשיטה של גימום עם 0
b ^= 5; // עכשיו יש פה את המספר 5
b <<= 3;
a |= b;
b >>= 1;
שימו לב שלא קיים אופרטור ■, השתמשנו באופרטור זה בתור הדגמה.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
תנאים
נמצאה תבנית הקוראת לעצמה: תבנית:C++ התוכניות שכתבנו עד כה פעלו באופן זהה כל פעם כשהרצנו אותן. אומנם לפעמים נרצה לגרום למחשב לבצע פעולות שונות בהתאם לקלט או מצב התוכנית עצמה. כדי לעשות זאת עלינו לגרום למחשב לבדוק איזשהו תנאי ובהתאם לתוצאה שהוא יקבל (האם התנאי אמיתי או שקרי) לבצע שורות קוד שונות. ב־C++ ישנם שני משפטי קבלת החלטה (if ו־switch) ואופרטור התניה אחד. נסביר את פעולתם בפרק זה.
משפט if
עריכהמשפט הבקרה הבסיסי שבודק תנאי כלשהו הוא משפט ה־if:
if(expression)
// code
קרי: "אם ... אז ...". משפט זה יבדוק את התנאי שנכתוב בין הסוגריים (expression) ואם הוא מתקיים אז הוא יבצע את הקוד שבא אחריו, אם התנאי לא נכון אז הוא ידלג על קוד זה. הבדיקה נעשית על ידי חישוב הערך של הביטוי הנתון, ואם יש צורך בכך, המרתו לטיפוס בוליאני (bool). אם התוצאה היא true אז התנאי הוא נכון (אמיתי) והשורה הבאה אחרי ה־if תתבצע. אם ערך הביטוי הוא false אז השורה הבאה אחרי ה־if לא תתבצע.
להלן דוגמה לשימוש במשפט זה:
int a;
cin >> a;
if(a < 0)
cout << "The number you've entered is negative.\n";
cout << "The number you've entered is " << a << endl;
קטע קוד זה קולט מספר שלם (שורה שנייה) ובודק האם המספר הוא שלילי, כלומר קטן מאפס (שורה שלישית). אם המספר אכן קטן מאפס אז התוכנית תדפיס הודעה מתאימה, אם המספר לא שלילי, אז התוכנית לא תדפיס הודעה זו. שים לב שהשורה החמישית תתבצע בכל מקרה ולכן המספר שהקלדנו תמיד יודפס על המסך בין אם הוא שלילי או לא.
אם נרצה שהתוכנית תבצע מספר פעולות כאשר התנאי מתקיים אז נצטרך להכניס אותן לתוך בלוק נפרד באמצעות הכנסתן לסוגריים מסולסלים. להלן תוכנית שמחשבת שורש ריבועי:
double a;
cin >> a;
if(a < 0)
{
cout << "The number you've entered is negative, it is treated as zero.\n";
a = 0;
}
cout << "sqrt(" << a << ") = " << sqrt(a) << endl;
קטע קוד זה יבדוק האם המספר שהוזן שלילי, אם הוא אכן שלילי אז התוכנית תבצע את כל הפקודות שנמצאות בין הסוגריים המסולסלים. במקרה זה תודפס הודעה שהמספר הוא שלילי והתוכנית תציב לתוך המשתנה את המספר 0. אם התנאי שקרי (המספר שהוזן הוא חיובי או שווה לאפס) אז התוכנית תדלג ישר לשורה השמינית. בכל מקרה התוכנית תדפיס את השורש הריבועי של המספר שהוקלד (אם הוא חיובי) או שורש של אפס (אם הוא שלילי).
משפט if else
עריכהבדוגמה שלמעלה כאשר המספר שהקלדנו הוא שלילי התוכנית מתייחסת אליו כלאפס, דבר שטיפה לא הגיוני. אם נרצה שהתוכנית או תדפיס את השורש של המספר או תתן הודעת שגיאה על שהמספר שלילי, נצטרך להשתמש במשפט טיפה יותר מורכב:
if(expression)
// code 1
else
// code 2
קרי: "אם ... אז ... אחרת ...". משפט זה יבדוק את התנאי ואם הוא נכון יבצע את קטע קוד הראשון (בדומה ל־if רגיל), אבל אם התנאי הוא לא נכון יתבצע קטע קוד השני, זה שאחרי המילה השמורה else. נשתמש במשפט זה כשנרצה שרק אחד מהקטעים יתבצע. לדוגמה, נשנה את הדוגמה עם השורש הריבועי:
double a;
cin >> a;
if(a < 0)
cout << "Can't calculate the square root of negative number.\n";
else
cout << "sqrt(" << a << ") = " << sqrt(a) << endl;
גם כאן נוכל להוסיף סוגריים כדי לבצע מספר פקודות אחרי ה־else.
משפט else if
עריכהבתקן שפת C++ עצמה לא מוגדר משפט מהסוג הזה (לעומת שפות אחרות כמו Visual Basic לדוגמה). למרות זאת עדיין ניתן להרכיב מבנה כזה:
if(expression)
// code 1
else if(expression)
// code 2
else
// code 3
מבנה כזה הוא שני משפטי "אם ... אז ... אחרת ..." המקוננים אחד בתוך השני, כלומר משפט ה־if השני משחק תפקיד של פקודה שבאה בתוך בלוק ה־else של המשפט הראשון. נתבונן בדוגמה הבאה:
int a;
cin >> a;
if(a > 0)
cout << a << " is positive.\n";
else
{
if(a < 0)
cout << a << " is negative.\n";
else
cout << "It's zero.\n";
}
כיוון שמשפט תנאי if הוא פקודה אחת בפני המהדר, נוכל להוריד את צמד הסוגריים המסולסלים. כמו כן שפת C++ מתעלמת מרווחים וירידות
שורה, לכן נוכל להעלות את ה־if לשורה של ה־else. זכרו שכיוון שאלה הן שתי מילות שמורות נפרדות, כפי שהוסבר כאן, יש להפריד אותן עם רווח, אין לכתוב elseif ביחד.
int a;
cin >> a;
if(a > 0)
cout << a << " is positive.\n";
else if(a < 0)
cout << a << " is negative.\n";
else
cout << "It's zero.\n";
בדוגמה הזאת התוכנית תדפיס רק הודעה אחת משלושת ההודעות האפשריות, בהתאם האם המספר חיובי, שלילי או אפס.
ניתן לקונן עוד משפטי if רבים בתוך חלק ה־else ככל שנרצה. התוכנית תבדוק את התנאים לפי הסדר עד שתגיע לתנאי אמיתי או לסוף המבנה - ה־else האחרון. כמו כן אנחנו לא חייבים להוסיף את חלק ה־else בסוף השרשרת הזו, במקרה כזה אם אף אחד מהתנאי לא מתקיים אז התוכנית תמשיך בביצוע שאר הפקודות שאחריו.
משפט if else מקוצר
עריכהנאתחל משתנה bool x=true, ניתן לשאול באופן מקוצר ולתת פקודה בשורת השאלה הבוליאנית.
bool x = true;
x ? cout << "true" : cout << "false";
זהו קיצור של if ו else באותו משפט, כשהסימן : משמש else. האם הביטוי משמאל ל(?) אמת? אם כן, אז הביטוי האמצעי יופעל, אם לא, אז הביטוי בסוף יופעל.
משפט switch
עריכהנתבונן בדוגמה הבאה:
cout << "Do you like apples (Y or N)?\n";
char answer;
cin >> answer;
if(answer == 'Y')
cout << "Take an Apple!\n";
else if(answer == 'N')
cout << "It's your problem...\n";
else
cout << "I can't understand you...\n";
קוד מהסוג הזה לא נוח לכתיבה, בעיקר כשמספר האפשרויות גדול מדי. כאשר אנו משווים את אותו הביטוי לערכים רבים ובהתאם לכך מבצעים פעולות כלשהן, ניתן לרשום את הבדיקה באמצעות משפט switch:
switch(answer)
{
case 'Y':
cout << "Take an Apple!\n";
break;
case 'N':
cout << "It's your problem...\n";
break;
default:
cout << "I can't understand you...\n";
}
בדוגמה הפשוטה הזאת לא נראה יתרון רב, היא אף תופסת יותר מקום. אך כתיב כזה לא רק ברור יותר אלא גם במקרים רבים דווקא מקצר את התוכנית.
משפט זה נקרא גם הוראת בחירה כיוון שהתוכנית בוחרת את אחת האפשרויות המפורטות אחרי ה־switch. נסביר את אופן הפעולה של המשפט הזה. תחילה התוכנית מחשבת את הביטוי שנרשם בין הסוגריים אחרי ה־switch. תוצאת הביטוי צריכה להיות מטיפוס שלם כלשהו (int, char, short ...). לאחר מכן התוכנית קופצת לאפשרות ה־case המתאימה. במקרה ואף אחד מהערכים הקבועים שרשמנו אחרי ה־caseים לא שווה לערך הביטוי הנבדק, התוכנית תקפוץ לחלק ה־default (ברירת מחדל), במקרה והוא קיים. שימו לב שאין מיגבלות לסדר שבו נרשום את כל האפשרויות, גם את ה־default ניתן לרשום בהתחלה, באמצע או בכלל לא לרשום.
פקודת ה־break אינה חלק בלתי נפרד מהמשפט הזה. כפי שנראה בעתיד בלולאות, פקודה זו משמשת ליציאה מבלוק פקודות שתחום בסוגריים מסולסלים וששייך ללולאה או להוראת בחירה. כאשר התוכנית מגיע להוראת ה־break היא תצא מתוך הבלוק של הוראת הבחירה ותמשיך הלאה, היא לא תבצע את הפקודות שבתוך הבלוק. במקרה שלנו, למשל אם answer מכיל את התו 'N' ואנחנו לא נרשום את הוראות ה־break, התוכנית תדפיס "It's your problem...\n" ותמשיך לחלק ה־default שבו תפלוט את המחרוזת "I can't understand you...\n". כתוצאה על המסך יופיעו שתי שורות שונות, במקום אחת.
אומנם, לא תמיד נרצה למנוע מהתוכנית להמשיך לבצע את שאר הפקודות שב־switch. נתבונן בדוגמה שבה דווקא אנחנו משתמשים בתכונה זו של משפט הבחירה:
switch(answer)
{
case 'y':
case 'Y':
cout << "Take an Apple!\n";
break;
case 'n':
case 'N':
cout << "It's your problem...\n";
break;
default:
cout << "I can't understand you...\n";
}
בדוגמה זו המשתמש אינו חייב להקליד דווקא אות גדולה, התוכנית תבין את תשובתו ללא תלות בגודל האות. אם למשל יקליד המשתמש את התו 'n', אז התוכנית תקפוץ ל־case השלישי ותמשיך עד לפקודת ה־break.
אופרטור ההתניה
עריכהאופרטור התניה הוא אופרטור טרנארי, כלומר הוא מקבל שלושה אופרנדים. אופרטור זה יראה כך:
expression ? true_expression : false_expression
אופרטור זה דומה למשפט if else רגיל; הוא יחשב את הביטוי הראשון (expression), ימיר את ערכו לערך בוליאני, ובהתאם לערך זה יחשב ויחזיר את ערך אחד מהביטויים true_expression או false_expression.
ניתן להשתמש באופרטור זה בדרכים שונות. לרוב משתמשים בו כדי לקצר משפטים מהסוג הזה:
int max;
if(a>b)
max = a;
else
max = b;
באמצעות המשפט הזה הקוד מקוצר עד לשורה אחת:
int max = a>b?a:b;
כיוון שמחושב רק אחד מהביטויים השני והשלישי, נוכל לקצר גם משפטי if אחרים. ובכל זאת לא כדאי להגזים, שימוש מופרז עלול לפגוע בקריאות הקוד:
int result = b!=0 ? a/b : throw math_error();
במקרה ש־b שווה לאפס, הקוד יזרוק חריגה (throw).
נמצאה תבנית הקוראת לעצמה: תבנית:C++
לולאות
נמצאה תבנית הקוראת לעצמה: תבנית:C++ כיצד נבצע את אותה הפעולה 10,000 פעמים? כיצד נבצע את אותה הפעולה מספר-לא-ידוע-מראש של פעמים? באמצעות התנאים התוכניות שלנו קיבלו יכולת להחליט על אופן פעולתן, אבל הן אף פעם לא חזרו לאותה שורה פעם שנייה. אמנם יכולנו להדפיס 10,000 פעמים את המחרוזת "Hello, world!\n" באמצעות שכפול פקודת ההדפסה 10,000 פעמים (דבר לא הגיוני בעליל), אך בכל אופן לא יכולנו לקלוט מהמשתמש מספר ולהדפיס את המחרוזת הנ"ל מספר זה של פעמים, לפי רצונו של המשתמש.
לולאות באות לסייע לנו בכתיבת קטעי קוד מהסוג הזה. לולאות הן "חזרות" על אותו קטע קוד כל עוד תנאי מסויים מתקיים. לקטע קוד זה קוראים גוף הלולאה. ב-C++ קיימים שלושה סוגים של לולאות: while, do while ו-for. כל לולאה שנרצה לכתוב ניתן לבטא באמצעות כל אחת משלוש האפשרויות הללו. ההבדל בינהן לא משמעותי, הרי כולן מבצעות את אותו קטע קוד כל עוד התנאי שכתבנו מתקיים, בכל זאת עבור כל מקרה פרטי נבחר את הלולאה המתאימה ביותר שתקצר את הקוד ותעשה אותו לברור ביותר.
לולאת while
עריכהwhile(expression)
// code
קרי: "כל עוד ... בצע ...".
לולאת while תבצע את אותו קטע קוד כל עוד הביטוי שכתבנו (expression) מקבל את הערך true בהמרה לטיפוס בוליאני bool. כאשר התוכנית תגיע לתחילת הלולאה, היא תבדוק תחילה את התנאי. אם התנאי שקרי התוכנית לא תבצע את הקוד שבלולאה אף לא פעם אחת. אם התנאי אמיתי אז התוכנית תבצע את הבלוק שבא לאחר הלולאה, ותחזור בסופו לשורה הראשונה בה נבדק התנאי. ניתן לראות בלולאה זו את משפט ה-if שחוזר על עצמו. בזהה למשפטי התנאי, כדי לבצע יותר מפקודה אחת בגוף הלולאה, יש לתחום אותן בסוגריים מסולסלים.
להלן דוגמה שקולטת מהמשתמש מספרים ומחשבת את הסכום שלהם. הלולאה תפסק כאשר המשתמש יזין את המספר 0, ואז יודפס הסכום של המספרים. קטע זה מניח את תקינות הקלט.
int num, sum = 0;
cin >> num;
while(num)
{
sum += num;
cin >> num;
}
cout << "sum = " << sum << endl;
תחילה התוכנית תאפס את הצובר שלנו, משתנה sum. בלולאה אנחנו נוסיף למשתנה זה את המספרים שהמשתמש מקליד, לכן בהתחלה הסכום צריך להיות 0. בשורה השניה התוכנית קולטת את המספר הראשון. לאחר ההכנות האלה אנחנו מגיעים לשורה הראשונה של הלולאה, בה אנחנו בודקים את התנאי.
כאשר אנחנו כותבים while(num)
המהדר ממיר את הערך של הביטוי שכתבנו לטיפוס בוליאני כדי לבדוק האם הוא אמיתי או שקרי. כנאמר בפרקים הקודמים, מספר שלם מומר ל-true כאשר הוא שונה מ-0 ול-false כאשר הוא שווה ל-0. אם לקחת את זה בחשבון ניתן לראות שהתנאי של הלולאה שכתבנו הוא בעצם גרסה מקוצרת של while(num != 0)
, או במילים: "כל עוד num שונה מ-0 בצע ...". כתוצאה קיבלנו תוכנית שתבצע את הקוד שבין הסוגריים המסולסלים אם ורק אם המספר הראשון שהקליד המשתמש שונה מ-0. אם המשתמש מקליד 0 כמספר ראשון, אזי התוכנית תדלג על הלולאה אל השורה האחרונה וישר תדפיס את הסכום, שאין פלא - הוא אכן אפס.
נברר מה יקרה אם המספר הראשון שנקלט שונה מ-0, למשל המשתמש יקליד 7. במקרה כזה תוצאת הביטוי שבתנאי הלולאה תהיה true, כי num שונה מאפס, והתוכנית תכנס לתוך הלולאה. הפקודה הראשונה שבגוף הלולאה תוסיף את הערך הנקלט השמור במשתנה num לתוך הצובר sum. בדוגמה שלנו, הצובר הזה יקבל את הערך 7. לאחר שעיבדנו את המספר הראשון שהמשתמש הקליד אין לנו יותר צורך בו, וכעת עלינו לעבור למספר הבא: cin >> num;
(בשורה השנייה של גוף הלולאה) יקלוט את המספר הבא (נגיד 4). עתה התוכנית תקפוץ לתחילת הלולאה ושוב תבדוק את התנאי, כיוון ש-4 מומר ל-true התוכנית שוב תבצע את גוף הלולאה והצובר יוגדל לערך 7+4=11. כך התוכנית תחזור חלילה עד שמספר 0 יקלט. שימו לב שקטע קוד זה יעבוד גם עם מספרים שליליים, לכן עבור הקלט:
7 4 -20 51 0
יהיה הפלט "sum = 42".
לולאת do while
עריכהלולאת do while היא לולאת while שהתנאי הנבדק הוא בסוף הלולאה ולא בתחילתה:
do
// code
while(expression);
בניגוד ללולאת while, גוף לולאה זו יתבצע לפחות פעם אחת. התוכנית תכנס לתוך הלולאה לפני שתבדוק את התנאי. התנאי יקבע האם התוכנית תחזור על הקוד שבלולאה בפעם השנייה, השלישית וכו'... כאשר תכתבו לולאת while שלפניה בא קוד שדומה לקוד שכתבתם בתוף גוף הלולאה, תשקלו לשנות את הלולאה ל-do while. לדוגמה, נוכל לשנות את הקוד שכתבנו למעלה ללולאת do while:
int num, sum = 0;
do {
cin >> num;
sum += num;
} while(num);
cout << "sum = " << sum << endl;
בדוגמה פרטית הזו לא הייתה לנו בעייה לעשות שינוי זה. הסיבה לכך שהפקודה הנוספת שהייתה לנו בתוך גוף הלולאה, היא sum += num;
. כאשר המספר שנקלט הוא אפס, הוא לא משפיע על הסכום ולכן נוכל לבדוק את תנאי העצירה אחרי שכבר התווסף לסכום. במקרה שלנו, גם אם תנאי העצירה שונה, נוכל לשנות את סדר הפקודות כדי שהקוד יתבצע באופן תקין.
שימו לב שאין כל יתרון ברור ללולאה זו על פני לולאת while רגילה. אם שימוש בלולאה זו לא מתבקש בעצמו, תעדיפו את לולאה ה-while הרגילה, היא לרוב מובנת יותר.
לולאת for
עריכהניגש לבעיה שהצגנו בתחילת פרק זה. כיצד נדפיס 10000 פעמים את המחרוזת "Hello, world!\n"? נוכל לעשות את זה באמצעות לולאת while:
int i = 0; // אתחול
while(i < 10000) // תנאי
{
cout << "Hello, world!\n";
i++; // קידום מונה הלולאה
}
בתוכנית זו הצהרנו על משתנה בשם i ואיתחלנו אותו ל-0, כי ברגע ההתחלתי הדפסנו "אפס" פעמים. כל פעם שהתוכנית תדפיס את המחרוזת "Hello, world!/n" היא תגדיל את המונה (המשתנה i) ב-1, בצורה כזאת בכל רגע נתון המונה יכיל את מספר השורות שכבר הודפסו. הלולאה תיפסק כאשר מספר זה יגיע ל-10000, לכן היא תתבצעה 10000 פעמים עבור ערכים של i החל מ-0 ועד ל-9999 כולל (סך הכל 10000 פעמים). בפעם האחרונה כאשר i יקבל את הערך הסופי שלו 10000 הלולאה תיפסק.
גם ללא שימוש בלולאת for ניתן לקצר קוד זה, אומנם לולאת for תשמש אותנו כדי לבטא את הרעיון של התוכנית בצורה הברורה ביותר. ניתן לראות שהקוד בדוגמה שלמעלה מחולק ל-4 חלקים: אתחול, תנאי, גוף הלולאה וקידום המונה. המבנה הזה לא כל כך יבלוט מלולאות גדולות. כמו כן יש פה חיסרון רב – קידום המונה נמצא בסוף גוף הלולאה והוא מנותק מהאתחול ומהתנאי.
נתבונן בלולאה for. לולאה זו מאפשרת לנו לפרק את הקוד לאותם 4 החלקים שדיברנו עליהם אך לארגן בצורה טיפה שונה:
for(initialization; condition; iteration)
// code
בשורה הראשונה נמצאים שלושת החלקים האחראים על בקרת ביצוע הלולאה ואילו גוף הלולאה בא אחר-כך. ננתח את השורה הראשונה:
- אתחול (initialization) – חלק זה הוא הוראה בפני עצמו. הוא הראשון שיתבצע כאשר התוכנית תגיע לשורת הלולאה והתוכנית לא תחזור עליו כשתחזור על ביצוע גוף הלולאה. נוכל להצהיר בחלק זה על משתנים מאותו טיפוס ולאתחלם לערכים כלשהם. טווח ההכרה של משתנים שיוצהרו כאן יהיה עד לסיום הלולאה, משמע הדבר שלא נוכל להשתמש בהם בהמשך הקוד מחוץ ללולאה. אם בכל זאת נרצה להשתמש באותו משתנה גם בתוך הלולאה וגם מחוץ לה, נצהיר עליו לפני הלולאה. ניתן לרשום במקום זה ביטוי כלשהו, למשל שיאתחל משתנה כזה שהוצהר לפני הלולאה. במקרה ונשאיר חלק זה ריק, התוכנית תתעלם ממנו.
- תנאי (condition) – תפקידו כתפקיד התנאי בלולאת while רגילה. זהו ביטוי שיבוצע אחרי האתחול או הקידום ויושווה לערך true. אם בתחילת הלולאה ביטוי זה שקרי, אזי הלולאה לא תתבצע אף לא פעם אחת. אם נשאיר ביטוי זה ריק, התוכנית תתייחס אליו כלאמיתי, כאילו שנכתוב בו true. כתוצאה הלולאה לא תעצר מעצמה, נצטרך להשתמש בהוראת break, בהוראת return, בהוראת goto, באופרטור throw, בפונקציית exit או בפונקציית terminate על מנת להפסיק את הלולאה. במקרה הגרוע התוכנית תתקע. כמו כן לעיתים נכתבות תוכניות שלא אמורות לצאת מלולאה כזו כלל, במקרה כזה הלולאה היא אינסופית.
- איטרציה (iteration) – כל פעם שהתוכנית תגיע לסוף גוף הלולאה ולפני שתבדוק את התנאי כדי להחליט האם להמשיך בביצוע הלולאה או לא, היא תבצע את הביטוי שנרשם פה. בדרך כלל נרשום פה הגדלה או הקטנה של מונה הלולאה, מעבר לחוליה הבאה ברשימה מקושרת וכד'... הערך הסופי של הביטוי הזה לא נבדק על ידי התוכנית ואין לו שום חשיבות. ביטוי הנרשם פה יתבצע פעם אחד פחות מהביטוי שבתנאי. כמו גם את שני החלקים הקודמים נוכל להשאיר גם את החלק הזה ריק.
כעת נשתמש בלולאת for כדי להדפיס 10000 פעמים את המחרוזת "Hello, world!\n":
for(int i = 0; i < 10000; i++)
cout << "Hello, world!\n";
כיוון שהתנאי יכול להיות כל ביטוי, נוכל להשוות את ערך ה-i למשתנה אחר במקום קבוע. נפתור את הבעיה השנייה שהצגנו בתחילת הפרק, נדפיס את המחרוזת מספר פעמים לפי רצונו של המשתמש:
int count;
cin >> count;
for(int i = 0; i < count; i++)
cout << "Hello, world!\n";
ניתן לקונן לולאות, כלומר לרשום לולאה אחת בתוך גוף הלולאה השנייה. שמות מקובלים למוני הלולאה הם i, j, k, כאשר משתמשים ב-i ללולאה החיצונית ביותר, ב-j למונה הלולאה הפנימית יותר וב-k ללולאה הפנימית ביותר. אם יצא לכם להשתמש ב-4 או יותר לולאות מקוננות, שקלו לפרק את הקוד לפונקציות או להשתמש ברקורסיה (למדו בהמשך). להלן דוגמה של לולאה מקוננת:
int height, width;
cin >> height >> width;
for(int i = 0; i < height; i++)
{
for(int j = 0; j < width; j++)
cout << '*';
cout << endl;
}
הלולאה הפנימית, זו שנמצאת בתוך בלוק של הלולאה הראשונה, מדפיסה שורה של כוכביות, בהתאם לערך שבמשתנה width (רוחב). לאחר השורה הזו התוכנית יורדת שורה. כיוון שהקוד שמדפיס שורה ומוריד את הסמן לשורה חדשה נמצא בתוך גוף הלולאה החיצונית, הוא יתבצע מספר פעמים, בהתאם לתנאי העצירה של הלולאה החיצונית. הלולאה החיצונית תתבצעה מספר פעמים בהתאם למשתנה height (גובה) ולכן התוכנית תדפיס מספר שורות של כוכביות השווה ל-height. כתוצאה יופיע בפלט מלבן של כוכביות שרוחבו הוא width וגובהו הוא height שהם הערכים שנקלטו מהמשתמש. מנוסחת שטח המלבן נוכל לחשב שהמלבן יכלול height*width כוכביות בסך הכל, ומכאן נוכל להסיק שגוף הלולאה הפנימית התבצע בדיוק מספר זה של פעמים.
הוראת goto
עריכהשימו לב: יש להמנע משימוש בהוראה זו. לרוב ניתן למצוא תחליף עדיף במקומה(לרוב אף אינה עובדת!!!). |
את כל הלולאות שנלמדו למעלה ניתן לבטא באמצעות קפיצות וקפיצות מותנות. הוראת goto מאפשרת לגרום לתוכנית לקפוץ (לדלג) לשורה מסויימת. את השורה שאליה נרצה לגרום לתוכנית לעבור נסמן באמצעות תגית ואחרי המילה השמורה goto נכתוב את שם התגית שאליה אנחנו רוצים לעבור:
cout << "Good ";
goto printMorning;
cout << "evening.\n";
printMorning:
cout << "morning.\n";
בדוגמה הזו כאשר התוכנית תגיע לשורה goto printMorning היא תדלג לשורה שאחרי printMorning. כתוצאה השורה "Good evening.\n" אף פעם לא תודפס, במקומה תופיע השורה "Good morning.\n".
הוראה זו מאפשרת לעבור בתוך גבולות אותה פונקציה (למד בהמשך על פונקציות) מכל שורה שהיא לכל שורה אחרת. המגבלה היחידה היא האיסור לדלג על איתחול המשתנים. כדוגמה נכתוב את הדוגמה עם לולאת ה-do while שלמעלה באמצעות הוראת ה-goto:
int num, sum = 0;
loopBegin:
cin >> num;
sum += num;
if(num)
goto loopBegin;
cout << "sum = " << sum << endl;
הוראה זו מסרבלת את הקוד והופכת אותו לפחות ברור ואף לעיתים הוא הופך לגמרי לבלתי קריא. הסיבה לקיום הוראה זו בשפת C++ היא בעיקר היסטורית. כמו כן היא משמשת תוכנות אוטומטיות לכתיבת קוד או לביצוע אופטימיזציות. יש להמנע משימוש בהוראה זו בקוד שנכתב על ידי בן-אדם. מלבד מקרים נדירים למדי ניתן למצוא תחליף להוראה זו.
כעת נתבונן בשתי הוראות שהן תחליף להוראת goto במקרים מסויימים.
הוראת continue
עריכהלפעמים נצטרך לבצע את החזרה על הלולאה לפני שהתוכנית תסיים לבצע את שאר גוף הלולאה. נוכל לבצע זאת באמצעות הכנסת הקטע המתאים לתוך משפט תנאי:
for(int i = 0; i < 10; i++)
{
double n;
cin >> n;
if(n >= 0)
{
/* ... */
}
}
אך דבר זה עלול לגרום לקינון משפטי תנאי רבים אחד בתוך השני. למקרה כזה נוכל להשתמש בהוראה מיוחדת, הוראת continue. נוכל לכתוב הוראה זו בכל אחת מהלולאות (while, do while, for). כאשר התוכנית תגיע להוראת continue היא תדלג לסוף גוף הלולאה ותבדוק שוב את תנאי הלולאה, כאילו שנבצע קפיצת goto לשורה שלפני הסוגר של סוף בלוק הלולאה. במקרה ומדובר בלולאת for התוכנית תבצע גם את ביטוי האיטרציה. להלן הלולאה שלמעלה הכתובה באמצעות הוראת continue:
for(int i = 0; i < 10; i++)
{
double n;
cin >> n;
if(n < 0)
continue;
/* ... */
}
בניגוד להוראת ה-goto, הוראת ה-continue ברורה יותר והשימוש בה מקובל.
הוראת break
עריכהלפעמים נרצה לסיים את ביצוע הלולאה לפני שהתנאי שלה יהפוך לשקרי. ניתן לעשות זאת באמצעות צירוף משתנה בוליאני נוסף done שתפקידו לשמור 'שקר' כל עוד ברצוננו להמשיך בביצוע רגיל של הלולאה ושיקבל 'אמת' כאשר נרצה לסיים את הלולאה. באמצעות הוראת continue נוכל לגרום לתוכנית לבדוק את המשתנה done ולסיים את הלולאה בכל שורה מתוך גוף הלולאה שנרצה:
bool done = false;
for(int i = 0; i < 10 && !done; i++)
{
double n;
cin >> n;
if(n < 0)
{
done = true;
continue;
}
/* ... */
}
אך כפי הנראה קוד זה מסורבל הוא. כדי להפסיק את הלולאה באמצע נוכל להשתמש בהוראת break שתפקידה להפסיק את הלולאה. כאשר התוכנית תגיע לשורה עם הוראת break היא תפסיק את ביצוע הלולאה ללא בדיקת כל תנאים שהם:
for(int i = 0; i < 10; i++)
{
double n;
cin >> n;
if(n < 0)
break;
/* ... */
}
ההבדל בין קטע קוד זה לבין קטע קוד הקודם (מלבד העדר המשתנה הנוסף) הוא שהתוכנית תצא מהלולאה ללא בדיקת התנאי וללא קידום המונה. לדוגמה אם המספר הראשון שיקלט, כאשר i יכיל את הערך 0, יהיה שלילי, אזי קטע קוד הראשון יצא מהלולאה כאשר ערכו של i יקודם ל-1, למרות זאת קטע קוד השני יצא מהלולאה כאשר ערכו של i ישאר 0. במקרה זה הדבר לא משמעותי גם מהסיבה שטווח ההכרה של המשתנה i הוא אך ורק גוף הלולאה וגם הדבר לא משפיע כל כך על היעילות.
בהוראת ה-break, כמו גם בהוראת ה-continue, נוכל להשתמש בכל שלושת הלולאות: while, do while ו-for. כמו כן שימוש נפוץ בהוראה זו הוא בלולאות אינסופיות. לעיתים נוח יותר לכתוב לולאה אינסופית (לולאת for ללא תנאי) ולצאת ממנה משורות שונות בגופה על ידי שימוש ב-break.
שימו לב שכאשר יש מספר לולאות מקוננות אז הוראת ה-break יוצאת מהלולאה הפנימית ביותר. בדוגמה הבאה יודפס 10 פעמים ערכו של i:
for(int i = 0; i < 10; i++)
{
for(int j = 0; j < 10; j++)
break;
cout << "i = " << i << endl;
}
כדי לצאת מתוך מספר לולאות מקוננות ללא הוספת משתני עזר ניתן להשתמש בהוראת goto:
for(int i = 0; i < 10; i++)
{
for(int j = 0; j < 10; j++)
goto endOfLoops;
cout << "i = " << i << endl;
}
endOfLoops:
במקרה זה ערכו של i יודפס אך ורק פעם אחת. אך כלל הזהב לגבי השימוש ב-goto עדיין תקף. יש להימנע משימוש ב-goto, ואכן יש פתרון יותר טוב. הדרך האליגנטית ביותר היא להכניס את הלולאות המקוננות לתוך פונקציה, אז נוכל לצאת מהן באמצעות הוראת return (למדו בהמשך).
נמצאה תבנית הקוראת לעצמה: תבנית:C++
תרגילים
נמצאה תבנית הקוראת לעצמה: תבנית:C++
ספירה לאחור
עריכהלפעמים יש צורך שמונה הלולאה יעבור על כל הערכים בקטע אך בסדר הפוך: מהגדול לקטן.
1. כתוב לולאה for שמונה הלולאה זו (נקראהו i) יעבור על כל הערכים בתחום זה: הראשון n-1 והאחרון 0 (כולל).
for(int i = n-1; i >= 0; i--)
cout << i << endl;
2. כתוב לולאה for שמונה הלולאה יהיה מטיפוס unsigned אך עדיין הלולאה תעבוד באותו אופן.
for(unsigned i = n; i--; )
cout << i << endl;
עצרת
עריכהכתוב תוכנית שתקלוט מספר שלם, תחשב ותדפיס את עצרת של המספר הזה: .
פתרון פשוט (3 משתנים):
#include <iostream>
using namespace std;
int main()
{
int n;
cin >> n;
int m = 1;
for(int i = 1; i <= n; i++)
m *= i;
cout << m << endl;
return 0;
}
מערכים
נמצאה תבנית הקוראת לעצמה: תבנית:C++ מערך הוא מבנה נתונים בסיסי. מערך מכיל מספר מסויים של איברים מטיפוסים זהים. לדוגמה: "מערך של 100 איברים מטיפוס int" הוא אזור בזיכרון שמכיל 100 מספרים שלמים.
תכונה חשובה של מערך היא שהזיכרון שמוקצה עבורו הוא זיכרון רציף, כלומר חלקי המערך לא מפוזרים במקומות שונים בזיכרון אלא כל האיברים נמצאים בכתובות עוקבות. בדוגמה שלנו, אם גודל משתנה שלם הוא 4 בתים, אז עבור המערך יוקצה זיכרון רציף בנפח של 100*4=400 בתים. כיוון שהזיכרון הזה הוא רציף, אם נדע את הכתובת של האיבר הראשון במערך, נוכל למצוא כל איבר אחר על ידי חישוב פשוט. למשל אם הכתובת של תחילת המערך (האיבר הראשון) היא 3400, אז כתובת האיבר העשירי תהיה: 3400 + 4*(10 - 1) = 3436.
אין צורך לעשות חישוב זה ידנית. ב-C++ קיימת פעולת גישה לפי אינדקס שנדגים אותה למטה. אינדקס הוא מספרו הסידורי של האיבר במערך. חשוב לזכור שב-C++ אינדקס האיבר הראשון הוא 0 ולא 1, באותו אופן אינדקס האיבר השני הוא 1, העשירי הוא 9, והאחרון במערך 100 איברים הוא 99. דבר זה חוסך מהמחשב את הפחתת ה-1 שביצענו בחישוב שלמעלה. יתר על כן, התחלת הספירה מ-0 נראת טבעית יותר ככל שמקבלים יותר ניסיון בתכנות.
השימוש במערכים בא ביחד עם לולאות ורקורסיה (נלמד בהמשך על פונקציות). אם ניצור 100 משתנים בשמות שונים, לא נוכל לעבוד עם כולם ביחד בתוך אלגוריתם אחד. כאמור במערך האיברים ממוספרים ולכן ניתן לכתוב אלגוריתמים שיעבדו מערכים ללא תלות בגודלם המעשי. למשל נוכל לכתוב אלגוריתם שמחפש את המקסימום בתוך מערך של מספרים שלמים וזה בעזרת לולאה אחת פשוטה. בדרך כלל יש תכונה שמאחדת את כל האיברים במערך מסויים. למשל בתוכנית ניהול של בית ספר, יתכן וניצור מערך של כל התלמידים עבור כל כיתה.
הגדרה
עריכההגדרת המערך תראה כמו הגדרת משתנה רגיל שאחריו נכתוב את גודל המערך (ולא את אינדקס האיבר האחרון):
int a[100];
גודל המערך צריך להיות קבוע שערכו ידוע בזמן ההידור. למשל נוכל, ואף עדיף, להגיד את המערך כך:
const int N = 100;
int a[N];
במקרה כזה אם נרצה לשנות את גודל המערך, נשנה רק מספר אחד.
גישה לפי אינדקס
עריכהכעת נוכל לפנות לכל איבר במערך באמצעות אופרטור הגישה לפי אינדקס []:
a[0] = 20;
a[3] = a[0]+2;
cout << a[3]+a[0] << endl;
הפלט יהיה 42.
לאופרטור הגישה לפי אינדקס שני אופרנדים: הראשון שלפני הסוגריים הוא המערך עצמו, השני שנכתב בתוך הסוגריים הוא האינדקס של אליו נרצה לגשת. שני האופרנדים הם ביטויים לכל דבר, לכן נוכל לרשום בתוך הסוגריים גם, למשל, משתנה:
for(int i = 0; i < N; i++)
cout << a[i];
לולאה זו מדפיסה את כל איברי המערך.
איתחול
עריכהעל מנת לאתחל מערך נוכל להשים לתוכו את הערכים הרצויים:
int prime[100];
prime[0] = 2;
prime[1] = 3;
prime[2] = 5;
prime[3] = 7;
prime[4] = 11;
// ...
אבל לכתוב 100 השמות לגמרי לא נוח. קיימת דרך לאתחל את המערך בשורת הגדרתו. אחרי הסימן = (שווה) נפתח סוגריים מסולסלים שבהם נרשום את רשימת הערכים שאיתם נרצה לאתחל את המערך:
int primes[100] = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37};
בשורת איתחול זו המתכנת התעצל לכתוב את כל 100 המספרים הראשוניים וכתב רק 12. הדבר לא יגרום לשגיאה, כל שאר הערכים, באינדקסים 12 עד 99 כולל, יואתחלו ל-0 אוטומטית. לעומת זאת אם נרשום בשורת האיתחול יותר איברים מגודל המערך שצויין לפני כן, המהדר יחזיר שגיאה.
לרוב כאשר נאתחל בדרך כזו את המערך, לא נתעניין במספר המעשי של האיברים בו. במקרה כזה נצטרך כל פעם שנשנה את איתחול המערך לספור מחדש את מספר האיברים ולעדכן את גודלו. הדבר לא נוח, לכן קיימת דרך נוספת. מותר לא לכתוב את הגודל בכלל ולהשאיר את הסוגריים ריקים. המהדר יחשב לבד את הגודל המעשי:
int primes[] = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37};
נשאלת השאלה: כיצד נדע את גודל המערך בשאר התוכנית? ניתן לחשבו בעזרת האופרטור sizeof. מספר האיברים במערך שווה לגודלו בבתים חלקי גודל איבר אחד בבתים. כדי לא לעשות את החישוב בכל מקום בתוכנית, נוכל להגדיר קבוע:
const size_t primesCount = sizeof(primes)/sizeof(primes[0]);
מערכים רב-מימדיים
עריכהניתן גם ליצור מערכים רב מימדיים, המשמעות היא שכל מקום במערך מכיל בתוכו עוד מספר מקומות אחרים, ניתן להסתכל על זה כמו על טבלה שיש בה מספר שורות וכל שורה יש בה מספר עמודות. לדוגמה כך תיצרו מערך בן 5 מקומות שכל מקום בתוכו מכיל 5 מקומות נוספים (ניתן כמובן לעשות עוד ועוד מימדים).
int array[5][5];
ניתן ליצור מערכים מאיזה סוג שנרצה (int, char וכו'). לדוגמא: בשביל לגשת לעמודה השניה בשורה השלישית נכתוב:
array[2][1]
כיון שכידוע ב- ++C המערך מתחיל מאינדקס [0] יש צורך להפחית 1 מהמקום המבוקש.
מחרוזות C
עריכהנמצאה תבנית הקוראת לעצמה: תבנית:C++
פונקציות
נמצאה תבנית הקוראת לעצמה: תבנית:C++ פונקציות הן אבני הבניין של תכנות פרוצדורלי. כל פונקציה היא חלק מהתוכנה שיכול לקבל פרמטרים ויכול להחזיר ערך. לכל פונקציה כזאת נוכל לקרוא בכל מיני מקומות שונים בתוכנית. בזמן הקריאה לפונקציה, התוכנית עוצרת במקום הקריאה ועוברת לביצוע קוד הפונקציה. כאשר הפונקציה מסתיימת התוכנית חוזרת למקום בו נכתב הזימון כאשר יש בידיה את הערך שהוחזר על ידי הפונקציה.
לפי פרדיגמת התכנות הפרוצדורלי יש לפרק את התוכנית לכמה שיותר פונקציות קטנות שכל אחת מהן תבצע מינימום פעולות פשוטות. פונקציות נקראות גם כן תת-תוכניות, שם זה מרמז על כך שהן חלקים מהתוכנית הגדולה כאשר כל פונקציה מתפקדת כתוכנית קטנה בפני עצמה.
עד כה כבר הכרתם פונקציות שכבר קיימות בספריות של C++, לדוגמה sqrt שמקבלת מספר כפרמטר ומחזירה את השורש הריבועי שלו. כמו כן נאמר שגם main היא פונקציה. ההבדל בין פונקציה sqrt לפונקציה main הוא שפונקציה sqrt כבר נכתבה ואנחנו רק השתמשנו בה (זימנו אותה), ואילו את main אנחנו כתבנו בעצמנו, בזאת הגדרנו מה היא תעשה, ומערכת ההפעלה זימנה אותה. נסביר כעת כיצד לכתוב פונקציות ולקרוא להן.
קריאה לפונקציות
עריכהנתבונן תחילה באופן השימוש בפונקציות. על מנת לקרוא לפונקציות שקיימות כבר בספריות C++ רשמנו את שם הפונקציה ואחריו את הפרמטרים בתוך סוגריים. לדוגמה:
double alpha = atan2(y, x);
כדאי לדעת: הפונקציה atan2 מקבלת שיעורי ווקטור ומחזירה את הזווית שבין הווקטור לבין ציר ה-x. תפקידה דומה ל-atan(y/x) מלבד ש-atan2 עובדת באופן תקין גם כאשר שיעור ה-x שווה ל-0. פונקציות מתמטיות אלה נמצאות בספרייה <cmath>. |
בדוגמה זו קראנו לפונקציה בשם atan2. שני הפרמטרים שנתנו לפונקציה הם y ו-x. כיוון שהפונקציה מחזירה ערך, אנחנו שמרנו אותו במשתנה alpha. לאו דווקא יש לשמור את הערך שמחזירה פונקציה. קיימות פונקציות רבות שהערך המוחזר הוא לא המטרה שעבורה נקראה הפונקציה, בעיקר כשערך זה מסמל שגיאה. לדוגמה הפונקציה הנפוצה שמשמשת את מתכנתי C לקלט, שמה scanf, מחזירה את מספר הערכים שנקלטו בהצלחה. למרות זאת, הרבה מניחים את תקינות הקלט ולא בודקים ערך זה.
קריאה לפונקציה היא מעבר ביצוע התוכנית ממקום אחד בקוד למקום שני. בניגוד ללולאות או תנאים, התוכנית תחזור למקום ממנו נקראה הפונקציה כאשר הפונקציה תסתיים. ברגע הקריאה לפונקציה התוכנית דוחפת את הפרמטרים של הפונקציה אל המחסנית (אזור מיוחד בזיכרון), שומרת את כתובת החזרה (גם על המחסנית) ורק אז מבצעת קפיצה אל קוד הפונקציה עצמה. כאשר התוכנית מסיימת לבצע את קוד הפונקציה היא שולפת מהמחסנית את כתובת החזרה ומבצעת קפיצה על פיה. שיטה זו מאפשרת, גם לקרוא לכל פונקציה שהיא מכל מקום בתוכנית, וגם קריאות רקורסיביות (קריאות לפונציה אחת מתוך עצמה).
חשוב להבין שלעומת שפות אחרות, כמו Pascal למשל, הקריאה לפונקציה לא נעשת כאשר אנו כותבים את שמה. שם הפונקציה מייצג את הכתובת שלה בזיכרון המחשב. הפונקציה נקראת על ידי אופרטור קריאה לפונקציה: () (סוגריים), הוא זה שמבצע את הקפיצה לפי כתובת זו. אופרטור זה מיוחד בכך שיכול לקבל מספר שרירותי של אופרנדים. האופרנד הראשון הוא שם הפונקציה שאותה אנו רוצים לקרוא לה, במקום שם הפונקציה ניתן לתת גם מצביע לפונקציה (למד בהמשך על מצביעים). שאר האופרנדים הם הפרמטרים שיש להעביר אל הפונקציה. הטיפוסים של הפרמטרים המועברים לפונקציה צריכים להתאים לטיפוסים של הפרמטרים בהכרזה על הפונקציה או ניתנים להמרת implicit אליהם (ראה למטה). כיוון שהקריאה מבוצעת על ידי אופרטור אז יש צורך לכתוב אותו גם כאשר לפונקציה אין פרמטרים.
נכתוב בפרק זה פונקציה שמטרתה תהיה לעגל מספר שיבוא בפרמטר הראשון אל הכפולה הקרובה ביותר של חזקה של 10. החזקה של 10 אליה יש לעגל תבוא בפרמטר השני. הפונקציה תחזיר את התוצאה לאחר העיגול. נקרא לפונקציה זו בשם roundTo. לדוגמה נביא מספר קריאות לפונקציה זו:
cout << roundTo(16, 0) << ", ":
cout << roundTo(16, 1) << ", ":
cout << roundTo(16, 2) << ", ":
cout << roundTo(54, 2) << endl:
הפלט התקין צריך להיות:
16, 20, 0, 100
הגדרת פונקציות
עריכהשימו לב: הכרזה או הגדרה של הפונקציה תעשה לפני השימוש הראשון בה. טרם למדנו על הכרזות, יש לכתוב את הגדרת הפונקציות לפני פונקציית main. |
כותרת הפונקציה
עריכהכל הגדרה של פונקציה תתחיל מכותרתה. כותרת הפונקציה מגדירה בסדר הבא את:
- הטיפוס של הערך המוחזר מהפונקציה – זהו טיפוס כלשהו, מובנה או מוגדר על ידי המתכנת. ניתן במקום זאת לרשום את המילה השמורה void שפירושה שהפונקציה לא מחזירה ערך (ראה בהמשך). לא נכון לקרוא לטיפוס המוחזר מהפונקציה בשם "טיפוס הפונקציה". זו טעות מכיוון שטיפוס הפונקציה הוא טיפוס המצביע אליה (למדו בפרק על מצביעים).
- שם הפונקציה – הכללים לבחירת שם הפונקציה זהים לכללים לבחירת כל שם אחר ב-C++. נשתמש בשם זה על מנת לקרוא לפונקציה משאר התוכנית.
- טיפוסים ושמות הפרמטרים – רשימה זו תבוא בסוגריים אחרי שם הפונקציה. כל פרמטר יהיה מופרד בפסיקים משאר הרשימה. הגדרת פרמטרים אלה דומה להגדרה של משתנים מקומיים, הם אף מתפקדים וממומשים כמשתנים מקומיים לכל דבר. ההבדל היחידי הוא בהגדרה: נצטרך לרשום טיפוס לכל פרמטר בנפרד, גם אם כולם מאותו הטיפוס. שם הפרמטר ישמש אותנו אך ורק בגוף הפונקציה בפניות אליו, לכן אם מסיבות כלשהן הפרמטר לא נמצא בשימוש, ניתן לא לתת לו שם.
להלן כדוגמה כותרת הפונקציה שנכתוב בפרק זה:
int roundTo(int number, int exponent)
משמעות הכותרת היא: הפונקציה נקראת roundTo, היא מקבלת שני פרמטרים מטיפוס שלם ששמם number ו-exponent והיא מחזירה ערך מטיפוס שלם.
גוף הפונקציה
עריכההגדרה של פונקציה מכילה את קוד הפונקציה, כלומר היא מגדירה בפירוש מה הפונקציה עושה. לעיתים קוראים להגדרה גם מימוש הפונקציה. כאשר אנו מגדירים פונקציה אנו נכתוב אחרי הכותרת את גופה בתוך בלוק התחום בסוגריים מסולסלים. בלוק זה יכיל הוראות הניתנות לביצוע, בין השאר, כמו בכל בלוק אחר, גם בבלוק הפונקציה נוכל להגדיר משתנים מקומיים (לוקאליים). משתנים אלה יושמדו בעת יציאה מהפונקציה. בתוך גוף הפונקציה נוכל להשתמש בפרמטרים שהפונקציה מקבלת. פרמטרים אלה גם מתפקדים כמשתנים מקומיים. נכתוב את גוף הפונקציה roundTo (כרגע לפני ה-main):
int roundTo(int number, int exponent)
{
int powerOfTen = 1;
for(int i = 0; i < exponent; i++)
powerOfTen *= 10;
int result = (number + powerOfTen/2)/powerOfTen*powerOfTen;
return result;
}
// main goes here:
int main()
{
cout << "A call to roundTo(34567, 2) returns " << roundTo(34567, 2) << endl;
return 0;
}
הפלט התקין צריך להיות:
A call to roundTO(34567, 2) returns 34600
בפונקציה זו הגדרנו משתנה לוקאלי powerOfTen ושמרנו לתוכו את החזקה של 10 שאליה ברצוננו לעגל את המספר. בשורה לפני האחרונה של גוף הפונקציה אנו מעגלים את המספר ושומרים את התוצאה במשתנה עזר result. לאחר מכן בשורה האחרונה אנו מחזירים (return) את הערך שנמצא במשתנה result.
המשתנים הלוקאליים (מקומיים) נקראים גם משתנים אוטומטיים (auto). הקצאת זיכרון עבור משתנים אלה מתבצעת באופן אוטומטי כמו גם שיחרורו. זיכרון זה הוא זיכרון המחסנית. כיוון שבעת יציאה מהפונקציה המחסנית מוחזרת למצבה הקודם לפני קריאתה, כל המשתנים הלוקאליים נשלפים ממנה.
הוראת return
עריכהעל מנת להחזיר ערך מהפונקציה נשתמש בהוראת return. הוראה זו מחזירה את ערך הביטוי שנכתב אחריה. בדוגמה שלמעלה אנחנו מחזירים את ערכו של המשתנה result, כמו כן, ניתן היה להסתדר גם בלעדיו: לכתוב את הביטוי אחרי המילה השמורה return ובכך להחזיר ישירות את תוצאתו. הטיפוס של הביטוי חייב להיות זהה לטיפוס של הערך המוחזר על ידי הפונקציה (מצויין בתחילת כותרת הפונקציה) או ניתן להמרת implicit אליו. בדוגמה שלנו הטיפוסים מתאימים זה לזה ולא תיווצר שום בעייה (מחזירים int).
את הוראת ה-return ניתן לכתוב לאו דווקא בסוף הפונקציה. כאשר מבוצעת הוראת ה-return מכל מקום אחר בתוך גוף הפונקציה היא מפסיקה את ביצוע העתידי שלה, במילים אחרות התוכנית יוצאת מהפונקציה. תכונה זו של הוראה זו מאפשרת את קיצור הקוד ומניעת קינון תנאים, בדומה להוראת break.
כל פונקציה שבכותרתה מצויין טיפוס הערך המוחזר חייבת להחזיר ערך, כלומר בכל פונקצייה שטיפוס הערך המוחזר הוא לא void חייבת לכלול הוראת return שבה תסתיים ריצתה. לדוגמה הפונקציה הבאה תקינה:
int f()
{
int n;
for(;;)
{
cout << "Enter a positive integer:\n";
cin >> n;
if(n > 0)
return n;
cout << "It's not positive! ";
}
}
בסוף גוף הפונקציה אין הוראת return, אבל התוכנית אף פעם לא תגיע לסוף בלוק הפונקציה מהסיבה שהיא מכילה לולאה אינסופית. הלולאה תיפסק רק כאשר תתבצעה הוראת ה-return שתצא מתוך הפונקציה ולכן ההוראה האחרונה שתתבצעה תהיה תמיד הוראת ה-return. לעומת זאת הפונקציה הבאה לא תקינה:
int f()
{
int n;
cout << "Enter a positive integer:\n";
cin >> n;
if(n > 0)
return n;
cout << "It's not positive! ";
}
הסיבה היא שכאשר התוכנית תקלוט מספר שלילי, היא תגיע לסוף הפונקציה, לא תמצא את הוראת ה-return ולא תדע איזה ערך להחזיר. מהדר טוב צריך להתריע על העדר הוראות ה-return.
טיפוס void
עריכהבשפות אחרות מפרידים את המושג "פונקצייה" מ-"פרוצדורה" (למשל ב-Pascal יש מילה שמורה function ומילה procedure, ב-Visual Basic יש Function ויש Sub). ב-C++ אין הפרדה זו: פרוצדורה היא פונקציה שלא מחזירה ערך. כדי להגדיר פונקציה שלא תחזיר ערך יש לכתוב את הטיפוס void (ריק) בטיפוס המוחזר שלה. טיפוס זה מיוחד בכך שלא ניתן ליצור משתנה מטיפוס זה. טיפוס זה משמש ליצירת טיפוסים מורכבים.
כאשר הפונקציה לא מחזירה ערך (פונקציית void), לא הכרחי להשתמש בהוראה return. אם בכל זאת נרצה לצאת מהפונקציה נוכל לרשום return ללא ביטוי אחריו:
void g()
{
int n;
cin >> n;
if(n < 0)
return;
cout << "I'm a void function!\n";
}
פונקציה זו תקינה. כמו כן, אם נכתוב אחרי ה-return ביטוי שהטיפוס שלו הוא לא void, המהדר יתן שגיאה.
אחרי ה-return כן ניתן לכתוב ביטויים שהטיפוס שלהם הוא void, לדוגמה ניתן להחזיר את ערך ה-void המוחזר מפונקציית void אחרת, או ליצור ערך "void" זמני (כמובן פעולות אלה הן אך ורק דרכי כתיבה שונות, הרי לא קיימים ערכי void). אפשרות זו נוספה ל-C++ כדי להקל על יצירת תבניות עם פרמטרי void.
return void();
return f();
בהנחה ש-f היא פונקציית void, שתי הוראות אלו תהינה נכונות כאשר נכתובן בפונקציה g.
פונקציית main
עריכהכפי שנאמר כבר פעמים רבות, main גם היא פונקציה. פונקציה זו מיוחדת ממספר בחינות, נדון בהן כאן.
הערך המוחזר מפונקציה main, מטרתו לסמל סיום מוצלח או שגיאה כלשהי. אפס מסמל סיום מוצלח, ואילו כל מספר אחר מסמל שגיאה. הערך הזה מוחזר למערכת ההפעלה שכביכול קראה לפונקציה main. במערכות הפעלה שונות ניתן לברר בדרכים שונות מה הערך המוחזר מתוכנית זו או אחרת. לרוב המשתמש לא יודע על ערך מוחזר זה, ומערכת ההפעלה אף תתעלם ממנו.
פונקציה main יוצאת דופן: על אף שהיא מחזירה ערך, היא לא חייבת לכלול את הוראת return. במקרה והיא תסתיים ללא הוראה זו, מערכת ההפעלה תקבל ערך המסמל סיום מוצלח, כלומר אפס. ניתן לראות זאת כאילו המהדר מוסיף אוטומטית הוראה return 0;
בסוף הפונקציה.
לפי התקן main צריכה להחזיר ערך int. חלק מהמהדרים מאפשרים להגדיר main שתחזיר void, הדבר לא לפי התקן.
שקלו לדלג על נושא זה נושא זה קשור למערכים ולמצביעים. ניתן להבין את הנכתב פה גם ללא ידע בנושאים אלה, אך עדיף לחזור לפה כשתלמדו אותם. |
עד כה main לא קיבלה פרמטרים. אבל, ניתן להגדיר פונקציה זו גם עם הכותרת הבאה:
int main(int argc, char *argv[])
במקרה זה הפונקציה תקבל שני פרמטרים שמשמשים לקבלת שורת הפקודה שאיתה הורצה התוכנית. בסביבות חלונאיות המשתמש לא מתעסק, בדרך כלל, עם שורה זו, אך עדיין שורה זו קיימת. כאשר מורצת התוכנית ניתן להעביר אליה ארגומנטים שונים שיקבעו את פעולתה. למשל ניתן להריץ את התוכנית program.exe כך (משורת הפקודה):
program.exe --read myfile.txt
(אופן הרצת התוכנית משורת הפקודה יכול להיות שונה במערכות הפעלה שונות). בדוגמה זו, שתפעל תחת Microsoft Windows או Linux, הפונקציה main תקבל את הפרמטרים הבאים:
- הפרמטר הראשון הוא מספר הארגומנטים שהועברו לתוכנית (פלוס אחד). בדוגמה זו הועברו 2 ארגומנטים, לכן argc == 3.
- הפרמטר השני הוא מערך של מחרוזות (על מערכים למד בהמשך). פשטנית: פרמטר זה מכיל את הארגומנטים שהועברו לתוכנית, בסדר שהם נכתבו. הפרמטר הראשון (מספרו אפס) הוא שם של קובץ ההרצה של התוכנית:
argv[0]: "program.exe" argv[1]: "--read" argv[2]: "myfile.txt"
כאשר argv[n] הוא האיבר במערך שמספרו n.
הכרזת פונקציות
עריכהלפני שנוכל להשתמש בשם כלשהו ב-C++, לרוב, תחילה נצטרך להכריז עליו או להגדיר אותו. במילים אחרות: המהדר שקורא את קבצי התוכנית מהתחלה עד הסוף, צריך להתקל כבר בהכרזת שם זה על מנת להכירו. ההכרזה אומרת למהדר שקיים שם מסויים (למשל i) ושיש להתייחס אליו כלדבר מסויים (למשל כלמשתנה מטיפוס int). דבר זה תקף גם עבור שמות הפונקציות. הפונקציות שנמצאות בספריות כלשהן (למשל atan2 שהשתמשנו בה למעלה), מוכרזות בקבצי ההכללה של הספריות אלה (דוגמה <cmath>).
במקרה של תוכנית פשוטה בעלת קובץ אחד, נוכל לסדר את הפונקציות בסדר כזה שכל אחת תהיה מוגדרת לפני השימוש הראשון בה. לדוגמה כאשר קראנו לפונקציה roundTo מפונקציית main, הגדרנו אותה לפני ה-main והסתדר ללא הכרזות. ארגון קוד כזה גורם להופעת פונקציה main בסוף הקוד בתור פונקציה אחרונה:
void f()
{
cout << "in f();\n";
}
void g()
{
cout << "in g();\n";
f();
}
int main()
{
g();
f();
return 0;
}
כמו כן קיימים מתכנתים המעדיפים להגדיר את הפונקציות אחרי ה-main על מנת ש-main תהיה בהתחלה. במקרה כזה עליהם להוסיף שורות נוספות לפני פונקציית main שהן שורות ההכרזה.
כאשר נכתוב פונקציה משלנו, לפעמים חובה להכריז עליה לפני שנשתמש בה. אנחנו חייבים להשתמש בהכרזה כאשר אנחנו מפרקים את התוכנית למודולים (למספר קבצי קוד, לרוב עם סיומת cpp), או כאשר אנחנו משתמשים בשתי פונקציות רקורסיביות הדדיות הקוראות זו לזו (למד בהמשך).
ההכרזה תכיל את כותרת הפונקציה שתסתיים בנקודה פסיק. גוף הפונקציה לא יבוא בהכרזה מהסיבה הפשוטה שהוא זה שמבדיל בין הגדרה והכרזה. לדוגמה הכרזת הפונקציה roundTo תראה כך:
int roundTo(int number, int exponent);
הכרזת הפונקציה צריכה להכיל את אותו השם, אותו טיפוס מוחזר ואותם טיפוסי הפרמטרים כמו גם הגדרתה. כנאמר למעלה שמות הפרמטרים ניתנים אך ורק על מנת לפנות אליהם מתוך גוף הפונקציה עצמה, לכן כאשר אין בהם צורך נוכל למחוק אותם:
int roundTo(int, int);
כאשר כותבים את הקריאה לפונקציה, סביבות פיתוח שונות נותנות רמזים שמכילים את כותרת הפונקציה. על מנת שרמזים אלה יעזרו להזכר (או להבין) במשמעות הפרמטרים, כדאי בכל זאת לכתוב את שמותם המלאים כדי שנדע מה תפקיד הפרמטר הראשון ומה תפקיד הפרמטר השני, גם כששניהם מטיפוס זהה. כמו כן אם ניתנים שמות לפרמטרים הם לא חייבים להיות זהים בין ההכרזה וההגדרה.
קוד הכתוב בסגנון "הגדרת פונקציה אחרי השימוש בה" יראה כך:
int roundTo(int number, int exponent);
// ... כאן תבואנה כל ההכרזות ...
// main
int main()
{
cout << "A call to roundTo(34567, 2) returns " << roundTo(34567, 2) << endl;
return 0;
}
int roundTo(int number, int exponent)
{
// מימוש הפונקציה
}
// כאן יבואו המימושים (ההגדרות) של שאר הפונקציות
בסגנון זה יש יתרון: ניתן לכתוב את הפונקציות בכל סדר שנרצה, אך קיים גם חיסרון: יש צורך להוסיף כל פונקציה לרשימה של ההכרזות בתחילת הקובץ ולזכור לשנות את ההכרזות כאשר משנים את הפרמטרים.
כאשר השתמשנו בקריאה לפונקציה שלא הכרזנו עליה, במקרה ולא הגדרנו את הפונקציה המהדר לא הכיר אותה ונתן שגיאה. כאשר הגדרנו אותו אבל קראנו עם פרמטרים שגויים המהדר יכל לתת לנו את השורה המתאימה בה הייתה השגיאה. כאשר אנחנו מכריזים על פונקציה אנחנו "משתיקים" את המהדר כדי שהוא לא יתלונן על השימוש בפונקציה שלא הוגדרה. הבדיקה של הטיפוסים והקישור של הקריאה אל הכתובת בה נמצאת הפונקציה, מתבצעים על ידי המקשר. במקרה והכרזנו על הפונקציה הקובץ יהודר ללא שגיאות. אבל במקרה ולא הגדרנו אותה השגיאה תתגלה בזמן קישור. בדרך כלל המקשר, מטבעו, לא יודע את המקום בו קרתה השגיאה בקוד המקור, ולכן יש להכיר יותר את השגיאות שהוא יתן:
- במקרה ולא קיימת הגדרה של פונקציה שהכרזנו או רשימת הפרמטרים שלה לא מתאימה להכרזה, המקשר יתן שגיאה בסגנון:
main.obj : error LNK2001: unresolved external symbol "int __cdecl f(void)" (?f@@YAHXZ)
- (נלקח מהמקשר של Microsoft Visual Studio 2005. הדבר המשותף למקשרים הוא שלא ניתנת שורה מסויימת בה קרתה השגיאה)
- במקרה והטיפוס המוחזר לא תואם בין ההגדרה וההכרזה המהדר יתן שגיאה.
- במקרה וההכרזה תואמת להגדרה אך הקריאה לא תואמת להכרזה, המהדר יתן שגיאה רגילה של אי התאמה במקום הקריאה.
רקורסיה
עריכהפונקציה רקורסיבית היא פונקציה שקוראת לעצמה, באופן ישיר או בעקיפין. למשל, לפי ההגדרה של סדרת פיבונאצ'י ניתן לכתוב את הפונקציה הבאה:
int Fibonacci(int n)
{
if(n == 0)
return 0;
else if(n == 1)
return 1;
else
return Fibonacci(n-1) + Fibonacci(n-2);
}
פונקציה זו מקבלת כפרמטר את אינדקס האיבר בסדרה, מחשבת ומחזירה את ערך האיבר עצמו. כמו בכל פונקציות רקורסיביות במחשב צריך להיות תנאי עצירה לרקורסיה, על מנת שהיא לא תהיה אינסופית. תנאי עצירה זה (המקרה הפשוט) הוא כאשר מספר האיבר בסדרה הוא 0 או 1. במקרה זה אנו יודעים את התשובה ומחזירים את הערך של האיבר בלי לחשבו. במקרה ואינדקס גדול מ-1 אנחנו מחשבים אותו על בסיס האיברים הקודמים לו: אנחנו מחברים את האיבר הקודם Fibonacci(n-1) ואת האיבר לפני הקודם Fibonacci(n-2). הנחנו את תקינות הפרמטר n ולא בדקנו את המקרה בו הוא שלילי.
פונקציה זו היא רקורסיבית מפני שהיא קוראת לעצמה: בכל קריאה שעבורה n גדול מ-1 היא קוראת לעצמה פעמיים. עבור כל קריאה לפונקציה כזאת נוצרים עותקים של כל המשתנים המקומיים (כולל הפרמטרים), והם מושמדים כאשר הפונקציה אליה הם שייכים תסתיים. מכיוון ש-Fibonacci קורא לעצמו לפני שהוא מסתיים, נוצר עוד עותק של הפרמטר n ושל כתובת החזרה מהפונקציה. כנאמר, המשתנים המקומיים, הפרמטרים וכתובת החזרה נמצאים על המחסנית. כיוון שהמחסנית היא אזור מוגבל בזיכרון, היא עלולה להתמלא. במקרה זה התוכנית תקרוס בזמן ריצה עקב גלישה מהמחסנית (Stack Overflow). במחשבים של היום, גודל המחסנית מספיק עבור רוב צרכי התוכנות, גם עבור פונקציות רקורסיביות. גלישה מהמחסנית תקרה כאשר יש שגיאה בתנאי העצירה של הרקורסיה והיא תהיה לאינסופית, או כאשר האלגוריתם שכתבנו באמת משתמש בעומק רקורסיה גדול מדיי.
נמחיש את אופן הקריאות הרקורסיביות באמצעות עץ:
כל צומת בעץ זה מייצג זימון אחד של הפונקציה Fibonacci;. החצים מייצגים את הזימונים עצמם, לדוגמה: Fibonacci(5) קורא ל-Fibonacci(4) ואחרי ש-Fibonacci(4) מסתיים Fibonacci(5) יקרא ל-Fibonacci(3). אף זימון של פונקציה לא יסתיים כל עוד מתבצע אחד הזימונים המקוננים (הרקורסיביים). הם המיוצגים על ידי הצאצאים של אותו הצומת. לדוגמה, כאשר התוכנית יורדת לעומק הרקורסיה המקסימלי (לתחתית העץ משמאל) קיימים על המחסנית 5 עותקים של כל המשתנים המקומיים. Fibonacci(5) לא יסתיים כל עוד כל תת-העצים מימין ומשמאל לא יסיימו להתבצע.
מניתוח מתמטי של הפונקציה הזו ניתן לקבל שזמן ריצתה שווה ל- (זהו מספר הצמתים בעץ שלמעלה). עומק הרקורסיה המרבי, הוא גובה העץ, ומשיקול פשוט ברור שערך זה הוא . שני המאפיינים הללו מראים על אי-יעילות האלגוריתם הרקורסיבי לחישוב מספרי פיבונאצ'י. אלגוריתם איטרטיבי (עם לולאה) יתבצע תוך ויקח מנפח הזיכרון. שימוש בנוסחה למספרי פיבונאצ'י תחושב תוך . מצד שני, נניח שבידינו מחשב 32 סיביות ועבור המחסנית מוקצה קטע זיכרון בגודל של מגה-בית אחד. במקרה זה הפרמטר n וכתובת החזרה יתפסו ביחד 8 ביתים, ולכן עומק הרקורסיה המקסימלי האפשרי יהיה בקירוב 131000. אבל, גודל מספר הפיבונאצי הזה הוא כ- מה שהרבה מעבר לגודל המשתנה של 32 סיביות, כתוצאה כל שאר הסיביות חוץ מ-32 הראשונות יעלמו. מספר פיבונאצי הגדול ביותר שיחושב בהצלחה, גם עבור אלגוריתם איטרטיבי, יהיה Fibonacci(47).
מלבד הקלות בפתרון בעיות שונות באמצעות רקורסיה, ניתן לעיתים גם ליעל את התוכנית באמצעותה. אלגוריתמים רבים המסתמכים על עקרון הפרד ומשול נכתבים לרוב באמצעות פונקציה רקורסיבית, לדוגמה: מיון מהיר, עצי BSP ועוד'. אלגוריתמים אחרים - אף על פי שהם קלים לכתיבה והבנה בצורה רקורסיבית, הפתרון האיטרטיבי שלהם גם הוא פשוט. לדוגמה: חיפוש בינארי, חיפוש בעץ, מיון מיזוג ועוד', כל אלה מתוארים באופן רקורסיבי, אבל ניתן לכתוב את אותו האלגוריתם ללא שימוש ברקורסיה.
במקרים פשוטים ניתן מלכתחילה להמנע מרקורסיה. מדובר במקרים בהם הפונקציה הרקורסיבית מזמנת את עצמה לכל היותר פעם אחת:
void f(/* params */)
{
if(/* termination condition */)
/* termination code */
else
{
/* pre-call code */
f(/* params */);
/* post-call code */
}
}
במקום פונקציות רקורסיביות מהסוג הזה ניתן להסתפק בשתי לולאות:
void f(/* params */)
{
int n = 0;
for( ; !/* termination condition */; n++)
/* pre-call code */
/* termination code */
for(int i = 0; i < n; i++)
/* post-call code */
}
כאשר אחרי הקריאה לפונקציה הרקורסיבית לא מתבצעות פעולות נוספות, כלומר אין קטע קוד "post-call code", הלולאה השנייה בפתרון האיטרטיבי מתבררת כמיותרת ולכן גם אין צורך במונה "עומק הרקורסיה" n. שימו לב שהפונקציה Fibonacci קוראת לעצמה לכל היותר פעמיים, לכן הפתרון האיטרטיבי שלה הוא לא מהתבנית הזו (ראה תרגילים).
פרמטרים שרירותיים
עריכהשימו לב: פונקציות אלה היו נפוצות בשפת C ולכן הן נתמכות גם ב-C++. ב-C++ קיימים תחליפים לפונקציות אלה ולכן נסביר נושא זה על ידי השוואה לשפת C. |
בשפת C ניתן היה להגדיר פונקציה שתקבל מספר שונה של פרטרים בכל קריאה אליה, כמו כן פרמטרים אלה יכלו להיות מכל טיפוס שהוא. בשפת C השתמשו בפונקציות כאלה בעיקר לצורך פלט, קלט ועיצוב המחרוזות. לדוגמה, נתבונן בהכרזת הפונקציה הידועה printf:
int printf(const char *format, ...);
הכרזת פונקציה זו נמצאה בספרייה <stdio.h> ב-C וניתן לגשת אליה גם מתוך <cstdio> ב-C++. פונקציה זו משמשת לפלט בקרב מתכנתי C. הפונקציה מאפשרת להדפיס כל מספר ערכים שנרצה בקריאה אחת. שלוש הנקודות בסוף רשימת הפרמטרים אומרות שיכולים לבוא פרמטרים נוספים בנוסף לפרמטרים המוגדרים לפני כן. ב-printf, הפרמטר הראשון הוא מחרוזת רגילה. מחרוזת זו מכילה סימנים מיוחדים במקומות בהם נרצה להכניס ערך של משתנה מסויים. הערכים עצמם יבואו בפרמטרים הבאים, מספרם וטיפוסם יכול להיות כל שנרצה. נראה שימוש לדוגמה:
double pi = 3.14;
int n = 20;
printf("The value of π is %g\n", pi);
printf("Square of %d is %d\n", n, n*n);
פלט קטע זה יהיה:
The value of π is 3.14
Square of 20 is 400
בדוגמה זו קראנו לפונקציה printf פעם אחת עם שני פרמטרים ופעם שנייה עם שלושה פרמטרים. הפונקציה צריכה באיזשהי דרך לדעת את מספר הפרמטרים המדוייק ואת טיפוסם על מנת להשתמש בהם, אם לא, התנהגות התוכנית לא מוגדרת והיא עלולה לקרוס. כדי לדעת אותם, הפונקציה חייבת לקבל לפחות פרמטר אחד שטיפוסו מוגדר במדוייק, לפיו הפונקציה תקבע את נתוני שאר הפרמטרים. בדוגמה עם printf, היא בודקת את מחרוזת ה-format ולפי מספר סימני האחוזים קובעת את מספר הפרמטרים. את טיפוס הפרמטרים הפונקציה קובעת לפי התווים שבאים אחרי כל אחוז, לתווים אלה יש משמעות מוגדרת מראש. בדוגמה שלנו, בקריאה הראשונה העברנו לפונקציה מספר ממשי (שבר) שיודפס במקום הצירוף "%g", בקריאה השנייה העברנו שני מספרים שלמים שהראשון יודפס במקום צירוף "%d" הראשון והשני במקום השני.
כיוון שאם הפונקציה לא תקבל אף פרמטר היא לא תדע זאת, הפונקציה חייבת לקבל לפחות פרמטר אחד מטיפוס מוגדר. משמע הדבר שלא ניתן להגדיר פונקציה מהסוג:
void impossibleFunction(...);
החסרון העיקרי של printf הוא שהשימוש בה מסוכן, על אחריות המתכנת להעביר מחרוזת format תקינה לפונקציה, כך שתתאים לשאר הפרמטרים המועברים אליה. כמו כן, שגיאה נפוצה, היא שמעבירים מחרוזת משתנה כפרמטר format, על מנת להדפיסה, דבר זה עלול לגרום לקריסת התוכנית או לחור אבטחה במקרה ובמחרוזת זו יופיע אחד הסימנים המיוחדים בעלי משמעות ל-printf.
ב-C++ השתמשו בפתרון אחר להדפסת ערכים רבים: העמיסו את אופרטור ה->> (למד בהמשך על העמסת אופרטורים). בשיטה זו ניתן להשתמש גם במקרים אחרים בהם משתמשים ברשימת פרמטרים שרירותית. במקום שתי קריאות printf שלמעלה, נרשום:
cout << "The value of π is " << pi << "\n";
cout << "Square of " << n << " is " << n*n << "\n";
במקרה זה המתכנת לא יכול לטעות ולהביא לקריסת התוכנית. המהדר הופך כל שימוש ב->> לקריאה לפונקציה נפרדת שמקבלת אך ורק שני פרמטרים: הראשון הוא cout והשני הוא הערך שיש להדפיס. עבור כל טיפוס של הערך המודפס קיימת פונקציה בפני עצמה, כך למשל עבור פלט pi ו-n נקראות פונקציות שונות, אחת לפלט ערכי double ואחת לפלט ערכי int. לשיטה זו קיימים יתרונות נוספים: כפי שנראה בהמשך הספר, ניתן להוסיף אפשרות לפלט של טיפוסים נוספים שלא קיימים בשפה, לעומת זאת printf לא ניתנת להרחבה.
פתרון אחר הוא ארגון הפרמטרים בתוך מערך בעל גודל ומבנה ידוע והעברתו כפרמטר אחד.
פרמטרים אופציונליים
עריכהלפעמים הפונקציה שנכתוב תקבל פרמטר אחד או יותר שיקנה לה גמישות אך ברוב הקריאות לפונקציה זו נעביר דרכו את אותו הערך. למשל בתוכנית גראפית כלשהי נרצה לכתוב פונקציה שתצייר קו על המסך. הקו ימשך מנקודה (x1, y1) לנקודה (x2, y2) ועוביו יקבע בפרמטר width. הכרזת הפונקציה תראה כך:
void DrawLine(int x1, int y1, int x2, int y2, int width);
הפרמטר width נותן שליטה על מראה הקו. אבל, בדרך כלל רוב הקווים שהתוכניות מציירות הם בעובי מסויים. גם בתוכנית שלנו, יתכן ורוב הקווים יהיו בעובי של יחידה אחת. במקרה כזה, ברוב הקריאות לפונקציה זו, הפרמטר האחרון יהיה 1. במקום זאת נוכל להגדיר את הפרמטר width כאופציונלי, כלומר, בזימון נוכל לא לכתובו והמהדר יציב לתוכו ערך ברירת מחדל. כדי להגדיר פרמטר מסוים כאופציונלי, נרשום אחריו את סימן ההשמה ואת ערך ברירת המחדל, בדומה לאיתחול משתנה מקומי:
void DrawLine(int x1, int y1, int x2, int y2, int width = 1);
מבחינת קוד הפונקציה, פרמטרים אופציונליים הם פרמטרים רגילים. כמו כן, אין דרך לדעת האם בקריאה לפונקציה DrawLine הופיע בפרמטר width הערך 1 או שלא הופיע פרמטר זה כלל.
בשימוש בפרמטרים אופציונליים קיימים מספר כללים נוספים:
נוכל להגדיר כמה שנרצה פרמטרים אופציונליים אך כולם יבואו בסוף רשימת הפרמטרים. כמו כן, אם יש לנו פונקציה עם שני פרמטרים אופציונליים או יותר, נוכל להעביר לה פרמטר אופציונלי מסויים אך ורק אם העברנו לה את הפרמטרים הקודמים לו ברשימה. למשל, בהנתן הבפונקציה:
void f(int a = 123, double b = pi);
נוכל לזמן אותה ללא ציון אף פרמטר, עם ציון פרמטר ראשון (a) ועם ציון שני הפרמטרים ביחד, a ו-b. לעומת זאת לא נוכל להעביר לה רק את b מבלי לציין את a:
f(); // a = 123, b = pi
f(1); // a = 1, b = pi
f(1, 2.2); // a = 1, b = 2.2
f(2.2); // a = 2, b = pi
f( , 2.2); // Error, not C++
f(b: 2.2); // Error, not C++
העמסת פונקציות
עריכההעמסת פונקציות (function overloading) היא יצירה של מספר פונקציות שלכולן אותו השם. הפונקציות נבדלות זו מזו על ידי רשימת הפרמטרים שלהן. באמצעות העמסת פונקציות נוכל לכתוב עבור טיפוסי פרמטרים שונים מספר מימושים של אותה הפונקציה. למשל נוכל להגדיר פונקציה שתדפיס מספר שלם, פונקציה שתדפיס מספר ממשי ופונקציה שתדפיס מחרוזת, כאשר שלושתן תקראנה print. ההבדל בין שלושת פונקציות אלה יהיה בטיפוסים של הפרמטרים שהן תקבלנה:
void print(int x)
{
// Print an integer number
}
void print(float x)
{
// Print a real number
}
void print(const char *str)
{
// Print a string
}
העמסת פונקציות עוזרת לנו להמנע מ-"סגנון C" בו הצטרכו המתכנתים להוסיף בסוף שם כל אחת מהפונקציות תוספת שתרמוז על הטיפוס של הפרמטרים: print_int, print_float, print_string וכד'... דוגמה נוספת מהחיים היא OpenGL. בספרייה זו רוב הפונקציות קיימות במספר גרסות עבור מספר טיפוסים של פרמטרים, למשל הפונקציה glColor קיימת ב-32 גרסות שונות כאשר לכל גרסה שם שונה. באמצעות העמסת פונקציות יכלו מפתחי הסיפרייה לקרוא לכולן באותו השם אך כיוון שהסיפרייה פותחה במקורה עבור שפת C, הדבר לא כך הוא.
מבחינת מימוש הפונקציות המועמסות לא חל שינוי. נתרכז כרגע באופן קריאתן. כיוון שיש מספר פונקציות עם אותו השם יש צורך לדעת איזו גרסה תקרא. המהדר יחליט לאיזו פונקציה לקרוא לפי בדיקת הטיפוסים של הפרמטרים המועברים במקום הקריאה והשוואתם לטיפוסים של הפרמטרים בפונקציות הקיימות באותו שם. הכללים הם הבאים:
- אם חלה התאמה מלאה של טיפוסי הפרמטרים ומספרם לאחת גרסות הפונקציה, זו הגרסה שתקרא.
- אחרת, המהדר יחפש גרסה שמתאימה לאחר ביצוע המרות מרחיבות של הפרמטרים.
- אחרת, המהדר יחפש גרסה שמתאימה לאחר ביצוע המרות מצרות של הפרמטרים.
- אחרת, המהדר יחפש גרסה שמתאימה לאחר ביצוע המרות המוגדרות על ידי המתכנת.
- אחרת, המהדר יחפש גרסה שמתאימה כי היא מקבלת פרמטרים שרירותיים.
- אחרת לא קיימת פונקציה מתאימה ותהיה שגיאת הידור.
אם יש מספר גרסות שמתאימות לכלל הראשון המתקיים מבין אלה, הקריאה אינה חד-משמעית וגם היא תגרום לשגיאת הידור. כמו כן, הטיפוס של הערך המוחזר לא משפיע על החלטתו של המהדר.
נציג כעת מספר דוגמות בעיתיות:
print(1.5); // Error
בהנתן ההגדרות שלמעלה, המהדר לא יודע לאיזו משתי הגרסות הראשונות לקרוא (הגרסה השלישית לא מתאימה בכל אופן). כיוון שטיפוס המספר 1.5 הוא double, גם המרה ל-int בגרסה הראשונה וגם המרה ל-float בגרסה השנייה הן המרות מצרות, לכן הכלל הראשון שמתאים (מס. 3) הוא דו-משמעי.
פרק זה לוקה בחסר. אתם מוזמנים לתרום לוויקיספר ולהשלים אותו. ראו פירוט בדף השיחה.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
תרגילים
נמצאה תבנית הקוראת לעצמה: תבנית:C++
רקורסיה
עריכהמספרי פיבונאצ'י
עריכהבהנתן ההגדרה הרקורסיבית של מספרי פיבונאצ'י, חשוב על פתרון אינטריבי. כתוב פונקציה לא רקורסיבית שתישם רעיון זה.
אחת האפשרויות מוצגת כאן. פתרון זה מבוסס על שמירת האיבר הקודם ולפני הקודם במשתנים f0 ו-f1. כאשר מחושב האיבר הבא (f2) כבר אין צורך באיבר שלפני הקודם וניתן "לזוז" איבר אחד קדימה.
int Fibonacci(int n)
{
if(n <= 1)
return n;
int f0 = 1, f1 = 1;
for(int i = 2; i < n; i++)
{
int f2 = f1 + f2;
f0 = f1;
f1 = f2;
}
return f1;
}
פתרון נוסף הוא שימוש בנוסחה ישירה לחישוב איברי הסדרה:
עצרת
עריכהבפרק על לולאות כתבת תוכנית שמחשבת עצרת: . כתוב פונקציה שתחשב עצרת באמצעות אלגוריתם רקורסיבי.
long Factorial(long n)
{
if(n <= 1)
return 1;
else
return Factorial(n-1)*n;
}
מצביעים והמשתנה המיוחס
נמצאה תבנית הקוראת לעצמה: תבנית:C++
מהו מצביע?
עריכהמצביע הוא משתנה המכיל כתובת זיכרון. בעזרת מצביעים ניתן לגשת לכתובות בזיכרון, לקרוא מהן, ולכתוב אליהן. אחת התועלות שישנן במצביעים היא שניתן להשתמש בהם כדי לחסוך בזיכרון ולהעביר כתובות של עצמים במקום ליצור את העצמים שוב ושוב. כמו כן, המצביעים נותנים לנו אפשרויות גמישות יותר לניהול זיכרון.
int *num = reinterpret_cast<int *>(7);
גם המרה בסגנון הישן תעבוד:
int *num = (int *)7;
בשני המקרים הנ"ל, אנו מגדירים מצביע מסוג int (כלומר, הוא מצביע לשטח בזיכרון בגודל של int). מצביע זה מצביע על הכתובת 7 בזיכרון.
אמנם בשפת C אפשר להציב ערך שלם ישירות לתוך מצביע, אך ב-++C נדרשת המרה מפורשת על מנת לאפשר הצבה כזו. יוצא מן הכלל הוא הצבת מספר 0 - עבור תאימות לאחור עם מצביע "אפס" הישן - NULL. NULL משמש לאיפוס מצביעים ומוגדר בפועל כמספר שלם 0. לפי התקן המעודכן של ++C משתמשים במילה שמורה nullptr לאיפוס מצביעים:
int *num = 0; // הגדרת מצביע למספר שלם עם הצבה מפורשת של 0
int *num = NULL; // הגדרת מצביע למספר שלם עם הצבת מצביע "אפס" בסגנון הישן
int *num = nullptr; // C++ הגדרת מצביע למספר שלם עם הצבת מצביע "אפס" לפי התקן המעודכן של
להלן דוגמה להגדרת מצביע המצביע לכתובת של משתנה אחר:
char c = 'A';
char *p = &c;
גישה למשתנה תתצבע באמצעות הוספת כוכבית (*) לפני שם המצביע. לדוגמה, בהמשך לדוגמת הקוד הקודמת נוכל לשנות את תוכנו של המצביע באמצעות:
*p = 'B';
מהו משתנה הפניה?
עריכההמשתנה הפניה פועל בדומה למצביע ונועד במקרים מסוימים להחליף אותו.
למשתנה הפניה ישנם כמה יתרונות וחסרונות לעומת המצביע. גם בעזרתו ניתן להשפיע בעקיפין על משתנה אחר, אלא שהוא חוסך את הכתיבה המסורבלת של אופרטורים נוספים. אפשר להבין אותו גם כשם חדש למשתנה שכבר הוכרז בזיכרון.
גם כאן, כמו במצביע, השימוש העיקרי של משתנה הפניה הוא בהעברתו כארגומנט לפונקציה, במקרה שאנו רוצים שהוא יושפע ממנה.
יישום בדוגמה פשוטה
עריכההבה נגדיר משתנה הפניה:
int num;
int &ref = num;
למשתנה הפניה קראנו ref, הכרזנו אותו כמפנה לטיפוס int, והקדמנו לשמו את התו &.
בדומה למקרה של הכרזת מצביע, שם נקדים את התו *.
הפננו את המשתנה ref למשתנה num.
שימו לב: לא התרחשה כאן השמת ערכים, אלא הפניה של המשתנה ref למשתנה num, כזו הפניה אגב, יכולה להתרחש רק פעם אחת בחייו של משתנה הפניה, והיא חייבת להתבצע בהכרזה שלו.
עכשיו אם נשנה את ערכו של משתנה ref, ישתנה גם ערכו של משתנה num, וגם להפך:
ref = 10;
cout << num; //10
num = 20;
cout << ref; //20
ניתן לראות שיצירת הפניה למשתנה דומה לנתינת שם חדש, נרדף, לאותו משתנה. את אותו הדבר כמובן ניתן ליישם בעזרת מצביעים, אם כי בדרך זו היינו זקוקים לשימוש רב באופרטורים.
שימוש בפונקציות
עריכהכמו שאמרנו קודם, השימוש העיקרי של משתני הפניה, כמו של מצביעים, הוא בפונקציות. לדוגמה, נכתוב פונקציה בשם ()swap שמחליפה בין ערכיהם של שני משתנים:
#include<iostream.h>
void swap(int &a, int &b);
int main() {
int num1 = 5, num2 = 10;
swap(num1, num2);
cout << num1 << endl << num2;
return 0;
}
void swap(int &a,int &b) {
int temp;
temp = a;
a = b;
b = temp;
}
שימו לב שהפרמטרים המועברים לפונקציה, הם ללא שום אופרטור מצביע.
ובכל זאת הפונקציה משפיעה על המשתנים num1 ו-num2 שנמצאים בתוכנית.
בתוך הפונקציה, הגישה לפרמטרים a ו-b היא שגרתית כאילו הם משתנים רגילים, לאחר שכל אחד מהם כבר מופנה למשתנה שנשלח לו בארגומנט, בהתאמה.
הפונקציה כמובן אינה מחזירה כלום ולכן מוגדרת כ-void.
משתני הפניה כערכים מוחזרים
עריכהניתן לייצור משתנה הפניה, שיוחזר מפונקציה (בשורות הקוד שלהלן num הוא משתנה גלובלי כלשהו):
int &getnum() {
int &ref = num;
return ref;
}
הטיפוס המוחזר של הפונקציה ()getnum הוא ערך של int בהפניה. אפשר להשתמש בפונקציה בכדי לקבל את ערכו של num כך:
cout << getnum();
פעולה כזאת אפשרית גם ללא משתני הפניה, ולכן דרך יותר מעניינת היא להציב ערך לnum דרך הפונקציה:
getnum() = 50;
עכשיו ערכו של num הוא 50.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
מבנים ואיגודים
נמצאה תבנית הקוראת לעצמה: תבנית:C++
מהו איגוד?
עריכהאיגוד הוא בעצם מבנה אשר לכל איבריו אותה הכתובת בזיכרון, ומכאן שהשמת ערך לאחד המשתנים שבאיגוד גוררת את השמתו של אותו ערך לכל שאר המשתנים שבו.
הגדרת האיגוד
עריכההגדרת האיגוד מתבצעת בדומה להגדרת מבנה אלא שמילת המפתח שונה, והיא union (איגוד). גם כאן מתרחשת הגדרה של טיפוס נתונים חדש, וכל איברי הטיפוס חולקים את מקומם בזיכרון זה עם זה. הנה דוגמה להגדרת איגוד:
union untype { short int num; char ch; };
בדוגמה הגדרנו טיפוס נתונים חדש בצורת איגוד בשם untype שבו שני איברים, שאמורים לקבל את אותה הכתובת בזיכרון. עכשיו נצהיר על משתנה איגוד בשם un_var כך:
untype un_var;
המשתנה נראה כך בזיכרון:
1 0 num ----------------------- ch ------------
num הוא משתנה מטיפוס short int, ולכן גודלו 16 סיביות, והמשתנה ch הוא מטיפוס char וגודלו 8 סיביות (בית אחד). אם כן, באיגוד untype שרשום למעלה ch מהווה את שמונה הסיביות הראשונות של num.
גישה לאיברי האיגוד
עריכהגם הגישה מתבצעת בדומה לגישה לאיברי המבנה, למשל כדי לגשת לאיבר num של המשתנה-איגוד un_var שהגדרנו קודם, נרשום את שורת הקוד הבאה:
un_var.num=577;
כלומר הצבנו את הערך 577 לתוך האיבר num בun_var.
לכן num נראה ושמור בזיכרון המחשב כסיביות בינאריות באופן הבא:
0000001001000001
ומכיוון שאיבר ch מהווה את 8 הסיביות הראשונות של איבר num, אז ערכו של ch הוא:
01000001 ז"א 65
ולכן ערך ה-ASCII של ch הוא 'A'.
איגודים חסרי שם
עריכהקיים טיפוס נוסף של איגוד השונה מן האיגוד הרגיל. הוא אינו הכרזה על טיפוס חדש, אלא בעצם הודעה למהדר שאיבריו, שהם בעצם משתנים רגילים, חולקים את אותו המקום בזיכרון. לאיגוד כזה אין שם ולכן נכתב כך:
union { short int num; char ch; };
בהצהרה זו קבענו שכאשר ישתנה ערכו של num ישתנה גם ערכו של ch, ואגב הגישה אליהם היא כאל משתנים רגילים לחלוטין:
num=12;
אזי גם ערכו של ch יהיה 12.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
זיכרון דינמי
נמצאה תבנית הקוראת לעצמה: תבנית:C++
מהי הקצאת זיכרון דינמית
עריכה- הקצאת זיכרון דינמית
- היא פעולה של הקצאת זיכרון בזמן ריצת התוכנה. פעולה זו מבוצעת בדרך כלל כאשר גודל הזיכרון הנדרש אינו ידוע מראש לפני הרצת התוכנה ויתברר רק לאחר קבלת קלט מהמשתמש, קריאת תוכן של קובץ כלשהו וכדומה.
הקצאה ושחרור זיכרון ב־C++
עריכהמאחר ובשפת C++ יש תמיכה מלאה בכל הספריות הסטנדרטיות של C, ניתן להשתמש בפונקציות של C כגון malloc ו-free, אך הדבר אינו מומלץ, כיוון שב־C++ יש כלים מיוחדים למטרה זו התומכים במחלקות (כלומר מריצות את הבנאים ומפרקים, למד בהמשך).
ב־C++ מקצים ומשחררים זיכרון באופן דינמי באמצעות האופרטורים new ו-delete עבור משתנים בודדים ו-[]new ו-delete[] עבור מערכים.
משתנה בודד
עריכהלהקצאת זיכרון עבור משתנה בודד נשתמש באופרטור new כאשר אחריו נרשום את סוג המשתנה. אופרטור זה מחזיר מצביע למשתנה זה, לכן עלינו לשמור אותו כדי להשתמש בו. דוגמה:
int *ptr = new int;
cin >> *ptr;
/*...*/
delete ptr;
כך נקצה משתנה מטיפוס int בזיכרון הדינמי ונשמור את המצביע אליו במשתנה ptr. לאחר השימוש בזיכרון זה נשחרר אותו ע"י האופרטור delete.
מערך
עריכהלהקצאת מערך בזיכרון הדינמי נשתמש באופטור []new. כדי להקצות מערך, בדומה להקצאת משתנה בודד, עלינו לציין את הטיפוס של האיברים במערך ובנוסף את גודל המערך. המצביע שיוחזר ע"י האופרטור []new יצביע לתחילת המערך ובו נשתמש כדי לשחרר את הזיכרון באמצעות []delete בתום השימוש. הנה קטע קוד לדוגמה אשר קולט מהמשתמש את גודל המערך ואת איבריו:
int length;
cin >> length;
int *array = new int[length];
for(int i = 0; i < length; i++)
cin >> array[i];
/*...*/
delete[] array;
שימו לב שבזכות ההקצאה הדינמית, אנו יכולים לציין את גודל המערך לא בהכרח כקבוע, כך נוכל לקבוע בזמן ריצת התוכנית את גודל הזיכרון המוקצה.
פרטים על האופרטורים new ו-delete
עריכה- כאשר אין אפשרות להקצות את הזיכרון הנדרש האופרטור new יזרוק חריגה bad_alloc (למד על חריגות בהמשך). אם נרצה שאופרטור זה יחזיר 0 (NULL) במקרה זה נציין nothrow בסוגריים עגולים אחרי המילה new.
- זיכרון שהוקצה ע"י new צריך לשחרר ע"י delete וזיכרון שהוקצה ע"י []new צריך לשחרר ע"י []delete, אחרת אופן פעולת התוכנית לא מוגדר והיא עלולה לקרוס.
- אם נשתמש בפונקציות של C להקצות זיכרון, אין לשחרר אותו באמצעות אופרטורי ++C, ולהיפך.
- על אף שלא ניתנת הודעת שגיאה על אי שחרור זיכרון שהוקצה ומערכת ההפעלה תשחרר, בדרך כלל, את הזיכרון באופן אוטומטי בעת סיום פעולת התוכנית, רצוי לשחרר את הזיכרון ע"י delete ישירות לאחר סוף השימוש בו. הסיבה לכך היא מניעת דליפת זיכרון וצורך להפעיל את המפרקים של המחלקות (למדו בהמשך).
- נוכל לציין את גודל הזיכרון המוקצה למערך שווה ל-0. דבר זה לא נחשב לשגיאה. זיכרו כי נצטרך לשחרר זיכרון זה כמו כל זיכרון אחר, הסיבה לכך היא המבנה של הערימה.
שינוי גודל הזיכרון שהוקצה
עריכהפעולה תכנותית נפוצה היא הוספה ומחיקה של פריטים למערך. פעולה זו הופכת לבעייתית כאשר אנו מגיעים לסוף המערך של N איברים וברצוננו להוסיף איבר נוסף. במקרה זה עלינו להגדיל את נפח הזיכרון שכבר הוקצה עבור המערך.
מתכנתי C רגילים לפונקציית realloc שמבצעת פעולה זו. בC++ אין לא אופרטור מיוחד ולא פונקציה מיוחדת לצורך זה. ישנן שתי דרכים להשיג את המטרה הרצויה:
- שימוש ב-new ו-delete - ניתן לדמות את פעולת הפונקציה realloc מ-C. דרך זו לא מקובלת ולא מומלצת. להלן פונקציה שעושה את זה:
char *my_realloc(char *old_memory, int old_size, int new_size)
{
char *new_memory = new char[new_size];
for(int i = 0; i < min(old_size, new_size); i++)
new_memory[i] = old_memory[i];
delete[] old_memory;
return new_memory;
}
לפעולה זו שני חסרונות. חסרון אחד הוא הצורך ב-(old_size + new_size) זיכרון פנוי רצוף תמיד. חיסרון שני הוא הצורך בהעתקת כל האיברים כל פעם שאנו רוצים להגדיל את המערך לפחות ב-1. לפונקציה realloc ישנה גישה ישירה לערימה (heap) ולכן היא יכולה לחסוך את שני החסרונות הללו כשיש אזור פנוי בזכרון הבא ישירות אחרי האזור המוקצה.
- שימוש ב-vector מ-STL - בספריה התקנית של ++C קיימת מחלקה vector אשר תפקידה לאפשר עבודה נוחה עם מערכים. מחלקה זו מכילה ממשק הכולל הכנסת איברים ומחיקת איברים ממערכים. להלן קטע תוכנית לדוגמה:
#include <vector>
/*...*/
vector<int> v(8); // ווקטור (מערך) של 8 איברים
size_t i, new_size;
// קלט 8 איברים ראשונים
for(i = 0; i < v.size(); i++)
cin >> v[i];
// קלט מספר והגדלת המערך למספר זה של איברים
cin >> new_size;
v.resize(new_size);
// קלט איברים חדשים
for(; i < v.size(); i++)
cin >> v[i];
// פלט
for(i = 0; i < v.size(); i++)
cout << v[i];
להבנה מעמיקה יותר של קטע זה יש ללמוד תחילה על מחלקות, תבניות וממשק של std::vector.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
מחלקות
נמצאה תבנית הקוראת לעצמה: תבנית:C++ מחלקה היא טיפוס המוגדר על ידי המתכנת. מלבד הגדרה פורמלית זו: מחלקה היא טיפוס מופשט שמתואר על ידי אוסף תכונות ופעולות שניתן לבצע על עצמים מטיפוס זה. בפרק זה נסביר כיצד להגדיר ולהשתמש במחלקות. לצורך זה נתבונן בדוגמה קלאסית של מחלקת התאריך.
הגדרת מחלקה
עריכהנגדיר את המחלקה בדומה למבנה. נרשום את המילה class ולאחריה את שם המחלקה:
class Date
{
public:
int d, m, y;
};
המילה public מציינת שחברי המחלקה המוגדרים בהמשך יהיו ציבוריים, כלומר מכל מקום בתוכנית נוכל להשתמש בהם כמו במבנה רגיל. בעצם הגדרת מחלקה זו שקולה להגדרת מבנה דומה (struct במקום class). שלושת משתני המחלקה (d, m, y) מייצגים את התאריך באמצעות יום, חודש ושנה. כיוון שנוכל לשנות אותם באופן ישיר על ידי השמה, עלולות להתעורר שגיאות נסתרות, למשל אם בטעות נכניס ל-m את הערך 13. גישה כזו נוגדת את עקרון תכנות מונחה העצמים.
עתה, כדי לעשות את התוכנית שלנו נוחה יותר, ברורה יותר ויציבה יותר, נחסום את הגישה למשתני המחלקה. לצורך זה נשנה את המילה public ל-private, מותר למחוק אותה כלל כיוון שחברי המחלקה הבאים ראשונים לאחר הסוגרים המסולסלים מוגדרים אוטומטית כפרטיים:
class Date
{
private: // שורה זו ניתן למחוק
int d, m, y;
};
כעת לא נוכל לגשת למשתני המחלקה d, m ו-y באופן ישיר, לכן נבנה ממשק נוח לטיפול במשתני מחלקה זו. ממשק זה יוגדר כציבורי והוא יבטיח שהערכים של משתני המחלקה תמיד יהיו תקינים (למשל m לא יהיה גדול מ-12). כמעט כל ממשק צריך לכלול פעולות המשנות את המחלקה ופונקציות לאחזור נתונים. בדוגמה זו נצהיר תחילה רק את כותרות הפונקציות (אתחול, הוספת יום/חודש/שנה, אחזור יום/חודש/שנה):
class Date
{
int d, m, y;
public:
void init(int dd, int mm, int yy);
void add_day(int n);
void add_month(int n);
void add_year(int n);
int day() const;
int month() const;
int year() const;
};
הערה: המילה const אחרי כותרת הפונקציה מציינת שהפונקציה לא משנה את משתני המחלקה. אלה פונקציות ממשק המחזירות את הערכים של משתנים אלה.
מילה זו לאחר שם הפונקציה מאפשרת לקרוא לפונקציה גם עם עצמים של המחלקה שהוגדרו קבועים.
הגדרת גופי פונקציות המחלקה
עריכהעד כה הצהרנו את כותרות הפונקציות, את ממשק המחלקה. עלינו לממש אותו, להגדיר את גוף כל פונקציה. את פונקציות המחלקה מגדירים בדומה לפונקציות רגילות. ניתן להגדיר אותן בשני מקומות:
- בתוך בלוק המחלקה. פונקציות אלה יהיו inline. לדוגמה, נגדיר את הפונקציה ()year:
class Date
{
int d, m, y;
public:
// ...
int year() const
{
return y;
}
};
- הגדרה מחוץ לבלוק המחלקה. בדרך זו נגדיר את שאר הפונקציות. הסיבה לכך היא שנרצה להגדיר אותן בד"כ בקובץ cpp ואילו הגדרת המחלקה עצמה תמצא בקובץ h. כדי לציין שהפונקציה שאנו מגדירים שייכת למחלקה מסוימת, נוסיף את שם המחלקה וארבע נקודות (::) לפני שמה, בזהה למרחבי שם. כדוגמה נגדיר את הפעולה ()init:
class Date
{
// ...
};
void Date::init(int dd, int mm, int yy)
{
d = dd;
m = mm;
y = yy;
}
הערה: בין אם גוף פונקצית המחלקה מוגדר בתוך או מחוץ למחלקה יש לה גישה לכל משתני המחלקה, פונקציות המחלקה וטיפוסים המוגדרים בתוך המחלקה.
שימוש בטיפוס שהוגדר
עריכהלאחר שהגדרנו מחלקה ומימשנו את כל הפונקציות שלה (לא נעשה את זה כאן), נוכל להשתמש בה בדומה לטיפוסים בסיסיים של C++. לצורך זה עלינו להצהיר על משתנה מטיפוס זה (מופע של מחלקה) ולגשת לפונקציות ומשתני המחלקה הציבוריים בדומה למבנים:
Date dat;
dat.init(18, 6, 2008);
int n;
cin >> n;
dat.add_day(n);
cout << dat.day() << '.' << dat.month() << '.' << dat.year();
כיוון שחסמנו את הגישה למשתני המחלקה לא נוכל לגשת אליהם ישירות מתוך פונקציות שהן לא חברות המחלקה:
dat.d = 32; // שגיאה
הבדל בין מבנים למחלקות
עריכהב-C++ אין הבדל משמעותי בין מבנים לבין מחלקות. כל מחלקה נוכל לשכתב בקלות למבנה וכל מבנה נוכל לשכתב בקלות למחלקה. ההבדל היחידי הוא שחברי המחלקה הם פרטיים כברירת מחדל ואילו חברי המבנה הם ציבוריים כברירת מחדל. משמעות הדבר שנוכל להגדיר גם משתני המבנה כפרטיים ולהוסיף פונקציות לטיפול בהם. שתי ההגדרות הבאות שקולות זו לזו:
class myType { // ...
struct myType { private: // ...
הבדל זה חל גם על כללי ההורשה (למד בהמשך).
אז כיצד נחליט מתי להשתמש במבנה ומתי במחלקה? אין כללים ברורים, נוכל להשתמש במבנים כמו שהשתמשנו ב-C ולהוסיף אליהם פונקציות ציבוריות. כאשר נרצה ליצור טיפוס בו נסתיר את המימוש ונפריד אותו מהממשק ואולי נשתמש בהורשה, נבחר במחלקה.
בנאים
עריכהבנאי הוא פונקציה שאנו מגדירים עבור המחלקה, הוא יופעל בעת היווצרות מופע חדש של מחלקה. תפקיד פונקציה זו הוא לבנות את המחלקה, כלומר לאתחל את משתני המחלקה.
עלינו לקרוא ל-init בכל פעם שנרצה לאתחל את המופע של המחלקה שהגדרנו למעלה. דבר זה גורר שגיאות, למשל אם נשכח לקרוא לפונקציה זו לפני השימוש הראשון במשתנה Date. בדומה לאי-אתחול של כל משתנה אחר, גם במשתני המחלקה יהיו ערכים לא מוגדרים - 'זבל'. הבנאים יאפשרו לנו לכתוב פונקציה שתאתחל את מופע המחלקה באופן אוטומטי באמצעות ערך חוקי כלשהו. הנה בנאי המאתחל את כל התאריכים ל-1/1/1970 (תוכלו לשנות בנאי זה שיאתחל את התאריך באמצעות התאריך של היום, ע"י שימוש בפונקציות מתאימות מהספרייה ctime):
class Date
{
int d, m, y;
public:
Date()
{
d = 1, m = 1, y = 1970;
}
// ...
};
הבנאים של כל מחלקה יקראו על שם המחלקה, במקרה שלנו Date, אך הם אף פעם לא יחזירו ערך ולכן גם לא נכתוב את טיפוס הערך המוחזר, גם לא void. דבר זה מובן מאילו כאשר נתבונן בדרך בה אנו מפעילים את הבנאי, הרי זו הצהרה רגילה על משתנה past (הבנאי מופעל אוטומטית):
Date past; // כאן מופעל הבנאי
cout << past.day() << '.' << past.month() << '.' << past.year();
בדוגמה זו יופיע תמיד בפלט 1.1.1970. ניתן להתייחס לבנאי כלפונקציה המחזירה את העצם החדש ולכן, גם מנקודת מבט זו, לא נציין את הטיפוס המוחזר.
הערה: לא נוכל לקרוא לבנאי באופן ישיר past.Date()
דבר זה נחשב לשגיאה, כי כל עצם מאותחל אך ורק פעם אחת.
נוכל להוסיף פרמטרים לבנאים שאנו כותבים, נזכור שהעמסת בנאים מותרת כמו העמסת שאר הפונקציות. לכן נוכל להגדיר בנאי המאתחל את העצם באמצעות הערכים הנמסרים לו כפרמטרים, הוא יחליף לנו את הפונקציה init. בדוגמה הבאה יש להוסיף את בדיקת תקינות הפרמטרים ולהחליט על דרך פעולה כלשהי כאשר הפרמטרים לא תקינים (למשל לזרוק חריגה):
class Date
{
int d, m, y;
public:
Date()
{
d = 1, m = 1, y = 1970;
}
Date(int dd, int mm, int yy)
{
d = dd, m = mm, y = yy;
}
// ...
};
כאן העמסנו בנאי נוסף לבנאי הקודם. כדי למסור פרמטרים לבנאי כלשהו בעת אתחול המשתנה, נרשום אותם בסוגריים עגולים לאחר שם המשתנה (הערה: אם אין פרמטרים אז אין לרשום סוגריים כלל):
Date future(1,1,2970); // בנאי עם פרמטרים
Date past; // בנאי ללא פרמטרים
הערה: לעתים קרובות אפשר לחסוך העמסה של פונקציה בנאית באמצעות פרמטרי ברירת מחדל. למשל, בדוגמה שלנו, נוכל לתת ערך ברירת מחדל 1.1.1970 ובכך לחסוך את הפונקציה הבנאית שאינה מקבלת פרמטרים:
class Date
{
int d, m, y;
public:
Date(int dd = 1, int mm = 1, int yy = 1970)
{
d = dd, m = mm, y = yy;
}
// ...
};
מפרקים
עריכהמפרק הוא פונקציה הפוכה לבנאי, כלומר, המטרה העיקרית של המפרק היא לשחרר את המשאבים שהוקצאו על-ידי הבנאי. המפרק נקרא בעת השמדת מופע המחלקה, דבר זה יקרה בעת יציאה מבלוק עם משתנים מקומיים מטיפוס המחלקה. שימוש ב-delete על עצם שהוקצה דינמית או בעת יציאה מפונקצית ה-main (יושמדו המשתנים הגלובליים והסטטיים).
למפרק ניתן את שם המחלקה שלפניו נוסיף טילדה (~). סימן זה בא לציין שמפרק הוא "לא בנאי", הטילדה ב-C++ הוא אופרטור הפיכת הסיביות (Bitwise NOT). כיוון שהמפרק נקרא אוטומטית, הוא לא יקבל פרמטרים ולא יחזיר ערך. לדוגמה, הנה חלק ממימוש אפשרי למחלקת מחרוזות:
class my_string
{
char *buf;
int length;
public:
// בנאי המעתיק מחרוזת קיימת
my_string(const my_string& str)
{
int i;
length = str.length;
buf = new char[length+1];
for(i = 0 ; i <= length ; i++)
{
buf[i] = str.buf[i];
}
}
~my_string()
{
// יש לשחרר הזיכרון שהוקצה
delete[] buf;
}
/* פונקציות ממשק לעבודה עם מחרוזות... */
};
הערה: ניתן לקרוא למפרק באופן ישיר מבלי לפנות את הזיכרון: str.~my_string()
אך זהו נושא מתקדם העוסק בעבודה עם זכרון לא מאותחל.
מצביע this
עריכהלחלק מהקוראים כנראה התעוררה השאלה: כיצד הפונקציה של המחלקה יודעת עבור איזה מופע של המחלקה קראו לה? בהתבוננות בקוד האסמבלי הנוצר ע"י המהדר נראה שכל פונקציה כזאת מקבלת כפרמטר נוסף את כתובת האובייקט עבורו היא נקראת. באותה דרך הצטרכו מתכנתי C לשלוח באופן ידני את המצביעים על המבנים לפונקציות העובדות איתם. לעיתים נרצה להשתמש במצביע זה גם בC++. ניתקל בבעייה זו כאשר נרצה לקשר בין אובייקטים, למשל בעבודה עם רשימות מקושרות, GUI וכד'... מצביע זה נקרא this והטיפוס שלו הוא (X *const)
כאשר X הוא שם המחלקה. משמע הדבר שהמצביע עצמו הוא קבוע ולא נוכל לשנותו. (ראה דוגמה בהמשך הפרק)
העתקת עצמים
עריכהב-C++ ישנו בנאי אחד ואופרטור אחד המוגדרים אוטומטית עבור כל מחלקה. תפקיד הבנאי הזה הוא לאתחל את המופע החדש על ידי עצם קיים ואילו תפקיד אופרטור ההשמה הזה הוא להעתיק את ערכו של עצם אחד לעצם שני. עבור טיפוס X כותרת הבנאי תהיה X::X(const X&)
וכותרת האופרטור תהיה X& X::operator = (const X&)
(על העמסת אופרטורים למד בהמשך). גם הבנאי וגם האופרטור, שניהם מעתיקים אחד אחד את משתני המבנה/המחלקה, דבר זה מאפשר לנו בקלות לכתוב כך:
Date myBirthday(18, 6, 1991);
Date tmp(myBirthday); // העתקה באמצעות בנאי
tmp.addDays(7);
tmp = myBirthday; // העתקה באמצעות אופרטור
אמנם גישה זו לא תתאים למחלקת myString. הבעייה היא שהנתונים אותם מאחסנת המחלקה נמצאים מחוץ למחלקה עצמה, הם לא משתני המחלקה אלא נמצאים בזיכרון דינמי אליו אנו שומרים מצביע. כאשר נאתחל מופע של myString בשם a באמצעות מופע b ונשתמש בבנאי ברירת מחדל המוגדר בשפה, יהיה לנו אזור בזיכרון אליו יצביעו שני מצביעים. שינוי מחרוזת a יגרור לשינוי מחרוזת b ולהפך. יתר על כן, כאשר יושמדו שני המופעים יהיה ניסיון לשחרר את אותו הזיכרון פעמיים, דבר העלול לגרום לקריסת התוכנית. כדי למנוע דבר זה עלינו להגדיר לבד בנאי זה, כפי שנעשה בדוגמה שלמעלה.
כאשר נשתמש באופרטור ההשמה a = b המצב יהיה גרוע יותר: לאזור אחד בזיכרון, השייך במקור ל-b יצביעו שני מצביעים כמו במקרה הקודם, ובנוסף יהיה לנו אזור שני, השייך במקור ל-a, אליו לא יהיה מצביע כלל. אזור זה לא ישוחרר. נציץ טיפה קדימה לנושא העמסת אופרטורים ונדגים כיצד להגדיר אופרטור השמה למחלקה שלנו, בדוגמה זו נשתמש במצביע this כדי לחשוף את המקרים בהם אנו משימים את המשתנה לעצמו (a = a):
class myString
{
// ...
public:
myString& operator = (const myString& str)
{
if (&str != this) // משימים אובייקט שונה
{
delete[] buf; // לשחרר את הזיכרון הישן
length = str.length;
buf = new char[length+1];
for(int i = 0; i <= length; i++)
buf[i] = str.buf[i];
}
return *this; // להחזיר את המופע של המחלקה כדי שנוכל לשרשר השמות
}
// ...
};
הערה: חיזרו לדוגמה זו כשתלמדו על העמסת אופרטורים.
לעיתים נרצה למנוע כלל העתקת אובייקטים של המחלקה, לצורך זה נגדיר את הבנאי והאופרטור המתאימים כפרטיים (באזור private) שלא יעשו דבר. בכך נאסור להשתמש בהם.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
הורשה
נמצאה תבנית הקוראת לעצמה: תבנית:C++ מאפיין בסיסי של תכנות מונחה עצמים הוא מנגנון ההורשה, שמאפשר לנו לתת פתרון ספציפי לבעיה מסויימת מתוך פתרון גנרי קיים. למשל, נתונה לנו מחלקה המתארת אוגר שיודע לאכול ולישון, ומוגדרת בצורה הבאה:
class Hamster
{
public:
Hamster();
~Hamster();
void Eat();
void Sleep();
private:
int teeth;
int colour;
};
אנחנו מעוניינים ליצור מחלקה חדשה, המתארת אוגר קרב. אוגר קרב יודע לעשות כל מה שאוגר רגיל עושה, ובנוסף הוא יודע גם לתקוף אוגרים אחרים. עד עכשיו, היינו צריכים להגדיר מחלקת אוגר קרב נפרדת, דבר שגם כרוך בהמון עבודה, וגם דורש מאיתנו להעמיס את שיטת התקיפה, כדי לאפשר לאוגר הקרב לתקוף כל אוגר אחר, בין אם הוא אוגר קרב או לא. הגדרת מחלקת אוגר הקרב בלי מנגנון ההורשה תיראה בערך ככה:
class BattleHamster
{
int teeth;
int colour;
public:
BattleHamster();
~BattleHamster()
void Eat();
void Sleep();
void Attack(Hamster &pVictim);
void Attack(BattleHamster &pVictim);
}
ברור שכתיבה כזאת תהיה מסורבלת מאוד, במיוחד בהתחשב בעובדה שאנחנו צריכים לממש כל שיטה מחדש מספר רב של פעמים. מנגנון ההורשה מאפשר לנו לקצר את התהליך ולהגדיר מחלקת אוגר קרב שהיא מקרה מיוחד של האוגר הרגיל שלנו. התחביר הוא כזה:
class BattleHamster: public Hamster
{
public:
void Attack(Hamster &pVictim);
}
אוגר הקרב החדש שלנו קיבל בירושה את כל התכונות של האוגר הרגיל, והוסיף להן תכונה חדשה משלו. מכאן אנחנו יכולים להגיד שאוגר קרב הוא סוג של אוגר, שמסוגל לתקוף. מילת המפתח בהורשה היא "סוג של", או "is a", מכיוון שמחלקה יורשת היא מצב מיוחד של המחלקה המורישה, כמו האוגר ואוגר הקרב. בשפת CPP ניתן להרחיב את ההורשה ליותר ממחלקה אחת. לדוגמה, נגדיר מחלקת חיות סיביריות, שהמאפיין שלהם הוא היכולת להיכנס לתרדמת חורף. המחלקה תוגדר כך:
class SybirianAnimal
{
public:
void Hibernate();
}
כעת, אנחנו רוצים להגדיר אוגר קרב סיבירי, שהוא אוגר קרב וגם חיה סיבירית. אנחנו יכולים לרשת את מחלקת אוגר הקרב הסיבירי משתי המחלקות הקודמות שהגדרנו, מחלקת החיה הסיבירית ומחלקת אוגר הקרב, בצורה הבאה:
class SybirianBattleHamster: BattleHamster, SybirianAnimal {};
באופן הזה יצרנו את מחלקת אוגר הקרב הסיבירי בשורת קוד אחת, וקיבלנו אוגר קרב שיודע לעשות גם את כל מה שחיה סיבירית יודעת לעשות.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
העמסת אופרטורים
נמצאה תבנית הקוראת לעצמה: תבנית:C++
כל ביטוי מורכב מפעולות ופרמטרים איתם מתבצעות הפעולות, פעולות אלו נקראות אופרטורים ואילו הפרמטרים איתם עובדים האופרטורים נקראים אופרנדים. ב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 נגדיר פונקצייה ללא פרמטרים, כדי להעמיס את האופרטור ה-postfix נוסיף לפונקציית האופרטור פרמטר 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, עלינו לעשות זאת במפורש. כאשר אנו קוראים לפונקציית האופרטור ללא שימוש באופרטור עצמו, לא מופעלים הבנאים והמפרקים, לכן עלינו לדאוג לדבר זה בעצמנו.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
המרות
נמצאה תבנית הקוראת לעצמה: תבנית:C++ שפת C++ היא בעלת טיפוסיות חזקה (Strongly-typed), משמע הדבר שהמהדר אוסר עלינו להמיר בין טיפוסים רבים. לדוגמה, אם לא נציין במפורש (explicit), לא נוכל להמיר נקודה צפה לשלם כי יש סיכוי לאיבוד מידע חשוב, לא נוכל להמיר שלם למצביע כי הדבר חסר כל משמעות. מנגנון זה דומה ליחידות המדידה בפיזיקה, הוא עוזר למצוא שגיאות בתכנון התוכנית או בכתיבת הקוד.
המרת טיפוסים
עריכהכיוון ששפת C++ היא הרחבה לשפת C, ניתן להשתמש בהמרות בסגנון C. כמו כן נוספו המרות בסגנון קריאה לפונקציה (לבנאי) ו-4 אופרטורים חדשים מיוחדים.
סגנון C
עריכהכדי להמיר ערך x מטיפוס A לטיפוס B נרשום לפני ה-x את שם הטיפוס B בסוגריים עגולים. דוגמה המוכרת למתכנתי C היא:
int *p = (int*)malloc(sizeof(int));
הפונקציה malloc מחזירה טיפוס (void*)
, אותו המהדר לא מאפשר להמיר ל-(int*)
אלא אם לא נציין זאת במפורש.
שימו לב, המהדר יאפשר לבצע המרה כזו רק אם הדבר בכלל אפשרי. לדוגמה טיפוס המוגדר על ידי המתכנת ניתן להמיר לשלם למשל, רק אם מוגדרת המרה כזו (ראה בהמשך). אם לא, תינתן הודעת שגיאה.
סגנון קריאה לפונקציה
עריכהכאשר נוספו ל-C++ הבנאים, נוספה האפשרות ליצור אובייקט זמני חסר שם בצורה הבאה:
complex foo = complex(1,2) + complex(2,3);
בדוגמה זו נוצרים שני אובייקטים זמניים מטיפוס complex ומאותחלים על ידי קריאה לבנאי מתאים. שני האובייקטים הזמניים מחוברים ונשמרים במשתנה בעל שם foo.
סגנון כתיבה זה, פירושו: "צור אובייקט מטיפוס B ואתחל באמצעות הפרמטרים הניתנים". כאשר יש לנו פרמטר אחד מטיפוס A נוכל להתיחס לפעולה זו כלהמרה של A ל-B:
A x = f();
B y = B(x);
שימו לב שלא כל כתיבה כזו היא קריאה לבנאי. יתכן שאין בנאי מתאים אך מועמס אופרטור המרה מתאים, כמו כן לטיפוסים המובנים אין בנאים:
int a = int(1.23456789);
אופרטורי ה-cast
עריכהארבעת אופרטורים הללו הנם חידוש משמעותי בשפה. אופרטורים אלה מאפשרים לציין באופן מפורש את סוג ההמרה אותה אנו רוצים לבצע ולכן עדיפים על שני הסגנונות הקודמים.
אופרטורים אלה נראים בדומה לתבניות (לימדו בהמשך), את הטיפוס אליו אנו ממירים רושמים בין סוגריים משולשים ואת הערך בסוגריים עגולים: _cast<B>(x)
. שימוש רב באופרטורים אלה מגעיל את הקוד. ניתן לראות בזה את כוונתו של ממציא השפה להפחית את השימוש בהמרות, ובכן נהוג להימנע מהמרות מיותרות.
- const_cast - משמש להמרה של קבוע ללא קבוע, כלומר "הורדת" ה-const. המרה זו מסוכנת כי אם האובייקט אכן נוצר כקבוע, המשך פעולת התכנית לא מוגדר (לפעמים שגיאה). להמרה זו יש משמעות כאשר המתכנת בטוח שהאובייקט הוא לא באמת קבוע.
- המרה זו יש לעשות במקרים נדירים למדי, לרוב ניתן להשתמש במקומה ב-mutable או לוודא שלא הגזמנו בשימוש ב-const בפרמטרי הפונקציות.
int a = 0x2A;
const int b = 42;
const int *cp1 = &a;
int *p1 = const_cast<int*>(cp1);
++*p1; // בסדר
const int *cp2 = &b;
int *p2 = const_cast<int*>(cp2);
++*p2; // רעעע
- reinterpret_cast - אומר למהדר להתעלם מהטיפוסים, בזה משמש להמרה בין משפחות שונות של טיפוסים, למשל מצביעים לשלמים. לרוב אופרטור זה יצור אובייקט בעל אותן הסיביות כמו האובייקט המקורי. כל האחריות על נכונות הערך החדש היא על המתכנת.
struct A { /* ... */ };
struct B { /* ... */ };
A *x = f();
B *y = reinterpret_cast<B*>(x);
- static_cast - משמש להמרה בין טיפוסים דומים, מאותה משפחה (לדוגמה מספרים ממשיים לשלמים, מצביעים למחלקות בתוך היררכית מחלקות). המהדר בודק את נכונות ההמרה. המרה מסוג זה עלולה לגרום לאיבוד מידע (למשל double ל-int), אך לעומת ה-reinterpret_cast היא בעלת הגיון אותו מבין המהדר.
int y = static_cast<int>(sin(x)*128.0+128.0);
- dynamic_cast - משמש להמרה בזמן ריצה. למד בהמשך בפרק על RTTI.
המרות טיפוסים של המתכנת
עריכהעד כאן הראינו כיצד לבטא ב-C++ את רצוננו להמיר טיפוס אחד לטיפוס אחר, אבל בפועל המרנו רק טיפוסים מובנים (מספרים, מצביעים). כנאמר כבר בספר זה, אחד הרעיונות בטמונים בשפת C++ הוא לתת אפשרות למתכנת ליצור טיפוסים אפקטיביים משלו שישתלבו כמו הטיפוסים המובנים. גם כאן נרצה להגדיר כיצד אנו רוצים להמיר בין מספר רציונלי לבין מספר עם נקודה צפה, בין תאריך בפורמט שהגדרנו בפרק על מחלקות לבין טיפוס time_t של C וכד'.
כדי לבצע המרה, נוכל כמובן לכתוב פונקציה שתקבל ערך מטיפוס אחד, ותחזיר ערך מטיפוס אחר. לעיתים נרצה להגדיר המרה שתעבוד כמו המרות ה-implicit או ה-explicit המובנות.
בנאים
עריכהכל המרה היא יצירת ערך חדש על בסיס ערך קיים. הדרך הפשוטה שאתם כבר בטח ניחשתם (ואם תרגלתם את החומר בפרקים הקודמים אז כנראה כבר השתמשתם בה), היא כתיבת בנאי המאתחל את המופע של המחלקה באמצעות הפרמטר. לדוגמה:
class rational {
// c + a/b
unsigned short a, b;
long c;
public:
rational(double x)
{
// קוד המייצג את המפרמטר בצורת שבר פשוט
}
};
בנאי זה מגדיר את ההמרה של double לטיפוס שלנו rational (האלגוריתם טיפה ארוך אך תוכלו לכתוב אותו כתרגיל, רמז). כעת נוכל להשתמש בו בצורה הבאה:
rational a;
double c;
// ...
a = rational(c);
הערה: לא הכרחי לציין את ההמרה במפורש אלא אם כן המבנאי המתאים מוגדר כ-explicit.
העמסת אופרטורים
עריכההבנאי יוצר מופע מחלקה על בסיס ערך מטיפוס אחר כלשהו. אין באפשרותנו להוסיף בנאי,למשל, ל"מחלקת int" (כי לא קיימת כזאת) אך נרצה להגדיר המרות גם לטיפוסים מובנים ולטיפוסים שאין לנו את קוד המקור שלהם. מסיבה זו ניתן להגדיר אופרטור המרה לכל טיפוס שנרצה כך:
class rational {
public:
operator double () const {
return c + (double)a/b;
}
};
לפונקציות אופרטורים אלה אין צורך לציין את הטיפוס המוחזר כיוון שהוא חייב להיות זהה לזה שאליו אנו ממירים.
חשוב לזכור כי המרות אלה הן implicit, כלומר אנחנו לא חייבים לכתוב במפורש שאנו רוצים לעשות המרה:
rational a;
// ...
double c = a;
ריבוי המרות מהסוג הזה עלול לגרום לדו-משמעיות או להפתעות שלא תצפו להן, לכן כדאי להמעיט בהמרות אלה.
נמצאה תבנית הקוראת לעצמה: תבנית:C++
פולימורפיזם
נמצאה תבנית הקוראת לעצמה: תבנית:C++
פולימורפיזם
עריכהרב צורתיות (בתרגום ישיר). פולימורפיזם הוא אחד מאבני היסוד של תכנות מונחה העצמים (יחד עם הקונספטים של כימוס, הורשה והפשטה). מטרת הפולימורפיזם היא להעלות את רמת ההפשטה של התוכנה, על ידי כך שבכל הפעלת פונקציה על אובייקט של מחלקה המממשת באופן ספציפי ממשק כללי, תופעל הפונקציה הרלוונטית שממומשת עבור מחלקה זו.
פונקציות וירטואליות
עריכהפרק זה לוקה בחסר. אתם מוזמנים לתרום לוויקיספר ולהשלים אותו. ראו פירוט בדף השיחה.
פונקציה וירטואלית היא פונקציה שמוגדרת במחלקת האב וניתן לכתוב אותה מחדש במחלקות הנגזרות ממנה.
פונקציה וירטואלית טהורה היא פונקציה שמוצהרת כוירטואלית במחלקת האב אך לא ממומשת. המטרה היא שיממשו אותה במחלקות היורשות.
פונקציה וירטואלית מוגדרת כווירטואלית כחלק ממחלקה, ואז ניתן לדרוס אותה (Override) במחלקות היורשות ממנה.
class Shape {
public:
virtual void draw() {
std::cout << "Cannot draw just any shape" << std::endl;
};
};
כעת בגלל שהפונקציה מוגדרת כווירטואלית ניתן לשכתב אותה במחלקות היורשות מ-Shape, באופן הבא:
class Circle: public Shape {
public:
void draw() {
/* ... Draw the Circle here ... */
};
};
פונקציה וירטואלית טהורה היא פונקציה שאינה מוגדרת כלל וכל מטרתה היא שתוגדר מחדש על ידי מחלקות שירשו מחלקה זו. מחלקה שכוללת פונקציה וירטואלית טהורה נקראת מחלקה אבסטרקטית (מופשטת), ולא ניתן ליצור אוביקטים ממחלקה זו. ניסיון לעשות זאת יגרור שגיאה בזמן ההידור. כלומר, אם המחלקה שלנו נראית כך:
class Shape {
public:
virtual void draw()=0; // draw is a pure virtual function
};
יצירת אובייקט מטיפוס Shape בעזרת הפקודה
Shape s;
תגרור שגיאת הידור. שימו לב שניתן ליצור מצביע לאובייקט מטיפוס Shape. הפקודה הבאה היא חוקית:
Shape *s;
קישורים חיצוניים
עריכהנמצאה תבנית הקוראת לעצמה: תבנית:C++
RTTI
קישור למידע על RTTI
תבניות
תבניות הן תכונה ב++C המאפשרת לנו ליצור קוד דינאמי ובכך לחסוך בכתיבת אותו הקוד שוב ושוב. נמצאה תבנית הקוראת לעצמה: תבנית:C++
הצורך בתבניות
עריכהעל מנת להבהיר את הצורך בתבניות, נביא דוגמת קוד:
void swap(int &a, int &b)
{
int temp = a;
a = b;
b = temp;
}
קוד זה עושה פעולה פשוטה מאד: הוא מקבל שני משתנים מיוחסים ל-int ומחליף ביניהם.
אז מה הבעיה? בואו ננסה לכתוב את אותו הקוד עבור טיפוס char:
void swap(char &a, char &b)
{
char temp = a;
a = b;
b = temp;
}
נראה מעט מוכר, לא?
נסביר מעט: ההבדל בין שתי הדוגמאות הוא רק סוגי המשתנים. חוץ מהם, הקוד זהה לחלוטין.
על מנת לחסוך ממנו את כתיבת אותו הקוד שוב ושוב רק כדי להתאים אותו לסוגי משתנים שונים, המציאו את התבניות. בעזרתן ניתן לכתוב קוד דינאמי שישתנה בהתאם לצורך שלנו ולסוגי המשתנים בהם נרצה להשתמש.
תבניות פונקציות
עריכהניתן להשתמש בתבניות לפונקצייה בודדת ולהתאים אותה לצרכים שלנו. לדוגמה, הפונקציה שהצגנו לפני כן עם שימוש בתבניות תראה כך:
template<typename T>
void swap(T &a, T &b)
{
T temp = a;
a = b;
b = temp;
}
ועכשיו נבין את הכתוב: בהתחלה, אנו מצהירים כי הפונקצייה שלנו היא תבנית (שורה 1) ומגדירים משתנה ששמו הוא T שישמש לנו כסוג הפונקצייה.
כעת, נוכל להשתמש ב-T כסוג משתנה לכל דבר ולהגדיר בעזרתו משתנים (כדוגמת המשתנה temp).
שימוש בפונקציה יראה כך:
swap<type>(a, b);
תבניות מחלקות
עריכהכשמחלקה שלמה צריכה להיות מותאמת לסוג אחד, נשתמש בתבניות מחלקות. התחביר מאד דומה לתבנית פונקציות:
template<class T>
class A {
T x;
T y;
public:
A(T a,T b)
{x=a;y=b;}
T func();
};
template<class T>
T A::<T>func()
{/*....*/}
נמצאה תבנית הקוראת לעצמה: תבנית:C++
מרחבי שם
נמצאה תבנית הקוראת לעצמה: תבנית:C++
ספרייה היא אוסף של פונקציות המשמשות את המתכנת על מנת שיוכל לבצע פעולות גם בלי לממש אותם.
מרחבי שם (name space) זהו בעצם כלי שמאפשר להקל על התוכנה על ידי כך שלא נייבא את כל הספרה <iostream> אלא רק את החלקים שאנו צריכים.
כל ספרייה מחולקת למרחבי שם רבים לדוגמת הספרייה <iostream> שאחד ממרחבי השם שלה הוא std שבו השתמשנו עד עכשיו.
בתחילת התוכנית עלינו ליבא מחלקות באמצעות "include" כפי שהוזכר בעמוד 2 "שלום עולם!" בצורה הבאה:
#include <iostream>
int main()
{
.
.
.
}
וכאשר נגדיר בצורה הזאת לפני שנשתמש בקלט ופלט נצטרך להוסיף לפני ::std כך:
#include <iostream>
int main()
{
std::cout<<"hello world";
return 0;
}
על מנת שלא נצטרך לכתוב כל פעם מחדש ::std וכדי שלא נצטרך לייבא את כל הספרייה <iostream> אנו נשתמש במרחב שם.
על מנת לייבא מרחב שם לתוכנית שלנו נשתמש בפונקציה using ובשם המרחב שברצוננו לייבא.
פקודות הקלט והפלט ורוב הפקודות הבסיסיות שאנו משתמשים בהם נמצאים במרחב השם std שנמצא בתוך הספרייה <iostream>.
כדי ליבא את std נרשום כך:
#include <iostream>
using namespace std;
int main()
{
cout<<"hello world";
return 0;
}
יש לשים לב לאחר שייבאנו את std הוא זמין לכל התוכנה.
עם זאת אפשר לייבא את std או מרחבי שמות אחרים רק לפונקציות או למחלקות ספציפיות ובמקרה כזה לא תהיה אפשרות להשתמש באותו מרחב שם מחוץ לפונקציה או למחלקה. נמצאה תבנית הקוראת לעצמה: תבנית:C++
חריגות
נמצאה תבנית הקוראת לעצמה: תבנית:C++
חריגות ב ++C
עריכההקדמה
עריכהחריגות ( exceptions באנגלית ) ב ++C הן המנגנון שמאפשר לטפל בשגיאות או בחריגות מהאפיון של המערכת(מכאן נובע השם).
בעזרתן, ניתן בכל רגע נתון, לעצור את הריצה של התוכנית, לצאת מ"הסקופ" ולבצע קוד שניכתב מראש כדי לטפל במצב השגיאה. חריגות ב ++C מזוהות על ידי קוד בדיקה שכותב המתכנת בתוך בלוק בדיקה שמוגדר באמצעות הפקודה TRY או בכינון הפונקציות הנקראות מתוך בלוק הבדיקה. כאשר תנאי הבדיקה לא מתקיימים יכול המתכנת ליזום חריגה בעזרת הפקודה THROW. הקוד לטיפול בחריגה יוגדר באמצעות פקודת CATCH בסיום בלוק הבדיקה. לאחר חריגה יקרא קוד הטיפול הקרוב למקום "לזריקת" החריגה.
כל שורת קוד שהמתכנת כותב מתורגמת לשפת מעבד ( אסמבלי או לאופ-קודים בשכבה נמוכה יותר ) שאותן המעבד מבצע לפי הסדר. ברגע שנמצאת חריגה/שגיאה, המעבד מקבל פקודה ( אופ-קוד ) שאומרת לו "זוהי שגיאה" ( איך זה קורה, נתאר בהמשך )ואז הוא מתחיל בטיפול בשגיאה.
מה שקורה מרגע זה עובד פחות או יותר כך:
- המעבד עוצר את רצף הפקודות של התוכנית.
- הוא מודיע למערכת ההפעלה שהתרחשה שגיאה.
- מערכת ההפעלה בודקת, איזו מן שגיאה זו ומי יודע לטפל בה.
- יש למערכת ההפעלה ( או יותר נכון ל CRT ) רשימה של handlers שיודעים לטפל בשגיאות.
- אם התוכנית רשמה handler לטיפול בשגיאה הספציפית - הטיפול בשגיאה עובר לתוכנית ( הקוד של המשתמש ).
- אם התוכנית לא רשמה handler מתאים אז מערכת ההפעלה מטפלת בשגיאה.
- מאחר והדבר היחיד שמעניין את מערכת ההפעלה זה להגן על עצמה ועל תהליכים אחרים שרצים בה, הטיפול יהיה לחסל את הפגיעה הנקודתית - או להרוג את התהליך שגרם לשגיאה.
חשוב להבין, שכשאנחנו כותבים קוד ומקמפלים אותו, המהדר עוטף אותו בהרבה שכבות שיודעות בסופו של דבר לדבר עם מערכת ההפעלה שעליה אנחנו רצים. הקוד הזה, הוא בלתי נראה למתכנת אבל בכל ריצה של התוכנית יש לו חלק בלתי נפרד מהריצה, מהאתחול של התהליך, דרך בדיקות שרצות במהלך הריצה ועד לירידה מסודרת של התהליך.
כל אחת משכבות יכולה "לזרוק" שגיאה ( או במילים אחרות להתריע - "שימו לב, משהו לא תקין" ) והשגיאה תעבור דרך כל השכבות מהמעבד ועד לקוד המשתמש עד שאחד המנגנונים "יתפוס" אותה ויטפל בשגיאה.
חריגות, הן לא דבר רע. להיפך - הן הדרך שלנו להתגונן בפני דברים רעים שעלולים לקרות,
מי יכול "לזרוק"?
עריכהכל קוד שרץ יכול להתריע על שגיאות. לכל שכבה יש את סט הבדיקות שלה וכל חריגה שמתגלה, תגרור "זריקה". למשל, אחד התפקידים של מערכת ההפעלה הוא ניהול הזיכרון שיש לה ( החומרה שעליה היא רצה ) וחלוקה שלו בין התהליכים השונים שרצים. את הזיכרון הזה היא מחלקת למספר סוגים:
- זיכרון שאפשר לקרוא ממנו.
- זיכרון שאפשר לכתוב אליו.
- זיכרון שאפשר להריץ ממנו קוד.
אם יש ניסיון לעשות משהו שאסור לעשות על קטע זיכרון מסויים, מערכת ההפעלה תתריע ו"תזרוק" שגיאה. נקרא לה בינתיים Access Violation.
דוגמא נוספת תהיה, שגיאה שנזרקת מתוך קוד המשתמש: כשהמכנת כותב קוד, הוא קובע ( בתקווה ) מראש מה התוכנית אמורה לעשות. הוא מחליט מה הן דרישות המערכת ומה היא אמורה לעשות. מתוך הדרישות האלו, ניתן לקבוע מה התוכנית לא אמורה לעשות. אם אנחנו מגיעים למשהו שאנחנו מזהים כמשהו שלא אמור לקרות אנחנו יכולים להודיע לשאר התוכנית של המשתמש שקרה משהו לא צפוי ע"י זריקת חריגה משלנו. השאלה המתבקשת כמובן היא - למה אני צריך לזרוק אם אני זורק את זה מתוך קוד משתמש וזה מגיע שוב לקוד משתמש? אז מערכות גדולות ובטח בתכנות מונחה עצמים כמו ב ++C אנחנו מפרידים בין מודולים שונים של התוכנית. כלומר, אנחנו נותנים אחריות שונה לכל אחד מהמודולים וכל אחד מהם "חי" בכוחות עצמו. למשל, אם אני רוצה לכתוב שרת שמקבל בקשות מקליינטים שונים ונותן להם שירותים, אני אחלק את זה לשני מודולים שונים - מודול שאחראי על השרת ומודול שאחראי על הקליינט. המודולים הללו יודיעו אחד לשני על חריגות מהצפוי בדיוק באופן הזה.
מה אפשר לזרוק
עריכהלמעשה, אפשר לזרוק כמעט כל דבר בין אם זה מספר ( int ) או מחרוזת (string) או מחלקה(Class) שאנחנו כתבנו. נראה זאת בהמשך.
נביט בדוגמא הבאה:
void throwingFunction()
{
throw "This is the exception!";
}
int main()
{
try {
throwingFunction();
}
catch(const char* string) {
cout << "Caught it:" << string << endl;
}
}
הסבר:
- הפונקציה throwingFunction זורקת מחרוזת ע"י שימוש בפקודה throw.
- פונקצית ה ()main שלנו עושה שימוש בבלוק מהצורה:
try { }
catch(type) { }
הבלוק הזה מאפשר לנו לתפוס חריגות שמתרחשות בתוך הסקופ של {}try במידה ואמרנו שאנחנו יודעים להתמודד עם הטיפוס של החריגה שנזרקה.
שפות אחרות
עריכהחריגות הן לא משהו ייחודי לשפת ++C וגם בשפות אחרות יש שימוש במנגנון זה. למשל - כך היינו עושים את זה בשפת C. נניח שאנו ממשים את הפונקציה sqrt המחזירה שורש של מספר. חתימת הפונקציה:
double sqrt(double num);
המספר שמועבר לפונקציה חייב להיות חיובי. ב-C, כדי לטפל בהכנסת ערך לא חוקי, היינו צריכים לציין זאת באמצעות ערך חזרה מיוחד:
double sqrt(double num){
if(num < 0.0) {
return -1.0;
}
// Do the rest of the root
)
והקוד שמשתמש בפונקציה, היה נראה כך:
double sqrtNum = sqrt(num);
if (sqrtNum < 0) {
// handler error...
}
בגלל שטיפול השגיאות אינו מובנה בשפה, אנו נאלצים לאחר כל פונקציה, לבדוק האם התרחשה שגיאה שעלולה לפגוע בנכונות התוכנית.
חריגות
עריכהפרק זה לוקה בחסר. אתם מוזמנים לתרום לוויקיספר ולהשלים אותו. ראו פירוט בדף השיחה.
כעת נציג את מנגנון החריגות ב C++. חריגה היא משהו ש"נזרק" ממקום שבו אריעה שגיאה בתוכנית. ברגע שחריגה נזרקת, הקוד הרגיל של התוכנית לא ימשיך, ויבוצע במקומו קוד מיוחד לטיפול בשגיאה, או שהתוכנית תסתיים.
מה בעצם ניתן לזרוק ? אובייקטים ופרימיטיבים של C++. כדי לזרוק חריגה משתמשים במילת המפתח throw. נדגים זאת כעת.
זריקת חריגה
עריכהקודם כל, נגדיר מחלקה מיוחדת, שנוכל לזרוק אובייקטים שלה. אין חובה לעשות זאת, וניתן לזרוק כל מחלקה, אך מומלץ ליצור היררכיית מחלקות מיוחדת עבור חריגות, כאשר כל מחלקה נועדה לסוג אחר של שגיאה.
class InvalidArgumentException{};
כעת, נממש מחדש את sqrt:
double sqrt(double num){
if(num < 0.0) {
throw InvalidArgumentException{};
}
// Do the rest of the root
)
נמצאה תבנית הקוראת לעצמה: תבנית:C++
פקודות לקדם-מעבד
פקודות לקדם מעבד הן בתחילת שורה ומתחילות עם התו # ומסתימות ללא התו ;
באופן כללי הקדם מעבד רץ לפני הקומפילציה ומחליף ומוסיף טקסט לפי הפקודות שהוא מקבל
פקודות נפוצות הן
include
define
לדוגמה קריאה לקדם מעבד להכליל את הספריה iostream
ניתן להכליל קבצים שאנחנו כותבים כך שאם יש לנו קובץ "A.h" ואנו רוצים להשתמש בו נשתמש בפקודת include כמו בשורה השניה בדוגמא
השורה הבאה היא פקודה לקדם מעבד להחליף את המחרוזת NUM במחרוזת 5
לדוגמא
#include <iostream>
#include "A.h"
#define NUM 5
int main()
{
int arr[NUM];
.
.
}
אפשר לא להגדיר ערך, זה שימושי בעיקר בהנחיות ifdef ו-ifndef.
אפשר גם להגדיר מין פונקציה כלולה (inline function).
כך:
#define MACRO(x) /
cout << x;
כל פעם שצריך לעבור שורה, משתמשים בתו /. אפשר להשתמש במקרו גם ב-#, שמשמעותו "הקף בגרשיים". כגון:
#define MACRO(x) #x
int main()
{
cout << MACRO(abcd)
}
נקבל בפלט abcd - תוצאת המקרו הייתה "abcd". אפשר גם להגדיר ## - שירשור של שמות משתנים. נגיד:
#define MACRO(x,y) x ## y
int main()
{
int ab = 6;
cout << MACRO(a,b);
}
נקבל 6. תוצאת המקרו הייתה ab.
ifdef...endif
אם מוגדר המקרו שאחרי ifdef, יבוצעו כל ההוראות על ל-endif (למעשה, זה אינו מגדיל את הקובץ הסופי, מכיוון שהקדם-מעבד בודק לפני ההידור אם מוגדר המקרו, ואם לא-הוא מסיר מהקוד את הפקודות עד endif).
לדוגמה:
#include <iostream>
using namespace std;
#define WINDOWS
int main()
{
#ifdef WINDOWS
cout << "Windows!";
#endif
}
אפשר לעשות גם מבנה:
#ifdef MACRO
//...
#else
//...
#endif
אם MACRO מוגדר, קטע הקוד אחרי ה-ifdef יבוצע. אם לא, קטע הקוד אחרי else יבוצע. אפשר גם לעשות ifndef - אם לא מוגדר המקרו.
pragma
הנחיה מתקדמת שמיועדת למתן הוראות כלשהן לקדם-המעבד. לכל מהדר יש הוראות pragma משלו - חפשו בתיעוד של המהדר שלכם.
התחביר:
#pragma הוראה
מבוא ל-STL
נמצאה תבנית הקוראת לעצמה: תבנית:C++
STL הוא קיצור של Standard Template Library, או בעברית - ספריית תבניות סטנדרטית. ה־STL מכילה אוסף של כלים, ובהם מימוש למספר רב של מבני נתונים נפוצים, אלגוריתמים, וכלים נוספים. השימוש ב־STL דורש הבנה של כמה עקרונות C++, ולכן לפני השימוש בו רצוי מאוד להכיר היטב את השפה.
ככלל, כאשר תרצו להשתמש במבנה נתונים נפוץ, כדאי לבדוק אם קיים מימוש שלו ב־STL. הסיבה היא, פרט לכך שתחסכו עבודה רבה, היא שה־STL קיימת כבר שנים והיא פרי עבודתם של אנשים רבים, מה שאומר שהמימושים הקיימים בה יעילים מאוד ועברו בקרת איכות רבה.
ב־STL תוכלו להשתמש לדוגמה בשביל רשימה במקום מערך, שברשימה תוכלו להוסיף ולמחוק את הערכים שבה בעזרת פעולות פשוטות שאותן אי אפשר עם מערך. תוכלו להשתמש ב־VECTOR לרשימה או ב־LIST ואם תרצו להחליף את char[] תוכלו להשתמש ב־STRING, שיותר נוח ופשוט לתפעול.
קישורים חיצוניים
עריכהנמצאה תבנית הקוראת לעצמה: תבנית:C++