לדלג לתוכן

תכנות מתקדם ב-Java/גרסה להדפסה

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


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

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

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


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

מה צריך לדעת לפני?

[עריכה]

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

הכנה

[עריכה]

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

אובייקטים

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java אובייקטים או עצמים, הם אבני היסוד של תכנות מונחה העצמים- Object Oriented Programming (OOP).

הקדמה - תכנות מונחה עצמים

[עריכה]

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

דוגמה - מכולת

[עריכה]

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

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

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

מבנה

[עריכה]

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

בנאים

[עריכה]

בנאי (באנגלית: Constructor), כשמו, הוא כלי באמצעותו יוצרים אובייקט. לכל אובייקט קיים בנאי, ולפעמים גם כמה סוגים של בנאים. חוץ מיצירת האובייקט, הבנאי יכול להכיל פעולות כאוות נפשו של המתכנת. הערה: בניגוד לשפות (כמו C++), בהן קיים צורך להשמיד (Destruct) כל אובייקט לאחר השימוש בו כדי לפנות את הזיכרון בו נעשה שימוש, בג'אווה אין צורך בכך - כלי בשם Garbage Collector (אוסף האשפה) אחראי על פינוי הזיכרון, ומסיר בכך את הנטל מעל כתפי המתכנתים. נעיר כי העדפה זו של הנוחות עולה במחיר מה של מהירות הריצה.

משתנים פנימיים

[עריכה]

משתנים פנימיים הם המשתנים אותם מכיל האובייקט בתוכו. הם יכולים להיות מכל סוג שהוא - משתנים פשוטים כמו int או String, מערכים, או אף אובייקטים אחרים. לעיתים קרובות הם מכונים גם "שדות".

שיטות

[עריכה]

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

איך זה נראה בג'אווה

[עריכה]

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

בנאי(Constructor)

[עריכה]

הבנאי הוא שיטה ששמה כשם המחלקה, ושאינה מחזירה ערך משום סוג. אם נניח ששם המחלקה הוא MyClass, הבנאי יראה כך:

public MyClass() {
	// Some code
}

בנאי יכול לקבל ערך, או ערכים רבים, מכל סוג, בצורה הבאה:

public MyClass(int x, float y) {
	// Some code
}

הקריאה לבנאי מתבצעת בעזרת המילה השמורה new, בצורה הבאה: MyClass obj = new MyClass(); או, אם הבנאי מקבל ערכים, למשל - בנאי שמקבל int ו-float: MyClass obj = new MyClass(1, 2.3); מרבית הקוראים בוודאי יתמהו: מדוע להכריז בצורה כזו על האובייקט? מדוע שלא להכריז על אובייקט כמו שמכריזים על משתנה פשוט - MyClass obj;? למען האמת, מורכבת ההכרזה שהצגנו כאן משני חלקים. החלק הראשון, השמאלי - MyClass obj יוצר הפנייה (מצביע) לאובייקט מטיפוס MyClass, הפנייה שעד יצירת האובייקט מכוונת לערך ריק - null. האובייקט עצמו לא נוצר. החלק השני, הימני, בו אנו פונים לבנאי, הוא החלק שמקצה זיכרון עבור האובייקט, ויוצר אותו במקום שהוקצה עבורו בזיכרון. כשתלמדו כמה צדדים מתוחכמים יותר של תכנות מונחה העצמים תוכלו להבין טוב יותר מדוע קיימת ההפרדה הזו.

משתנים ושיטות

[עריכה]

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

פנייה למשתנים ושיטות

[עריכה]

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

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

תוכנית המכולת

[עריכה]

נראה כאן מימוש (עדיין לא מספק) בג'אווה של ה"מכולת" אותה תיארנו קודם. נעבוד עם שלושה קבצים: Item.java יכיל את המחלקה המטפלת במוצרים. Stock.java יכיל את המחלקה המטפלת במלאי. הקובץ השלישי אינו חלק מהמימוש עצמו, אך הוא יראה כיצד מחלקה אחרת ניגשת אל המחלקות שיצרנו ועושה בהן שימוש.

// Item.java

public class Item {
	
	// Item's name
	private String _name;
	// Item's description
	private String _description;
	// Item's price
	private double _price;
	
	/*
	 * Constructor
	 */
	public Item(String name, String desc, double price) {
		_name = name;
		_description = desc;
		_price = price;
	}
	
	// Get item's name
	public String getName() {
		return _name;
	}
	
	// Get item's price
	public double getPrice() {
		return _price;
	}
	
	// Print item details
	public void printItem() {
		System.out.println("Item: "+_name);
		System.out.println("Description: "+_description);
		System.out.println("Price: "+_price);
	}
}

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

// Stock.java

public class Stock {
	
	// Items in stock
	private Item _item1;
	private Item _item2;
	// Quantity of each product 
	private int _item1Q;
	private int _item2Q;
	// Days until expiration for each product
	private int _exp1;
	private int _exp2;
	
	/*
	 * Constructor
	 */
	public Stock() {
		_item1 = null;
		_item2 = null;
	}
	
	// Add item to stock
	public void addItem(Item it, int quantity, int expiration) {
		if(_item1 == null) {
			_item1 = it;
			_item1Q = quantity;
			_exp1 = expiration;
		}
		else if(_item2 == null) {
			_item2 = it;
			_item2Q = quantity;
			_exp2 = expiration;
		}
		else
			System.out.println("Stock is full, cannot add "+it.getName());
	}
	
	// Print all items in stock
	public void printStock() {
		if(_item1 != null) {
			_item1.printItem();
			System.out.println("Quantity: "+_item1Q+" Expiration: "+_exp1);
		}
		if(_item2 != null) {
			_item2.printItem();
			System.out.println("Quantity: "+_item2Q+" Expiration: "+_exp2);
		}
	}
	
	// Calculates the value of all items in stock
	public double sumStock() {
		double sum = 0.0;
		if(_item1 != null)
			sum+=_item1.getPrice()*_item1Q;
		if(_item2 != null)
			sum+=_item2.getPrice()*_item2Q;
		return sum;
	}
}

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

// Grocery.java

public class Grocery {
	
	public static void main(String[] args) {
		// Build a stock object
		Stock stck = new Stock();
		// Print the stock - it is empty now
		stck.printStock();
		// Add items to stock:
		Item it1 = new Item("Cheese","Smelly green cheese", 1.5);
		Item it2 = new Item("Tomato","Fresh tomato", 2.6);
		stck.addItem(it1, 2, 7);
		stck.addItem(it2, 3, 5);
		stck.printStock();
		System.out.println("Total price of stock: " + stck.sumStock());
	}
}

אופן הפעולה

[עריכה]

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

הפעלת אובייקטים

[עריכה]

המחלקה Grocery מעוניינת בשימוש באובייקט מלאי. לשם כך, נוצר אובייקט מטיפוס Stock בעזרת הבנאי הנתון של Stock, ואז ניתן היה לבצע על האובייקט החדש שיצרנו פעולות שונות. למשל, כדי להוסיף מוצר למלאי, השתמשנו בשיטה addItem בדרך הבאה: ראשית, יצרנו אובייקט מוצר אחד בעזרת השורה Item it1 = new Item("Cheese","Smelly green cheese", 1.5); שורה זו פנתה לבנאי של המחלקה Item. לאחר מכן, הוספנו מוצר זה למלאי בעזרת הפקודה stck.addItem(it1, 2, 7); באופן דומה, במחלקה Stock, כדי לבצע את השיטה שמדפיסה מוצר על האובייקט _item1, השתמשנו בשורה _item1.printItem(); הפעולה התבצעה על האובייקט המסויים item1_, והשתמשה בנתונים שלו. פעולה זהה על האובייקט item1_ ועל האובייקט item2_ לא תיתן תוצאה זהה, כי הנתונים של אובייקט item1_ שונים (בדרך כלל) מאלה של item2_.

תקשורת בין מחלקות

[עריכה]

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

הסבר

[עריכה]

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

תרגילים

על מנת לפתור תרגיל זה, יש להעתיק תחילה את הקבצים Item.java ו-Stock.java כפי שהם מופיעים בפרק. במידה ואתם עובדים עם סביבת עבודה, צרו פרוייקט שיכיל את שני הקבצים האלו. במידה ואתם עובדים משורת הפקודה, דאגו שהקבצים הנוגעים לתרגיל יופיעו באותה תיקייה, וההידור יתבצע באמצעות הפקודה javac *.java בהנחה שרק הקבצים הנוגעים לתרגיל נמצאים בתיקייה זו.

ממשק נוח יותר

[עריכה]

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

What do you want to do now?
1. Print stock
2. Calculate total stock value
3. Quit
Enter your choice:

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

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

פתרון
import java.util.Scanner;

// Grocery.java

public class Grocery {
	
	// Const variables
	private final static String MENU = 
		"What do you want to do now?\n"+
		"1. Print stock\n2. Calculate total stock value\n3. Quit\n";
	private final static String CHOICE = "Enter your choice: ";
	private final static String WRONG_CHOICE = "Wrong choice, choose again: ";
	private final static int PRINT_STOCK = 1;
	private final static int CALCULATE = 2;
	private final static int QUIT = 3;
	private final static int MAX_CHOICE = 3;
	private final static int MIN_CHOICE = 1;
	
	// Print the menu
	private static void printMenu() {
		System.out.print(MENU);
	}
	
	// Get the user choice. Check for legal input
	private static int getUserChoice() {
		Scanner s = new Scanner(System.in);
		System.out.print(CHOICE);
		int c = s.nextInt();
		while(c<MIN_CHOICE || c>MAX_CHOICE) {
			System.out.print(WRONG_CHOICE);
			c = s.nextInt();
		}
		return c;
	}
	
	private static void run(Stock st) {
		int userChoice = 0;
		while(userChoice != QUIT) {
			printMenu();
			userChoice = getUserChoice();
			switch(userChoice) {
			case PRINT_STOCK:
				st.printStock();
				break;
			case CALCULATE:
				System.out.println("Sum stock value: "+st.sumStock());
				break;
			}
		}
	}
	
	public static void main(String[] args) {
		// Build a stock object
		Stock stck = new Stock();
		// Add items to stock:
		Item it1 = new Item("Cheese","Smelly green cheese", 1.5);
		Item it2 = new Item("Tomato","Fresh tomato", 2.6);
		stck.addItem(it1, 2, 7);
		stck.addItem(it2, 3, 5);
		run(stck);
	}

}


חיפוש של מוצר

[עריכה]

בשלב זה ננסה לשפר את המחלקה Stock. הוסיפו למחלקה שיטה בשם Contains, המקבלת מחרוזת, ומחזירה "אמת" אם מוצר בשם זה קיים במלאי, ו"שקר", אם לא. כותרת השיטה היא

public boolean Contains(String item)

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

פתרון
// Method to add to Stock.java
// Check if an item with the given name is in stock
public boolean Contains(String item) {
	String name;
	if(_item1 != null) { 
		name = _item1.getName();
		if(name.equals(item)) return true;
	}
	if(_item2 != null) {
		name = _item2.getName();
		if(name.equals(item)) return true;
	}
	return false;
}

// Grocery.java

import java.util.Scanner;

public class Grocery {
	
	// Const variables
	private final static String MENU = 
		"What do you want to do now?\n"+
		"1. Print stock\n2. Calculate total stock value\n"+
		"3. Search item\n4. Quit\n";
	private final static String CHOICE = "Enter your choice: ";
	private final static String WRONG_CHOICE = "Wrong choice, choose again: ";
	private final static String ITEM_FIND = "Enter item to search: ";
	private final static int PRINT_STOCK = 1;
	private final static int CALCULATE = 2;
	private final static int CONTAINS = 3;
	private final static int QUIT = 4;
	private final static int MAX_CHOICE = 4;
	private final static int MIN_CHOICE = 1;
	
	// Print the menu
	private static void printMenu() {
		System.out.print(MENU);
	}
	
	// Get the user choice. Check for legal input
	private static int getUserChoice() {
		Scanner s = new Scanner(System.in);
		System.out.print(CHOICE);
		int c = s.nextInt();
		while(c<MIN_CHOICE || c>MAX_CHOICE) {
			System.out.print(WRONG_CHOICE);
			c = s.nextInt();
		}
		return c;
	}
	
	// Get item name to search
	private static String getItemName() {
		System.out.print(ITEM_FIND);
		Scanner s = new Scanner(System.in);
		String str = s.next();
		return str;
	}
	
	private static void run(Stock st) {
		int userChoice = 0;
		while(userChoice != QUIT) {
			printMenu();
			userChoice = getUserChoice();
			switch(userChoice) {
			case PRINT_STOCK:
				st.printStock();
				break;
			case CALCULATE:
				System.out.println("Sum stock value: "+st.sumStock());
				break;
			case CONTAINS:
				String item = getItemName();
				System.out.println("Stock contains "+item+": "+st.Contains(item));
			}
		}
	}
	
