תכנות מתקדם ב-Java/פולימורפיזם

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

דוגמה מהעולם האמיתי

עריכה

נניח שאנו רוצים לכתוב אוסף פשוט של הוראות להכנת סלט ירקות:

  1. קחו את הירקות ושטפו אותם.
  2. קצצו אותם.
  3. הוסיפו תבלינים.
  4. ערבבו היטב.

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

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

פולימורפיזם בתכנות

עריכה

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

דוגמה - בית הספר

עריכה

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

//Class: Person.java

public class Person {

	// Say hi
	public void sayHi() {
		System.out.println("Hello there");
	}
}

// Class: Student.java

public final class Student extends Person {
	
	// Student says hi
	public void sayHi() {
		System.out.println("Hello there. I'm a student");
	}

}

//Class: Teacher.java

public class Teacher extends Person {

	// Teacher says hi
	public void sayHi() {
		System.out.println("Hello there. I'm a teacher");
	}

}

// Program driver
public static void main(String[] args) {
	Person p1 = new Person();
	Person p2 = new Teacher();
	Person p3 = new Student();
	p1.sayHi();
	p2.sayHi();
	p3.sayHi();
}

נסו להריץ את התוכנית ולראות מה ייצא. הפלט שצפויה התוכנית להדפיס יהיה:

Hello there
Hello there. I'm a teacher
Hello there. I'm a student


הסבר

עריכה

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

Person p2 = new Teacher();

נוצר כאן אובייקט חדש מטיפוס Teacher.

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

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

Student s1 = new Person();

אינה הכרזה חוקית. השורה הבאה:

Student s1 = (Student) p2;

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

Student s1 = (Student) p3;

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

סיכום ביניים

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

שימושים

עריכה

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

public interface Instruction {
	
	/**
	 * Process this instruction
	 * @return result of processing that instruction
	 */
	public double process();
	
	/**
	 * @return String representation of that instruction
	 */
	public String toString();
	
}

הבטחנו כאן כי כל פעולה של המחשבון תדע לבצע את הפעולות process (שהיא פעולת החישוב עצמה) ו-toString (החזרת מחרוזת שהיא ייצוג של הפעולה, למשל 1 + 2), ולמעשה - אלו הפעולות היחידות בהן נוכל להשתמש.

כעת, נבנה את המחשבון עצמו:

public class Calculator {
	
	// Array that holds all the instructions
	private Instruction[] _instructions;
	
	/**
	 * Constructor
	 * @param instructions Array with list of instructions to process
	 */
	public Calculator(Instruction[] instructions) {
		_instructions = instructions;
	}
	
	/**
	 * Prints the result of calculating all the instructions 
	 */
	public void calculateAll() {
		for(int i=0; i < _instructions.length; i++) {
			System.out.println(_instructions[i].toString() + " = " + _instructions[i].process());
		}
	}

}

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

// AddInstruction.java
public class AddInstruction implements Instruction {

	private double _a;
	private double _b;
	
	public AddInstruction(double a, double b) {
		_a = a;
		_b = b;
	}
	
	public double process() {
		return _a + _b;
	}
	
	public String toString() {
		return _a + " + " + _b;
	}

}

// MulInstruction.java
public class MulInstruction implements Instruction {

	private double _a;
	private double _b;
	
	public MulInstruction(double a, double b) {
		_a = a;
		_b = b;
	}
	
	public double process() {
		return _a * _b;
	}

	public String toString() {
		return _a + " * " + _b;
	}
	
}

// AbsInstruction
public class AbsInstruction implements Instruction {

	private double _a;
	
	public AbsInstruction(double a) {
		_a = a;
	}
	
	public double process() {
		if (_a < 0) return -_a;
		return _a;
	}
	
	public String toString() {
		return "|" + _a + "|";
	}

}

שיטת Main לדוגמה:

// Driver
public static void main(String[] args) {
	Instruction[] insts = new Instruction[3];
	insts[0] = new AddInstruction(10.0, 5.0);
	insts[1] = new MulInstruction(3.0, 1.5);
	insts[2] = new AbsInstruction(-3.0);
	Calculator calc = new Calculator(insts);
	calc.calculateAll();
	
}

התוצאה, כפי שבוודאי ניחשתם, תהייה:

10.0 + 5.0 = 15.0
3.0 * 1.5 = 4.5
|-3.0| = 3.0

למעשה, רק בזמן ההרצה התוכנית יכולה לדעת מה הפונקציה שתבצע כל הוראה. העובדה שמימשנו את הממשק Interface אישר למהדר כי חוקי להריץ את ההוראה process על כל איבר במערך שלנו, אך לא איפשר לו לדעת איזו מהן תתבצע. רק בזמן הריצה יכול היה המהדר לדעת: אם הוא נתקל בהוראה מהטיפוס MulInstruction, הוא ביצע את פעולת ה-process במחלקה MulInstruction, וכאשר נתקל בפקודה process עבור איבר מהטיפוס AbsInstruction - הוא ידע לבצע את השיטה process ששייכת ל-AbsInstruction, וכן הלאה.

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

public double process() throws CalculatorException {
	switch(_type) {
	case MUL: 
		return a * b;
	case DIV: 
		return a + b;
	case ABS:
		return (a < 0) ? -a : a;
	default:
		throw new CalculatorException("Illegal instruction");
	}
}

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

מדוע, אם כן, עדיף להשתמש בדרך הראשונה שראינו?

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

enum נותן לנו דרך אלגנטית לתרגם מספרים לביטויים קריאים.

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

public enum Colors {YELLOW, RED, GREEN};

הפנייה אל הערכים מתבצעת כמו פנייה למשתנים במחלקה, למשל Colors.YELLOW. הגדרה של enum מתבצעת בדרך כלל בתוך מחלקה אחרת, ולכן היחס אליה יהיה כאל מחלקה פנימית, כלומר - אם הגדרנו את ה-enum בתוך המחלקה MyClass, הפנייה תיעשה בצורה MyClass.Colors, ואל שדה מסויים - MyClass.Colors.RED (כרגיל, ניתן להשתמש ב-import כדי לחסוך את כל הקידומת). כמו כן, ניתן להשתמש בערכים של enum במשפט switch (חשוב: כאשר משתמשים בערכי enum במשפט switch, אסור לכתוב את הנתיב המלא אלא יש להשתמש ב-import ל-enum הרצוי, ובמשפט ה-switch עצמו להשתמש בשמות ללא הנתיב המלא. בדוגמה שלנו - לא MyClass.Color.RED אלא פשוט RED, מה שיחייב אותנו לייבא את ה-enum שלנו: import MyClass.Colors).

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


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