Глава 12. Обработка на изключения

В тази тема...

В настоящата тема ще се запознаем с изключенията в обектно-ориентира­ното програмиране и в частност в езика C#. Ще се научим как да ги прихващаме чрез конструкцията try-catch, как да ги предаваме на извикващите методи и как да хвърляме собствени или прихванати изклю­чения чрез конструкцията throw. Ще дадем редица примери за използва­нето на изключения. Ще разгледаме типовете изключения и йерархията, която образуват в .NET Framework. Накрая ще се запознаем с предим­ствата при използването на изключения и с това как най-правилно да ги прилагаме в конкретни ситуации.

Съдържание

Видео

Презентация

Мисловни карти


Какво е изключение?

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

Изключения

Изключение (exception) в програмирането в общия случай представ­лява уведомление за дадено събитие, нарушаващо нормалната работа на една програма. Изключенията дават възможност необичайните събития да бъдат обработвани и програ­мата да реагира на тях по някакъв начин. Когато възникне изключение, конкрет­ното състояние на програмата се запазва и се търси обработчик на изключението (exception handler).

Изключенията се предизвикват или "хвърлят" (throw an exception) от програмен код, който трябва да сигнализира на изпълняващата се прог­рама за грешка или необичайна ситуация. Например ако се опитваме да отворим файл, който не съществува, кодът, който отваря файла, ще установи това и ще хвърли изключение с подходящо съобщение за грешка.

Изключенията са една от основните парадигми на обектно-ориентираното програмиране, което е описано подробно в темата "Принципи на обектно-ориентираното програмиране".

Прихващане и обработка на изключения

Exception handling (инфраструктура за обработка на изключе­нията) е механизъм, който позволява хвърлянето и прихващането на изключения. Този механизъм се предоставя от средата за контролирано изпълнение на .NET код, наречена CLR. Част от тази инфраструктура са дефини­раните езикови конструкции в C# за хвърляне и прихващане на изключения. CLR се грижи и затова след като веднъж е възникнало всяко изключение да стигне до кода, който може да го обработи.

Изключенията в ООП

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

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

Друга основна концепция при изключенията е тяхната йерархична същ­ност. Изключенията в ООП са класове и като такива могат да образуват йерархии посредством наследяване. При прихващането на изключения може да се обработват наведнъж цял клас от грешки, а не само дадена определена грешка (както е в процедурното програмиране).

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

Понякога изключенията се използват за очаквани събития, а не само в случай на проблем, което не е много правилно. Кое е очаквано и кое неочаквано събитие е описано към края на тази глава.

Изключенията в .NET

Изключение (exception) в .NET представлява събитие, което уведомява програмиста, че е възникнало обстоятелство (грешка), непредвидено в нормалния ход на програмата. Това става като методът, в който е възник­нала грешката изхвърля специален обект съдържащ информация за вида на грешката, мястото в програмата, където е възникнала, и състоянието на програмата в момента на възникване на грешката.

Всяко изключение в .NET носи т.нар. stack trace (няма да се мъчим да го превеждаме), който информация за това къде точно в кода е възникнала грешката. Ще го дискутираме подробно малко по-късно.

Пример за код, който хвърля изключения

Типичен пример за код, който хвърля изключения е следният код:

class Demo1

{

      static void Main()

      {

            string filename = "WrongTextFile.txt";

            ReadFile(filename);

      }

 

      static void ReadFile(string filename)

      {

            TextReader reader = new StreamReader(filename);

            string line = reader.ReadLine();

            Console.WriteLine(line);

            reader.Close();

      }

}

В примера е даден код, който се опитва да отвори текстов файл и да прочете първия ред от него. Повече за работата с файлове ще научите в главата "Текстови файлове". За момента, нека се съсредо­точим не в класовете и методите за работа с файлове, а в конструкциите за работа с изключения. Резултатът от изпълнението на програмата е следният:

clip_image002

Първите два реда на метода ReadFile() съдържат код, в които се хвърлят изключения. В примера конструкторът StreamReader(string fileName) хвърля FileNotFoundException, ако не съществува файл с име, каквото му се подава. Методите на потоците, като например ReadLine(), хвърлят IOException ако възникне неочакван проблем при входно-изходните опе­рации.

Кодът от примера ще се компилира, но при изпълнение (at run-time) ще хвърли изключение, защото файлът WrongTextFile.txt не съществува. Крайният резултат от грешката в този случай е съобщение за грешка, изписано на конзолата, заедно с обяснения къде и как е възникнала тази грешка.

Как работят изключенията?

Ако по време на нормалния ход на програмата някой от извикваните методи неочаквано хвърли изключение, то нормалният ход на програмата се преустановява. Това ще се случи, ако например възн­икне изклю­чение от типа FileNotFoundException при инициал­изиране на файловия поток от горния пример. Нека разгледаме следния програмен ред:

TextReader reader = new StreamReader("WrongTextFile.txt");

Ако се случи изключение в този ред, променливата reader няма да бъде инициализи­рана и ще остане със стойност null и нито един от следващите редове след този ред от метода няма да бъде изпълнен. Програмата ще преустанови своя ход докато средата за изпълнение CLR не намери обработчик на възникналото из­ключение FileNotFoundException.

Прихващане на изключения в C#

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

Всяка програма на .NET започва с Main(…) метод.  В него може да се извика друг метод – да го наречем "Метод 1", който от своя страна извиква "Метод 2" и т.н., докато се извика "Метод N".

Когато "Метод N" свърши работата си, управлението на програмата се връща към предходния метод и т. н., докато се стигне до Main(…) метода. След като се излезе от него, завършва и цялата програма.

Общият принцип е, че когато се извиква нов метод той се добавя най-отгоре в стека, а като завърши изпълнението му, той се изважда от стека. Така в стека за изпълнение на програмата във всеки един момент стоят всички методи, извикани един от друг – от началния метод Main() до най-последния извикан метод, който в този момент се изпълнява.

Можем да визуализираме този процес на извикване на методите един от друг по следния начин (стъпки от 1 до 5):

clip_image004

Процесът на търсене и прихващане на изключение е обратният на този за извикване на методи. Започва се от метода, в който е възникнало изклю­чението и се върви в обратна посока докато се намери метод, където изключението е прихва­нато (стъпки от 6 до 10). Ако не бъде намерен такъв метод, изключението се прихваща от CLR, който показва съобщение за грешка (изписва я в конзолата или я показва в специален прозорец).

Програмна конструкция try-catch

За да прихванем изключение, обгръщаме парчето код, където може да възникне изключение, с програмната конструкция try-catch:

try

{

      // Some code that may throw an exception

}

catch (ExceptionType objectName)

{

      // Code handling an Exception

}

catch (ExceptionType objectName)

{

      // Code handling an Exception

}

Конструкцията се състои от един try блок, обгръщащ валидни конструк­ции на C#, които могат да хвърлят изключения, следван от един или няколко catch блока, които обработват съответно различни по тип изключения. В catch блока ExceptionType трябва да е тип на клас, който е наследник на класа System.Exception. В противен случай ще получим проблем при компилация. Изразът в скобите след catch играе роля на декларация на променлива и затова вътре в блока catch можем да използваме обекта objectName, за да извикваме методите или да използ­ваме свойствата на изключението.

Прихващане на изключения – пример

Нека сега направим така, че методът в горния пример сам да обработва изключенията си. За целта заграждаме целия проблемен код, където могат да се хвърлят изключения с try-catch блок и добавяме прихва­щане на двата вида изключения:

static void ReadFile(string filename)