	public static void main(String[] args) {
		// Build a stock object
		Stock stck = new Stock();
		// Add items to stock:
		Item it1 = new Item("Cheese","Smelly green cheese", 1.5);
		Item it2 = new Item("Tomato","Fresh tomato", 2.6);
		stck.addItem(it1, 2, 7);
		stck.addItem(it2, 3, 5);
		run(stck);
	}

}

עבודה לפי ממשק

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java בפרק הקודם ראינו כיצד יוצרים ומפעילים אובייקט. כעת נראה כמה היבטים נוספים בעקרונות התכנות מונחה העצמים, ובעקרונות התכנות בכלל.

עקרון הכימוס

[עריכה]

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

למה זה טוב?

[עריכה]

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

הימנעות משגיאות

[עריכה]

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

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

מודולריות

[עריכה]

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

כיצד זה עובד

[עריכה]

הרשאה ציבורית ופרטית

[עריכה]

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

getters/setters

[עריכה]

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

// Get item's name
public String getName() {
	return name;
}

יתרונות

[עריכה]
  1. אם יש צורך במשתנה שכל שינוי בו יגרור שינויים נוספים - ניתן לבצע זאת בעזרת שיטת ה-set.
  2. החזרה בעזרת שיטת get מאפשרת לנו למנוע מהמשתמש (כלומר, מי שניגש לקבל את האובייקט) לשנות את המשתנה שאנו מחזירים. כאשר מחזירים משתנים פרימיטיביים, חוזר עותק של המשתנה ולא המקור (החזרה של ערך המשתנה ולא של המשתנה עצמו - By value). לכן, לא ניתן לשנות ערכים אלה של המחלקה בעזרת שינוי התוצאה המתקבלת בעזרת get. כאשר מדובר באובייקטים או מערכים אפשר לדאוג לכך שנחזיר רק עותק של האובייקט - ראו בהמשך.


כדאי לדעת:

בחלק מסביבות העבודה ניתן ליצור את שיטות ה-get וה-set באופן אוטומטי.

החזרה של מערכים ואובייקטים

[עריכה]

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

public class Blackbox {

	private int someField;	
	
	public int getField() { 
		return someField; 
	}	
	
	public void setField(int val) { 
		someField = val; 
	}
}

המחלקה ClosedObject תיראה כך:

public class ClosedObject {
	
	private Blackbox box;
	
	public ClosedObject() {
		box = new Blackbox();
		box.setField(10);
	}
	
	public Blackbox getBox() {
		return box;
	}
	
	public void printBox() {
		System.out.println("What is in the box? "+box.getField());
	}
}

כעת, נניח שמחלקה אחרת כלשהי משתמשת באובייקט מסוג ClosedObject, באופן הבא:

ClosedObject obj = new ClosedObject();
obj.printBox();
Blackbox myBlackBox = obj.getBox();
myBlackBox.setField(11);
obj.printBox();

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

public Blackbox getBox() {
	Blackbox temp = new Blackbox();
	temp.setField(box.getField());
	return temp;
}

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

עבודה על פי ממשק

[עריכה]

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

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

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

לסיכום

[עריכה]

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

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

תרגילים

בחזרה למכולת

[עריכה]

נחזור לתוכנית המכולת, עם כמה שינויים קלים. הממשק שלנו יראה כך:

// Adds an item with the given details to the stock. Returns true if that item added, false if not
boolean addItem(String itemName, String description, double price);
 // Search the stock for a given item. Returns true only if item with the same name is in stock
boolean contains(String itemName);
// Remove an item from the stock. Returns true if the item removed, false if not
boolean removeItem(String itemName);
// Print all items in the stock
void printStock();
// Calculates the value of the whole stock
double sumStock();

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

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

שלב ראשון - בחירת מבנה הנתונים

[עריכה]

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

פתרון
// Stock.java

public class Stock {
	
	private static final int STOCK_SIZE = 10; 
	private Item _items[];
	
	public Stock() {
		_items = new Item[STOCK_SIZE];
	}
	
}


שלב שני - מימוש החוזה

[עריכה]

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

שיטות עזר

[עריכה]

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

// Search for a given item name and returns its location in array. Returns -1 if not found
int findItem(String itemName);

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

// Search for the first empty array cell. Returns -1 if all cells are full
int findEmptyCell();

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

פתרון
private int findItem(String itemName) {
	for(int i=0; i<STOCK_SIZE; i++) {
		if(_items[i] != null) {
			if(_items[i].getName().equals(itemName))
				return i;
		}
	}
	return -1;
}

private int findEmptyCell() {
	for(int i=0; i<STOCK_SIZE; i++) {
		if(_items[i] == null) 
			return i;
	}
	return -1;
}


חיפוש

[עריכה]

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

פתרון
public boolean contains(String item) {
	return (findItem(item) != -1);
}


הוספה והסרה

[עריכה]

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

פתרון
boolean addItem(String itemName, String description, double price) {
	int index = findEmptyCell();
	if(index == -1) return false;
	_items[index] = new Item(itemName, description, price);
	return true;
	
}
boolean removeItem(String itemName) {
	int index = findItem(itemName);
	if(index == -1) return false;
	_items[index] = null;
	return true;
}


שיטות אחרונות

[עריכה]

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

פתרון
void printStock() {
	for(int i=0; i<STOCK_SIZE; i++) {
		if(_items[i] != null) 
			_items[i].printItem();
	}
}

double sumStock() {
	double sum = 0;
	for(int i=0; i<STOCK_SIZE; i++) {
		if(_items[i] != null) 
			sum+=_items[i].getPrice();
	}	
	return sum;
}


שלב אחרון - בדיקות

[עריכה]

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

// TestStock.java

public class TestStock {
	
	private static void check(boolean value, boolean expected) {
		if(!(value == expected)) {
			System.err.println("Problem found on test!");
			System.exit(1);
		}
		else System.out.println("OK");
	}
	
	private static void check(double value, double expected) {
		if(!(value == expected)) {
			System.err.println("Problem found on test!");
			System.exit(1);
		}
		else System.out.println("OK");
	}
	
	public static void main(String[] args) {
		Stock stck = new Stock();
		System.out.print("Check initial value of stock: ");
		check(stck.sumStock(), 0);
		System.out.print("Check add item 1: ");
		check(stck.addItem("Tomato", "Red", 1.5), true);
		System.out.print("Check add item 2: ");
		check(stck.addItem("Cucumber", "Rotten", 3.5), true);
		System.out.print("Check add item 3: ");
		check(stck.addItem("Onion", "White", 4), true);
		System.out.print("Check sum value function: ");
		check(stck.sumStock(), 9);
		System.out.println("Print stock: ");
		stck.printStock();
		System.out.print("Check remove item 1: ");
		check(stck.removeItem("Tomato"), true);
		System.out.print("Check remove item 2: ");
		check(stck.removeItem("Onion"), true);
		System.out.print("Try to remove item not in stock: ");
		check(stck.removeItem("Gold"), false);
		System.out.print("Try to remove item that already removed: ");
		check(stck.removeItem("Tomato"), false);
		System.out.print("Check remove item 3: ");
		check(stck.removeItem("Cucumber"), true);
		System.out.println("Print stock again (Should be empty): ");
		stck.printStock();
		System.out.print("Check sum value function again: ");
		check(stck.sumStock(), 0);
		System.out.println("Well done!");
	}

}

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

פתרון (חלקי)

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


מחשבות על התכנון

[עריכה]

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

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

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

תיעוד

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

צורת כתיבה

[עריכה]

תזכורת

[עריכה]

עד כה הכרנו שני סוגים של הערות:

  • הערות קצרות - בדרך כלל נמצאות בתוך הקוד.
// This is a short comment
  • בלוק הערה - מאפשר לכתוב הערות ארוכות יותר בצורה נוחה.
/*
* Simple comment block
*/

דרך נוספת - Javadoc

[עריכה]

ג'אווה מאפשרת שיטה נוספת של תיעוד - Javadoc. כתיבה של תיעוד כזה דומה מאוד לבלוק ההערה שכבר ראינו קודם, עם "*" אחת נוספת:

/**
* Javadoc comment block
*/

בשביל מה אנחנו צריכים את זה?

[עריכה]

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

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

שימוש וסימנים נפוצים

[עריכה]

Javadoc מאפשר שימוש בתגים. קיים מגוון של תגים שונים, כאשר המשותף לכולם הוא סימון של "@" בתחילתם. נפרט כאן חלק מהם.

  • בראש כל מחלקה יש לשים בלוק Javadoc הכולל תיאור של המחלקה. תיאור המחלקה חשוב מאוד; התגים המפורטים כאן למטה - פחות.
    • תג ה-author@ מציין מיהו כותב הקוד.
    • תג ה-version@ מציין מה גירסת התוכנית.

דוגמה:

/**
* This class does nothing useful
* @author Somebody
* @version 0.99
*/
public MyClass {
...
}
  • בראש כל שיטה ציבורית, יש לשים בלוק Javadoc הכולל תיאור של השיטה. כאן תפקיד התגים חשוב מאוד.
    • תג ה-param@ מתאר את הפרמטרים אותם מקבלת השיטה. יש לכתוב את שם המשתנה, ואז את תיאורו. על כל משתנה - יש לשים תג param. אין צורך לציין טיפוס - הכלי האוטומטי מסוגל לזהות זאת בעצמו. עם זאת, חשוב להקפיד על כתיבה נכונה של שמות המשתנים.
    • תג ה-return@ מתאר מה השיטה מחזירה. כאן אין צורך לכתוב את שם המשתנה, מספיק לכתוב את תיאורו.
    • תג ה-throws@ דרוש במקרה והשיטה זורקת חריגות זמן-ריצה כלשהן (יילמד בהמשך).
    • גם התגים author@ ו-version@ ניתנים לשימוש כאן, אך הם בדרך כלל פחות רלוונטיים.

דוגמה:

/**
* This function calculates something
* @param num1 The first number
* @param num2 The second number
* @return What we get from those two numbers
*/
public int myFunc(int num1, int num2) {
...
}
  • גם לפני משתנים ציבוריים בעלי משמעות רצוי לשים בלוק קצר של Javadoc שיסביר עליהם, לדוגמה:
/**
* Right direction
*/
public static final int RIGHT = 1;

תגים נוספים

[עריכה]
Tag & Parameter Usage Applies to Since
@author name Describes an author. Class, Interface, Enum
@version version Provides software version entry. Max one per Class or Interface. Class, Interface, Enum
@since since-text Describes when this functionality has first existed. Class, Interface, Enum, Field, Method
@see reference Provides a link to other element of documentation. Class, Interface, Enum, Field, Method
@param name description Describes a method parameter. Method
@return description Describes the return value. Method
@exception classname description
@throws classname description
Describes an exception that may be thrown from this method. Method
@deprecated description Describes an outdated method. Method
{@inheritDoc} Copies the description from the overridden method. Overriding Method 1.4.0
{@link reference} Link to other symbol. Class, Interface, Enum, Field, Method
{@value} Return the value of a static field. Static Field 1.4.0

יצירת תיעוד בעזרת הכלי האוטומטי

[עריכה]

כדי ליצור תיעוד אוטומטי, כתבו בשורת הפקודה (ובתיקייה בה נמצאים הקבצים להם תרצו ליצור תיעוד. בדוגמה זו file1.java ו-file2.java): javadoc file1.java file2.java אפשר ליצור תיעוד עבור כל הקבצים בתיקייה באמצעות "*" (בדיוק כמו עם ההידור): javadoc *.java כברירת מחדל, התיעוד נוצר בספרייה בה נמצאים קבצי התוכנית. כדי ליצור את התיעוד בספרייה אחרת, השתמשו בדגל -d ואחריו כתבו את הספרייה הרצוייה, למשל: javadoc -d ./Documentation *.java הוצגו כאן רק חלק קטן מהאפשרויות. קיימות אפשרויות נוספות רבות. למעוניינים, ראו הסברים נוספים באתר חברת סאן.

אם אתם עובדים בסביבת העבודה eclipse, תוכלו לעשות זאת מתוך התפריטים: בתפריט Project בחרו Generate Javadoc... ושם בחרו את ההגדרות הנוחות לכם.

פלט ואזהרות

[עריכה]

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

בנאים

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


בנאים בסיסיים ובנאי ברירת המחדל

[עריכה]

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

בנאי יכול להיות ריק, כמו זה:


public Constructors() {}

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

public class Constructors {

	public Constructors() {
		for(int i=0; i<10; i++) {
			System.out.println(i);
		}
	}
	
	public static void main(String[] args) {
		new Constructors();
	}

}

