האם חריגים מסומנים טובים או רעים?

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

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

מהם חריגים?

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

ניסיונות מוקדמים לזהות חריגים כללו החזרת ערכים מיוחדים המעידים על כישלון. לדוגמא, fopen()הפונקציה של שפת C חוזרת NULLכאשר היא לא יכולה לפתוח קובץ. כמו כן, mysql_query()הפונקציה של PHP חוזרת FALSEכאשר מתרחשת כשל SQL. עליך לחפש במקום אחר את קוד הכשל בפועל. אף על פי שהיא קלה ליישום, יש שתי בעיות בגישה זו "להחזיר ערך מיוחד" להכרה בחריגים:

  • ערכים מיוחדים אינם מתארים את החריג. מה פירוש NULLאו FALSEבאמת אומר? הכל תלוי במחבר הפונקציונליות המחזירה את הערך המיוחד. יתר על כן, איך אתה מתייחס ערך מיוחד להקשר של התוכנית כאשר החריג התרחש כדי שתוכל להציג הודעה משמעותית למשתמש?
  • קל מדי להתעלם מערך מיוחד. לדוגמה, int c; FILE *fp = fopen("data.txt", "r"); c = fgetc(fp);הוא בעייתי מכיוון שקטע קוד C זה מבצע fgetc()כדי לקרוא תו מהקובץ גם כאשר הוא fopen()חוזר NULL. במקרה זה, fgetc()לא יצליח: יש לנו באג שעשוי להיות קשה למצוא.

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

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

חריגים וג'אווה

Java משתמשת בכיתות לתיאור חריגים ושגיאות. שיעורים אלה מאורגנים בהיררכיה המושרשת java.lang.Throwableבכיתה. (הסיבה שבגללה Throwableנבחר למנות את הכיתה המיוחדת הזו תתברר בקרוב.) ישירות מתחת Throwableלהלן ה- class java.lang.Exceptionוה- java.lang.Errorclass המתארים חריגים ושגיאות בהתאמה.

לדוגמה, ספריית Java כוללת java.net.URISyntaxException, המרחיבה Exceptionומציינת שלא ניתן לנתח מחרוזת כהפניה לזיהוי משאבים אחיד. שים לב URISyntaxExceptionשעוקב אחר מוסכמת שמות בה שם מחלקה חריג מסתיים במילה Exception. אמנה דומה חלה על שמות מחלקות שגיאות, כגון java.lang.OutOfMemoryError.

Exceptionהוא תחת סיווג של java.lang.RuntimeException, שהוא מעמד העל של אותם חריגים שניתן להשליך במהלך הפעולה הרגילה של מכונת ה- Java Virtual (JVM). לדוגמא, java.lang.ArithmeticExceptionמתאר כשלים בחשבון כגון ניסיונות לחלק את המספרים השלמים במספר שלם 0. כמו כן, java.lang.NullPointerExceptionמתאר ניסיונות גישה לחברי האובייקט באמצעות ההפניה האפסית.

דרך נוספת להסתכל RuntimeException

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

כאשר מתרחש חריג או שגיאה, נוצר אובייקט מהסוג המתאים Exceptionאו Errorהמשנה ומועבר ל- JVM. פעולת העברת האובייקט ידועה כזרקת החריג . Java מספקת את throwההצהרה למטרה זו. לדוגמא, throw new IOException("unable to read file");יוצר java.io.IOExceptionאובייקט חדש שמאותחל לטקסט שצוין. אובייקט זה נזרק לאחר מכן ל- JVM.

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

try { method(); } // ... void method() { throw new NullPointerException("some text"); }

בשבר קוד זה, הביצוע נכנס tryלחסימה ומפעיל method(), מה שזורק מופע של NullPointerException.

ה- JVM מקבל את הזריקה ומחפש במחסנית שיחת השיטה אחר מטפל לטיפול בחריג. RuntimeExceptionלעתים קרובות מטפלים בחריגים שלא נגזרים מהם; לעתים רחוקות מטפלים בחריגים ושגיאות בזמן ריצה.