{

      // Exceptions could be thrown in the code below

      try

      {

            TextReader reader = new StreamReader(filename);

            string line = reader.ReadLine();

            Console.WriteLine(line);

            reader.Close();

      }

      catch (FileNotFoundException fnfe)

      {

            // Exception handler for FileNotFoundException

            // We just inform the user that there is no such file

            Console.WriteLine("The file '{0}' is not found.", filename);

      }

      catch (IOException ioe)

      {

            // Exception handler for other input/output exceptions

            // We just print the stack trace on the console

            Console.WriteLine(ioe.StackTrace);

      }

}

Добре, сега методът работи по малко по-различен начин. При възникване на FileNotFoundException по време на изпълне­нието на конструкцията new StreamReader(string fileName) средата за изпълнение (Common Language Runtime - CLR) няма да изпълни следващите редове, а ще прескочи чак на реда, където изключението е прихванато с конструкцията catch (FileNotFoundException fnfe):

catch (FileNotFoundException fnfe)

{

      // Exception handler for FileNotFoundException

      // We just inform the user that there is no such file

      Console.WriteLine("The file '{0}' is not found.", filename);

}

Като обработка на изключението потребителите просто ще бъдат инфор­мирани, че такъв файл не съществува. Това се извършва чрез съобщение, изведено на стандартния изход:

clip_image005

Аналогично, ако възникне изключение от тип IOException по време на изпъл­нението на метода reader.ReadLine(), то се обработва от блока:

catch (IOException ioe)

{

      // Exception handler for FileNotFoundException

      // We just print the stack trace on the screen

      Console.WriteLine(ioe.StackTrace);

}

Понеже не знаем естеството на грешката, породила грешно четене, отпе­чатваме цялата информация за изключението на стандартния изход.

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

clip_image006

Отпечатването на цялата информация от изключението (stack trace) на потребителя не винаги е добра практика! Как най-правилно се обработват изключения е описано в частта за добри практики.

Stack Trace

Информацията, която носи т. нар. stack trace, съдържа подробно описа­ние на естеството на изключе­нието и на мястото в програмата, където то е възникнало. Stack trace се използва от програмистите, за да се намерят причините за въз­никването на изключението. Stack trace съдържа голямо количество инфор­мация и е предназначен за анализиране само от програ­мистите и админи­страторите, но не и от крайните потребители на програ­мата, които не са длъжни да са технически лица. Stack trace е стандартно средство за търсене и отстраняване (дебъгване) на проблеми.

Stack Trace – пример

Ето как изглежда stack trace на изключение за липсващ файл от първия пример (без try-catch клаузите):

Unhandled Exception: System.IO.FileNotFoundException: Could not find file '…\WrongTextFile.txt'.

   at System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath)

   at System.IO.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, Boolean useRights, FileShare share, Int32 bufferSize, FileOptions options, SECURITY_ATTRIBUTES secAttrs, String msgPath, Boolean bFromProxy, Boolean useLongPath)

   at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32 bufferSize, FileOptions options)

   at System.IO.StreamReader..ctor(String path, Encoding encoding, Boolean detectEncodingFromByteOrderMarks, Int32 bufferSize)

   at System.IO.StreamReader..ctor(String path)

   at Exceptions.Demo1.ReadFile(String filename) in Program.cs:line 17

   at Exceptions.Demo1.Main() in Program.cs:line 11

Press any key to continue . . .

Системата не може да намери този файл и затова, за да съобщи за възникващ проблем хвърля изключението FileNotFoundException.

Как да разчетем "Stack Trace"?

За да се ориентираме в един stack trace трябва да можем да го разчетем правилно и да знаем неговата структура.

Stack trace съдържа следната информация в себе си:

-     Пълното име на класа на изключението;

-     Съобщение – информация за естеството на грешката;

-     Информация за стека на извикване на методите.

От примера по-горе пълното име на изключението е System.IO. FileNotFoundException. Следва съобщението за грешка. То донякъде повтаря името на самото изключение: "Could not find file '…\WrongTextFile.txt'.". Следва целият стек на извик­ване на методите, който по традиция е най-дългата част от всеки stack trace. Един ред от стека съдържа нещо такова:

 at <namespace>.<class>.<method> in <source file>.cs:line <line>

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

   at Exceptions.Demo1.ReadFile(String filename) in …\Program.cs:line 17

Редовете са налични само ако класът е компилиран с опция да включва дебъг информация (тя включва номера на редове, имена на променливи и друга информация, спомагаща дебъгването на програмата). Дебъг инфор­мацията се намира извън .NET асемблитата, в т.нар. debug symbols file (.pdb). Както се вижда от примерния stack trace, за някои асемблита е налична дебъг информация и се извеждат номерата на редовете от стека, а за други (например системните асемблита от .NET Framework) такава информация липсва и не е ясно на кой ред и в кой файл със сорс код е възникнала проблемната ситуация.

Ако методът е конструктор, то вместо името му се изписва служебното наименование .ctor, например: System.IO.StreamReader..ctor(String path). Ако липсва информа­ция за сорс файла и номера на реда, където е възникнало изключението, не се изписва име на файл и номер на ред.

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

Хвърляне на изключения (конструкцията throw)

Изключения в C# се хвърлят с ключовата дума throw, като първо се създава инстан­ция на изключението и се попълва нужната информация за него. Изключенията са обикновени класове, като единственото изискване за тях е да наследяват System.Exception.

Ето един пример:

static void Main()

{

      Exception e = new Exception("There was a problem");

      throw e;

}

Резултатът от изпълнението на програмата е следният:

Unhandled Exception: System.Exception: There was a problem

   at Exceptions.Demo1.Main() in Program.cs:line 11

Press any key to continue . . .

Йерархия на изключенията

В .NET Framework има два типа от изключения: изключения генерирани от дадена програма (ApplicationException) и изключения генерирани от средата за изпълнение (SystemException). Всяко едно от тези изключения включва собствена йерархия от изключения-наследници.

clip_image007

Тъй като наследниците на всеки от тези класове имат различни характе­ристики, ще разгледаме всеки от тях поотделно.

Класът Exception

В .NET Framework Exception е базовият клас на всички изключения. Няколко класа на изключения го наследяват директно, включително ApplicationException и SystemException. Тези два класа са базови за почти всички изключения, възникващи по време на изпълнение на програмата.

Класът Exception съдържа копие на стека по време на създаването на изключе­нието. Съдържа още кратко текстово съобщение описващо греш­ката (попълва се от метода, който хвърля изключението). Всяко изключе­ние може да съдържа още причина (cause) за възникването му, която представлява друго изключение – оригиналната причина за появата на проблема. Можем да го наричаме вътрешно (обвито) изключение (inner / wrapped exception) или вложено изключение.

Външното изключение се нарича обгръщащо (обвиващо) изключение. Така може да се навържат много изключения. В този случай говорим за верига от изключения (exception chain).

Exception – конструктори, методи, свойства

Ето как изглежда класът System.Exception:

[SerializableAttribute]

[ComVisibleAttribute(true)]

[ClassInterfaceAttribute(ClassInterfaceType.None)]

public class Exception : ISerializable, _Exception

{

      public Exception();

      public Exception(string message);

      public Exception(string message, Exception innerException);

      public virtual IDictionary Data { get; }

      public virtual string HelpLink { get; set; }

      protected int HResult { get; set; }

      public Exception InnerException { get; }

      public virtual string Message { get; }

      public virtual string Source { get; set; }

      public virtual string StackTrace { get; }

      public MethodBase TargetSite { get; }

      public virtual Exception GetBaseException();

}

Нека обясним накратко по-важните от тези методи, тъй като те се наследяват от всички изключения в .NET Framework:

-     Имаме три конструктора с различните комбинации за съобщение и обвито изключение.

-     Свойството Message връща текстово описание на изключението. Например, ако изключе­нието е FileNotFoundException, то описани­ето може да обяснява кой точно файл не е намерен. Всяко изклю­чение само решава какво съобщение за грешка да върне. Най-често се позволява на хвърлящият изключението код да подаде това описание на конструктора на хвърляното изключение. След като е веднъж зададено, свойството Message не може повече да се променя.

