Rozdział 5. Obsługa błędów

> Dodaj do ulubionych

Pisanie programów, które działają, gdy wszystko się udaje jest dobre na początek. Jednak prawdziwe wyzwanie to napisać program, który potrafi odpowiednio się zachować, gdy wystąpią jakieś niespodziewane zdarzenia.

Wyróżnia się dwa rodzaje trudnych sytuacji, w jakich może znaleźć się program: spowodowane błędem programisty i przez czynniki zewnętrzne. Przykładem pierwszego rodzaju problemów jest niepodanie funkcji wymaganego argumentu. Natomiast czynnikiem zewnętrznym niezależnym od programisty jest np. sytuacja, gdy program wymaga podania hasła, a zamiast niego otrzymuje pusty łańcuch.

Ogólnie rzecz biorąc błędy programistyczne po prostu trzeba znaleźć i poprawić. Natomiast błędy spowodowane czynnikami zewnętrznymi należy przewidzieć, aby opracować algorytmy pozwalające programowi wyjść z trudnych sytuacji (np. ponownie wyświetlając prośbę o podanie imienia) albo przynajmniej zakończyć działanie w elegancki i kontrolowany sposób.


Ważne jest, aby umieć oceniać, do której kategorii należy dany błąd. Weźmy np. naszą starą funkcję power:

function power(base, exponent) {
  var result = 1;
  for (var count = 0; count < exponent; count++)
    result *= base;
  return result;
}

Gdy jakiś wariat spróbuje wykonać wywołanie power("Królik", 4), to jest to oczywiście błąd programistyczny, ale czy wywołanie power(9, 0.5) też nim jest? Nasza funkcja nie obsługuje potęg ułamkowych, ale w matematyce takie potęgowanie jest jak najbardziej dozwolone (funkcja Math.pow też je obsługuje). Jeśli nie ma całkowitej jasności co do tego, jakie wartości przyjmuje funkcja, zazwyczaj dobrym posunięciem jest wypisanie przyjmowanych argumentów w komentarzu.


Błędy

Co powinna zrobić funkcja, gdy napotka problem, którego sama nie może rozwiązać? W rozdziale 4 napisaliśmy funkcję o nazwie between:

function between(string, start, end) {
  var startAt = string.indexOf(start) + start.length;
  var endAt = string.indexOf(end, startAt);
  return string.slice(startAt, endAt);
}

Jeśli ciągi start i end nie zostaną znalezione w łańcuchu, funkcja indexOf zwróci -1 i funkcja between zwróci same bzdury: wywołanie between("Your mother!", "{-", "-}") zwróci "our mother".

Gdy w czasie działania programu funkcja zostanie wywołana w taki sposób, kod który ją wywołał otrzyma łańcuch zgodnie z oczekiwaniami i będzie na nim dalej operował. Jednak łańcuch zwrócony przez funkcję jest nieprawidłowy i wynik działań na nim wykonywanych również będzie niepoprawny. A jeśli będziesz mieć pecha, błąd ten ujawni się dopiero po tym, jak zostanie wykonanych kolejnych 20 funkcji. Znalezienie przyczyny problemów w takiej sytuacji jest bardzo trudne.

W niektórych rzadkich przypadkach można sobie darować sprawdzanie, czy funkcja działa prawidłowo. Jeśli np. wiadomo, że funkcja będzie wywoływana tylko w kilku miejscach i w każdym z nich otrzyma poprawne dane wejściowe, to zazwyczaj nie ma sensu trudzić się i rozbudowywać funkcję o niepotrzebne mechanizmy zachowania w trudnych sytuacjach.

Jednak najczęściej funkcje, które w żaden sposób nie informują o błędach są trudne w użyciu, a nawet niebezpieczne. Co by było, gdyby w kodzie wywołującym funkcję between chciano sprawdzić, czy wszystko poszło dobrze? Nie da się tego zrobić, chyba że zrobi się jeszcze raz to samo, co zrobiła funkcja between i porówna otrzymany wynik z wynikiem zwróconym przez tę funkcję. Tak nie powinno być. Jednym z możliwych rozwiązań jest sprawienie, aby funkcja between zwracała jakąś specjalną wartość, np. false albo undefined, gdy wystąpi błąd w jej działaniu.

function between(string, start, end) {
  var startAt = string.indexOf(start);
  if (startAt == -1)
    return undefined;
  startAt += start.length;
  var endAt = string.indexOf(end, startAt);
  if (endAt == -1)
    return undefined;

  return string.slice(startAt, endAt);
}

Nietrudno zauważyć, że kod wychwytujący błędy raczej nie dodaje funkcjom urody. Ale teraz w kodzie, który wywoła funkcję between można napisać coś takiego:

var input = prompt("Powiedz mi coś", "");
var parenthesized = between(input, "(", ")");
if (parenthesized != undefined)
  print("Napisałeś w nawiasie „", parenthesized, "”.");

Zwracanie specjalnej wartości

Czasami zwrócenie specjalnej wartości jest idealnym rozwiązaniem na wypadek wystąpienia błędu. Metoda ta ma jednak wady. Po pierwsze funkcja może i bez tego zwracać wszystkie możliwe wartości. Spójrz np. na poniższą funkcję, która pobiera ostatni element z tablicy:

function lastElement(array) {
  if (array.length > 0)
    return array[array.length - 1];
  else
    return undefined;
}

show(lastElement([1, 2, undefined]));

Czy tablica miała ostatni element? Po samej wartości zwróconej przez funkcję lastElement nie można się o tym dowiedzieć.

Druga wada metody zwracania specjalnej wartości jest to, że jej zastosowanie może powodować bałagan. Jeśli w jakimś miejscu funkcja between zostanie wywołana 10 razy, to trzeba będzie 10 razy sprawdzić, czy została zwrócona wartość undefined. Ponadto, jeśli funkcja between zostanie wywołana przez inną funkcję nie mającą mechanizmu ochronnego przed awarią, będzie musiała sprawdzić wartość zwrotną funkcji between, i jeśli będzie nią undefined, funkcja ta może zwrócić wywołującemu undefined lub jakąś inną specjalną wartość.

Czasami, gdy wydarzy się coś dziwnego, najlepszym rozwiązaniem jest natychmiastowe wstrzymanie dalszych działań i przejście w miejsce zawierające algorytm pozwalający rozwiązać ten problem.

Na szczęście konstrukcje tego typu występują w wielu językach programowania. Ogólnie techniki te nazywają się obsługą błędów.


Słowo kluczowe throw

Teoretycznie obsługa błędów polega na zgłaszaniu przez kod (ang. raise lub throw) wyjątków, które są wartościami. Zgłaszanie wyjątków to trochę jak turbodoładowany zwrot wartości przez funkcję — następuje nie tylko wyjście z bieżącej funkcji, ale i z kodu wywołującego aż do najwyższego poziomu, gdzie rozpoczęła się bieżąca ścieżka wykonywania. Proces ten nazywa się rozwijaniem stosu. Przypomnij sobie stos wywołań, o którym była mowa w rozdziale 3. Wyjątek przebiega przez cały ten stos i odrzuca po drodze wszystkie napotkane konteksty wywołań.

Gdyby wyjątek przechodził przez stos bez żadnych przeszkód, nie byłby przydatny i stanowiłby jedynie nowatorski sposób wywoływania awarii w programie. Na szczęście w różnych miejscach stosu na wyjątki można zastawiać pułapki. Służą do tego klauzule catch, które pozwalają przechwycić wyjątek i podjąć w związku z tym jakieś czynności, po wykonaniu których program może kontynuować działanie od miejsca, w którym wyjątek został przechwycony.

Na przykład:

function lastElement(array) {
  if (array.length > 0)
    return array[array.length - 1];
  else
    throw "Nie można pobrać ostatniego elementu pustej tablicy.";
}

function lastElementPlusTen(array) {
  return lastElement(array) + 10;

}

try {
  print(lastElementPlusTen([]));
}
catch (error) {
  print("Coś poszło nie tak: ", error);
}

throw to słowo kluczowe służące do zgłaszania wyjątków. Za pomocą słowa kluczowego try zastawia się pułapki na wyjątki: jeśli kod znajdujący się za nim zgłosi wyjątek, zostanie wykonany blok kodu w klauzuli catch. Zmienna, której nazwa znajduje się w nawiasie za słowem catch jest nazwą wartości wyjątku wewnątrz tego bloku.

Zwróć uwagę, że w funkcji lastElementPlusTen kompletni zignorowano to, że wykonywanie funkcji lastElement mogłoby się nie powieść. Jest to wielka zaleta wyjątków — kod obsługi błędów jest potrzebny tylko w miejscu wystąpienia błędu i jego obsługi. W funkcjach znajdujących się pomiędzy nie trzeba się tym przejmować.

No, może prawie.


Rozważmy następujący przykład: funkcja o nazwie processThing chce sprawić, aby podczas jej wykonywania zmienna najwyższego poziomu currentThing wskazywała określoną wartość, aby inne funkcje również miały dostęp do tej wartości. Normalnie oczywiście wartość tę przekazałoby się jako argument, ale przyjmij na chwilę, że byłoby to niepraktyczne. Gdy funkcja zakończy działanie, zmienna currentThing powinna zostać ustawiona z powrotem na null.

var currentThing = null;

