חזור לתיקייה: 02340124 - מבוא לתכנות מערכות

5 - ירושה

חיפוש בתיקיה
Ctrl K
לחץ כאן או הקש Ctrl K כדי לחפש בכל התיקיה
  • ירושה
  • שליטה בגישה
  • בנאים במחלקות יורשות
  • הורסים במחלקות יורשות
  • דריסה של שיטות
  • פתרון ראשון
  • הפתרון הנכון: שיטות וירטואליות
  • המילה השמורה override
  • מחלקה אבסטרקטית
  • מחלקת ממשק - Interface
  • Polymorphism
  • פולימורפיזם ובנאי העתקה
  • כשצריך סוג ספציפי של יצור
  • סיכום השמות(casting)
  • ירושה ושגיאות

5 - ירושה

02340124

מבוא לתכנות מערכות

רשימות הרצאה לקריאה בשבת

ירושה

הופק מתוך תקיית התואר של שקד

uni-folder.vercel.app

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

נניח ויש לנו יישום HR ויש לנו שני סוגים של עובדים: מהנדסים ואנשי מכירות.
נניח והיינו מתארים את שניהם במחלקה אחת, היינו מגיעים לבעיה, למהנדס יש תארים ולאיש המכירות אין בעוד שלאיש המכירות יש commisioncommisioncommision ולמהנס אין.
אם נכתוב מחלקה אחת, נגיע לבלבול:

class Employee
{
public:
	// ...
	string getName() const;
	void giveRaise(int amout);
	enum Type { ENGINEER, SALESPERSON };

private:
	string name;
	Date birth;
	int salary;
	Type type;
	Set<string> degrees; // for an engineer
	double comissionRate; // for a sales person
};

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

הפתרון הנאיבי הוא לצור מחלקה בשם EmployeeEmployeeEmployee ולשים שדה שלה במחלקות SalesPersonSalesPersonSalesPerson ו-EngineerEngineerEngineer ואז נשתמש בשיטות שלנו במחלקות החיצוניות יותר בשיטות של EmployeeEmployeeEmployee. (נניח getName()getName()getName() של EngineerEngineerEngineer פשוט יקרא ל-getName()getName()getName() של EmployeeEmployeeEmployee) זה גם לא טוב במיוחד.

הערה

ב-C++C++C++ נוכל להגדיר המרה אוטומטית בין EngineerEngineerEngineer ו-SalesPersonSalesPersonSalesPerson ל-EmployeeEmployeeEmployee ואז כל פונקציה שמשתמשת ב-EmployeeEmployeeEmployee נוכל לשלוח אליה מהמחלקות האחרות.
זה גם קצת מציק.

ירושה

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

דוגמה (בהמשך להקדמה)

class Employee
{
public:
	// ...
	string getName() const;
	void giveRaise(int amount);

private:
	string name;
	Date birth;
	int salary;
};

class Engineer : public Employee
{
public:
	// engineer-only functions ...

private:
	Set<string> degrees;
};

השימוש יהיה נחמד למדי:

void f(Engineer& engineer)
{
	engineer.addDegree("B.Sc. Computer Science");
	engineer.giveRaise(10);
}

תכף גם נראה למה חשוב לשלוח את המחלקה כרפרנס.

הערה

יש גם גרסה של ירושה עם :private: private:private אבל עליה לא נדבר בקורס.

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

הגדרה 1 (טרמינולוגיה)

עבור שתי מחלקות A,BA,BA,B כאשר BBB יורשת מ-AAA נגיד כי:
א. BBB יורשת מ-AAA.
ב. B derives from A
ג. BBB היא מחלקת הבן ו-AAA מחלקת האב.
ד. A is the base class and B is the derived class
ה. A is the superclass and B is the subclass.

ב-UMLUMLUML אנו נסמן ירושה עם ראש חץ ריק.

BA

שליטה בגישה

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

class Employee
{
public:
	string getName() const;
	void giveRaise(int amount);
private:
	string name;
	Date birth;
protected:
	int salary;
};

class Engineer : public Employee
{
public:
	void addReview(string text, int score);
private:
	Set<string> reviews;
};