אמנם סגנון כזה הוא מסורבל ונהוג שלא להשתמש בו, אבל מותר.

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

public class Constructors {
	
	public static void main(String[] args) {
		Constructors c = new Constructors();
	}

}

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

העמסה של שיטות

[עריכה]

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

private void print(String s) {
	System.out.println("Print a string: "+s);
}

private void print(int x) {
	System.out.println("Your number is "+x);
}

אם תגשו לשיטה עם ארגומנט מטיפוס String - תופעל השיטה הראשונה, ואם תגשו עם ארגומנט מטיפוס int - תופעל השיטה השנייה. מספר הארגומנטים אינו חייב להיות זהה, לדוגמה:

private void print(String s, int x) {
	System.out.println("Print a string: "+s+" number "+x);
}

private void print(int x) {
	System.out.println("Your number is "+x);
}

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

private int sameSame(int a) {
	return a;
}

private String sameSame(int b) {
	return String.valueOf(b);
}

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

בנוסף, גם שם המשתנה ששונה (a בעליונה ו b בתחתונה) לא יעזור לנו ועדיין תתרחש שגיאה.

העמסה של בנאים

[עריכה]

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

public class Constructors {
	
	private int _x;
	private int _y;
	
	public Constructors() {
		_x = 0;
		_y = 0;
	}
	
	public Constructors(int x) {
		_x = x;
		_y = 0;
	}
	
	public Constructors(int x, int y) {
		_x = x;
		_y = y;
	}

}

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

Constructors c1 = new Constructors();
Constructors c2 = new Constructors(1);
Constructors c3 = new Constructors(1, 2);

בנאים שקוראים לבנאים

[עריכה]

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

public class Constructors {
	
	private int[][] _arr;
	
	public Constructors(int x, int y) {
		_arr = new int[x][y];
		for(int i=0; i<x; i++) {
			for(int j=0; j<y; j++) {
				_arr[i][j] = -1;
			}
		}
	}

}

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

המילה this

[עריכה]

משמעות המילה this ב-Java היא קריאה של האובייקט לעצמו. למשל: אם השיטה someMethod באובייקט Obj קוראת לשיטה אחרת באובייקט בשם anotherMethod, הפקודה anotherMethod(); היא למעשה this.anotherMethod(); אין צורך להוסיף את המילה this ברוב המקרים, אך לפעמים יהיו מקרים בהם נרצה להשתמש בה בכל זאת (למשל - אם נרצה ששיטה תחזיר את האובייקט עצמו, או שתתייחס לשדה באותו אובייקט). כך, נוכל להתייחס לשדות (instances) בעצמים יותר בקלות, ולא נצטרך לתת להם שם מיוחד, משום שמהדר יודע שבעזרת המילה this אנו פונים לשדה בעצם, ולא לפרמטר או משתנה אחר, למשל.

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

private final static int DEFAULT_X = 10;
private final static int DEFAULT_Y = 10;

public Constructors() {
	this(DEFAULT_X, DEFAULT_Y);
}

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

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

בנאי העתקה

[עריכה]

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

	public Constructors(Constructors c) {
	_arr = c._arr;
}

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

public Constructors(Constructors c) {
	_arr = new int[c._arr.length][c._arr[0].length];
	for(int i=0; i<_arr.length; i++) {
		for(int j=0; j<_arr[0].length; j++) {
			_arr[i][j] = c._arr[i][j];
		}
	}
}

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

לסיכום

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

תרגילים

הקשקשן

[עריכה]

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

המצולעים

[עריכה]

את המצולעים שנצייר נייצג בעזרת המחלקה Shape

כל מצולע שנרצה להציג יכיל (לכל הפחות) את הנתונים הבאים:

  • מיקומי הקדקודים של המצולע. חישבו על החלון בו נציג את הצורות כמערכת צירים שמתחילה ב-(0,0) ומסתיימת ב-(640,480). את המיקומים של הקדקודים יכילו שני מערכים מטיפוס int, כאשר כל מיקום מורכב מזוג משתנים - מערך אחד יכיל מיקום אנכי והשני יכיל מיקום אפקי. כך, לדוגמה, אם נרצה שהקדקוד הראשון של המצולע יהיה במיקום (100, 100), נכניס לאיבר הראשון במערך הראשון ולאיבר השני במערך השני את הערך 100.
  • צבע המצולע. לשם ההצגה נשתמש באובייקט מסוג Color. בשביל שנוכל להשתמש בו, נוסיף את הפקודה הבאה בתחילת המחלקה: import java.awt.Color;
  • מספר הקדקודים במצולע.

אם כך, שדות אפשריים עבור המחלקה הם:

// Arrays holding vertices locations
private int[] _xLocs;
private int[] _yLocs;
// Number of vertices
private int _vertices;
// Shape color
private Color _col;

ניתן יהיה לאתחל מצולע בכמה צורות:

/**
 * Constructor. Create the shape with the default color
 * @param xLocs X locations
 * @param yLocs Y locations
 */
public Shape(int[] xLocs, int[] yLocs)

/**
 * Constructor
 * @param xLocs X locations
 * @param yLocs Y locations
 * @param color Shape color
 */
public Shape(int[] xLocs, int[] yLocs, Color color)

/**
 * Copy constructor
 * @param other Other shape to copy
 */
public Shape(Shape other)

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

/**
 * @return Shape color
 */
public Color getColor()

/**
 * @return X locations
 */
public int[] getXLocations()

/**
 * @return Y locations
 */	
public int[] getYLocations()

/**
 * @return Number of vertices
 */
public int getVertices()

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

לוח השרטוט

[עריכה]

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

import java.awt.*;
import javax.swing.*;

@SuppressWarnings("serial")
public class Animator extends JFrame {

	// Prefered window size
	private static final int HEIGHT = 640;
	private static final int WIDTH = 480;
	// Shapes to draw
	private Shape[] _shapes;

	/**
	 * Constructor. Creates the window.
	 * @param shapes Shapes to draw
	 */
	public Animator(Shape[] shapes) {
		super("Animator");
		_shapes = shapes;
		setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		setBackground(Color.WHITE);
		setPreferredSize(new Dimension(HEIGHT, WIDTH));
		pack();
		setResizable(false);
		setVisible(true);
	}

	/**
	 * Draws the shapes. Ignores null shapes.
	 */
	public void paint(Graphics g) {
		for(int i=0; i<_shapes.length; i++) {
			if(_shapes[i] != null) {
				g.setColor(_shapes[i].getColor());
				g.drawPolygon(
						_shapes[i].getXLocations(), 
						_shapes[i].getYLocations(),
						_shapes[i].getVertices());
			}
		}
	}

}

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

שיטות נוספות

[עריכה]

שיטות נוספות שנרצה לממש הן שיטות שיאפשרו שינויים פשוטים בצורות שיצרנו:

/**
 * Moves the shape
 * @param dx horizontal alignment
 * @param dy vertical alignment
 */
public void move(int dx, int dy)

/**
 * Sets the shape color
 * @param c new color for that shape
 */
public void setColor(Color c)

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

מימוש

[עריכה]

משימתנו, אם כך, היא:

  1. ליצור את המחלקה Shape, כפי שתוארה בתחילת הקטע, ובה יהיו:
    • משתנים שיחזיקו את הנתונים הדרושים. ניתן להשתמש במשתנים שפורטו בתחילת הקטע, אך זה לא הכרחי: כל עוד השיטות הציבוריות יחזירו את הערכים הרצויים - לא משנה באיזה דרך בחרתם להחזיק את הנתונים (כמובן, כל עוד היא לא בלתי-יעילה לחלוטין).
    • בנאים - כל שלושת הבנאים שפורטו. הערה: ערך ברירת המחדל של הצבע הוא שחור, שמיוצג באמצעות Color.BLACK. אל תשכחו לייבא את המחלקה המתאימה באמצעות השורה import java.awt.Color; בתחילת המחלקה Shape.
    • כל השיטות הציבוריות שפורטו.
  2. ליצור את המחלקה ShapeDriver, שתכיל את ה-main של התוכנית.
    • המחלקה תיצור כמה צורות, תכניס אותן למערך, ואז, בהנחה ששם המערך הוא myShapes, תיצור מופע של אובייקט מסוג Animator (המחלקה הגרפית) באמצעות השורה new Animator(myShapes);.
  3. בעת הכתיבה, נסו להקפיד על כמה דברים:
    • תיעוד נכון - השתמשו ב-Javadoc.
    • ממשו את כל השיטות הציבוריות הדרושות. בשעת הצורך השתמשו בשיטות ובמשתנים פרטיים.
    • במימוש הבנאים, אל תשכפלו קוד. בשעת הצורך, פנו מבנאי לבנאי. זכרו את הכללים הנוגעים לכך שפורטו בפרק.
    • אל תתעלמו ממקרי הקצה; אם משתמש ינסה ליצור צורה לא הגיונית (לדוגמה - ייתן מערכים שהם Null, מערכים שלא תואמים באורכם עבור ערכי ה-X וה-Y, או כל מקרה חריג אחר) - הדפיסו הודעה מתאימה וצאו מהתוכנית.

המחלקה Shape

[עריכה]
פתרון
import java.awt.Color;

/**
 * Class: Shape.java
 * 
 * Represents a single colored polygon
 */
public class Shape {

	//	Arrays holding vertices locations
	private int[] _xLocs;
	private int[] _yLocs;
	//	Number of vertices
	private int _vertices;
	//	Shape color
	private Color _col;

	// Default color
	private static final Color DEFAULT_COLOR = Color.BLACK;

	/**
	 * Constructor. Create the shape with the default color
	 * @param xLocs X locations
	 * @param yLocs Y locations
	 */
	public Shape(int[] xLocs, int[] yLocs) {
		this(xLocs, yLocs, DEFAULT_COLOR);
	}

	/**
	 * Constructor
	 * @param xLocs X locations
	 * @param yLocs Y locations
	 * @param color Shape color
	 */
	public Shape(int[] xLocs, int[] yLocs, Color color) {
		if(xLocs == null || yLocs == null) 
			reportError("Null array");
		if(xLocs.length != yLocs.length) 
			reportError("Non-matching X and Y arrays");
		_vertices = xLocs.length;
		_xLocs = new int[_vertices];
		_yLocs = new int[_vertices];
		for(int i=0; i<_vertices; i++) {
			_xLocs[i] = xLocs[i];
			_yLocs[i] = yLocs[i];
		}
		_col = color;
	}

	/**
	 * Copy constructor
	 * @param other Other shape to copy
	 */
	public Shape(Shape other) {
		this(other._xLocs, other._yLocs, other._col);
	}

	/**
	 * @return Shape color
	 */
	public Color getColor() {
		return _col;
	}

	// Clone a given integer array
	private static int[] cloneArr(int[] arr) {
		int[] clone = new int[arr.length];
		for(int i=0; i<arr.length; i++) {
			clone[i] = arr[i];
		}
		return clone;
	}
	
	/**
	 * @return X locations
	 */
	public int[] getXLocations() {
		int[] temp = cloneArr(_xLocs);
		return temp;
	}

	/**
	 * @return Y locations
	 */	
	public int[] getYLocations() {
		int[] temp = cloneArr(_yLocs);
		return temp;
	}

	/**
	 * @return Number of vertices
	 */
	public int getVertices() {
		return _vertices;
	}

	/**
	 * Moves the shape
	 * @param dx horizontal alignment
	 * @param dy vertical alignment
	 */
	public void move(int dx, int dy) {
		for(int i=0; i<_vertices; i++) {
			_xLocs[i]+=dx;
			_yLocs[i]+=dy;
		}
	}

	/**
	 * Sets the shape color
	 * @param c new color for that shape
	 */
	public void setColor(Color c) {
		_col = c;
	}

	// Report critical error and quit
	private void reportError(String err) {
		System.err.println("Critical error: "+err);
		System.exit(1);
	}

}


המנוע

[עריכה]
פתרון
import java.awt.Color;

/**
 * Simple driver for the animator program
 * 
 */
public class ShapeDriver {

	public static void main(String[] args) {
		int[] xLocs1 = {100, 200, 200, 100};
		int[] yLocs1 = {100, 100, 200, 200};
		int[] xLocs2 = {50, 0, 100};
		int[] yLocs2 = {50, 100, 100};
		Shape s1, s2, s3;
		s1 = new Shape(xLocs1, yLocs1, Color.BLUE);
		s2 = new Shape(s1);
		s2.move(100, 100);
		s2.setColor(Color.GREEN);
		s3 = new Shape(xLocs2, yLocs2);
		Shape[] myShapes = {s1, s2, s3};
		new Animator(myShapes);
	}

}

מבני נתונים

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

מבנה נתונים בגודל משתנה

[עריכה]

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

וקטור - מערך בגודל משתנה