-     Свойството InnerException връща вътрешното (обвитото) изключе­ние или null, ако няма такова.

-     Методът GetBaseException() връща най-вътрешното изключение. Извикването на този метод за всяко изключение от една верига изключения трябва да върне един и същ резултат – изключението, което е възникнало първо.

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

Application vs. System Exceptions

Изключенията в .NET Framework са два вида – системни и потребителски. Системните изключения са дефинирани в библиотеките от .NET Framework и се ползват вътрешно от него, а потребителските изключения се дефи­нират от програмиста и се използват от софтуера, по който той работи. При разработката на приложение, което хвърля собствени изключения, е добра практика тези изключения да наследяват Exception. Наследява­нето на класа SystemException би трябвало да става само вътрешно от .NET Framework.

Най-тежките изключения – тези хвърляни от средата за изпълнение – включват ExecutionEngineException (вътрешна грешка при работата на  CLR), StackOverflowException (препълване на стека, най-вероятно заради бездънна рекурсия) и OutOfMemoryException (препълване на паметта). И при трите изключения възможностите за адекватна реакция от страна на вашата програма са минимални. На практика тези изключения означават фатално счупване (crash) на приложението.

Изключенията при взаимодействие с външни за средата за изпълнение компоненти наследяват ExternalException. Такива са COMException, Win32Exception и SEHException.

Хвърляне и прихващане на изключения

Нека разгледаме в детайли някои особености при хвърлянето и прихва­щането на изключения.

Вложени (nested) изключения

Вече споменахме, че в едно изключение може да съдържа в себе си вложено (опаковано) друго изключение. Защо се налага едно изключение да бъде опаковано в друго? Нека обясним тази често използвана практика при обработката на изключения в ООП.

Добра практика в софтуерното инженерство е всеки модул / компонент / програма да дефинира малък брой application exceptions (изключения написани от автора на модула / програмата) и този компонент да се ограничава само до тях, а не да хвърля стандартни .NET изключения, наричани още системни изключения (system exceptions). Така ползвателят на този модул / компонент знае какви изключения могат да възникнат в него и няма нужда да се занимава с технически подробности.

Например един модул, който се занимава с олихвяването в една банка би трябвало да хвърля изключения само от неговата бизнес област, примерно InterestCalculationException и InvalidPeriodException, но не и изключения като FileNotFoundException, DivideByZeroException и NullReferenceException. При възникване на някое изключение, което не е свързано директно с проблемите на олихвяването, то се обвива в друго изключение от тип InterestCalculationException и така извикващия метод получава информация, че олихвяването не е успешно, а като детайли за неуспеха може да разгледа оригиналното изключение, причи­нител на проблема, от което примерно може да стане ясно, че няма връзка със сървъра за бази данни.

Тези application exceptions от бизнес областта на решавания проблем, за които дадохме пример, обаче не съдържат достатъчно информация за възникналата грешка, за да бъде поправена тя. Затова е добра практика в тях да има и техническа информация за оригиналния причинител на проблема, която е много полезна, например при дебъгване.

Същото обяснение от друга гледна точка: един компонент A има дефини­рани малък брой изключения (A-изключения). Този компонент използва друг компонент Б. Ако Б хвърли Б-изключение, то A не може да си свърши работата и също трябва да хвърли изключение, но не може да хвърли Б-изключение, затова хвърля А-изключение, съдържащо изключението Б като вложено изключение.

Защо A не може да хвърли Б-изключение? Има много причини:

-     Ползвателите на A не трябва да знаят за съществуването на Б (за повече информация разгледайте точката за абстракция от главата за принципите на ООП).

-     Компонентът A не е дефинирал, че ще хвърля Б-изключения.

-     Ползвателите на A не са подготвени за Б-изключения. Те очакват само А-изключения.

Как да разчетем "Stack Trace" на вериги изключения?

Сега ще дадем пример как можем да създадем верига от изключения и ще демонстрираме как се изписва на екрана вложено изключение. Нека имаме следния код (забележете, че вляво са дадени редовете от кода):

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

static void Main()

{

      try

      {

            string fileName = "WrongFileName.txt";

            ReadFile(fileName);

      }

      catch (Exception e)

      {

            throw new ApplicationException("Smth bad happened", e);

      }

}

static void ReadFile(string fileName)

{

      TextReader reader = new StreamReader(fileName);

      string line = reader.ReadLine();

      Console.WriteLine(line);

      reader.Close();

}

В този пример извикваме метода ReadFile(), който хвърля изключение, защото файлът не съществува. В Main() метода прихващаме всички изключения, опаковаме ги в наше собствено изключение от тип ApplicationException и ги хвърляме отново. Резултатът от изпъл­нението на този код е следният:

clip_image009

Нека се опитаме заедно да проследим редовете от stack trace в сорс кода. Забелязваме, че се появява секция, която описва край на вложеното изключение:

--- End of inner exception stack trace ---

Това ни дава полезна информация за това как се е стигнало до хвърлянето на изключе­нието, което разглеждаме.

Забележете първия ред. Той има следния вид:

Unhandled Exception: Exception1: Msg1 ---> Exception2: Msg2

Това показва, че изключение от тип Exception1 е обвило изключение от тип Exception2. След всяко изключение се изписва и съответното му  съобщение за грешка (свойството Message). Всеки метод от стека съдържа името на файла, в който е възникнало съответното изключение и номера на реда. Може да проследим по номерата на редовете от примера къде и как точно са възникнали изключенията, отпечатани на конзолата.

Визуализация на изключения

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

clip_image011

В конзолните приложения най-често грешката се из­писва на конзолата във вид на stack trace, въпреки че това може и да не е най-удобния за крайния потребител начин за уведомление за проблеми.

При уеб приложения грешката се визуализира като червен текст в началото на страницата или около след полето, за което се отнася.

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

Кои изключения да обработим и кои не?

Има едно универсално правило за обработката на изключенията:

clip_image006[1]

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

Ако изключенията се предават по гореописания начин от метод на метод и не се прихванат никъде, те неминуемо ще достигнат до началния метод от програмата – Main() метода – и ако и той не ги прихване, средата за изпълнение ще ги отпечата на конзолата (или ще ги визуализира по друг начин, ако няма конзола) и ще преустанови изпълнението на програмата.

Какво означава един метод да е "компетентен, за да обработи да едно изключение"? Това означава, че той очаква това изключение и знае кога точно може да възникне и знае как да реагира в този специален случай. Ето един пример. Имаме метод, който трябва да прочете даден текстов файл, а ако файлът не съществува, трябва да върне празен низ. Този метод би могъл да прихване съобщението FileNotFoundException и да го обработи. Той знае какво да прави, когато файлът липсва – трябва да върне празен низ. Какво става, обаче, ако при отварянето на файла се получи OutOfMemoryException? Компетентен ли е методът да обработи тази ситуация? Как може да я обработи? Дали трябва да върне празен низ, дали трябва да хвърли друго изключение или да направи нещо друго? Очевидно методът за четене на файл не е компетентен да се справи със ситуацията "недостиг на памет" и най-доброто, което може да направи е да остави изключението необработено. Така то може да бъде прихванато на друго ниво от някой по-компетентен метод. Това е цялата идея: всеки метод прихваща изключенията, от които разбира, а остана­лите ги остава на останалите методи. Така методите си поделят по ясен и систематичен начин отговорностите.

Изхвърляне на изключения от Main() метода – пример

Изхвърлянето на изключения от Main() метода по принцип не е жела­телно. Вместо това се препоръчва всички изклю­чения да бъдат прихва­нати и обработени. Изхвърлянето на изключения от Main() метода все пак е възможно, както от всеки друг метод:

static void Main()

{

      throw new Exception("Ooops!");

}

Всички изключения изхвърлени от Main() метода се прихващат от самата среда за изпълнение (.NET CLR) и се обработват по един и същ начин – пълният stack trace на изключението се изписва на конзолата или се визуализира по друг начин. Такова изхвър­ляне на изключенията, възник­ващи в Main() метода е много удобно, когато пишем кратка програмка набързо и не искаме да обработваме евентуално възникващите изключе­ния. Това е бягане от отговорност, което се прави при малки прости програмки, но не трябва да се случва при големи и сериозни приложения.

Прихващане на изключения на нива – пример

Възможността за пропускане на изключения през даден метод ни позво­лява да разгледаме един по-сложен пример: прихващане на изклю­чения на нива. Прихващането на нива е комбинация от прихващането на опре­делени изключения в дадени методи и пропускане на всички оста­нали изключения към предходните методи (нива) в стека. В примера по-долу изключенията възник­ващи в метода ReadFile() се прихващат на две нива (в try-catch блока на ReadFile(…) метода и в try-catch блока на Main() метода):

static void Main()

{

      try

      {

            string fileName = "WrongFileName.txt";

            ReadFile(fileName);

      }

      catch (Exception e)

      {

            throw new ApplicationException("Bad thing happened", e);

      }

}

static void ReadFile(string fileName)

{

      try

      {

            TextReader reader = new StreamReader(fileName);

            string line = reader.ReadLine();

            Console.WriteLine(line);

            reader.Close();

      }

      catch (FileNotFoundException fnfe)

      {

            Console.WriteLine("The file {0} does not exist!",

                  filename);

      }

}

Първото ниво на прихващане на изключенията в примера е в метода ReadFile(), а второто ниво е в Main() метода. Методът ReadFile() прихваща само изключенията от тип FileNotFoundException, а пропуска всички останали IOException изклю­чения към Main() метода, където те биват прихванати и обработени. Всички останали изключения, които не са от групата IOException (например OutOfMemoryException) не се прихва­щат на никое от двете нива и се оставят на CLR да се погрижи за тях.

Ако Main() методът подаде име на несъществуващ файл то ще възникне FileNotFoundException, което ще се прихване в ReadFile(). Ако обаче се подаде име на съществуващ файл и възникне грешка при самото четене на файла (например няма права за достъп до файла), то изключението ще се прихване в Main() метода.

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

Конструкцията try-finally

Всеки блок try може да съдържа блок finally. Блокът finally се изпълнява винаги при излизане от try блока, независимо как се излиза от try блока. Това гарантира изпълнението на finally блока, дори ако възникне неочаквано изключение или методът завърши с израз return.

clip_image006[2]

Блокът finally няма да се изпълни, ако по време на изпълнението на блока try средата за изпълнение CLR прекрати изпълнението си!

Блокът finally има следната основна форма:

try {

      Some code that could or could not cause an exception

} finally {

      // Code here will allways execute

}

Всеки try блок може да има нула или повече catch блокове и максимум един блок finally. Възможна е и комбинация с множество catch блокове и един finally блок:

try {

      some code

} catch (…) {

      // Code handling an exception

} catch (…) {

      // Code handling another exception

} finally {

      // This code will allways execute

}

Кога да използваме try-finally?

В много приложения се налага да се работи с външни за програмата ресурси: файлове, мрежови връзки, графични елементи от операционната система, комуникационни канали (pipes), потоци от и към различни периферни устройства (принтер, звукова карта, карточетец и други). При работата с външни ресурси е важно след като веднъж е заделен даден ресурс, той да бъде освободен възможно най-скоро след като вече не е нужен на програмата. Например, ако отворим някакъв файл, за да прочетем съдър­жанието му (примерно за да заредим JPEG картинка), е важно да го затворим веднага след като го прочетем. Ако оставим файла отворен, това ограничава достъпа на останалите потребители като забра­нява някои операции, например промяна на файла и изтриване. Може би ви се е случвало да не можете да изтриете дадена директория с файлове, нали? Най-вероятната причина за това е, че някой от файловете в дирек­торията е отворен в момента от друго приложение и така изтриването му е блокирано от операционната система.

Блокът finally е незаменим при нужда от освобождаване на вече заети ресурси. Ако го нямаше, никога не бихме били сигурни дали разчист­ването на заделените ресурси няма случайно да бъде прескочено при нео­чаквано изключение или заради използването на някой от изразите return, continue или break.

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

Освобождаване на ресурси – дефиниране на проблема

В примера, който разглеждаме, искаме да прочетен даден файл. Имаме четец (reader), който задължително трябва да се затвори след като файлът е прочетен. Най-правилният начин това да се направи е с try- finally блок обграждащ редовете, където се използват съответните потоци. Да си припомним примера:

static void ReadFile(string fileName)

{

      TextReader reader = new StreamReader(fileName);

      string line = reader.ReadLine();

      Console.WriteLine(line);

      reader.Close();

}

Какъв е проблемът с този код? Той би трябвало да отваря файлов четец, да чете данни от него и накрая следва задължително да затвори файла преди да завърши изпълнението на метода. Задължителното затваряне на файловете е проблемна ситуация, защото от метода може да се излезе по няколко начина:

-     По време на инициализиране на четеца може да възникне непред­видено изключение (например ако липсва файлът).

-     По време на четенето на данните може възникне непред­видено изключение (например ако файлът се намира на отдалечено мрежово устройство, до което бъде изгубена връзката).

-     Между инициализирането и затварянето на потоците се изпълни операторът return.

-     Всичко е нормално и не възникват никакви изключения.

Така написан примерният код за четене на файл е логически грешен, защото четецът ще се затвори правилно само в последния случай (ако не възникнат никакви изключения). Във всички останали случаи четецът няма да се затвори, защото ще възникне изключение и кодът за затва­ряне на файла няма да се извика. Имаме проблем, макар и да не взимаме под внимание възможността отварянето, използването и затварянето на потока да е част от тяло на цикъл, където може да се използват изразите continue и break, което също ще доведе до незатваряне на потоците.

Освобождаване на ресурси – решение на проблема

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

Всички тези главоболия можем да си спестим като използваме конструк­цията try-finally. Ще разгледаме първо пример с един ресурс (в случая файл), а след това и с два и повече ресурса.

Сигурното затваряне на файл (поток) може да се извърши по следния начин:

static void ReadFile(string fileName)

{

      TextReader reader = null;

      try

      {

            reader = new StreamReader(fileName);

            string line = reader.ReadLine();

            Console.WriteLine(line);

      }

      finally

      {

            // Always close "reader" (first check if properly opened)

            if (reader != null)

            {

                  reader.Close();

            }

      }

}

Да анализираме примера. Първоначално декларираме променлива reader от тип TextReader, след това отваряме try блок, в който инициали­зираме нов четец, използваме го и накрая го затваряме във finally блок. Каквото и да стане при използването и инициализацията, сме сигурни, че четецът и свързания с него файл ще бъдат затворени. Ако има проблем при инициализацията, например липсващ файл, то ще се хвърли FileNotFoundException и променливата reader ще остане със стойност null. За този случай и за да се избегне NullReferenceException е необходимо да се прибави проверка дали reader не е null преди да се извика методът Close() за затваряне на четеца. Ако имаме null, то четецът изобщо не е бил инициали­зиран и няма нужда да бъде затварян. При всички сценарии на изпълнение (при нормално четене, при грешка или при някакъв друг проблем) се гарантира, че ако файлът е бил отворен, той ще бъде съответно затворен преди излизане от метода.

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

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

Освобождаване на ресурси – алтернативно решение