void Engineer::addReview(string text, int score)
{
	reviews.add(text);
	if (score == 5)
		salary += 1000;
}

void f(Engineer& engineer)
{
	engineer.salary = 1000; // ERROR!!!!!
}

ב-C++C++C++ בירושה אנו נדרשים להשתמש במצביעים ורפרנסים.
בכך, אם BBB יורשת מ-AAA אזי, ניתן להשתמש ב-B∗B^*B∗ כ-A∗A^*A∗ וניתן להשתמש ב-B&B\&B& כ-A&A\&A&. אך לא הפוך

דוגמה

Engineer engineer("Jane Doe");
SalesPerson salesPerson("John Doe");
Employee employee("John Smith");

Employee& ref = engineer; // ok
Employee* ptr = &salesPerson; // ok

Engineer* ptr2 = &employee; // ERROR!

Employee = copy = engineer; // compiles, but probebly a bug

Employee* array[] = {
	&engineer, &salesPerson, &employee
};

בנאים במחלקות יורשות

בנאים לא עוברים בירושה ובנוסף הבנאי של הבן חייב לקרוא לבנאי של האב.
הסיבה לחלק הראשון היא שהבנאי היא אינה שיטה אלא יצור אחר, היא בעצם שיטה של המחלקה ולא של האובייקט. (קיימות שפות שבהן יש ירושה של הבנאים - C++C++C++ היא לא אחת מהן)

דוגמה

Employee::Empyloyee(string name)
	: name(name), salary(STARTING_SALARY)
{}

Engineer::Engineer(string name)
	: Employee(name), degrees()
{}
הערה

אם קיים למחלקת האב בנאי ברירת מחדל ואנו לא קוראים לבנאי בבנאי של הבן, הבנאי ברירת המחדל יקרא.

הערה

הדרך היחידה למנוע ירושה ב-C++C++C++ היא למנוע קיום של בנאים ציבוריים(בנאי פרטי זה סבבה)

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

הורסים במחלקות יורשות

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

ההבדל פה מבנאים, הוא שלא צריך לקרוא ידנית להורס של האב.

Engineer::~Engineer()
{
	// ...
	// no need to call ~Employee()
}
דוגמה

class Engine
{
public:
	Engine() { cout << "Engine ctor" << endl; }
	~Engine() { cout << "Engine dtor" << endl; }
};

class Battery
{
public:
	Battery() { cout << "Battery ctor" << endl; }
	~Battery() { cout << "Battery dtor" << endl; }
};

class Car
{
	Engine engine;
public:
	Car() { cout << "Car ctor" << endl; }
	~Car() { cout << "Car dtor" << endl; }
};

class ElectricCar : public Car
{
	Battery battery;
public:
	ElectricCar() { cout << "ElectricCar ctor" << endl; }
	~ElectricCar() { cout << "ElectricCar dtor" << endl; }
};

// MAIN
int main()
{
	ElectricCar car;
	cout << "========" << endl;
	return 0;
}

עבור הקוד הנ"ל נקבל את הפלט:

Engine ctor
Car ctor
Battery ctor
ElectricCar ctor
========
ElectricCar dtor
Battary dtor
Car dtor
Engine dtor

דריסה של שיטות

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

דוגמה

void Employee::giveRaise(int amount)
{
	salary += amount;
}

class SalesPerson : public Employee
{
public:
	// ...
	void giveRaise(int amount);
};

void SalesPerson::giveRaise(int amount)
{
	Employee::giveRaise(amount + comissionRate * sales);
}
הערה

לא ניתן לדרוס שדות!!!!

אך מה קורה אם יש לנו מערך למצביעים של EmployeeEmployeeEmployee, ואנו קוראים על אחד האיברים, איזו שיטה צריכה להיקרא? הזאת של EmployeeEmployeeEmployee או הזאת של המחלקה היורשת המתאימה?

פתרון ראשון

הפתרון הראשון די מגעיל והוא דורש השמה בתצורה כזו:

Employee* employees[] = { ... };

for (int i = 0; i < n; ++i)
{
	Employee *e = employees[i];
	((SalesPerson*)e)->giveRaise(100);
}