[עריכה]

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

הסבר

[עריכה]

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

מימוש

[עריכה]

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

שדות ובנאים
[עריכה]

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

// Array holding all data elements
private int _arr[];
// Current size of vector
private int _size;

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

/**
 * Constructor
 * @param size Initial size of vector
 */
public MyVector(int size) {
	if(size < 0) {
		reportError("Illegal size");
	}
	_size = size;
	_arr = new int[_size];
}
גישה
[עריכה]

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

/**
 * Returns element at the given location
 * @param loc Location of element to retrieve
 * @return Element at the given location
 */
public int get(int loc) {
	return _arr[loc];
}

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

// Resize the vector array
private void resizeArray(int newSize) {
	int tempArr[] = new int[newSize];
	for(int i=0; i<_size; i++) {
		tempArr[i] = _arr[i];
	}
	_arr = tempArr;
	_size = newSize;
}

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

/**
 * Put data element at the given location
 * @param loc Location of element to put
 * @param data Data to put in that location
 */
public void put(int loc, int data) {
	if(loc < _size) {
		_arr[loc] = data;
	}
	else {
		int newSize = loc * 2;
		resizeArray(newSize);
		_arr[loc] = data;
	}

}


הקוד המלא של התוכנית
/**
 * Implements a simple vector
 * 
 * @author Johnny Zoo
 */
public class MyVector {
	// Array holding all data elements
	private int _arr[];
	// Current size of vector
	private int _size;

	/**
	 * Constructor
	 * @param size Initial size of vector
	 */
	public MyVector(int size) {
		if(size < 0) {
			reportError("Illegal size");
		}
		_size = size;
		_arr = new int[_size];
	}
	
	/**
	 * Returns element at the given location
	 * @param loc Location of element to retrieve
	 * @return Element at the given location
	 */
	public int get(int loc) {
		return _arr[loc];
	}
	
	/**
	 * Put data element at the given location
	 * @param loc Location of element to put
	 * @param data Data to put in that location
	 */
	public void put(int loc, int data) {
		if(loc < _size) {
			_arr[loc] = data;
		}
		else {
			int newSize = loc * 2;
			resizeArray(newSize);
			_arr[loc] = data;
		}
		
	}
	
	/**
	 * @return Vector current size
	 */
	public int getSize() {
		return _size;
	}
	
	// Resize the vector array
	private void resizeArray(int newSize) {
		int tempArr[] = new int[newSize];
		for(int i=0; i<_size; i++) {
			tempArr[i] = _arr[i];
		}
		_arr = tempArr;
		_size = newSize;
	}
	
	// Print error message and quit
	private void reportError(String err) {
		System.err.println(err);
		System.exit(1);
	}
}


מחיר

[עריכה]

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

רשימה מקושרת

[עריכה]
רשימה מקושרת פשוטה. התאים מכילים את המידע 12, 99, ו-37. החוליה "12" היא החוליה הראשונה - ראש הרשימה.

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

הסבר

[עריכה]

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

הוספה של איבר לרשימה מקושרת. במקרה זה נוסף האיבר למקום שאינו ראש הרשימה.

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

מחיקה של איבר מרשימה מקושרת.

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

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

מימוש

[עריכה]
1.חוליות
[עריכה]

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




/**
 * Implements a node in the list
 */
private class Node {
	
	public Node(int data) {
		_data = data;
		_next = null;
	}
	
	int _data;
	Node _next;
}
הרשימה והבנאי
[עריכה]

התוכנית מכילה רק שדה אחד עבור הרשימה: השדה _head, שמכיל את ראש הרשימה (וכך, למעשה, נותן לנו גישה לכל הרשימה):

// Head of the list
private Node _head;

נכתוב גם בנאי לרשימה (שייצור רשימה ריקה):

/**
 * Simple constructor
 */
public MyLinkedList() {
	_head = null;
}

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

/**
 * @return True if the list is empty, else if not
 */
public boolean isEmpty() {
	return (_head == null);
}
הוספת איבר
[עריכה]

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

/**
 * Add new element to the list
 * @param data Data to add
 */
public void addElement(int data) {
	if(isEmpty()) {
		_head = new Node(data);
	}
	else {
		Node newNode = new Node(data);
		newNode._next = _head;
		_head = newNode;
	}
}
הסרת איבר
[עריכה]

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

/**
 * Removes a given element from the list
 * @param data Data to remove
 * @return True if this data removed, false if not
 */
public boolean removeElement(int data) {
	if(isEmpty()) return false;
	Node runner = _head;
	// If the data is in the head - we can just remove the head
	if(runner._data == data) {
		_head = _head._next;
		return true;
	}
	// The data is not in the first node
	else {
		Node prev = _head;
		runner = _head._next;
		while(runner != null) {
			if(runner._data == data) {
				// Now the previous node points on the one after
				// the one we removed
				prev._next = runner._next;
				return true;
			}
			runner = runner._next;
			prev = prev._next;
		}
	}
	// The element we wanted to remove is not in list
	return false;
}
חיפוש
[עריכה]

חיפוש מתבצע על ידי מעבר סדרתי על איברי הרשימה.

/**
 * Search the list for a given element
 * @param data Data to find
 * @return True if this element is in list, false if not
 */
public boolean contains(int data) {
	Node runner = _head;
	while(runner != null) {
		if(runner._data == data) {
			return true;
		}
		runner = runner._next;
	}
	return false;
}

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

גישה
[עריכה]

נרצה גם דרך לגשת לאיברי הרשימה השונים.

/**
 * Returns the element at the given index
 * 
 * @param loc
 *            Index of data to retrieve
 * @return The data that the element at the given index holds
 */
public int elementAt(int loc) {
	Node runner = _head;
	if (loc < 0)
		throw new IndexOutOfBoundsException();
	for (int i = 0; i > loc; i++) {
		runner = runner._next;
		if (runner == null)
			throw new IndexOutOfBoundsException();
	}
	return runner._data;
}

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


הקוד המלא של התוכנית
/**
 * Implements a simple singly-linked list
 * @author Johnny Zoo
 */
public class MyLinkedList {

	/**
	 * Implements a node in the list
	 */
	private class Node {

		public Node(int data) {
			_data = data;
			_next = null;
		}

		int _data;
		Node _next;
	}

//	Head of the list
	private Node _head;

	/**
	 * Simple constructor
	 */
	public MyLinkedList() {
		_head = null;
	}

	/**
	 * @return True if the list is empty, else if not
	 */
	public boolean isEmpty() {
		return (_head == null);
	}

	/**
	 * Add new element to the list
	 * @param data Data to add
	 */
	public void addElement(int data) {
		if(isEmpty()) {
			_head = new Node(data);
		}
		else {
			Node newNode = new Node(data);
			newNode._next = _head;
			_head = newNode;
		}
	}

	/**
	 * Removes a given element from the list
	 * @param data Data to remove
	 * @return True if this data removed, false if not
	 */
	public boolean removeElement(int data) {
		if(isEmpty()) return false;
		Node runner = _head;
		// If the data is in the head - we can just remove the head
		if(runner._data == data) {
			_head = _head._next;
			return true;
		}
		// The data is not in the first node
		else {
			Node prev = _head;
			runner = _head._next;
			while(runner != null) {
				if(runner._data == data) {
					// Now the previous node points on the one after
					// the one we removed
					prev._next = runner._next;
					return true;
				}
				runner = runner._next;
				prev = prev._next;
			}
		}
		// The element we wanted to remove is not in list
		return false;
	}

	/**
	 * Returns the element at the given index
	 * 
	 * @param loc
	 *            Index of data to retrieve
	 * @return The data that the element at the given index holds
	 */
	public int elementAt(int loc) {
		Node runner = _head;
		if (loc < 0)
			throw new IndexOutOfBoundsException();
		for (int i = 0; i < loc; i++) {
			runner = runner._next;
			if (runner == null)
				throw new IndexOutOfBoundsException();
		}
		return runner._data;
	}

	/**
	 * Search the list for a given element
	 * @param data Data to find
	 * @return True if this element is in list, false if not
	 */
	public boolean contains(int data) {
		Node runner = _head;
		while(runner != null) {
			if(runner._data == data) {
				return true;
			}
			runner = runner._next;
		}
		return false;
	}

	/**
	 * Prints the whole list
	 */
	public void printList() {
		Node runner = _head;
		System.out.print("[ ");
		while(runner != null) {
			System.out.print(runner._data + " => ");
			runner = runner._next;
		}
		System.out.println("]");
	}

}


מחיר

[עריכה]

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

סיכום

[עריכה]

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

הורשה

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-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).
  • מחלקה יכולה לממש ממשקים רבים. אם מחלקה לא אבסטרקטית מכריזה על מימוש של ממשק כלשהו - היא חייבת לממש את כל השיטות בו.

שימוש

[עריכה]

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

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

פולימורפיזם

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-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 מספק עוד כמה אפשרויות מועילות, אך לא נתעכב עליהן כאן. פרטים נוספים ניתן למצוא באתר של חברת סאן.

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

תרגילים

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

כללי המשחק

[עריכה]

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

מהלך המשחק

[עריכה]

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

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

שחקנים

[עריכה]

במשחק יכולים להשתתף כמה סוגי שחקנים:

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

הנחיות ורמזים

[עריכה]
  • כדי לממש את סוגי השחקנים השונים, השתמשו במחלקה מופשטת או ממשק בשם Player, אותו/ה יממשו סוגי השחקנים השונים, כאשר כל שחקן שמשתתף במשחק ייוצג על ידי אובייקט כזה. בתוכנית הראשית, השתמשו במערך בודד שמכיל אובייקטים מטיפוס Player כדי להכיל את השחקנים השונים (וכל הנתונים הדרושים להם). על פי הצורך, ניתן ליצור תתי-מחלקות נוספת עבור קבוצות שונות של סוגי שחקנים (כאשר אתם באים לשקול זאת, חשבו: האם יש נקודות המשותפות רק לחלק מסוגי השחקנים?).
  • תזכורת: כדי לזהות האם אובייקט מסויים הוא מסוג כלשהו ניתן להשתמש במילה השמורה instanceof, למשל: if (currentPlayer instanceof CrazyPlayer) .... זה יכול לסייע אם תבחרו לממש תתי-מחלקות נוספות ביניהן תצטרכו להבדיל.
  • קרוב לוודאי שתזדקקו ל-switch כלשהו בזמן האתחול. אמנם, קיימות שיטות מתוחכמות יותר שמאפשרות להימנע לחלוטין משימוש ב-switch, אך אין חובה להשתמש בהן כאן (אם המימוש שלכם נכון, השינוי שיידרש במקרה של הוספת שחקן חדש יהיה זניח).
  • הגדירו במחלקה הראשית (זו שמריצה את המשחק) משתנה קבוע מטיפוס int שיגדיר מהו המספר המקסימלי שיכול המחשב לנחש. המספר המינימלי הוא 0.
  • שימוש בשיטות ריקות במחלקה יורשת נחשב במרבית המקרים להרגל רע. השתדלו להימנע מכך.
  • זהו תרגיל שעשוי לקחת זמן רב - הקדישו זמן לתכנון, ונסו לעשות זאת בכוחות עצמכם.


פתרון
// GuessGame.java

import java.util.Scanner;

public class GuessGame {

	/**
	 * Possible results for number guessing
	 */ 
	public enum Result {HIGH, LOW, EQUAL};

	/** 
	 * Max possible number
	 */
	public static final int RANGE = 1000;

	// This array contains all players in the game
	private Player[] _players;
	// This is the number that the computer guessed
	private int _number;

	/*
	 * Check a given number, compared to the computer's number: too high, too low, or the correct one 
	 */
	private Result check(int number) {
		if (number > _number) return Result.HIGH;
		if (number < _number) return Result.LOW;
		return Result.EQUAL;
	}

	/*
	 * Given a player type (represented by integer), returns new instance of this player type
	 */
	private Player getPlayer(int type) {
		switch(type) {
		case 0: 
			return new HumanPlayer();
		case 1:
			return new MonkeyPlayer();
		case 2:
			return new AIMediumPlayer();
		case 3:
			return new AISmartPlayer();
		default:
			return null;
		}
	}

	/*
	 * Prints error message and exits the program
	 */
	private void terminate(String msg) {
		System.err.println(msg+", aborting.");
		System.exit(0);
	}

	/*
	 * Refresh game data - the computer's number and the players' data
	 */
	private void refreshGameData() {
		_number = (int) (Math.random() * RANGE);
		for(int i = 0; i < _players.length; i++) {
			if(_players[i] instanceof AIPlayer) {
				((AIPlayer) _players[i]).init();
			}
		}
	}
	
