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


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