· Свещеният сметач ·

Брой 29    "Свещеният сметач"   ДЗБЕ   Разум



Обработка на изключения в Си++

По материали от Джон Коп (John Kopp):
http://cplus.about.com/library/weekly/aa122202a.htm
Превод със съкращения и редакции по текста: Тодор Арнаудов


Увод

        Най-честите "изключения от правилата", появяващи се по време на изпълнение на програма, са недостиг на оперативна памет, недостатъчно пространство върху външна памет, недостъпност до някой входно-изходен канал ("порт"); деление на нула, препълване при събиране и умножение.
        Изключенията, които биха могли да се случат при работата на програмата, която пишем, могат да бъдат предвидени, така че да се вземат необходимите мерки за измъкването от тях.
        Съществуват три основни вида "мерки", които бихме могли да вземем при засичане на изключение от правилата:

1. Да не обработим изключението, въпреки че знаем за него, създавайки опасност програмата, а дори и цялата операционна система да "забият" и да се наложи ново включване на на програмата или дори на цялата машина.
2. Да изведем съобщение за грешка.
3. Да обработим изключението и изпълнението да продължи, без потребителят да бъде известен за случилото се.

        Първият начин не е за препоръчване. Вторият е малко по-добър, но съобщението за грешка не поправя проблема, а само нервира потребителя, че се е появил такъв...
        Добрият програмист се грижи за изключенията по третия начин, така че програмата да продължи да работи без сътресения.
* * *
        С какви похвати бихме могли да обработваме изключенията? Да използваме ли една и съща част от изходния код и за предизвикване на изключения, и за обработката им?
        Например ако се появи изключение при заявка за заемане на памет - операционната система съобщи на програмата, че в наличност няма толкова свободна памет, колкото програмата иска - самата функция ли трябва да се справи с грешката, или е по-добре друга функция да се погрижи?
        По-доброто решение е друга функция да се погрижи за изключението, тъй като разделянето позволява различни програми, които използват едни и същи класове и методи, в които се появяват изключения, да ги обработват по различен начин.
        Методът, в който се е случило изключението, може само да съобщи за случването му на подпрограмата, която го е извикала; по този начин частта от програмата, в която се очаква да настъпи изключение, може да се пише отделно от частта, която го обработва.

За да предаваме данните за изключението към викащата подпрограма, е необходимо да съществува начин да натрупваме информацията и да имаме готови методи, които да подпомагат обработката на данните за изключенията.

Изключенията могат да се случват и да бъдат обработени на различни места от програмата. Всеки обект, включително клас, може да бъде предаван към подпрограмата, обработваща изключение. Обектите, съдържащи информация за изключението, могат да съдържат всякакви данни - и процедурни, и непроцедурни  - които биха подпомогнали за преодоляването на изключителното състояние.


Основи на обработката на изключения в Си++

        Част от програмата, която предизвиква или засича грешка по време на изпълнение (деление на нула, недостиг на паметта) изхвърля ("поражда" - throw) изключение.
Изключението е обектът, който е "изхвърлен", като е възможно той да бъде както от най-прост основен тип, напр. цяло число "int", така и произволно сложен новосъздаден клас.

Изключението се "улавя" от друга част на програмата. Самият обект, описващ изключението, служи за да предаде информация от частта от програмата, която го изхвърля, до частта, която го "улавя" и поема обработката му.

        Отделянето на пораждането на изключения от обработката им е много важно. Програмата от по-високо ниво може да се справи с изключението по-добре в много случаи; например ако изключението възникне в библиотечна подпрограма - поради универсалността си тя не би могла да знае как да отговори по начин, който би бил подходящ за конкретната програма, която я е извикала. В някои случаи, подходящият отговор може да бъде програмата да приключи изпълнението си; в друг случай, подходящият отговор може да бъде предупредително съобщение; в трети сучай изключението може да бъде прихванато и пренебрегнато, без да бъде обработено.
Частите от програмата, коите "изхвърлят" изключение, се заграждат с т.нар. блокове "try" (изпитай). Изключенията се изхвърлят от кода, оградени в скобите на блока, и се улавят от операцията "catch" (улови).


#include < iostream>

using namespace std;

int main()
{
    int x = 5;
    int y = 0;
    int result;

    int exceptionCode = 25;

    try {
        if (y == 0) {
            throw exceptionCode;
        }

        result = x/y;
    }
    catch (int e) {
        if (e == 25) {
            cout << "Деление на нула!" << endl;
        }
        else {
            cout << "Неизвестно изключение!" << endl;
        }
    }

    cout << "Край." << endl;    return 0;
}