	/**
	 * This method initializes all the required information for the game play
	 */
	public void init() {
		Scanner s = new Scanner(System.in);
		System.out.print("Enter number of players (1 - 6): ");
		int playersNumber = s.nextInt();
		if(playersNumber < 1 || playersNumber > 6) {
			terminate("Illegal number of players");
		}
		_players = new Player[playersNumber];
		for(int i = 0; i < _players.length; i++) {
			System.out.print("Enter name for player " + i + ": ");
			String name = s.next();
			System.out.print("Enter type for " + name + " (0 - 3): ");
			int type = s.nextInt();
			_players[i] = getPlayer(type);
			if(_players[i] == null) {
				terminate("No such type");
			}
			_players[i].setName(name);
		}
	}

	/**
	 * This method prints the game results
	 */
	public void printResults() {
		int maxPoints = 0;
		int winner = -1;
		System.out.println("Game results");
		for(int i = 0; i < _players.length; i++) {
			System.out.println(_players[i].getName()+"\t\t"+_players[i].getPoints());
			if(_players[i].getPoints() > maxPoints) {
				maxPoints = _players[i].getPoints();
				winner = i;
			}
		}
		if(winner == -1) {
			System.out.println("No winner!");
		}
		else {
			System.out.println("The winner is "+_players[winner].getName()+" with "+maxPoints+" points.");
		}
	}

	/*
	 * Announce the beginning of a game
	 */
	private void announceGame() {
		System.out.println("Welcome to the game! I'm thinking about a number between 0 and "+RANGE+".");
		System.out.println("Can you guess it?");
	}

	/*
	 * Ask the player if they want to play again. Returns true if the player want to play again, false if not
	 */
	private boolean playAgain() {
		System.out.print("Play again (y/n)? ");
		Scanner s = new Scanner(System.in);
		String res = s.next();
		if(res.equalsIgnoreCase("y")) return true;
		return false;
	}

	/*
	 * Returns a string given that describes a guess result
	 */
	private String announceResults(Result result) {
		switch(result) {
		case HIGH:
			return "and it was too high";
		case LOW:
			return "and it was too low";
		case EQUAL:
			return "and it was right";
		default:
			return "";
		}
	}

	/**
	 * Runs the game
	 */
	public void play() {
		boolean gameOn = true, playing;
		do {
			refreshGameData();
			announceGame();
			playing = true;
			while(playing) {
				for(int i = 0; i < _players.length && playing; i++) {
					int guess = _players[i].guess();
					Result result = check(guess);
					System.out.println(_players[i].getName()+" guessed "+guess+" "+announceResults(result));
					if(result != Result.EQUAL) {
						for(int j = 0; j < _players.length; j++) {
							if(_players[j] instanceof AIPlayer) {
								((AIPlayer) _players[j]).listen(guess, result);
							}
						}
					} else {
						System.out.println(_players[i].getName()+" wins!");
						_players[i].setPoints(_players[i].getPoints() + 1);
						playing = false;
					}
				}
			}
			gameOn = playAgain();
		} while(gameOn);
	}

	/**
	 * Driver
	 */
	public static void main(String[] args) {
		GuessGame game = new GuessGame();
		game.init();
		game.play();
		game.printResults();
	}

}

// Player.java

public abstract class Player {
	
	// Current player score
	private int _points;
	// Name of that player
	private String _name;

	/**
	 * Constructor
	 */
	public Player() {
		_points = 0;
	}

	/**
	 * @return Name of that player
	 */
	public String getName() {
		return _name;
	}

	/**
	 * Set the player name
	 */
	public void setName(String name) {
		_name = name;
	}
	
	/**
	 * @return Current player score
	 */
	public int getPoints() {
		return _points;
	}

	/**
	 * Set player's score
	 * @param points New score to set
	 */
	public void setPoints(int points) {
		_points = points;
	}

	/**
	 * Guess a number in the range
	 */
	public abstract int guess();

}

// HumanPlayer.java

import java.util.Scanner;

public class HumanPlayer extends Player {

	@Override
	public int guess() {
		System.out.print(getName()+ ", Enter your guess: ");
		Scanner s = new Scanner(System.in);
		return s.nextInt();
	}

}

// MonkeyPlayer.java

public class MonkeyPlayer extends Player {

	@Override
	public int guess() {
		return (int) (Math.random() * GuessGame.RANGE);
	}

}

// AIPlayer.java

public abstract class AIPlayer extends Player {

	// Minimum number in range
	protected int _min;
	// Maximum number in range
	protected int _max;
	
	@Override
	abstract public int guess();
	
	/**
	 * Listen to the result of some player's guess
	 * @param number Number that was guessed
	 * @param result Result of this guess
	 */
	public void listen(int number, GuessGame.Result result) {
		if(result == GuessGame.Result.HIGH) {
			_max = Math.min(_max, number);
		}
		else if(result == GuessGame.Result.LOW) {
			_min = Math.max(_min, number);
		}
	}
	
	/**
	 * Initialize this player values, that represents its knowledge on the current game state
	 */
	public void init() {
		_min = 0;
		_max = GuessGame.RANGE;
	}

}

// AIMediumPlayer.java

public class AIMediumPlayer extends AIPlayer {
	
	@Override
	public int guess() {
		return _min + (int) (Math.random() * (_max - _min));
	}

}

// AISmartPlayer.java

public class AISmartPlayer extends AIPlayer {


	@Override
	public int guess() {
		if((int) (Math.random()*2)==0)
		return _max-1;
		return _min+1;
	}

}

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

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

חריגות זמן ריצה

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

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

int ret;
data *a;

ret = do_something(a);
if (FAILED(ret)) {
   goto fail;
}

ret = do_another_thing(a);
if (FAILED(ret)) {
   goto fail;
}

ret = do_more(a);
if (FAILED(ret)) {
   goto fail;
}

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

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

טיפול בחריגות

[עריכה]

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

public class ExceptionExample {

    public static void main(String[] args) {

	int arr[] = {1, 2, 3};
	for(int i=1; i <= arr.length; i++) {
	    System.out.println(arr[i]);
	}

    }

}

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

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3 at ExceptionExample.main(ExceptionExample.java:8)

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

public class ExceptionExample {

    public static void main(String[] args) {

	int arr[] = {1, 2, 3};
	for(int i=1; i <= arr.length; i++) {
	    try {
		System.out.println(arr[i]);
	    } catch (ArrayIndexOutOfBoundsException e) {
		System.out.println("Illegal index");
	    }
	}

    }

}

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

בלוק Try-Catch

[עריכה]

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

try {
   doSomething
   ...
}
catch(SomeException e1) {
...
}
catch(AnotherException e2) {
...
}

בלוק ה-Catch מקבל את החריגה שנזרקה, במקרה הזה - הבלוק הראשון מקבל חריגה מסוג SomeException בשם e1, והבלוק השני מקבל חריגה מסוג AnotherException בשם e2. עם החריגה שקיבלנו אפשר לעשות כל מיני דברים: לקרוא ממנה נתונים הקשורים לבעייה שבגללה אירעה החריגה, לזרוק אותה הלאה, וכדומה. אם לא תפסנו את החריגה באף אחד מבלוקי ה-Catch, הטיפול בה יהיה בדיוק כמו הטיפול בכל חריגה שלא נתפסה. הערה: בלוקים של Try ו-Catch, כמו בלוקים אחרים (למשל: לולאת For), נחשבים כתחום הגדרה בפני עצמו. המשמעות היא שאם הגדרתם משתנה בתוך בלוק Try - הוא לא יוכר מחוץ לבלוק. כדי לעקוף את הבעייה, רצוי להגדיר משתנים (כאלה שיש להם משמעות גם מחוץ לבלוק) לפני הכניסה לבלוק ה-Try ולתת להם ערך התחלתי ריק כלשהו (אם מדובר באובייקטים, Null).

בלוק Finally

[עריכה]

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

public class ExceptionExample {

    public static void main(String[] args) {

		String arr[] = {"One", "Two", "Three"};
		try {
			String myStr = null;
			System.out.println(myStr.length());
			for(int i=1; i <= arr.length; i++) {			
				System.out.println(arr[i]);
			} 
		} catch (ArrayIndexOutOfBoundsException e1) {
			System.out.println("Illegal index");
		} catch (NullPointerException e2) {
			System.out.println("Null pointer exception");		
		} finally {
			System.out.println("Finished");
		}
	
	}

}

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

שימוש בחריגות

[עריכה]

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

זריקה של חריגה מתבצעת בעזרת הפקודה throw, בצורה הבאה: throw <exception> כאשר במקום <exception> נכתוב מהי החריגה שנרצה לזרוק, עם הפרמטרים המתאימים. חריגה שניתן לזרוק היא כל מחלקה שמרחיבה את המחלקה המובנית Exception. הספריות הבסיסיות של ג'אווה מכילות סוגי חריגות רבים (שאת חלקם כבר ראיתם, כמו - NullPointerException), וניתן ליצור בקלות גם סוגים חדשים של חריגות. בדרך כלל, השם של מחלקות כאלה יכלול בסופו את המילה Exception. זה לא הכרחי, אבל רצוי לדבוק במנהג זה כדי שאפשר יהיה לזהות בקלות מחלקות שהן חריגות זמן ריצה.

דוגמאות

[עריכה]

אם כך, כדי לזרוק חריגה חדשה מטיפוס Exception, נשתמש בפקודה: throw new Exception(); כדי לזרוק חריגה מטיפוס RunTimeException עם הפרמטר "Illegal Input", נשתמש בפקודה: throw new RunTimeException("Illegal Input"); חריגות מטיפוסים שונים עשויות לקבל פרמטרים שונים.

מתי משתמשים בחריגות

[עריכה]

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

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

דוגמה (מוקצנת מעט) לשימוש מיותר בחריגה:

import java.util.Scanner;

public class BadExceptionExample {

	public static void main(String[] args) {
		
		boolean flag = false;
		Scanner s = new Scanner(System.in);
		int num = 0;
		while(!flag) {
			try {
				System.out.println("Enter a number between 0 and 10: ");
				num = s.nextInt();
				if(num < 0 || num > 10) throw new Exception();
				flag = true;
			} catch(Exception e) {
				flag = false;
			}
		}
		System.out.println("Your number: "+num);
	}
	
}

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

import java.util.Scanner;

public class BetterExceptionExample {

	public static void main(String[] args) {
		
		boolean flag = false;
		Scanner s = new Scanner(System.in);
		int num = 0;
		do {
			System.out.println("Enter a number between 0 and 10: ");
			String tmp = s.next();
			try {
				num = Integer.parseInt(tmp);
				flag = true;
			} catch(NumberFormatException e) { // This exception is thrown when the input is not an integer
				flag = false;
			}
		} while(!flag || num < 0 || num > 10);
		System.out.println("Your number: "+num);
	}
	
}

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

הכרזה על חריגות שיזרקו

[עריכה]

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

private void myMethod() throws SomeException {
   ...
}

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

private void fileProcessor(File f) throws IOException, SQLException {
   ...
}

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

/**
* ...
* @throws IOException if the file could not be read
* @throws SQLException for any database related problem
*/
private void fileProcessor(File f) throws IOException, SQLException {
   ...
}

התוצאה של הכרזה כזו היא שכעת מי שמשתמש בשיטה חייב לדאוג לתפיסה של החריגות אותה היא זורקת, אם זה באמצעות בלוק Try-Catch, ואם באמצעות הוספה של הכרזת throws באותה השיטה, שמשמעותה - זריקה של אותה חריגה הלאה.

סוגי חריגות מיוחדים

[עריכה]

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

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

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

היררכייה של חריגות

[עריכה]

כאמור, כל החריגות הרגילות (Checked exceptions) יורשות את המחלקה Exception (או מחלקות שיורשות ממנה). שימוש בירושה עם חריגות מאפשר לנו גמישות רבה, ובפרט:

  • שיטה יכולה להכריז כי היא זורקת חריגה מסויימת, ובפועל תזרוק חריגה מטיפוס אחר, שיורש את הטיפוס עליו הכריזה.
  • בתוך בלוק Try-Catch, בלוק Catch עבור טיפוס מסויים של חריגות תופס גם חריגות שיורשות חריגה זו.

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

public class WebServerException extends Exception
public class ProtocolException extends WebServerException
public class ClientException extends WebServerException

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

נניח שכתבנו את השיטה הבאה:

public void doSomething() throws WebServerException {
...
}

השיטה הזו יכולה לזרוק חריגות מטיפוס WebServerException, מטיפוס ProtocolException, או מטיפוס ClientException. בלוק Try-Catch שבתוכו תהייה קריאה לשיטה, לעומת זאת, חייב לתפוס את WebServerException או מחלקה שנמצאת גבוה ממנה בסולם הירושה (במקרה זה - Exception). במידת הצורך, ניתן לתפוס בתוך הבלוק גם חריגות מטיפוס ProtocolException או ClientException. דוגמה לבלוק Try-Catch שקורא לשיטה זו:

try {
   doSomething();
} catch(ProtocolException e) {
   ...
} catch(WebServerException e) {
   ...
} catch(Exception e) {
   ...
}

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

try {
   doSomething();
} catch(WebServerException e) {
   ...
} catch(ProtocolException e) {
   ...
}

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

אם כך:

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

כתיבה של מחלקות חריגות חדשות

[עריכה]

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

public class ProtocolException extends Exception {

	// Serial number
	private static final long serialVersionUID = -8787530943809965100L;

	/**
	 * Empty constructor
	 */
	public ProtocolException() {}

	/**
	 * @param arg0 Error message
	 */
	public ProtocolException(String arg0) {
		super(arg0);
	}

	/**
	 * @param arg0 Nested exception
	 */
	public ProtocolException(Throwable arg0) {
		super(arg0);
	}

	/**
	 * @param arg0 Error message
	 * @param arg1 Nested exception
	 */
	public ProtocolException(String arg0, Throwable arg1) {
		super(arg0, arg1);
	}

}

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

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

private static String addError(String message) {
	return "ERROR: "+message;
} 

public ProtocolException(String message) {
	super(addError(message));
}

public ProtocolException(String message, Throwable cause) {
	super(addError(message), cause);
}

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

תכנות גנרי

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

תזכורת - רשימה מקושרת

[עריכה]

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

גישה ראשונה - שימוש במחלקה Object

[עריכה]

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

private class Node {

	public Node(Object data) {
		_data = data;
		_next = null;
	}

	Object _data;
	Node _next;
}

נשווה כאן את השיטה contains, שבודקת האם איבר נתון נמצא ברשימה. בתוכנית המקורית, הקוד נראה כך:

public boolean contains(int data) {
	Node runner = _head;
	while (runner != null) {
		if (runner._data == data) {
			return true;
		}
		runner = runner._next;
	}
	return false;
}

כעת, הקוד יראה כך:

public boolean contains(Object data) {
	Node runner = _head;
	while (runner != null) {
		if (runner._data.equals(data)) {
			return true;
		}
		runner = runner._next;
	}
	return false;
}

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


הקוד המלא של התוכנית הגנרית
/**
 * Implements a generic singly-linked list
 * 
 * @author Johnny Zoo
 */
public class GenericLinkedList {

	/**
	 * Implements a node in the list
	 */
	private class Node {

		public Node(Object data) {
			_data = data;
			_next = null;
		}

		Object _data;
		Node _next;
	}

	// Head of the list
	private Node _head;

	/**
	 * Simple constructor
	 */
	public GenericLinkedList() {
		_head = null;
	}

	/**
	 * @return True if the list is empty, else if not
	 */
	public boolean isEmpty() {
		return (_head == null);
	}

	/**
	 * Add new element to the list
	 * 
	 * @param data
	 *            Data to add
	 */
	public void addElement(Object data) {
		if (isEmpty()) {
			_head = new Node(data);
		} else {
			Node newNode = new Node(data);
			newNode._next = _head;
			_head = newNode;
		}
	}

	/**
	 * Returns the element at the given index
	 * 
	 * @param loc
	 *            Index of data to retrieve
	 * @return The data that the element at the given index holds
	 */
	public Object elementAt(int loc) {
		Node runner = _head;
		if (loc < 0)
			throw new IndexOutOfBoundsException();
		for (int i = 0; i < loc; i++) {
			runner = runner._next;
			if (runner == null)
				throw new IndexOutOfBoundsException();
		}
		return runner._data;
	}

	/**
	 * Removes a given element from the list
	 * 
	 * @param data
	 *            Data to remove
	 * @return True if this data removed, false if not
	 */
	public boolean removeElement(Object data) {
		if (isEmpty())
			return false;
		Node runner = _head;
		// If the data is in the head - we can just remove the head
		if (runner._data.equals(data)) {
			_head = _head._next;
			return true;
		}
		// The data is not in the first node
		else {
			Node prev = _head;
			runner = _head._next;
			while (runner != null) {
				if (runner._data.equals(data)) {
					// Now the previous node points on the one after
					// the one we removed
					prev._next = runner._next;
					return true;
				}
				runner = runner._next;
				prev = prev._next;
			}
		}
		// The element we wanted to remove is not in list
		return false;
	}

	/**
	 * Search the list for a given element
	 * 
	 * @param data
	 *            Data to find
	 * @return True if this element is in list, false if not
	 */
	public boolean contains(Object data) {
		Node runner = _head;
		while (runner != null) {
			if (runner._data.equals(data)) {
				return true;
			}
			runner = runner._next;
		}
		return false;
	}
}


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

GenericLinkedList gl = new GenericLinkedList();
gl.addElement(new Integer(1));
gl.addElement(new Integer(2));
gl.addElement(new Integer(3));
int sum = 0;
for (int i = 0; i < 3; i++) {
	sum += (Integer) gl.elementAt(i);
}
System.out.println("Sum: " + sum);

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

gl.addElement(new Integer(1));
gl.addElement(new Integer(2));
gl.addElement("Three");
int sum = 0;
for (int i = 0; i < 3; i++) {
	sum += (Integer) gl.elementAt(i);
}
System.out.println("Sum: " + sum);

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

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

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

גישה שנייה - תבניות

[עריכה]

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

סימון ודוגמת שימוש במחלקה גנרית

[עריכה]

כשנרצה להשתמש באובייקט גנרי ולשייך אותו לטיפוס מסויים, נעשה זאת בעזרת סוגריים משולשים שבתוכם שם הטיפוס. למשל, אם יש לנו מחלקה גנרית בשם LinkdedList שמממשת רשימה מקושרת, ונרצה ליצור מופע שלה בו תחזיק הרשימה משתנים מטיפוס String, נעשה זאת בצורה הבאה: LinkedList<String> list = new LinkedList<String>(); מעתה ואילך, המהדר יוכל לדעת שהמשתנה list מחזיק משתנים מטיפוס String. בפרט, כשניגש לאיברים ברשימה, היחס אליהם יהיה כאל משתנים מטיפוס String, מה שאומר שלא נזדקק להמרות, ושלא נוכל להכניס איברים שאינם מטיפוס String (או יורשים אותו).

יצירת מחלקה גנרית

[עריכה]

השלב הבא הוא יצירה של מחלקות גנריות. גם זו מטלה פשוטה למדי: כאשר נכריז על המחלקה, נוסיף לשמה סוגריים משולשים עם מילה כלשהי שתייצג את טיפוס המשתנה, באופן הבא: public class MyClass<T> כעת, בקוד של אותה המחלקה, נוכל לכתוב שם זה (בדוגמה שלנו - T) במקום טיפוס משתנה מסויים. שימו לב: אין חובה, אך נהוג להשתמש בT למטרה זו. אחרי ההקדמה הזו, נראה את המחלקה GenericLinkedList. השינוי הראשון הוא בהגדרת המחלקה, שעכשיו תיראה כך:

public class GenericLinkedList<T>

המחלקה הפנימית Node, עם תבניות:

private class Node {

	public Node(T data) {
		_data = data;
		_next = null;
	}

	T _data;
	Node _next;
}

והשיטה contains תיראה כך:

public boolean contains(T data) {
	Node runner = _head;
	while (runner != null) {
		if (runner._data.equals(data)) {
			return true;
		}
		runner = runner._next;
	}
	return false;
}

גם עכשיו, השינוי הוא קטן מאוד.


הקוד המלא של התוכנית הגנרית (כעת, בעזרת תבניות)
/**
 * Implements a generic singly-linked list
 * 
 * @author Johnny Zoo
 */
public class GenericLinkedList<T> {

	/**
	 * Implements a node in the list
	 */
	private class Node {

		public Node(T data) {
			_data = data;
			_next = null;
		}

		T _data;
		Node _next;
	}

	// Head of the list
	private Node _head;

	/**
	 * Simple constructor
	 */
	public GenericLinkedList() {
		_head = null;
	}

	/**
	 * @return True if the list is empty, else if not
	 */
	public boolean isEmpty() {
		return (_head == null);
	}

	/**
	 * Add new element to the list
	 * 
	 * @param data
	 *            Data to add
	 */
	public void addElement(T data) {
		if (isEmpty()) {
			_head = new Node(data);
		} else {
			Node newNode = new Node(data);
			newNode._next = _head;
			_head = newNode;
		}
	}

	/**
	 * Returns the element at the given index
	 * 
	 * @param loc
	 *            Index of data to retrieve
	 * @return The data that the element at the given index holds
	 */
	public T elementAt(int loc) {
		Node runner = _head;
		if (loc < 0)
			throw new IndexOutOfBoundsException();
		for (int i = 0; i < loc; i++) {
			runner = runner._next;
			if (runner == null)
				throw new IndexOutOfBoundsException();
		}
		return runner._data;
	}

	/**
	 * Removes a given element from the list
	 * 
	 * @param data
	 *            Data to remove
	 * @return True if this data removed, false if not
	 */
	public boolean removeElement(T data) {
		if (isEmpty())
			return false;
		Node runner = _head;
		// If the data is in the head - we can just remove the head
		if (runner._data.equals(data)) {
			_head = _head._next;
			return true;
		}
		// The data is not in the first node
		else {
			Node prev = _head;
			runner = _head._next;
			while (runner != null) {
				if (runner._data.equals(data)) {
					// Now the previous node points on the one after
					// the one we removed
					prev._next = runner._next;
					return true;
				}
				runner = runner._next;
				prev = prev._next;
			}
		}
		// The element we wanted to remove is not in list
		return false;
	}

	/**
	 * Search the list for a given element
	 * 
	 * @param data
	 *            Data to find
	 * @return True if this element is in list, false if not
	 */
	public boolean contains(T data) {
		Node runner = _head;
		while (runner != null) {
			if (runner._data.equals(data)) {
				return true;
			}
			runner = runner._next;
		}
		return false;
	}
}


דוגמת שימוש קצרה במחלקה:

GenericLinkedList<Integer> gl = new GenericLinkedList<Integer>();
gl.addElement(new Integer(1));
gl.addElement(new Integer(2));
gl.removeElement(new Integer(2));
gl.addElement(new Integer(3));
int sum = 0;
for (int i = 0; i < 2; i++) {
	sum += (Integer) gl.elementAt(i);
}
System.out.println("Sum: " + sum);

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

תבניות מיוחדות

[עריכה]