Горната конструкция е вярна, но е излишно сложна. Нека разгледаме един неин опростен вариант:

static void ReadFile(string fileName)

{

      TextReader reader = new StreamReader(fileName);

      try

      {

            string line = reader.ReadLine();

            Console.WriteLine(line);

      }

      finally

      {

            reader.Close();

      }

}

Предимството на този вариант е по-краткия запис – спестяваме една излишна декларация на променливата reader и избягваме проверката за null. Проверката за null е излишна, защото инициализацията на потока е извън try блока и ако е възникнало изключение докато тя се изпълнява изобщо няма да се стигне до изпълнение на finally блока и затварянето на потока.

Този вариант е по-чист, по-кратък и по-ясен и е известен като шаблон за освобождаване на ресурси (dispose pattern).

Освобождаване на множество ресурси

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

За освобождаването на множество ресурси могат да се използват горните два подхода като try-finally блоковете се влагат един в друг:

static void ReadFile(string filename)

{

      Resource r1 = new Resource1();

      try

      {

            Resource r2 = new Resource2();

            try

            {

                  // Use r1 and r2

            }

            finally

            {

                  r2.Release();

            }

      }

      finally

      {

            r1.Release();

      }

}

 

Другият вариант е всички ресурси да се декларират предварително и накрая да се освободят в един единствен finally блок с проверка за null:

static void ReadFile(string filename)

{

      Resource r1 = null;

      Resource r2 = null;

      try

      {

            Resource r1 = new Resource1();

            Resource r2 = new Resource2();

 

            // Use r1 and r2

      }

      finally

      {

            r1.Release();

            r2.Release();

      }

}

 

И двата подхода са правилни със съответните предимства и недостатъци и се прилагат в зависимост от предпочитанията на програмиста съобразно конкретната ситуация. Все пак вторият подход е малко рисков, тъй като ако във finally блока възникне изключение (което почти никога не се случва) при затварянето на първия ресурс, вторият ресурс няма да бъде затворен. При първия подход няма такъв проблем, но се пише повече код.

IDisposable и конструкцията using

Време е да обясним и един съкратен запис в езика C# за освобождаване на някои видове ресурси. Ще покажем кои точно ресурси могат да се възползват от този запис и как точно изглежда той.

IDisposable

Основната употреба на интерфейса IDisposable е за освобождаване не ресурси. В .NET такива ресурси са графични елементи (window handles), файлове, потоци и др. За интер­фейси ще стане дума в главата "Принципи на обектно-ориентираното програмиране", но за момента можете да считате, че интер­фейсът е инди­кация, че даден тип обекти (на­пример потоците за четене на файлове) поддържат определено множество операции (напри­мер зат­варяне на потока и освобождаване на свързаните с него ресурси).

Няма да навлизаме в подробности как се имплементира IDisposable (нито ще дадем примери), защото ще трябва да навлезем в доста сложна мате­рия и да обясним как работи системата за почистване на паметта (garbage collector) и как се работи с деструктори, неуправлявани ресурси и т.н.

Важният метод в интерфейса IDisposable е Dispose(). Основното, което трябва да се знае за него е, че той освобождава ресурсите на класа, който го имплементира. В случая, когато ресурсите са потоци, четци или файлове, освобождаването им може да се извърши с метода Dispose() от интерфейса IDisposable, който извиква метода им Close(), който ги затваря и освобождава свързаните с тях ресурси от операционната система. Така затварянето на един поток може да стане по следния начин:

StreamReader reader = new StreamReader(fileName);

try

{

      // Use the reader here

}

finally

{

      if (reader != null)

      {

            reader.Dispose();

      }

}

Ключовата дума using

Последният пример може да се запише съкратено с помощта на ключовата дума using в езика C# по следния начин:

using (StreamReader reader = new StreamReader(fileName) )

{

      // Use the reader here

}

Определено този вариант изглежда доста по-кратък и по-ясен, нали? Не е нужно нито да имаме try-finally, нито да викаме изрично някакви методи за освобождава­нето на ресурсите. Компилаторът се грижи да сложи автоматично try-finally блок, с който при излизане от using блока, т.е. достигане на неговата затваряща скоба }, да извика метода Dispose() за освобожда­ване на използвания в блока ресурс.

Вложени using конструкции

Конструкциите using могат да се влагат една в друга:

using (ResourceType r1 = )

      using (ResourceType r2 = )

            ...

                  using (ResourceType rN = )

                        statements;

Горният код може да се запише съкратено и по следния начин:

using (ResourceType r1 = , r2 = , , rN = )

{

      statements;

}

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

Кога да използване using?

Има много просто правило кога трябва да се използва using при работата с някой .NET клас:

clip_image006[3]

Използвайте using при работа с всички класове, които имплементират IDisposable. Проверявайте за IDisposable в MSDN.

Когато даден клас имплементира IDisposable, това означава, че авторът на този клас е предвидил той да бъде използван с конструкцията using. Това означава, че този клас обвива в себе си някакъв ресурс, който е ценен и не може да се оставя неосвободен, дори при екстремни условия. Ако даден клас имплементира IDisposable, значи трябва да се освобож­дава задължително веднага след като работата с него приключи и това става най-лесно с конструкцията using в C#.

Предимства при използване на изключения

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

Отделяне на кода за обработка на грешките

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

void ReadFile()

{

      OpenTheFile();

      while (FileHasMoreLines)

      {

            ReadNextLineFromTheFile();

            PrintTheLine();

      }

      CloseTheFile();

}

Нека сега преведем последователността от действия на български:

-     Отваряме файл;

-     Докато има следващ ред:

o    Четем следващ ред от файла;

o    Изписваме прочетения ред;

-     Затваряме файла;

Методът е добре написан, но ако се вгледаме по-внимателно започват да възникват въпроси:

-     Какво ще стане, ако няма такъв файл?

-     Какво ще стане, ако файлът не може да се отвори (например, ако друг процес вече го е отворил за писане)?

-     Какво ще стане, ако пропадне четенето на някой ред?

-     Какво ще стане, ако файлът не може да се затвори?

Да допишем метода, така че да взима под внимание тези въпроси, без да използваме изключения, а да използваме кодове за грешка връщани от всеки използван метод. Кодовете за грешка са стандартен похват за обработка на грешките в процедурно ориентираното програмиране, при който всеки метод връща int, който дава информация дали методът е изпълнен правилно. Код за грешка 0 означава, че всичко е правилно, код различен от 0 означава някаква грешка. Различните видове грешки имат различен код (обикновено отрицателно число).

int ReadFile()

{

      errorCode = 0;

      openFileErrorCode = OpenTheFile();

 

      // Check whether the file is open

      if (openFileErrorCode == 0)

      {

            while (FileHasMoreLines)

            {

                  readLineErrorCode = ReadNextLineFromTheFile();

                  if (readLineErrorCode == 0)

                  {

                        // Line has been read properly

                        PrintTheLine();

                  }

                  else

                  {

                        // Error during line reading

                        errorCode = -1;

                        break;

                  }

            }

            closeFileErrorCode = CloseTheFile();

            if (closeFileErrorCode != 0 && errorCode == 0)

            {

                  errorCode = -2;

            }

            else

            {

                  errorCode = -3;

            }

      }

      else if (openFileErrorCode = -1)

      {

            // File does not exists

            errorCode = -4;

      }

      else if (openFileErrorCode = -2)

      {

            // File can’t be open

            errorCode = -5;

      }

      return errorCode;

}