זה מגעיל ויכול ליצור מלאאא בגים! אנו לא רוצים את הפתרון הראשון.

דרך לפתור את הבעיות עם המצביעים היא להשתמש ב-static_caststatic\_caststatic_cast שהוא החלפה טובה יותר של השמה של CCC ביצור חדיש יותר של C++C++C++. בתצורה זו, השמה בין שני דברים לא קשורים לא תתקמפל.

Employee* employees[] = { ... };

for (int i = 0; i < n; ++i)
{
	if (employees[i]->getType() == "manager")
		static_cast<SalesPerson*>(cmployees[i])->giveRaise(100);
}

זה יותר טוב, אך אנו עדיין לא אוהבים את זה כל כך.

הפתרון הנכון: שיטות וירטואליות

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

דוגמה

class Employee
{
public:
	// ...
	virtual void giveRaise(int amount);
};

class SalesPerson : public Employee
{
public:
	// ...
	void giveRaise(int amount);
};

int func()
{
	SalsesPerson salesPerson("John Doe");
	Employee* emp = &salesPerson;
	
	emp->giveRaise(1000);
	
	return 0;
}
הערה

ניתן לא לדרוס שיטות virtualvirtualvirtual במחלקת הבן, ואז נקראת אוטומטית השיטה של מחלקת האב.

המילה השמורה overrideoverrideoverride

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

דוגמה

class Employee
{
public:
	virtual void giveRaise(int amount);
};

class QASpecialist : public Employee
{
public:
	// ...
	void giveRaise(int amount) override;
};

מחלקה אבסטרקטית

בהרבה מקרים מחלקת האב מייצגת קונספט אבסטרקטי שאין לו מימוש בפני עצמו.
במקרים כאלו אנו יכולים להגדיר פונקציה וירטואלית לגמרי(pure  virtual  functionpure\;virtual\;functionpurevirtualfunction) אשר אין לה מימוש במחלקת האב.

דוגמה

class Employee
{
public:
	// ...
	virtual void giveRaise(int amount) = 0;
};

class SalesPerson : public Employee
{
public:
	// ...
	void giveRaise(int amount) override;
};
הגדרה 2 (מחלקה אבסטרקטית)

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

משפט

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

הערה

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

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

class Employee
{
	// ...
public:
	virtual ~Employee() {}
	// ...
};

מחלקת ממשק - InterfaceInterfaceInterface

הגדרה 3 (ממשק)

מחלקה אבסטרקטית נקראת ממשק אם יש לך אך ורק פונקציות וירטואליות לגמרי(ללא מימוש).

ממשק מתאר את ההתנהגות הבסיסית של מחלקה מבלי לתאר איזשהו מימוש מדוייק.

דוגמה

class Shape
{
public:
	virtual ~Shape() {}
	virtual void rotate(double angle) = 0;
	virtual void draw() const = 0;
	virtaul double area() const = 0;
};

PolymorphismPolymorphismPolymorphism

אנו דיברנו כבר על PolymorphismPolymorphismPolymorphism שכן דיברנו כבר על תכנות גנרי ואמרנו שזה Parametric  PolymorphismParametric\;PolymorphismParametricPolymorphism.
בקורס הזה אנו קוראים ל-Subtyping  PolymorphismSubtyping\;PolymorphismSubtypingPolymorphism פשוט PolymorphismPolymorphismPolymorphism.
הרעיון של פולימורפיזם הוא פשוט שכאשר אנו יוצרים מחלקה יורשת חדשה, שום חלק במחלקת האב לא צריך להשתנות.

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

פולימורפיזם ובנאי העתקה

נזכר כי אם יש לנו מחלקת אב EmployeeEmployeeEmployee ומחלקת בן EngineerEngineerEngineer ואנו כותבים את הקוד הבא:

Engineer eng("Bjarne Stroustrup");
Employee emp = eng;

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

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

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

class Shape
{
	// ...
public:
	virtual Shape* clone() const = 0;
	// ...
};

class Circle : public Shape
{
	// ...
public:
	Circle(double radius);
	Shape* clone() const override;
	// ...
};