לפעמים נרצה לדעת שטיפוס אותו תכיל המחלקה הגנרית שלנו הוא בעל תכונות מסויימות. למשל: נניח שמימשנו מבנה נתונים משוכלל, שדורש שהאיברים בו יהיו ברי השוואה, כלומר - יממשו את השיטה compareTo, שמאפשרת השוואה בין שני אובייקטים. לשם כך, נבנה Interface בשם Comparable שמכיל את השיטה public int compareTo(Object other) (הערה: זהו ממשק שקיים בג'אווה, אם תרצו להשתמש בו - אין צורך לכתוב אותו מחדש). כעת, כדי לדאוג לכך שאיברי המחלקה הגנרית יממשו את Comparable, נגדיר אותה באופן הבא: public class MyDataStructure<E implements Comparable> { ... ובכך נבטיח שטיפוס הנתונים במחלקה יממש את Comparable, ונוכל להשתמש ללא חשש בשיטה compareTo. נניח כעת שנרצה לשפר את Comparable ולהפוך אותו לגנרי. השיטה compareTo תיראה כעת כך: public int compareTo(T other) והגדרת המחלקה הגנרית שלנו תיראה כך: public class MyDataStructure<E extends Comparable<E>> { ...

שיטות גנריות

[עריכה]

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

public static <T> void printAll(T[] arr) {
	for(int i=0; i < arr.length; i++) {
		System.out.println(arr[i]);
	}
}

פולימורפיזם

[עריכה]

במחלקות גנריות לא קיים פולימורפיזם באופן המוכר לנו. לדוגמה: נניח ויש לנו שתי מחלקות, המחלקה Base, והמחלקה Derived שמרחיבה אותה. נניח כי יצרנו רשימה גנרית שמכילה איברים מהמחלקה Derived בעזרת הפקודה: GenericLinkedList<Derived> derivedList = new GenericLinkedList<Derived>(); ומצביע נוסף לאותה הרשימה: GenericLinkedList<Base> baseList = derivedList; במקרה כזה נקבל שגיאת הידור. הסיבה להגבלה הזו נעוצה בכך שאפשר היה להתייחס לאותה הרשימה בשתי צורות: בעזרת המשתנה derivedList נוכל להתייחס אליה כרשימה שמכילה איברים מטיפוס Derived, בעוד שעם baseList נוכל להתייחס אליה כרשימה שמכילה איברים מטיפוס Base. זה יכול היה להוביל למצבים לא נעימים, למשל: בעזרת baseList נוכל להכניס לרשימה איברים מטיפוס Base, ולאחר מכן, כאשר נשלוף אותם בעזרת derivedList, נקבל איברים שאמורים להיות מטיפוס Derived אך בפועל הם מטיפוס Base.

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

GenericLinkedList<?> list = new GenericLinkedList<String>();
list = new GenericLinkedList<Integer>();

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

public static void addNumbers(GenericLinkedList<?> list) {
	list.addElement(new Integer(1));
	list.addElement(new Integer(2));
	list.addElement(new Integer(3));
}

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

public static void printFirstElement(GenericLinkedList<?> list) {
	System.out.println(list.elementAt(0));
}

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

המוגבלויות של השימוש בתבניות

[עריכה]

עם היתרון הגדול של התבניות, הן סובלות משתי מוגבלויות:

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

int=>Integer boolean=>Boolean long=>Long float=>Float double=>Double char=>Character

(String הוא אובייקט ועל כן נשאר אותו דבר)

  • לא ניתן לפנות לבנאי של טיפוס גנרי. למשל, השורה T t = new T(); אינה חוקית. מסיבה זו, גם אתחול של מערך אינו חוקי. המגבלה הזו קיימת מכיוון שלא ניתן להבטיח שטיפוס הנתונים שנכניס לתבנית הזו מכיל בנאי ריק (או בכל צורה אחרת).

קישורים חיצוניים

[עריכה]

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

מבני נתונים מתקדמים

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


שימו לב:

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

יעילות

[עריכה]

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

אלגוריתמים

[עריכה]

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

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

  1. אתחל משתנה בשם Max וקבע אותו ל-0.
  2. עבור על איברי המערך בזה אחר זה. אם נתקלת באיבר שגדול מ-Max - קבע את Max להיות איבר זה.
  3. החזר את Max.

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

public static int findMaxInt(int[] arr) {
    if(arr == null) return 0;
    int max = 0;
    for(int i=0; i<arr.length; i++) {
	if(arr[i] > max) max = arr[i];
    }
    return max;
}

עכשיו תורכם:

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

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

סיבוכיות

[עריכה]

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

סימונים

[עריכה]

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

חישוב הסיבוכיות

[עריכה]
מציאת המקסימלי במערך
[עריכה]

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

מיון בחירה
[עריכה]

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

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

כמה פעולות דורש המיון? עבור כל איבר במערך, מתבצעות כמה פעולות:

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

סך הכל, עבור כל איבר במערך (בו יש איברים), מתבצעות פעולות שעלותן . לכן, העלות הכוללת של המיון היא .

חישובים כלליים
[עריכה]

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

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

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

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

מבני נתונים

[עריכה]

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

תור ומחסנית

[עריכה]

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

  • תור (Queue) מאפשר הצצה לראש התור, הוצאת איבר מראש התור, הכנסת איבר לסוף התור. התור עובד בשיטה של "בא ראשון - יוצא ראשון" (First in first out - FIFO).
  • מחסנית (Stack) מאפשרת הצצה לראש המחסנית, הוצאת איבר מתחילת המחסנית, והכנסת איבר לתחילת המחסנית. המחסנית עובדת בשיטה של "בא ראשון - יוצא אחרון" (First in last out - FILO).

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

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

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

יעילות

[עריכה]

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

עצים בינאריים

[עריכה]
עץ בינארי לא סדור

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

עץ חיפוש בינארי פשוט

[עריכה]
בניית העץ והכנסת איברים
[עריכה]

העץ הבינארי הפשוט ביותר נבנה בצורה הבאה:

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

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

מחיקת איבר
[עריכה]

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

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

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

יעילות

[עריכה]

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

טבלת גיבוב

[עריכה]

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

פונקציית הגיבוב

[עריכה]

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

  • עבור שני נתונים זהים, הפונקציה תחזיר מספר זהה.
  • במידת האפשר, עבור שני נתונים שונים, הפונקציה תחזיר מספר שונה.

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

  • ש=21. האות הראשונה במילה, ערכה מוכפל ב-1.
  • ל=12. האות השנייה במילה, ערכה מוכפל ב-26.
  • ו=6. האות השלישית במילה, ערכה מוכפל ב-26*26 = 676.
  • מ=13. האות הרביעית במילה, ערכה מוכפל ב-26*26*26 = 17576.

סך הכל: 21 * 1 + 12 * 26 + 6 * 676 + 13 * 17576 = 232877.

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

השימוש בטבלת הגיבוב

[עריכה]

כאשר יש לנו פונקציית הגיבוב, שאר העבודה הוא פשוט למדי: כשנרצה להכניס נתון מסוים, נחשב את מספרו הסידורי בעזרת פונקציית הגיבוב, ולתא שזה מספרו נכניס את הנתון. כאשר נרצה לשלוף נתון, נחשב את מספרו הסידורי, ולפי זה ניגש לתא הרצוי ונשלוף משם את הנתון. עם זאת, יש כאן בעייה נוספת: ייתכן שהמספר הסידורי שקיבלנו עובר את גודלה של הטבלה בה אנו משתמשים לאחסון. הפתרון הנפוץ: שימוש בפונקציית המודולו (שארית). למשל: נניח שנרצה להכניס לטבלה בגודל 15 (לשם הפשטות, נניח שמספרי התאים הם 1 עד 15) את המספרים הסידוריים 13, 17, ו-33. 13 מודולו 15 שווה ל-13, ולכן נכניס אותו לתא מספר 13. 17 מודולו 15 שווה ל-2, לכן נכניס אותו לתא מספר 2. 33 מודולו 15 שווה ל-3, לכן נכניס אותו לתא מספר 3.

ניהול התנגשויות

[עריכה]

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

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

כמו תמיד, יש יתרונות וחסרונות בכל אחת מהשיטות האלו.

יעילות

[עריכה]

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

יתרונות וחסרונות

[עריכה]

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

סיכום

[עריכה]

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

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

ביטויי למבדה

תכנות מתקדם ב-Java/ביטויי למבדה

זרמים

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

זרמים

[עריכה]

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

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

דוגמה - קריאת קובץ טקסט

[עריכה]

נראה כאן תוכנית פשוטה שקוראת את כל תוכנו של קובץ טקסט (ששמו ניתן כפרמטר לתוכנית) ומדפיסה אותו.

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class TextFileReader {

    public static void main(String[] args) throws IOException {
        String fileName = args[0];
        File f = new File(fileName);
        FileInputStream fi = new FileInputStream(f);
        int ch;
        while((ch = fi.read()) != -1) {
            System.out.print((char) ch);
        }
        fi.close();
    }

}

למען הקריאות והפשטות, התוכנית לא תופסת חריגות ולא בודקת דבר, אך כמובן שבתוכנית שימושית חובה להוסיף טיפול בשגיאות. נעבור כאן על משמעותן של הפקודות העיקריות: File f = new File(fileName); באופן לא מפתיע, פקודה זו יוצרת אובייקט מסוג File, שהוא הכלי הבסיסי בעזרתו מתבצעת כל עבודה עם קבצים. לאובייקט File קיימים כמה בנאים, כאן השתמשנו באחד הפשוטים - בנאי שמקבל מחרוזת שהיא שם קובץ ויוצר אובייקט שמתייחס לאותו הקובץ. FileInputStream fi = new FileInputStream(f); כאן אנו מגיעים לזרם עצמו: האובייקט FileInputStream הוא אובייקט מיוחד שמגלם זרם של מידע שמגיע מקובץ. אחרי שיצרנו את הזרם, אפשר להתחיל לעבוד:

int ch;
while((ch = fi.read()) != -1) {
      System.out.print((char) ch);
}

שורות קוד אלו הן לב התוכנית, כאן מתבצעת הקריאה מהקובץ וההדפסה. נתעמק בהן יותר: ch = fi.read() כאן מתבצעת הקריאה עצמה. מהזרם fi נקרא תו בודד (מהטיפוס int. שימו לב שבהדפסה המרנו אותו לטיפוס char). הקריאה נעשתה כ-int ולא כאוסף של מילים - הזרם בו השתמשנו משמש לקריאה של ביטים, לאו דווקא לקריאה של מילים ומשפטים. while((ch = fi.read()) != -1) משמעות הפקודה הזו היא: קרא כל עוד הקובץ לא נגמר. כאשר הזרם מחזיר "-1", ניתן לדעת שהגענו לסוף הקובץ. fi.close(); לא פחות חשוב מהשאר: כאשר מתעסקים עם זרמים, חובה לסגור אותם בסוף השימוש. אפשר להשאיר זרמים פתוחים - התוכנית תרוץ, אך במקרים רבים זהו מתכון לצרות.

עטיפה של זרמים

[עריכה]

הזרמים הבסיסיים מכילים מספר מצומצם של תכונות: קריאה או כתיבה פשוטה בלבד. כדי לעשות אותם נוחים יותר לעבודה, ניתן לעטוף אותם. למשל: הזרם הבסיסי שראינו, FileInputStream, מאפשר קריאה סדרתית של הקובץ. נניח כעת שאנו רוצים להוסיף לו חוצץ (Buffer) - מעין מערך פנימי בו נאגרים הנתונים שנקראים מהקובץ. למטרה זו, קיימת המחלקה BufferedInputStream, שיכולה לעטוף אובייקט מטיפוס InputStream. העטיפה נעשית בצורה הבאה (בהנחה ש-f הוא אובייקט מטיפוס File שכבר אותחל קודם): BufferedInputStream bfi = new BufferedInputStream(new FileInputStream(f)); כעת האובייקט החדש שיצרנו מהווה מעין "עטיפה" סביב האובייקט המקורי. את הפעולות שנרצה לבצע - נבצע על האובייקט החדש.

זרמים שכדאי להכיר

[עריכה]

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

זרמים המיועדים לטקסט

[עריכה]

קריאה

[עריכה]

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

  • המחלקה FileReader מיועדת לקריאה של קבצי טקסט.
  • המחלקה StringReader מיועדת לקריאה של מחרוזות כזרם.

כתיבה

[עריכה]

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

  • המחלקה FileWriter מיועדת לכתיבה בקבצי טקסט.
  • המחלקה StringWriter מיועדת לכתיבה למחרוזות.

זרמים המיועדים למידע גולמי

[עריכה]

קריאה

[עריכה]

המחלקה הבסיסית עבור קריאה של מידע גולמי נקראת InputStream, ובדומה ל-Reader, היא מחלקה מופשטת. הרחבות עיקריות שלה:

  • המחלקה FileInputStream מיועדת לקריאת קבצים.

כתיבה

[עריכה]

המחלקה הבסיסית כאן היא OutputStream. הרחבות עיקריות:

  • המחלקה FileOutputStream מיועדת לכתיבת מידע גולמי לקבצים.

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

זרמים נוספים

[עריכה]
  • עבור כל סוגי הזרמים הבסיסיים שראינו, קיים זרם עוטף המצייד אותו בחוצץ זמני להחזקת הנתונים, כלומר - בכל פעם החוצץ הזמני קורא כמות מסויימת של נתונים ואוגר אותה. שמות זרמים אלו מתחילים תמיד במילה Buffered. למשל: BufferedInputStream, או BufferedWriter.

עבודה עם קידודים מיוחדים

[עריכה]

כדי לכתוב (ולקרוא) בקידודים מיוחדים, כמו למשל - קידוד UTF8, יש להשתמש בזרם מסוג InputStreamReader (לקריאה) או OutputStreamWriter (לכתיבה), ולהגדיר את סוג הקידוד הרצוי. אלו הם זרמים שעוטפים זרמים קיימים, למשל - זרם קריאה מקובץ. נראה כאן דוגמה לפתיחת זרמים עבור קריאה וכתיבה מקובץ, כאשר הקידוד הרצוי הוא UTF8. אחרי יצירת הזרמים האלו אנו עוטפים אותם בזרמים מטיפוס BufferedReader ו-BufferedWriter, איתם נוח יותר לעבוד.

// Write to file.txt

// Open the file
File f1 = new File("file.txt");
// Open a file writer object
FileOutputStream fos = new FileOutputStream(f);
// Wrap the file writer with OutputStreamWriter with UTF8 encoding
OutputStreamWriter osw = new OutputStreamWriter(fos, "UTF8");
// Wrap it with buffered writer
BufferedWriter writer = new BufferedWriter(osw);

// Read from file.txt

// Open the file
File f2 = new File("file.txt");
// Open a file reader object
FileInputStream fis = new FileInputStream(f2);
// Wrap the file reader with InputStreamReader with UTF8 encoding
InputStreamReader isr = new InputStreamReader(fis, "UTF8");
// Wrap it with buffered reader
BufferedReader reader = new BufferedReader(isr);

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

// Write to file, UTF8
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File("file.txt"), "UTF8")));
// Read from file, UTF8
BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(new File("file.txt"), "UTF8")));

