Java Exceptions Debriefing

Det tog lång tid innan jag verkligen förstod hur jag skulle använda exceptions i Java. Om jag säger mer än ett år av proffesionell programmering med Java så tror jag inte jag överdriver. Om man som i mitt fall då lägger in ett decennium av utvecklarerfarenhet i botten av det så framgår det kanske att det finns en motivation för mig att få bena ut det på det här viset. Kalla det debriefing. Nu hände detta sig för mer än ett decennium sedan och jag anser det preskriberat (erkännanden om mina nuvarande professionella tillkortakommanden får ni hålla er till tåls med till tidigast 2021).

Jag har under ett lång tid utvecklat och underhållit stora mjukvaror med många utvecklare involverade där jag har sett och fått bita i effekterna av att man ibland neglicherar rekommenderade mönster för felhantering allmänhet och exceptionhantering i synnerhet. Kostnaden för detta har visat sig onödigt hög, med många och svårfunna buggar som följd, och jag har därför funnit att det lönsamt att lägga energi på att förbättra kodkvaliteten i detta avséende.

Egentligen är det enkelt: För att kunna fånga och kasta ett checked exception behöver du deklarera detta exception i signaturen för metoden, och i falled unchecked exception behöver du inte det. Och bägge används för att signalera saker som betecknas som ett fel eller en undantagssituation. Hur svårt kan det vara? Det är också enligt mitt minne ungefär nivån för att klara Certified Java Programmer [letar febrilt i bokhyllan för att belägga påståendet, men jag har tydligen lånat ut certifieringsboken].

Det är emellertid skillnad på att kunna beskriva hur en mekanism ser ut och att förstå den.

Det finns en debatt där man ifrågasätter nyttan med hela konstruktionen checked exceptions, se t.ex. http://www.c2.com/cgi/wiki?CheckedExceptionsAreOfDubiousValue, men i den bikupan tänker jag inte ge mig in och veva just idag. Och i .NET och i C++ finns inte checked exceptions. Idag håller jag mig till hur Java exceptions fungerar och hur man bäst använder det.

Fastän checked och unchecked exceptions till synes är intimt relaterade via deras namn och syntax, så har de väsensskilda syften och användningsområden. Jag tycker att frågan om hur man vet om man skall använda checked eller unchecked exceptions underbart felställd, lite som att undra i vilka situationer man hellre vill ha ett Märklintåg än ett Luciatåg.

Checked exceptions är en del av API-designen och kan ses som ett alternativ till returvärden i metodanrop – men med exception-syntax. Den semantiska twisten är att detta exception i sammanhanget representerar något som kan klassificeras som ett undantag eller ett fel, som dock anses kunna inträffa under normal drift, och att en applikation av industriell kvalitet därför förväntas ta hand om tillståndet och hantera det, och, inte minst, att det skall finnas tester som verifierar att undantaget hanteras korrekt.

Unchecked exceptions används för att signalera en bugg (RuntimeException) eller ett miljöfel (Error). Dessa kan man normalt inte göra så mycket åt förutom att meddela felet och därefter kasta in handduken. Det finns emellertid undantag som jag tar upp senare.

Våra möjligheter att förstå Java exceptions till fullo kompliceras att Javas klasshierarki för exceptions skevar i och med att RuntimeException ärver av Exception, att checked exceptions inte har något egen basklass och att RuntimeException och Error inte har en gemensam basklass. Konceptet checked/unchecked är helt enkelt inte modellerat i klasshierarkin. Jag anser att detta är ett designmisstag. Om har en metod där man vill slänga ett checked exception men inte har något annat än ett message att bifoga så kan man därför frestas att deklarera metoden att kasta basklassen Exception, med motivationen att det inte finns någon anledning att deklarera en subtyp som inte tillför något utöver Exception. Detta är helt enligt den minimalistiska läroboken, vilken ju i allmänhet är en sund och bra princip.

Detta medför att den som använder metoden blir tvingad till

class Foo {
  void faultyDesignedMethod() throws Exception { …}
}
Foo foo = ...
try {
  foo.faultyDesignedMethod();
} catch (Exception e) {
  // Handle e
  ...
}

Detta innebär att anroparen inte ges möjlighet att skilja hanteringen av unchecked exceptions, t.ex. NullPointerException, från det förväntade exception som API-et deklarerar.

En korrekt anrop som ”botar” det trasiga API-et skulle då behöva kodas enligt

try {
  foo.faultyDesignedMethod();
} catch (RuntimeException e) {
  throw e;
} catch (Exception e) {
  // Handle e
  ...
}

Det leder mig till slutsatsen att det helt enkelt alltid är fel att kasta basklassen Exception. Likaså är all kod som fångar Exception också alltid fel, eftersom en sådan catch även slukar alla RuntimeException.

Det finns ett alldeles vanligt sorts randfall där man frestas fånga Exception av ren bekvämlighet:

Method m = …
try {
  m.invoke(object);
} catch (IllegalAccessException e) {
  ...some code
} catch (InvocationTargetException e) {
  ...some code
} catch (IllegalArgumentException e) {
  ...some code
}

om hanteringen är samma för alla tre fall så kan man frestas att lösa detta genom att istället fånga den gemensamma basklassen Exception:

Method m = …
try {
  m.invoke(object);
} catch (Exception e) {
  ...some code
}

Koden blir kompaktare men priset är att den t.ex. sväljer buggen att m är null, samt alla buggar i invoke. Och visst, nu råkar vi veta att just Method.invoke nog är hyggligt buggfri vid det här laget – men – konstruktioner som denna dyker alltid upp senare som varningar när man kör Sonar, code inspection, Findbugs och liknande och då måste man åtgärda det på något sätt, antingen genom att göra suppress eller att expandera det igen. Det senare är i allmänhet enklare. Dessutom finns det inte något standardiserat sätt att göra suppress på som fungerar överallt.