Както се вижда, се получава един доста замотан, трудно разбираем и лесно объркващ – "спагети" код. Логиката на програмата е силно смесена с логиката за обработка на грешките и непредвидените ситуации. По-голяма част от кода е тази за правилна обработка на грешките. Същин­ският код се губи сред обработката на грешки. Грешките нямат тип, нямат текстово описание (съобщение), нямат stack trace и трябва да гадаем какво означават кодовете -1, -2, -3 и т.н. Дори много хора биха се замислили как са програмирали програмистите на C и подобни езици едно време без изключения. Звучи толкова мазохистично като да чистиш леща с боксови ръкавици.

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

void ReadFile()

{

      try

      {

            OpenTheFile();

            while (FileHasMoreLines)

            {

                  ReadNextLineFromTheFile();

                  PrintTheLine();

            }

      }

      catch (FileNotFoundException)

      {

            DoSomething();

      }

      catch (IOException)

      {

            DoSomethingElse();

      }

      finally

      {

            CloseTheFile();

      }

}

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

Групиране на различните видове грешки

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

catch (IOException e)

{

      // Handle IOException and all its descendants

}

Горният пример ще прихване не само IOException, но и всички негови наследници в това число FileNotFoundException, EndOfStreamException, PathTooLongException и много други. Няма да бъдат прихванати изключения като UnauthorizedAccessException (липса на права за извър­шване на дадена операция) OutOfMemoryException (препълване на па­метта), тъй като те не са наследници на IOException. Ако се съмнявате кои изключения да прихванете, разгледайте йерархията на изключенията в MSDN.

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

catch (Exception e)

{

      // A (too) general exception handler

}

Прихващането на Exception и всички негови наследници като цяло не е добра практика. За предпочитане е прихващането на по-конкретни групи от изключения като IOException или на един единствен тип изключение като например FileNotFoundException.

Предаване на грешките за обработка в стека на методите – прихващане на нива

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

Method3()

{

      Method2();

}

 

Method2()

{

      Method1();

}

 

Method1()

{

      ReadFile();

}

Метода Method3() извиква Method2(), който от своя страна извиква Method1() където се вика ReadFile(). Да предположим, че Method3() е този, който се интересува от възможна възникнала грешка в метода ReadFile(). Ако възникне такава грешка в ReadFile(), при традиционния подход с кодове на грешка прехвърлянето й до Method3() не би било никак лесно:

void Method3()

{

      errorCode = Method2();

      if (errorCode != 0)

            process the error;

      else

            DoTheActualWork();

}

 

int Method2()

{

      errorCode = Method1();

      if (errorCode != 0)

            return errorCode;

      else

            DoTheActualWork();

}

 

int Method1()

{

      errorCode = ReadFile();

      if (errorCode != 0)

            return errorCode;

      else

            DoTheActualWork();

}

Като начало в Method1() трябва анализираме кода за грешка връщан от метода ReadFile() и евентуално да предадем на Method2(). В Method2() трябва да анализираме кода за грешка връщан от Method1() и евентуално да го предадем на Method3(), където да се обработи самата грешка.

Как можем да избегнем всичко това? Да си припомним, че средата за изпълнение (CLR) търси прихващане на изключения назад в стека на извикване на методите и позволява на всеки един от методите в стека да дефинира прихващане и обработка на изключенията. Ако методът не е заинтере­сован да прихване някое изключение, то просто се препраща назад в стека:

void Method3()

{

      try

      {

            Method2();

      }

      catch (Exception e)

      {

            process the exception;

      }

}

 

void Method2()

{

      Method1();

}

 

void Method1()

{

      ReadFile();

}

Ако възникне грешка при четенето на файла, то тя ще се пропусне от Method1() и Method2() и ще се прихване и обработи чак в Method3(), където всъщност е най-подходящото място за обработка на грешката. Да си припомним отново най-важното правило: всеки метод трябва да прихваща само грешките, които е компетентен да обработи и трябва да пропуска всички останали грешки.

Добри практики при работа с изключения

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

Кога да разчитаме на изключения?

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

Срещу подобно събитие можем да се защитим като първо проверим дали файлът съществува и чак тогава да се опитаме да го отворим:

static void ReadFile(string fileName)

{

      if (!File.Exists(fileName))

      {

            Console.WriteLine(

                  "The file '{0}' does not exist.", fileName);

            return;

      }

 

      StreamReader reader = new StreamReader(fileName);

      using (reader)

      {

            while (!reader.EndOfStream)

            {

                  string line = reader.ReadLine();

                  Console.WriteLine(line);

            }

      }

}

Ако изпълним метода и файлът липсва, ще получим следното съобщение на конзолата:

The file 'WrongTextFile.txt' does not exist.

Другият вариант да имплементираме същата логика е следният:

static void ReadFile(string filename)

{

      StreamReader reader = null;

      try

      {

            reader = new StreamReader(filename);

            while (!reader.EndOfStream)

            {

                  string line = reader.ReadLine();

                  Console.WriteLine(line);

            }

            reader.Close();

      }

      catch (FileNotFoundException)

      {

            Console.WriteLine(

                  "The file '{0}' does not exist.", filename);

      }

      finally

      {

            if (reader != null)

            {

                  reader.Close();

            }

      }

}

По принцип вторият вариант се счита за по-лош, тъй като изключенията трябва да се ползват за изключителни ситуации, а липсата на файла в нашия случай е по-скоро обичайна ситуация.

Недобра практика е да се разчита на изключения за обработка на очак­вани събития и от още една гледна точка: производителност. Хвърлянето на изклю­чение е бавна операция, защото трябва да се създаден обект, съдържащ изключението, да се инициализира stack trace, да се открие обработчик на това изключение и т.н.

clip_image006[4]

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

Да хвърляме ли изключения на потребителя?

Изключенията са неясни и объркващи за обикновения потребител. Те създават впечатление за лошо написана програма, която "гърми некон­тролирано" и "има бъгове". Представете си какво ще си помисли една въз­растна служителка (с оглед на дадения пример), която въвежда фактури, ако внезапно приложението й покаже следния диалог:

clip_image013

Този диалог е много подходящ за технически лица (например програмисти и администратори), но е изключително неподходящ за крайния потре­бител (особено, когато той няма технически познания).

Вместо този диалог можем да покажем друг, много по-дружелюбен и разбираем за обикновения потребител диалог:

clip_image014

Това е добрият начин да показваме съобщения за грешка: хем да има разбираемо съобщение на езика на потребителя (в случая на български език), хем да има и техническа информация, която може да бъде извлечена при нужда, но не се показва в самото начало, за да не стряска потребителите.

Препоръчително е изключения, които не са хванати от никой (такива може да са само runtime изключенията), да се хващат от общ глобален "прихващач", който да ги записва (в най-общия случай) някъде по твърдия диск, а на потребителя да показва "приятелско" съобщение в стил: "Възникна грешка, опитайте по-късно". Добре е винаги да показвате освен съобще­ние разбираемо за потребителя и техническа информация (stack trace), която обаче да е достъпна само ако потребителят я поиска.

Хвърляйте изключенията на съответното ниво на абстракция!

Когато хвърляте ваши изключения, съобразявайте се с абстракциите, в контекста, на които работи вашият метод. Например, ако вашият метод се отнася за работа с масиви, може да хвърлите IndexOutOfRangeException или NullReferenceException, тъй като вашият метод работи на ниско ниво и оперира директно с паметта и с елементите на масивите. Ако, обаче имате метод, който извършва олихвяване на всички сметки в една банка, той не трябва да хвърля IndexOutOfRangeException, тъй като това изключение не е от бизнес областта на банковия сектор и олихвяването. Нормално е олихвяването в банковия софтуер да хвърли изключение InvalidInterestException с подхо­дящо съобщение за грешка от бизнес областта на банките, за което би могло да бъде закачено (вложено) оригиналното изключение IndexOutOfRangeException.