דוגמה

[עריכה]

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

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

/**
 * This class copies the contents of a text file into anoter text file
 */
public class Main {

    /**
     * @param args Input file and output file names
     */
    public static void main(String[] args) {
        // The file read
        File in = new File(args[0]);
        // The target file
        File out = new File(args[1]);
        FileReader fr = null;
        FileWriter fw = null;
        // Try block: Most stream operations may throw IO exception
        try {
            // Create file reader and file writer objects
            fr = new FileReader(in);
            fw = new FileWriter(out);
            // Wrap the reader and the writer with buffered streams
            BufferedReader reader = new BufferedReader(fr);
            BufferedWriter writer = new BufferedWriter(fw);
            String line;
            while ((line = reader.readLine()) != null) {
                // Print the line read and write it to the output file
                System.out.println(line);
                writer.write(line + "\n");
            }
            // Close the streams
            reader.close();
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
            System.exit(0);
        }
    }
}

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

עבודה עם קבצים

תכנות מתקדם ב-Java/עבודה עם קבצים

עבודה ברשת



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

מושגים

[עריכה]

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

  • פרוטוקול - דרך ידועה ומוסכמת להתקשרות. כאשר מדובר בתקשורת אלקטרונית, חיוני ששני הצדדים ישתמשו בפרוטוקול שמוכר לשניהם, אחרת - לא יוכלו להבין אחד את השני. המקבילה האנושית לכך היא שיחה בין בני אדם: אם שני בני אדם דוברים את אותה השפה, הם יכולים לשוחח. אם לא - לא תהייה להם אפשרות לתקשר. כמובן שקיימים הבדלים רבים מאוד בין תקשורת אנושית ואלקטרונית, אך זו הכוונה הכללית.
  • חבילה (Packet) - כאשר מתבצעת תקשורת בין מחשבים, המידע הגולמי נפרס לחתיכות קטנות של מידע. כל פיסת מידע כזאת מכונה "חבילה", ובנוסף למידע המקורי, מכילה גם מידע נוסף, כמו רצף ביטים שמשמש לווידוא המידע.
  • TCP ו-UDP - שני סוגים נפוצים של פרוטוקולים להעברת נתונים. פרוטוקולים אלו אחראים להעברה של חבילות מידע ממחשב למחשב, מבלי להתייחס לתוכן החבילות. המימוש של הפרוטוקולים האלו מתבצע ברמת מערכת ההפעלה, והם "שקופים" למשתמש: מערכת ההפעלה דואגת להביא אל המשתמש את תוכן המידע המקורי שנשלח, מבלי שיהיה צורך להתעסק בחבילות המידע עצמן. פרוטוקול TCP הוא פרוטוקול שמספק אמינות: מנגנונים שונים מוודאים כי מידע שנשלח באמצעות TCP יגיע אל היעד בשלמותו, ובסדר הנכון בו הוא נשלח. פרוטוקול UDP הוא פשוט יותר, אך בעייתי כאשר יש צורך במידע מדוייק: חבילות שנשלח באמצעות UDP עלולות להשתבש, להיאבד ולהגיע בסדר לא נכון. היתרון של פרוטוקול UDP הוא בכך שהוא מהיר יותר וצורך פחות משאבים מ-TCP. לדוגמה, גלישה באינטרנט תיעשה באמצעות פרוטוקול TCP, כיוון שיש חשיבות שהמידע יהיה מדוייק ומסודר. שיחת טלפון על גבי הרשת תיעשה בעזרת UDP, בגלל שבמקרה זה הדגש הוא על תקשורת מהירה, ואין בעייה אם מדי פעם השיחה תשתבש מעט.
  • מודל השכבות - מודל נפוץ לתיאור אופן העבודה של תקשורת מחשבים, המתבסס על 7 (או 5) שכבות נפרדות, החל מהשכבות הנמוכות שתפקידן הוא להעביר - פיזית - אותות ממכונה למכונה, וכלה בשכבות העליונות שמתעלמות מהשאלה כיצד הועבר המידע ומתמקדות באופן שבו "מדברים ביניהם" יישומים ספציפיים.
  • כתובת IP - כתובת IP היא כתובת המורכבת (בשלב זה) מצירוף של 4 מספרים שנעים בין 0 ל-255. כאשר מחשבים מתקשרים ביניהם, הם משתמשים בכתובות ה-IP כדי לאתר זה את זה.
  • פורט - ערוץ לוגי להעברת נתונים. אם נקביל שליחת חבילות בין מחשבים לשליחת דואר, הרי שכתובת IP תהייה כתובת הבית, והפורט יהיה מספר הדירה. שימוש בפורטים מספק דרך נוחה לעבודה עם יישומים רבים שמחוברים לרשת בו זמנית. פורט מסומן במספר, ומספרים רבים "שמורים" לשימושים שונים. למשל: דפדפנים משתמשים בפורט 80.

איך זה עובד

[עריכה]

מימוש

[עריכה]

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


נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

ממשק גרפי

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

חבילות (packages)

[עריכה]

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

package newpack;

class a

{
...
}

class b
{
...
}

כל המחלקות שבקובץ שיצרנו, a ו b במקרה שלנו, תהיינה שייכות לחבילה newpack. אם לא נגדיר שום חבילה יגדיר המהדר את החבילה לחבילה חסרת שם כרצונו.

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

[עריכה]

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

import.java.awt.Frame;

כלומר: ייבאנו את המחלקה Frame, שבספרייה הגרפית, שנמצאת במחלקת הבסיס java. אם נרצה להשתמש בכמה מחלקות מהספריה הגרפית, נוכל בקיצור לכתוב:

import.java.awt.*;

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

import java.awt.*;

public class a extends Frame

{

Panel p= new Panel();

}

פה יבאנו את הספריה הגרפית על מחלקותיה, בנינו מחלקה a שמרחיבה את מחלקת Frame ובתוכה הצהרנו על עצם p מסוג panel.

חבילות לדוגמה שקיימות בג'אווה

[עריכה]

יצירת תוכניות מבוססות אינטרנט - java.aplet

הספריה הגרפית, לציור גרפיקה (ממשקי משתמש) - java.awt

לפונקציות של קלט ופלט - java.io תמיכה כללית (מיובאת אוטומטית) - java.lang

פונקציות מתמטיות - java.math

הספריה הגרפית

[עריכה]

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

(abstract window toolkit), ובעזרת שימוש בה המתכנת יוכל ליצור חלונות, לצייר צורות גיאומטריות שונות, ליצור כפתורים למיניהם, תפריטים נוחים וכו'. הרכיבים היסודיים בספריה הם: Component - רכיב. כל סוגי הרכיבים שמאפשרים ממשק עם המשתמש. למשל: כפתור, כפתור רדיו, תיבות גלילה, רשימה שאפשר לבחור ממנה. Container - מיכל. רכיב שמכיל רכיבים אחרים (למשל מהסוג הקודם) למשל: חלון מסגרת - frame, תיבת דו שיח. Menu - תפריטים. השורה בראש החלון, המכילה פקודות כמו: קובץ, עריכה, עזרה וכו'. כמו כן קיים מודל הארועים (event model) - המנגנון ששולט על דרך תגובת התוכנית לארוע שהתרחש. כלומר אם בנינו מסגרת המכילה שני כפתורים, בעזרת מודל הארועים נשלוט במה שיקרה בלחיצה על כל אחד מהכפתורים האלה. העיקרון בבנית התוכנית יהיה "הכלה": נגדיר מיכל (container) כלשהו, למשל מסגרת חלון, שבו "נשים" כל מיני רכיבים (Components) כמו כפתורים למשל, ע"י שימוש בפונקצית ()add שמוגדרת במחלקה שלו, ולבסוף נגדיר מאזין לרכיב - listener, כלומר קשר בין הרכיב לתוכנית, כך שפעולה על הרכיב, כמו הקלקה עליו באמצעות העכבר, תעשה משהו ספציפי בתוכנית. נראה תוכנית לדוגמא:

import java.awt.*;

public class App

{

Frame f=new Frame("first aplication");

public App()

{

f.setLayout(new FlowLayout());

Button b1=new Button ("button1");

Button b2=new Button("button2");

f.add(b1);

f.add(b2);

f.setVisible(true);

}

public static void main (String[]args)
{
App app=new App();

}

}

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

[עריכה]

יבאנו את הספריה הגרפית (ע"י import). הצהרנו על המחלקה App, שבה הצהרנו על עצם f, מיכל מסוג Frame. המחרוזת שהועברה לקונסטרקטור של העצם היא תווית הזיהוי שתתנוסס מעל החלון: first application. בקונסטרקטור של המחלקה עצמה הצהרנו בהתחלה על צורת ההכנסה של הרכיבים לתוך החלון ע"י שימוש ב setLayout, כך:

f.setLayout(new flowLayout());

קיימים מספר סוגים של סידורים אוטומטיים של החלון:


FlowLayout - היא אחת מהאפשרויות לסידור הרכיבים במיכל. הרכיבים יונחו לפי הסדר משמאל לימין.

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

GridLayout - המסגרת תחולק לשורות וטורים.

דוגמה:

f.setLayout(new GridLayout(2,0));

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

Button b1 = new Button ("button1");
Button b2 = new Button ("button2");

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

f.add(b1);
f.add(b2);

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

f.setVisible(true);

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

רכיבים - components

[עריכה]

קיימים מספר סוגים של רכיבים שכיחים:

כפתור - Button

[עריכה]

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

Button b1 = new Button ("button1");
Button b2 = new Button ("button2");
b1.setEnabled(false);
f.add(b1);
f.add(b2);

יצרנו שני כפתורים, את השני הפכנו לכפתור לא פעיל, והוספנו אותם למסגרת f.

תווית - Label

[עריכה]

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

Label l = new Label ("my first label");
f.add(l);

הוספנו לחלון תוית, שעליה יהיה כתוב my first label.

רכיבי טקסט - text components

[עריכה]

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

TextField tf = new TextField (20);
TextArea ta = new TextArea (10, 20);
f.add(tf);
f.add(ta);

הוספנו שדה טקסט באורך 20 תוים, ואזור טקסט בגודל 10 שורות ובאורך 20 תוים.

תיבת סימון - checkbox

[עריכה]

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

נוסיף רכיבים לתוכנית:

Checkbox cb1 = new Checkbox ("Checkbox 1");
CheckboxGroup cbg = new CheckboxGroup ();
Checkbox cb2 = new Checkbox ("Checkbox 2", cbg, true);
Checkbox cb3 = new Checkbox ("Checkbox 2", cbg, false);
f.add(cb1);
f.add(cb2);
f.add(cb3);

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

בחירה - Choice

[עריכה]

בחירת אפשרות אחת מתוך "רשימה קופצת" (popup) של אפשרויות. נוסיף לתוכנית:

Choice c = new Choice();
c.add("sunday");
c.add("monday");
c.add("saturday");
f.add(c);

רשימה - List

[עריכה]

בדומה לבחירה, אבל ניתן יהיה לבחור מספר אפשרויות:

List l = new List();
l.add("one");
l.add("two");
l.add("zero");
f.add(l);

הוספנו רשימה, שממנה יהיה ניתן לבחור מספר ספרות.

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

f.setSize (330, 330);

נראה שוב כיצד תיראה התוכנית בשלמותה:

import java.awt.*;
public class App

{

Frame f=new Frame("first aplication");

public App()

{

f.setLayout(new FlowLayout());
f.setSize (330, 330);

Button b1=new Button ("button1");

Button b2=new Button("button2");
b1.setEnabled(false);
f.add(b1);

f.add(b2);


Label l = new Label ("my first label");
f.add(l);

TextField tf = new TextField (20);
TextArea ta = new TextArea (10, 20);
f.add(tf);
f.add(ta);

Checkbox cb1 = new Checkbox ("Checkbox 1");
CheckboxGroup cbp = new CheckboxGroup ();
Checkbox cb2 = new Checkbox ("Checkbox 2", cbg, true);
Checkbox cb3 = new Checkbox ("Checbox 2", cbg, false);
f.add(cb1);
f.add(cb2);
f.add(cb3);


Choice c = new Choice();
c.add("sunday");
c.add("monday");
c.add("saturday");
f.add(c);



List list = new List();
list.add("one");
list.add("two");
list.add("zero");
f.add(list);

f.setVisible(true);

}
}

נמצאה תבנית הקוראת לעצמה: תבנית:תכנות מתקדם ב-Java

תהליכונים

תכנות מתקדם ב-Java/תהליכונים

סיכום ותרגילים

תכנות מתקדם ב-Java/סיכום ותרגילים