function processThing(thing) {
  if (currentThing != null)
    throw "O, nie! Już coś przetwarzamy!";

  currentThing = thing;
  /* jakieś skomplikowane operacje... */
  currentThing = null;
}

A co będzie, jeśli wyjątek zostanie zgłoszony w trakcie wykonywania tych skomplikowanych operacji? Wówczas wywołanie funkcji processThing zostanie wyrzucone ze stosu przez wyjątek i zmienna currentThing nie zostanie z powrotem ustawiona na null.

Słowa kluczowe try i finally

Po instrukcjach try może znajdować się dodatkowo słowo kluczowe finally określające blok kodu, który ma zostać wykonany po próbie wykonania bloku try bez względu na to, co się stanie. Jeśli funkcja musi coś po sobie uporządkować, to ten kod porządkujący powinien właśnie być umieszczony w bloku finally:

function processThing(thing) {
  if (currentThing != null)
    throw "O, nie! Już coś przetwarzamy!";

  currentThing = thing;
  try {
    /* jakieś skomplikowane operacje... */
  }
  finally {
    currentThing = null;
  }
}

W programach JavaScript występuje wiele różnych błędów, które powodują zgłoszenie wyjątków przez środowisko. Na przykład:

try {
  print(Sasquatch);
}
catch (error) {
  print("Wyjątek: " + error.message);
}

W takich przypadkach zgłaszane są specjalne obiekty wyjątków. Każdy z nich ma własność message zawierającą opis problemu. Podobne obiekty można tworzyć za pomocą słowa kluczowego new i konstruktora Error:

throw new Error("Pożar!");

Jeśli wyjątek przejdzie przez cały stos i nic go po drodze nie przechwyci, to zostanie obsłużony przez środowisko. Obsługa ta w każdej przeglądarce może być inna. Niektóre aplikacje mogą zapisywać informacje o błędzie w dzienniku, a inne wyświetlać okno z opisem błędu.

Błędy powodowane przez kod wpisany w konsoli na tej stronie są przechwytywane przez konsolę i wyświetlane wraz z innymi wynikami.


Wyjątki

Dla większości programistów wyjątki to nic więcej, jak mechanizm obsługi błędów. Jednak w istocie są one kolejnym sposobem sterowania wykonywaniem programu. Można je np. wykorzystać jako rodzaj instrukcji break w funkcjach rekurencyjnych. Poniżej znajduje się kod dość dziwnej funkcji, która sprawdza czy obiekt, i znajdujące się w jego wnętrzu obiekty, zawiera przynajmniej siedem wartości true:

var FoundSeven = {};

function hasSevenTruths(object) {
  var counted = 0;

  function count(object) {
    for (var name in object) {
      if (object[name] === true) {
        counted++;
        if (counted == 7)
          throw FoundSeven;
      }
      else if (typeof object[name] == "object") {
        count(object[name]);
      }
    }
  }

  try {
    count(object);
    return false;
  }
  catch (exception) {
    if (exception != FoundSeven)
      throw exception;
    return true;
  }
}

Wewnętrzna funkcja count jest rekurencyjnie wywoływana dla każdego obiektu będącego częścią argumentu. Gdy wartość zmiennej counted dojdzie do siedmiu, nie ma sensu kontynuować liczenia, ale sam zwrot z bieżącego wywołania funkcji count niekoniecznie zatrzyma liczenie, ponieważ pod nim mogą być jeszcze inne wywołania. Dlatego użyliśmy instrukcji throw, która powoduje wyjście z wywołań funkcji count i przejście do bloku catch.

Jednak zwrócenie jedynie wartości true w przypadku wyjątku jest niepoprawne. Coś innego mogłoby pójść nie tak i dlatego najpierw sprawdzamy, czy wyjątek jest utworzonym specjalnie na tę okazję obiektem FoundSeven. Jeśli nie, ten blok catch nie wie, jak go obsłużyć, a więc ponawia jego zgłoszenie.

W ten sposób często działa się też przy obsłudze błędów ― blok catch powinien obsługiwać tylko te wyjątki, które potrafi obsłużyć. Zwracanie wartości łańcuchowych za pomocą instrukcji throw, jak w niektórych pokazanych w tym rozdziale przykładach, rzadko kiedy jest dobrym pomysłem, ponieważ trudno jest rozpoznać typ wyjątku. Lepszym pomysłem jest zwracanie niepowtarzalnych wartości, jak np. obiekt FoundSeven albo wprowadzenie nowego typu obiektów, o czym będzie mowa w rozdziale 8.

Autor: Marijn Haverbeke

Źródło: http://eloquentjavascript.net/1st_edition/chapter5.html

Tłumaczenie: Łukasz Piwko

Treść tej strony jest dostępna na zasadach licencji CC BY 3.0