לדלג לתוכן

תכנות מתקדם ב-Java/הורשה

מתוך ויקיספר, אוסף הספרים והמדריכים החופשי

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

עולם החי

[עריכה]

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

הקדמה

[עריכה]

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

דוגמה

[עריכה]
//Class: Person.java

import java.util.Scanner;

/**
 * This class represents a person.
 */
public class Person {
	
	public String _name;
	public String _email;
	
	public void getInformation() {
		Scanner s = new Scanner(System.in);
		System.out.print("Enter name: ");
		_name = s.nextLine();
		System.out.print("Enter E-mail address: ");
		_email = s.next();
	}

}

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

// Class: Student.java

/**
 * This class represents a student.
 */
public class Student extends Person {
	
	public int[] _grades;
	public double _average;

	public void checkAverage() {
		if(_average < PASS) 
			System.out.println("Failed");
		else 
			System.out.println("Passed");
	}

}

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

// Class: Teacher.java

/**
 * This class represents a teacher.
 */
public class Teacher extends Person {
	
	public Student[] _students;

}

הסבר

[עריכה]

בדוגמה שהצגנו כאן ישנן שלוש מחלקות. המחלקה הראשונה נקראת Person, והיא מחלקה פשוטה מאוד המייצגת אדם בבית ספר. המחלקות האחרות מרחיבות את המחלקה הראשונה: גם Student וגם Teacher, שהן שתי מחלקות המייצגות תלמידים ומורים, מכילות בתוכן את המחלקה Person - אדם. כלומר: כל תלמיד וכל מורה הם - לפני הכל - בני אדם. לכן, כל אובייקט מטיפוס Teacher או Student מכיל גם את השדות _name ו-_email ואת השיטה getInformation. בנוסף לכך, מכילות המחלקות האלו את החלקים שהוספנו להם: כל מורה מחזיק מערך של תלמידים, וכל תלמיד מחזיק מידע לגבי הציונים שלו והממוצע שלהם. עם זאת, אובייקט מסוג Teacher אינו מכיל את השדות שהוספנו ל-Student, ולהפך. לשם הנוחות, כל המשתנים בדוגמה הוגדרו כ-public. זה כמובן לא מצב רצוי ברוב המקרים.

היררכייה

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

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

מאפיינים וצורת עבודה

[עריכה]

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

גישה

[עריכה]

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

הרשאת ה-Protected

[עריכה]

זה הזמן להכיר הרשאת גישה חדשה: protected. כמו שתי ההרשאות המוכרות לנו כבר, public ו-private, גם ההרשאה protected ניתנת לשימוש עם משתנים ועם שיטות. ההבדל הוא שהגישה מותרת רק למחלקות היורשות. אם כך:

  • private - לא מאפשרת גישה לאף מחלקה חיצונית, ובכלל זה גם לא למחלקות יורשות.
  • protected - מאפשרת גישה למחלקות יורשות ולמחלקות באותה החבילה, לא מאפשרת גישה למחלקות חיצונית אחרות.
  • public - מאפשרת גישה לכל מחלקה.

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

בנאים

[עריכה]