Представете си да сте си купили билет за автобус и пристигайки на автогарата омаслен монтьор да ви обясни, че ходовата част на автобуса има нужда от регулиране и кормилната рейка е нестабилна. Освен, ако не сте монтьор или специалист по авто­мобили, тази информация не ви помага с нищо. Нито става ясно колко ще се забави вашето пътуване, нито дали въобще ще пътувате. Вие очаквате, ако има проблем, да ви посрещне усмихната девойка от фирмата-превоз­вач и да ви обясни, че резервният автобус ще дойде след 10 минути и до тогава можете да изчакате на топло в кафенето.

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

Можем да дадем още един пример: извикваме метод, който сортира масив с числа и той хвърля изключение TransactionAbortedException. Това е също толкова неадекватно съобщение, колкото и NullReferenceException при изпълнение на олихвяването в една банка. Веднага ще си помислите "Каква транзакция, какви пет лева? Нали сортираме масив!" и този въпрос е напълно адек­ватен. Затова се съобразявайте с нивото на абстракция, на което работи даденият метод, когато хвърляте изключение от него.

Ако изключението има причинител, запазвайте го!

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

Това правилo е частен случай на по-генералното правило:

clip_image006[5]

Всяко изключение трябва да носи в себе си максимално подробна информация за настъпилия проблем.

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

Давайте подробно описателно съобщение при хвърляне на изключение!

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

Да вземем един пример: имате метод, който прочита настройките на дадено приложение от текстов файл. Това са примерно местоположенията и размерите на всички прозорци в приложението и други настройки. Случва се проблем при четенето на файла с настройките и получавате съобщение за грешка:

Error.

Това достатъчно ли ви е, за да разберете какъв е проблемът? Очевидно не, нали? Какво съобщение трябва да дадем, така че то да е достатъчно информативно? Това съобщение по-добро ли е?

Error reading settings file.

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

Error reading settings file: C:\Users\Administrator\MyApp\MyApp.settings

Това съобщение очевидно е по-добро, защото ни подсказва в кой файл е проблемът (нещо, което би ни спестило много време, особено ако не сме запознати с приложението и не знаем къде точно то пази файла с настройките си). Може ситуацията да е дори по-лоша – може да нямаме сорс кода на въпросното приложение или модул, който генерира греш­ката. Тогава е възможно да нямаме пълен stack trace (ако сме компи­лирали без дебъг информация) или ако имаме stack trace, той не ни върши работа, защото нямаме сорс кода на проблемния файл, хвърлил изключението. Затова съобщението за грешка трябва да е още по-подробно, например като това:

Error reading settings file: C:\Users\Administrator\MyApp\MyApp.settings. Number expected at line 17.

Това съобщение вече само говори за проблема. Очевидно имаме грешка на ред 17 във файла MyApp.settings, който се намира в папката C:\Users\Administrator\MyApp. В този ред трябва да има число, а има нещо друго. Ако отворим файл, бързо можем да намерим проблема, нали?

Изводът от този пример е само един:

clip_image006[6]

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

Ще дадем още няколко примера:

-     Имаме метод, който търси число в масив. Ако той хвърли IndexOutOfRangeException, от изключително значение е в съобщението за грешка да се упомене индексът, който не може да бъде достъпен, примерно 18 при масив с дължина 7. Ако не знаем позицията, трудно ще разберем защо се получава излизане от масива.

-     Имаме метод, който чете числа от файл. Ако във файла се срещне някой ред, на който няма число, би трябвало да получим грешка, която обяснява, че на ред 17 (примерно) се очаква число, а там има символен низ (и да се отпечата точно какъв символен низ има там).

-     Имаме метод, който изчислява стойността на числен израз. Ако намерим грешка в израза, изключението трябва да съобщава каква грешка е възникнала и на коя позиция. Кодът, който предизвиква грешката може да ползва String.Format(…), за да построи съоб­щението за грешка. Ето един пример:

throw new FormatException(

      string.Format("Invalid character at position {0}. " +

      "Number expected but character '{1}' found.", index, ch));

Съобщение за грешка с невярно съдържание

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

clip_image006[7]

Внимавайте да не отпечатвате съобщения за грешка с невярно съдържание!

За съобщенията за грешки използвайте английски

Използвайте английски език в съобщенията за грешки. Това правило е много просто. То е частен случай на принципа, че целият сорс код на програмите ви (включително коментарите и съобщенията за грешки) трябва да са на английски език. Причината за това е, че английският е единственият език, който е разбираем за всички програмисти по света. Никога не знаете дали кодът, който пишете няма в някой слънчев ден да се ползва от чужденци. Хубаво ли ще ви е, ако ползвате чужд код и той ви съобщава за грешки примерно на виетнамски език?

Никога не игнорирайте прихванатите изключения!

Никога не игнорирайте изключенията, които прихващате, без да ги обра­ботите. Ето един пример как не трябва да правите:

try

{

      string fileName = "WrongTextFile.txt";

      ReadFile(fileName);

}

catch (Exception e)

{ }

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

Начинаещите програмисти понякога пишат такъв код. Вие нямате причина да пишете такъв код, нали?

Ако понякога се наложи да игнорирате изключение, нарочно и съзна­телно, добавете изричен коментар, който да помага при четене на кода. Ето един пример:

int number = 0;

try

{

      string line = Console.ReadLine();

      number = Int32.Parse(line);

}

catch (Exception)

{

      // Incorrect numbers are intentionally considered 0

}

Console.WriteLine("The number is: " + number);

Кодът по-горе може да се подобри като или се използва Int32. TryParse(…) или като променливата number се занулява в catch блока, а не предварително. Във втория случай коментарът в кода няма да е необходим и няма да има нужда от празен catch блок.

Отпечатвайте съобщенията за грешка на конзолата само в краен случай!

Представете си например нашия метод, който чете настройките на прило­жението от текстов файл. Ако възникне грешка, той би могъл да я отпечата на конзолата, но какво ще стане с извикващия метод? Той ще си помисли, че настройките са били успешно прочетени, нали?

Има едно много важно правило в програмирането:

clip_image006[8]

Един метод или трябва да върши работата, за която е предназначен, или трябва да хвърля изключение.

Това правило е много, много важно и затова ще го повторим в малко по-разширена форма:

clip_image006[9]

Един метод или трябва да върши работата, за която е предназначен, или трябва да хвърля изключение. При грешни входни данни методът трябва да връща изключение, а не грешен резултат!

Това правило можем да обясним в по-големи детайли: Един метод се пише, за да свърши някаква работа. Какво върши методът трябва да става ясно от неговото име. Ако не можем да дадем добро име на метода, значи той прави много неща и трябва да се раздели на части, всяка от които да е в отделен метод. Ако един метод не може да свърши работата, за която е предназначен, той трябва да хвърли изключение. Например, ако имаме метод за сортиране на масив с числа, ако масивът е празен, методът или трябва да върне празен масив, или да съобщи за грешка. Грешните входни данни трябва да предизвикват изключение, не грешен резултат! Например, ако се опитаме да вземем от даден символен низ с дължина 10 символа подниз от позиция 7 до позиция 12, трябва да получим изключение, не да върнем по-малко символи. Ако обърнете внимание, ще се уверите, че точно така работи методът Substring() в класа String.

Ще дадем още един, по-убедителен пример, който потвърждава правилото, че един метод или трябва да свърши работата, за която е написан, или трябва да хвърли изключение. Да си представим, че копираме голям файл от локалния диск към USB flash устройство. Може да се случи така, че мястото на flash устройството не достига и файлът не може да бъде копиран. Кое от следните е правилно да направи програмата за копиране на файлове (примерно Windows Explorer)?

-     Файлът не се копира и копирането завършва тихо, без съобщение за грешка.

-     Файлът се копира частично, доколкото има място на flash устрой­ството. Част от файла се копира, а останалата част се отрязва. Не се показва съобщение за грешка.