Програмата ще изведе:

Деление на нула!
Край.

Вижда се, че частта от програмата, която проверява за неправилно състояние и изхвърля изключение, е оградена с блок "try" (изпитай). Улавящият блок "catch" в примера е поставен веднага след блока "try" поради простотата на примера; както ще разберем по-късно, съседността на "try" и "catch" не е задължителна.

Да проследим изпълнението на програмата стъпка по стъпка:

Когато изпълнението стигне до блока "try", се извършва проверка дали y==0. По-нагоре в програмата на y е присвоена стойност 0, затова условието се определя като вярно, което означава да се изпълни операцията след "ако". Операцията след "ако" е "throw exceptionCode". Когато изпълнението стигне до операцията "throw", то се прехвърля на блока "catch", който следва блока "try".

if (y == 0) {
            throw exceptionCode;
            } 

Тъй като типа на изключението е "int", и той съвпада с типа на улавящото условие:

    catch (int e) {
        if (e == 25) {
            cout << "Деление на нула!" << endl;
        }

операциите в блока "catch" се изпълняват и се извежда съобщението за грешка.
Частта от програмата, която следва непосредствено "throw":  result = x/y;
се прескача. След като изключението е уловено, изпълнението не се връща към частта от програмата, следваща изхвърлянето на грешката; в примера по-горе: изразът "result = x/y" не се изчислява; всъщност именно това бе целта на добавените проверки: да се избегне деление на нула, защото то ще накара операционната система да прекъсне изпълнението на програмата.

Породеното изключение е от тип "int"; блокът за улавяне "catch" съдържа средствата за да разпознае кода на грешката и да изведе подходящо съобщение.

Този начин на проектиране работи, но не е най-доброто, което може да се направи, защото грешките да се задават с номера не е най-добрия начин в език за програмиране, който позволява изключението да бъде обект от произволен тип.


Низ вместо число

Да опитаме да подобрим примерната програма, като изпращаме на средставата за улавяне на изключения самото съобщение за грешка, - низ от знаци - вместо числов код.

#include < iostream>

using namespace std;

int main()
{
    int x = 5;
    int y = 0;
    int result;

    try {
        if (y == 0) {
            throw "Деление нула!";
        }


        result = x/y;
    }
    catch (char *e) {
        cout << e << endl;
    }

    cout << "Край." << endl;
    return 0;

}

Програмата ще изведе:
Деление на нула!
Край.

Второто изпълнение на програмата е по-четливо от първото. Блокът "catch" вече не се нуждае от подробна информация за изключението - кой номер на изключение какво означава. Самата част от програмата, която поражда изключение, предава подробностите за него - в случая съобщение за грешка.

Да видим как ще изглежда същата програма, но написана с клас "string" от стандартните библиотеки на Си++, вместо с указател към низ, както го направихме по-горе на "старомоден" Си.

#include < iostream>

#include < string>
using namespace std;

int main()
{
    int x = 5;
    int y = 0;
    int result;

    try {
        if (y == 0) {
            string s = "Деление на нула!";
            throw s;
        }


        result = x/y;
    }
    catch (string e) {
        cout << e << endl;
    }


    cout << "Край." << endl;
    return 0;

}


В случаите, когато е необходимо да съхраняваме в междинна памет съобщения, кодове на изключения и стойности на променливи, описващи състоянието на програмата по време на възникване на изключение, можем да използваме специализирани новосъздадени класове за изключения.

Изключенията могат да се обработват и с класове.

В Си++, като обектноориентиран език за програмиране, съобщенията за възникване на изключения обикновено се изпълняват като обекти на клас. Ползата от този начин е в големия обем информация, който може да се събира в съобщението, и че част от тази информация може да бъде процедурна - "методи" на даден клас; функции, в които е описано как да се обработи изключението.

В примера по-долу стойностите на числителя и знаменателя се съдържат в съобщението за изключение и класът за изключение притежава спомогателни функции за работа с тези членове на класа; и друга функция, която връща "обикновено" съобщение, предназначено за хора (низ от знаци).

Забележете, че типа на изключението, описано в израза "catch", съвпада с типа на породеното изключение.

/*
  numerator - num - числител
  denominator - denom - знаменател
*/

#include < iostream>

#include < string>
using namespace std;

class DivideByZero {
public:
    DivideByZero(int n, int d) : num(n), denom(d), message("Деление на нула") {}
    ~DivideByZero() {}

    int getNumerator() {return num;}
    int getDenominator() {return denom;}
    string getMessage() {return message;}

private:
    int num;
    int denom;
    string message;
};

int main()
{
    int x = 5;
    int y = 0;
    int result;

    try {
        if (y == 0) {
            throw DivideByZero(x, y);
        }

        result = x/y;
    }
    catch (DivideByZero e) {
        cout << e.getMessage() << endl;
        cout << "Числител: " << e.getNumerator() << endl;
        cout << "Знаменател: " << e.getDenominator() << endl;
    }

    cout << "Край." << endl;
    return 0;

}


Програмата ще изведе:

Деление на нула!
Числител: 5
Знаменател: 0
Край.


Клас за стек

Стекът (stack - "купчина") е структура от данни, която с използва за натрупване и извеждане на информация по т.нар. принцип "първи влязъл, последен излязъл", или равносилното "последен влязъл, първи излязъл".

Данните се въвеждат в стека както се трупат книги на купчина, и се извеждат от него както се взимат книги от върха на купчината - първата, която можем да вземем, е последната, която сме оставили.

Примерният стек ще бъде с постоянен размер, заявен при създаването му и ще поражда изключение за препълване, когато програмата опита да натрупа данни  на върха на пълен стек. Съответно, стекът ще поражда изключение за "празнота", когато се опитваме да вземем данни, ако в него няма нищо за "даване".

#include < iostream>
#include < string>

using namespace std;

class DivideByZero {
public:
    DivideByZero(int n, int d) : num(n), denom(d), message("Деление на нула!") {}
    ~DivideByZero() {}

    void getMessage()
    {
        cout << message << endl;
        cout << "Numerator:" << num << endl;
        cout << "Denominator: " << denom << endl;
    }
private:
    int num;
    int denom;
    string message;
};

class StackOverflowException {
public:
    StackOverflowException() {}
    ~StackOverflowException() {}
    void getMessage()
    {
        cout << "Exception: Stack Full" << endl;
    }
};

class StackEmptyException {
public:
    StackEmptyException() {}
    ~StackEmptyException() {}
    void getMessage()
    {
        cout << "Exception: Stack Empty" << endl;
    }
};

//Грубо изпълнение на стек, което служи само за да покаже работата на изключенията

class CrudeStack {
public:
    CrudeStack() : index(-1) {}
    ~CrudeStack() {}
    void push(int val)
    {
        index++;

        if (index >= 20)
        {
            throw StackOverflowException();
        }

        data[index] = val;

    }

    int pop()
    {
        if (index < 0)
        {
            throw StackEmptyException();
        }

        int val = data[index];
        index--;

        return val;
    }

private:
    int data[20];
    int index;
};

int divide(int num, int den)
{
    if (den == 0)
    {
        throw DivideByZero(num,den);
    }
    return num/den;
}

int main()
{
    CrudeStack stack;
    int num;
    int den;
    int val;

    cout << "Enter num: ";
    cin >> num;
    cout << "Enter den: ";
    cin >> den;
    try {
        stack.push(num);
        stack.push(den);

        divide(num, den);

        val = stack.pop();
        cout << "popped " << val << endl;
        val = stack.pop();
        cout << "popped " << val << endl;
        val = stack.pop();
        cout << "popped " << val << endl;
    }
    catch (StackOverflowException e) {
        e.getMessage();
    }
    catch (StackEmptyException e) {
        e.getMessage();
    }
    catch (DivideByZero e) {
        e.getMessage();
    }
    catch (...) {
        cout << "Неизвестно изключение." << endl;
    }

    cout << "Край." << endl;
    return 0;

}


В примера по-горе описахме три класа за изключения:

StackOverflowException
StackEmptyException
DivideByZero

Изключенията "StackOverflowException" и "StackEmptyException" се пораждат от функциите, които, съответно, поставят елемент на върха на стека или взимат елемент от класа "CrudeStack".

Забележете, че породените изключения се улавят в част на програмата различна от тази, където са описани; в примера: в главната функция "main". Така е направено и с изключението за деление на нула (DivideByZero): изключението се поражда от функцията "divide", но се улавя на друго място.



  • © Тодор Илиев Арнаудов (Тош), март 2004
  • Списание "Свещеният сметач": http://eim.hit.bg