נניח כי יש לנו מחלקה בשם Base, ומחלקה בשם Derived, שיורשת אותה (כלומר - Derived extends Base. אובייקט של מחלקה יורשת (Derived) למעשה מכיל, בין השאר, אובייקט של המחלקה אותו הוא יורש (Base). לכן, בכל פעם שיוצרים בעזרת הבנאי אובייקט כזה, צריך לדאוג גם ליצירת האובייקט שמתחתיו בעזרת פנייה לבנאי שלו, מה שאומר במקרה שלנו שהבנאי של המחלקה Derived חייב להכיל פנייה לבנאי של המחלקה Base. מקרה יוצא דופן הוא כאשר המחלקה אותה הוא יורש מכילה בנאי ריק (כלומר, בנאי שלא צריך לקבל פרמטרים) - המהדר מוסיף בצורה אוטומטית בתחילת הבנאי של המחלקה היורשת פנייה לבנאי של המחלקה אותה הוא יורש. זה המקרה בדוגמה למעלה, ולכן שם לא היה צורך בפנייה לבנאים. הפנייה עצמה מתבצעת בעזרת המילה השמורה super, בצורה דומה לשימוש במילה this. כמו עם המילה this, גם כאן הפנייה חייבת להיות בשורה הראשונה של הבנאי. נראה דוגמה לכך, עם המחלקות שהזכרנו:

// Class: Base.java
public class Base {
	
	protected String _myString;
	
	public Base(String str) {
		_myString = str;		
	}

}

// Class: Derived.java
public class Derived extends Base {
	
	public Derived() {
		super("Ha ha ha");
	}

}

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

// Class: Base.java
public class Base {
	
	protected String _myString;
	
	public Base() {
		_myString = "Base";
	}
	
	public Base(String str) {
		_myString = str;		
	}

}

// Class: Derived.java
public class Derived extends Base {
	
	public Derived() {}

}

והסיבה שלא נזקקנו כאן לפנייה לבנאי של Base היא שהמחלקה הזו מכילה בנאי ריק.

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

[עריכה]

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

// Person says hi
public void sayHi() {
	System.out.println("Hello, my name is "+_name);
}

כזכור, המחלקה Teacher יורשת את Person. נדרוס בה את השיטה sayHi בצורה הבאה:

// Teacher says hi
public void sayHi() {
	System.out.println("Hello, I am a teacher and my name is "+_name);
}

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

public class MyDriver {

	public static void main(String[] args) {
		Person p = new Person("Tami");
		Teacher t = new Teacher("Ami");
		
		p.sayHi();
		t.sayHi();
	}
	
}

ונקבל את הפלט:

Hello, my name is Tami

Hello, I am a teacher and my name is Ami


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

קריאה לאחור

[עריכה]

אם נרצה לקרוא לשיטה שקדמה למחלקה שלנו בסולם הירושה, נשתמש במילה השמורה super. נניח, לדוגמה, ששיטה מסויימת במחלקה Teacher, המרחיבה את המחלקה Person, תרצה לפנות לשיטה sayHi של המחלקה Person. כדי לעשות זאת, נשתמש בפקודה ;()super.sayHi נניח כעת שיצרנו מחלקה בשם MathTeacher, שמרחיבה את המחלקה Teacher. אם נרצה לקרוא ממנה (כלומר, משיטה כלשהי שנמצאת בה) לשיטה sayHi של המחלקה Teacher, נשתמש בפקודה ;()super.sayHi

נושאים נוספים

[עריכה]

מחלקות ושיטות סופיות

[עריכה]

לפעמים, מסיבות שונות, נרצה למנוע אפשרות של הרחבת מחלקה או שיטה שכתבנו. כדי לעשות זאת, נשתמש במילה השמורה final, מילה שבוודאי כבר מוכרת לכם מתחום אחר - הכרזה על משתנים קבועים. כל שצריך זה להוסיף בהכרזה את המילה final. למשל: public final class MyClass או במקרה של שיטה: protected final void MyMethod()

המחלקה Object

[עריכה]

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

  • הפעולה ()clone מחזירה עצם חדש, זהה לעצם שעליו ביצענו את הפעולה
  • הפעולה (equals(Object obj תבדוק האם שני עצמים זהים, ותחזיר ערך בוליאני בהתאם.

המילה השמורה instanceof

[עריכה]

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

Stam s1 = new MegaStam();
Stam s2 = new MultiStam();
if(s1 instanceof Stam) {
	System.out.println("S1 is Stam");
}
if(s2 instanceof Stam) {
	System.out.println("S2 is Stam");
}
if(s1 instanceof MegaStam) {
	System.out.println("S1 is Mega");
}
if(s2 instanceof MegaStam) {
	System.out.println("S2 is Mega");
}
if(s1 instanceof MultiStam) {
	System.out.println("S1 is Multi");
}
if(s2 instanceof MultiStam) {
	System.out.println("S2 is Multi");
}

התוצאה תהייה:

S1 is Stam
S2 is Stam
S1 is Mega
S2 is Multi


הסיבה לתוצאה זו היא שגם s1 וגם s2 הם מופעים של המחלקה Stam, בעוד s1 הוא גם מופע של המחלקה MegaStam ו-s2 הוא גם מופע של המחלקה MultiStam. מבחינה זו, אובייקט הוא מופע של המחלקה שלו, ושל כל המחלקות אותן הוא יורש (לכן, לדוגמה, כל המחלקות הן גם מופע של Object).

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

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

מחלקות מופשטות וממשקים

[עריכה]

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

מחלקות מופשטות

[עריכה]

מחלקות מופשטות הן סוג מיוחד של מחלקות, שהתכונה המבדילה בינן לבין מחלקות רגילות היא שלא ניתן ליצור אובייקטים שלהן. כלומר: אם נכתוב מחלקה מופשטת - לא נוכל ליצור אובייקט שלה, אך ניתן להרחיב אותה בעזרת מחלקה רגילה וליצור אובייקט של המחלקה הרגילה הזאת. מחלקה מופשטת יכולה להכיל גם שיטות מופשטות, כלומר - שיטות לא ממומשות (לא כתובות; שיטות שרק הכרזנו עליהן באמצעות הכותרת אך לא כתבנו בהן קוד כלשהו), המיועדות להתממש על ידי מחלקה יורשת כלשהי. בשני המקרים, נשתמש במילה השמורה abstract כדי להכריז על השיטה או המחלקה, למשל: public abstract class MyAbstract או במקרה של שיטה: ;()public abstract void myMethod שימו לב - חובה להוסיף ; בסוף ההכרזה על שיטה מופשטת (כלומר, שיטה שרק מוכרזת אך לא ממומשת). אם הכרזנו על שיטה מסויימת כמופשטת, כל מחלקה יורשת חייבת לממש אותה, אחרת תתרחש שגיאת הידור. הכוונה בלממש שיטה היא לכתוב שיטה עם כותרת זהה (כלומר, עם אותו שם, אותם ארגומנטים, ואותו סוג החזרה - כמו בדריסה ובהעמסה). נעיר כאן שאין שום פסול מבחינת המהדר במימוש ריק של שיטה (כלומר, כתיבה של שיטה שלא עושה דבר, כמו למשל public void myEmptyMethod(int a, int b) {}). עם זאת, זוהי טכניקה שבדרך כלל מצביעה על פגם בתכנון התוכנית, וראוי להימנע ממנה.

תכונות המחלקה המופשטת

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

ממשקים

[עריכה]

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

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

	public interface MyInterface {
	
	public static final String MY_STR="Something";
	public static final int NUMBER = 123;
	
	// Do something with a and b
	public void func1(int a, int b);
	// Do something else with a
	public int func2(String a);

}

מחלקות יכולות לממש ממשק; כאשר מחלקה מממשת ממשק כלשהו, היא חייבת לממש את כל השיטות הריקות שהוכרזו בו. בניגוד לירושה, שמוגבלת לירושה של מחלקה אחת בלבד, מחלקה יכולה לממש ממשקים רבים. הכרזה על מימוש של ממשק נעשית באמצעות המילה השמורה implements. אם נרצה שהמחלקה שלנו תממש את הממשקים inter1 ו-inter2, ההכרזה שלה תיראה כך: public class MyClass implements inter1, inter2 במקרה זה, המחלקה MyClass תהייה חייבת לממש את כל השיטות הריקות שהוכרזו בממשקים אותם היא מממשת.

תכונות הממשק

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

שימוש

[עריכה]

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

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


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