Shape* Circle::clone() const
{
	return new Circle(*this);
}

int main()
{
	Shape* ptr = new Circle(0,0,3);
	Shape* copy = ptr->clone();
}

כשצריך סוג ספציפי של יצור

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

נשים לב כי אם יש לנו מצביע של EmployeeEmployeeEmployee אנו לא נוכל לקרוא לשיטה שמוגדרת רק ב-EngineerEngineerEngineer שכן אנו לא ידועים אם זה באמת מהנדס או לא.
משהו נוסף שלא נעשה הוא להוסיף שיטה typetypetype אשר בעזרתה נבדוק איזה סוג זה כי זה מגעיל.

הפתרון לבעיה המוצעת פה היא שימוש בסוגים דינאמים וב-dynamic_castdynamic\_castdynamic_cast.
ה-dynamic_castdynamic\_castdynamic_cast משים אובייקט מסוג של מחלקת אב לאובייקט מסוג טיפוס של הבן והיא תחזיר nullptrnullptrnullptr אם זה לא הגיוני

דוגמה

void printDegrees(const Employee* employees[], int n)
{
	for (int i = 0; i < n; ++i)
	{
		const Engineer* eng = dynamic_cast<const Engineer*>(employees[i]);
		if (eng != nullptr)
		{
			eng->printDegrees();
		}
	}
}
הערה

ה-dynamic_castdynamic\_castdynamic_cast יעבוד אך ורק אם במחלקת האב יש פונקציה ורטואלית אחת לפחות.

סיכום השמות(castingcastingcasting)

ראינו כי לבצע השמות כמו שעשינו ב-CCC זה לא טוב כלל וכלל!!!!

const double height = 175.3;
int* height_ptr = (int*) &height; //NOOOOOOOOO

יש לנו ב-C++C++C++ שני סוגים של השמות:

  1. static_caststatic\_caststatic_cast מאפשר לנו להמיר בין שני סוגים שידועים בזמן ההמרה, כלומר, לא מחלקות עם ירושה.
    כדאי להשתמש באלו רק עבור טיפוסים פרימיטביים.
  2. dynamic_castdynamic\_castdynamic_cast מאפשר לנו להמיר בין שני מצביעים או רפרנסים שיש להם איזשהו קשר ירושתי שכזה.
    האחד הזה נבדק במהלך זמן הריצה.

ירושה ושגיאות

ראינו שגיאות באחד מהפרקים הקודמים וכעת נוכל לשפר את יכולת ההתמודדות שלנו עם שגיאות.
בבלוק catchcatchcatch התפיסה של סוג מתחשב גם בכל הבנים שלו, נניח אם אנחנו תופסים EmployeeEmployeeEmployee זה יתפוס גם EngineerEngineerEngineer ו-SalesPersonSalesPersonSalesPerson וכו'.

דוגמה

class GradesException
{
public:
	virtual ~GradesException() {}
};

class NotEnrolled : public GradesException {};
class NoSuchStudent : public GradesException {};

void GradesSystem::printGrade(string student, int course)
{
	try
	{
		cout << "grade is: " getGrade(student, course);
	}
	catch (const NotEnrolled&)
	{
		cerr << studentName << " is not registered";
	}
	catch (const GradesException&)
	{
		cerr << "Data not avilable";
	}
}

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

class exception
{
public:
	exception();
	exception(const exception&);
	exception& operator=(const exception&);
	virtual ~exception();
	virtual const char* what() const noexcept; // noexcpet says there wont be an exception comming from this function
};

יש לנו שני סוגים בסיסיים של שגיאות:

  1. logic_errorlogic\_errorlogic_error - מייצג את השגיאות שהקורא לפונקציה יכול היה לגלות עליהן, לדוגמה: index  out  of  boundsindex\;out\;of\;boundsindexoutofbounds.
  2. runtime_errorruntime\_errorruntime_error - מייצג את השגיאות שקורות כאשר הפונקציות רצות ולא היה ניתן לדעת עליהן בהקדם(שגיאות משתמש נניח)
הערה

הסיווג הזה לא מוגדר היטב ולכן לא כל כך חשוב.