-     Файлът се копира частично, доколкото има място на flash устрой­ството и се показва съобщение за грешка.

-     Файлът не се копира и се показва съобщение за грешка.

Единственото коректно от гледна точка на очакванията на потребителя поведение е последното: при проблем файлът трябва да не се копира частично и трябва да се покаже съобщение за грешка. Същото важи, ако трябва да напишем метод, който копира файлове. Той или трябва да копира напълно и до край зададения файл или трябва да предизвика изключение като същевременно не оставя следи от започната и недовър­шена работа (т.е. трябва да изтрие частичният резултат, ако е създаден такъв).

Не прихващайте всички изключения!

Една много често срещана грешка при работата с изключения е да се прихващат всички грешки, без оглед на техния тип. Ето един пример, при който грешките се обработват некоректно:

try

{

      ReadFile("CorrectTextFile.txt");

}

catch (Exception)

{

      Console.WriteLine("File not found.");

}

В този код предполагаме, че имаме метод ReadFile(), който прочита текстов файл и го връща като string. Забелязваме, че catch блокът прихваща наведнъж всички изключения (независимо от типа им), не само FileNotFoundException, и при всички случаи отпечатва, че файлът не е намерен. Хубаво, обаче има ситуации, които са непредви­дени. Например какво става, когато файлът е заключен от друг процес в операционната система. В такъв случай средата за изпълнение CLR ще генерира UnauthorizedAccessException, но съобщението за грешка, което програ­мата ще изведе към потребителя, ще е грешно и подвеждащо. Файлът ще го има, а програмата ще твърди, че го няма, нали? По същия начин, ако при отварянето на файла свърши паметта, ще се генерира съобщение OurOfMemoryException, но отпечатаната грешка ще е отново некоректна.

Прихващайте само изключения, от които разбирате и знаете как да обработите!

Какъв е изводът от последния пример? Трябва да обработваме само греш­ките, които очакваме и за които сме подготвени. Останалите грешки (изключе­ния) не трябва въобще да ги прихващаме, а трябва да ги оставим да ги прихване някой друг метод, който знае какво да ги прави.

clip_image006[10]

Един метод трябва да прихваща само изключенията, които е компетентен да обработи адекватно, а не всички.

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

Упражнения

1.      Да се намерят всички стандартни изключения от йерархията на System.IO.IOException.

2.    Да се намерят всички стандартни изключения от йерархията на System.IO.FileNotFoundException.

3.    Да се намерят всички стандартни изключения от йерархията на System.ApplicationException.

4.    Обяснете какво представляват изключенията, кога се използват и как се прихващат.

5.    Обяснете ситуациите, при които се използва try-finally конструк­цията. Обяснете връзката между try-finally и using конструкциите.

6.    Обяснете предимствата на използването на изключения.

7.    Напишете програма, която прочита от конзолата цяло положително число и отпечатва на конзолата корен квадратен от това число. Ако числото е отрицателно или невалидно, да се изпише "Invalid Number" на конзолата. Във всички случаи да се принтира на конзолата       "Good Bye".

8.      Напишете метод ReadNumber(int start, int end), който въвежда от конзолата число в диапазона [start…end]. В случай на въведено невалидно число или число, което не е в подадения диапазон хвърлете подходящо изключение. Използвайки този метод напишете програма, която въвежда 10 числа a1, a2, …, a10, такива, че 1 < a1 < … < a10 < 100.

9.    Напишете метод, който приема като параметър име на текстов файл, прочита съдържанието му и го връща като string. Какво е правилно да направи методът с евентуално възникващите изключения?

10. Напишете метод, който приема като параметър име на бинарен файл и прочита съдържанието на файла и го връща като масив от байтове. Напишете метод, който записва прочетеното съдържание в друг файл. Сравнете двата файла.

11. Потърсете информация в Интернет и дефинирайте собствен клас за изключение FileParseException. Вашето изключение трябва да съ­държа в себе си името на файл, който се обработва и номер на ред, в който е възникнал проблем. Добавете подходящи конструктори за вашето изключение. Напишете програма, която чете от текстов файл числа. Ако при четенето се стигне до ред, който не съдържа число, хвърлете FileParseException и го обработете в извикващия метод.

12. Напишете програма, която прочита от потребителя пълен път до даден файл (например C:\Windows\win.ini), прочита съдържанието на файла и го извежда на конзолата. Намерете в MSDN как да използвате метода System.IO.File.ReadAllText(…). Уверете се, че прихващате всички възможни изключения, които могат да възникнат по време на работа на метода и извеждайте на конзолата съобщения за грешка, разбираеми за обикновения потребител.

13.   Напишете програма, която изтегля файл от Интернет по даден URL адрес, примерно (http://www.devbg.org/img/Logo-BASD.jpg).

Решения и упътвания

1.      Потърсете в MSDN. Най-лесният начин да направите това е да напишете в Google "IOException MSDN".

2.    Разгледайте упътването за предходната задача.

3.    Разгледайте упътването за предходната задача.

4.    Използвайте информацията от началото на настоящата тема.

5.    При затруднения използвайте информацията от секцията "Конструк­цията try-finally".

6.    При затруднения използвайте информацията от секцията "Предимства при използване на изключения".

7.    Направете try{} - catch(){} - finally{} конструкция.

8.    При въведено невалидно число може да хвърляте изключението Exception поради липсва на друг клас изключения, който по-точно да описва проблема. Алтернативно можете да дефинирате собствен клас изключение InvalidNumberException.

9.    Първо прочетете главата "Текстови файлове". Прочетете файла ред по ред с класа System.IO.StreamReader и добавяйте редовете в System.Text.StringBuilder. Изхвърляйте всички изключе­ния от метода без да ги прихващате.

10. Малко е вероятно да напишете коректно този метод от първи път без чужда помощ. Първо прочетете в Интернет как се работи с бинарни потоци. След това следвайте препоръките по-долу за четенето на файла:

-     Използвайте за четене FileStream, а прочетените данни запис­вайте в MemoryStream. Трябва да четете файла на части, примерно на последователни порции по 64 KB, като последната порция може да е по-малка.

-     Внимавайте с метода за четене на байтове FileStream.Read( byte[] buffer, int offset, int count). Този метод може да прочете по-малко байтове, отколкото сте заявили. Колкото байта прочетете от входния поток, толкова трябва да запишете. Трябва да организирате цикъл, който завършва при връщане на стойност 0 за броя прочетени байтове.

-     Използвайте using, за да затваряте коректно потоците.

Записването на масив от байтове във файл е далеч по-проста задача. Отворете FileStream и започнете да пишете в него байтовете от MemoryStream. Използвайте using, за да затваряте потоците правилно.

Накрая тествайте с някой много голям ZIP архив (примерно 300 MB). Ако програмата ви работи неко­ректно, ще счупвате структурата на архива и ще се получава грешка при отварянето му.

11. Наследете класа Exception и добавете подходящ конструктор, при­мерно FileParseException(string message, string filename, int line). След това ползвайте вашето изключение както ползвате за всички други изключения, които познавате. Числата можете да четете с класа StreamReader.

12. Потърсете всички възможни изключения, които възникват в следствие на работата на метода и за всяко от тях дефинирайте catch блок.

13.   Потърсете в Интернет статии на тема изтегляне на файл от C#. Ако се затруднявате, потърсете информация и примери за използване конкретно на класа WebClient. Уверете се, че прихващате и обра­ботвате правилно всички изключения, които могат да възникнат.

Демонстрации (сорс код)

Изтеглете демонстрационните примери към настоящата глава от книгата: Обработка-на-изключения-Демонстрации.zip.

Дискусионен форум

Коментирайте книгата и задачите в нея във: форума на софтуерната академия.


Share
Тагове: , , , , , , , , , ,

Коментирай