Java 7 tillåter att man deklarerar flera exceptions i samma catch:

Method m = …
try {
  m.invoke(object);
} catch (IllegalAccessException | InvocationTargetException | IllegalArgumentException e) {
  ...some code
}

En bra lösning som tar bort problemet.

Men om jag vill fånga både ett checked exception och ett IllegalArgumentException och utföra samma åtgärd? Då den gemensamma basklassen för IOException och IllegalArgumentException är Exception så är det i någon mening rätt att skriva:

try {
  // …code here throws IOException and IllegalArgumentException
} catch (Exception e) {
  … handle e
}

Men, eftersom denna konstruktion normalt utgör en potentiell bugg (FindBugs REC_CATCH_EXCEPTION) så bör alltså en sådan konstruktion såväl motiveras i en kommentar som förses med suppress-uttryck för de inspektionsverktyg som används. Eftersom vi inte har för avsikt att lämna koden halvfärdig landar det alltid på att det bättre alternativet blir att aldrig skriva sådan kod eftersom det är lätt att helt undvika:

try {
  // …code here throws IOException
} catch (IllegalArgumentException e) {
  handle(e);
} catch (IOException e) {
  handle(e);
}

Den mesta kod som fångar subklasser till RuntimeException är också fel med motivationen att ett RuntimeException indikerar ett programfel, t.ex. ett felaktigt användande av ett API. Om man fångar RuntimeException blir det då lite som att skriva

int a = 43; // Bug. Should be 42
if (a != 42) {
  a = 42;
}

Det är liksom bättre att fixa felet i koden (int a = 43 → int a = 42) tycker jag, istället för att lägga in ny kod som försöker hantera felet. Det är kanske lite väl övertydligt i det här fallet.

När fångar man ett unchecked exception? I undantagsfall såklart (ignorera den språkliga leken). Poängen är att om man fångar ett unchecked exception så är det ett undantag från best practices och därför är en liten Javadoc-uppsats motiverad.

Ett exempel är om man designar ett pluginramverk och vill kunna rapportera och agera på programfel i pluginer. Eller i en server, i början på ett anrop som omsluter hela anropet för att hålla data konsistent även om vi drabbas av programfel i ett anrop. Början på en tråd bör fånga Throwable och förses med vettig felhantering.

När fångar man inte RuntimeException? Det finns gott om skräckexempel. Här är ett:

try {
  // tusen rader grötig kod
} catch (NullPointerException e) {
  ...
}

Motivationen att fånga NPE var att det var för jobbigt att hålla reda på vad som kunde vara null och vad som inte kunde vara null inom det grötiga stycket med tusen rader kod, så koden säkrades på ovanstående vis.

Det kommer mer:

boolean doExtraComplicatedStuff = true;
boolean passed = false;
do {
  try {
    // (Lots of complicated stuff here)
    // …
    if (doExtraComplicatedStuff) {
      // (Lots of extra messy stuff here)
      // ...
    }
    passed = true;
  } catch (NullPointerException e) {
  if (!doExtraComplicatedStuff) {
    throw e; // Even the fallback code failed
  }
  doExtraComplicatedStuff = false;
} while (!passed);

Studera ovanstående kod en stund. Lova sedan mig att aldrig skriva något som ens liknar det 😉

Ibland kan verkligheten dock bli kall och hård, och då kan jag finna mig själv checka in nåt som liknar det här:

try {
  // Todo: This method is broken and may throw NPE but it is impossible to fix
  // this problem because [insert ~10kB additional explanation here]
  // Promise. And yes, I really tried.
  UtilityHardToChange.methodWithDefectImplementation();
} catch (NullPointerException e) {
  // …
}

Att det har varit motstridiga viljor involverade när Java skapades är följande ett exempel på

try {
  Integer.parseInt(str);
} catch (NumberFormatException e) {
  // …
}

NumberFormatException är ett RuntimeException! Parametern str förväntas alltså vara korrekt och NumberFormatException signalerar att vi har brutit mot kontraktet i API-et och skickat in något den inte kan tolka. Problemet är att det saknas metoder att validera att en sträng går att parsa med parseInt(). Den här skevheten utgör inget praktiskt problem. Problemet är att den utgör ett dåligt exempel på API-design i Java.

När fångar man Error då?

Om du skriver något sånt här så kommer åtminstånde inte jag att jaga dig:

try {
  // try fast recursive algorithm
} catch (StackOverFlowError e) {
  // use slow and safe algorithm
}

Ibland kan det också vara nödvändigt att fånga OutOfMemoryError på samma sätt av liknande skäl. Om din kod jobbar med reflection så kommer du att behöva fånga t.ex. NoClassDefFoundError.

Innan vi lämnar unchecked exceptions så vill jag att du lovar att aldrig deklarera metoder att kasta Exception i produktionskod (i JUnit-kod så sprayar man däremot normalt med det), att aldrig fånga Exception överhuvudtaget, att aldrig fånga Throwable utom i undantagsfall, t.ex. början av en tråd, och att bara fånga RuntimeException om du avser att fånga programfel att du då gör något vettigt med detta och dessutom ackompanjerar det med en liten uppsats i javadocen.

Bra!

IT Consultant at CAG Edge. Cloud and Continuous Delivery specialist, software developer and architect, Node.js, Java.

Publicerad i Java

Kategorier

WP to LinkedIn Auto Publish Powered By : XYZScripts.com