מדוע נדיר לטפל בשגיאות

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

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

try { method(); } catch (NullPointerException npe) { System.out.println("attempt to access object member via null reference"); } // ... void method() { throw new NullPointerException("some text"); }

בשבר קוד זה צירפתי catchבלוק tryלבלוק. כאשר NullPointerExceptionהאובייקט נזרק ממנו method(), ה- JVM מאתר ומעביר את הביצוע catchלבלוק, שמפיק הודעה.

סוף סוף חסימות

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

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

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

try { method(); } catch (IOException ioe) { System.out.println("I/O failure"); } // ... void method() throws IOException { throw new IOException("some text"); }

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

טוענים בעד ונגד חריגים בדוקים

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

יוצאים מן הכלל מסומנים טובים

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

חומרת אי בדיקת ערכי ההחזר

Not checking return values might seem like no big deal, but this sloppiness can have life-or-death consequences. For example, think about such buggy software controlling missile guidance systems and driverless cars.

Gosling also pointed out that college programming courses don't adequately discuss error handling (although that may have changed since 2003). When you go through college and you're doing assignments, they just ask you to code up the one true path [of execution where failure isn't a consideration]. I certainly never experienced a college course where error handling was at all discussed. You come out of college and the only stuff you've had to deal with is the one true path.

Focusing only on the one true path, laziness, or another factor has resulted in a lot of buggy code being written. Checked exceptions require the programmer to consider the source code's design and hopefully achieve more robust software.

Checked exceptions are bad

Many programmers hate checked exceptions because they're forced to deal with APIs that overuse them or incorrectly specify checked exceptions instead of unchecked exceptions as part of their contracts. For example, a method that sets a sensor's value is passed an invalid number and throws a checked exception instead of an instance of the unchecked java.lang.IllegalArgumentException class.

Here are a few other reasons for disliking checked exceptions; I've excerpted them from Slashdot's Interviews: Ask James Gosling About Java and Ocean Exploring Robots discussion:

  • Checked exceptions are easy to ignore by rethrowing them as RuntimeException instances, so what's the point of having them? I've lost count of the number of times I've written this block of code:
    try { // do stuff } catch (AnnoyingcheckedException e) { throw new RuntimeException(e); }

    99% of the time I can't do anything about it. Finally blocks do any necessary cleanup (or at least they should).

  • Checked exceptions can be ignored by swallowing them, so what's the point of having them? I've also lost count of the number of times I've seen this:
    try { // do stuff } catch (AnnoyingCheckedException e) { // do nothing }

    Why? Because someone had to deal with it and was lazy. Was it wrong? Sure. Does it happen? Absolutely. What if this were an unchecked exception instead? The app would've just died (which is preferable to swallowing an exception).

  • Checked exceptions result in multiple throws clause declarations. The problem with checked exceptions is they encourage people to swallow important details (namely, the exception class). If you choose not to swallow that detail, then you have to keep adding throws declarations across your whole app. This means 1) that a new exception type will affect lots of function signatures, and 2) you can miss a specific instance of the exception you actually -want- to catch (say you open a secondary file for a function that writes data to a file. The secondary file is optional, so you can ignore its errors, but because the signature throws IOException, it's easy to overlook this).
  • Checked exceptions are not really exceptions. The thing about checked exceptions is that they are not really exceptions by the usual understanding of the concept. Instead, they are API alternative return values.

    The whole idea of exceptions is that an error thrown somewhere way down the call chain can bubble up and be handled by code somewhere further up, without the intervening code having to worry about it. Checked exceptions, on the other hand, require every level of code between the thrower and the catcher to declare they know about all forms of exception that can go through them. This is really little different in practice to if checked exceptions were simply special return values which the caller had to check for.

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

סיכום

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