24/05/2018, 16:37

Phát sinh và bắt giữ ngoại lệ

Một điều quan trọng để phân chia giữa bug, lỗi, và ngoại lệ. Một bug là một lỗi lập trình có thể được sửa chữa trước khi mã nguồn được chuyển giao. Những ngoại lệ thì không được bảo vệ và tương phản với những bug. Mặc dù một bug có thể là nguyên nhân sinh ra ...

Một điều quan trọng để phân chia giữa bug, lỗi, và ngoại lệ. Một bug là một lỗi lập trình có thể được sửa chữa trước khi mã nguồn được chuyển giao. Những ngoại lệ thì không được bảo vệ và tương phản với những bug. Mặc dù một bug có thể là nguyên nhân sinh ra ngoại lệ, chúng ta cũng không dựa vào những ngoại lệ để xử lý những bug trong chương trình, tốt hơn là chúng ta nên sửa chữa những bug này.

Một lỗi có nguyên nhân là do phía hành động của người sử dụng. Ví dụ, người sử dụng nhập vào một số nhưng họ lại nhập vào ký tự chữ cái. Một lần nữa, lỗi có thể làm xuất hiện ngoại lệ, nhưng chúng ta có thể ngăn ngừa điều này bằng cách bắt giữ lỗi với mã hợp lệ. Những lỗi có thể được đoán trước và được ngăn ngừa.

Thậm chí nếu chúng ta xóa tất cả những bug và dự đoán tất cả các lỗi của người dùng, chúng ta cũng có thể gặp phải những vấn đề không mong đợi, như là xuất hiện trạng thái thiếu bộ nhớ (out of memory), thiếu tài nguyên hệ thống,... những nguyên nhân này có thể do các chương trành khác cùng hoạt động ảnh hưởng đến. Chúng ta không thể ngăn ngừa các ngoại lệ này, nhưng chúng ta có thể xử lý chúng để chúng không thể làm tổn hại đến chương trình.

Khi một chương trình gặp một tình huống ngoại lệ, như là thiếu bộ nhớ thì nó sẽ tạo một ngoại lệ. Khi một ngoại lệ được tạo ra, việc thực thi của các chức năng hiện hành sẽ bị treo cho đến khi nào việc xử lý ngoại lệ tương ứng được tìm thấy.

Điều này có nghĩa rằng nếu chức năng hoạt động hiện hành không thực hiện việc xử lý ngoại lệ, thì chức năng này sẽ bị chấm dứt và hàm gọi sẽ nhận sự thay đổi đến việc xử lý ngoại lệ. Nếu hàm gọi này không thực hiện việc xử lý ngoại lệ, ngoại lệ sẽ được xử lý sớm bởi CLR, điều này dẫn đến chương trình của chúng ta sẽ kết thúc.

Một trình xử lý ngoại lệ là một khối lệnh chương trình được thiết kế xử lý các ngoại lệ mà chương trình phát sinh. Xử lý ngoại lệ được thực thi trong trong câu lệnh catch. Một cách lý tưởng thì nếu một ngoại lệ được bắt và được xử lý, thì chương trình có thể sửa chữa được vấn đề và tiếp tục thực hiện hoạt động. Thậm chí nếu chương trình không tiếp tục, bằng việc bắt giữ ngoại lệ chúng ta có cơ hội để in ra những thông điệp có ý nghĩa và kết thúc chương trình một cách rõ ràng.

Nếu đoạn chương trình của chúng ta thực hiện mà không quan tâm đến bất cứ ngoại lệ nào mà chúng ta có thể gặp (như khi giải phóng tài nguyên mà chương trình được cấp phát), chúng ta có thể đặt đoạn mã này trong khối finally, khi đó nó sẽ chắc chắn sẽ được thực hiện thậm chí ngay cả khi có một ngoại lệ xuất hiện.

Trong ngôn ngữ C#, chúng ta chỉ có thể phát sinh (throw) những đối tượng các kiểu dữ liệu là System.Exception, hay những đối tượng được dẫn xuất từ kiểu dữ liệu này. Namespace System của CLR chứa một số các kiểu dữ liệu xử lý ngoại lệ mà chúng ta có thể sử dụng trong chương trình. Những kiểu dữ liệu ngoại lệ này bao gồm ArgumentNull- Exception, InValidCastException, và OverflowException, cũng như nhiều lớp khác nữa.

Để phát tín hiệu một sự không bình thường trong một lớp của ngôn ngữ C#, chúng ta phát sinh một ngoại lệ. Để làm được điều này, chúng ta sử dụng từ khóa throw. Dòng lệnh sau tạo ra một thể hiện mới của System.Exception và sau đó throw nó:

throw new System.Exception();

Khi phát sinh ngoại lệ thì ngay tức khắc sẽ làm ngừng việc thực thi trong khi CLR sẽ tìm kiếm một trình xử lý ngoại lệ. Nếu một trình xử lý ngoại lệ không được tìm thấy trong phương thức hiện thời, thì CLR tiếp tục tìm trong phương thức gọi cho đến khi nào tìm thấy. Nếu CLR trả về lớp Main() mà không tìm thấy bất cứ trình xử lý ngoại lệ nào, thì nó sẽ kết thúc chương trình.

Throw ngoại lệ.

-----------------------------------------------------------------------------

namespace Programming_CSharp

{

using System;

public class Test

{

public static void Main()

{

Console.WriteLine("Enter Main...."); 

Test t = new Test();

t.Func1();

Console.WriteLine("Exit Main...");

}

public void Func1()

{

Console.WriteLine("Enter Func1..."); 

Func2();

Console.WriteLine("Exit Func1...");

}

public void Func2()

{

Console.WriteLine("Enter Func2..."); 

throw new System.Exception(); 

Console.WriteLine("Exit Func2...");

}

}

}

-----------------------------------------------------------------------------

Kết quả:

Enter Main....

Enter Func1...

Enter Func2...

Exception occurred: System.Exception: An exception of type System.Exception was throw.

at Programming_CSharp.Test.Func2() in ... exception01.cs:line 26 at Programming_CSharp.Test.Func1() in ... exception01.cs:line 20 at Programming_CSharp.Test.Main() in ... exception01.cs:line 12

-----------------------------------------------------------------------------

Ví dụ minh họa đơn giản này viết ra màn hình console thông tin khi nó nhập vào trong một hàm và chuẩn bị đi ra từ một hàm. Hàm Main() tạo thể hiện mới của kiểu Test và sau đó gọi hàm Func1(). Sau khi in thông điệp “Enter Func1”, hàm Func1() này gọi hàm Func2(). Hàm Func2() in ra thông điệp đầu tiên và phát sinh một ngoại lệ kiểu System.Exception. Việc thực thi sẽ ngưng ngay tức khắc, và CLR sẽ tìm kiếm trình xử lý ngoại lệ trong hàm Func2(). Do không tìm thấy ở đây, CLR tiếp tục vào stack lấy hàm đã gọi trước tức là Func1 và tìm kiếm trình xử lý ngoại lệ. Một lần nữa trong Func1 cũng không có đoạn xử lý ngoại lệ. Và CLR trả về hàm Main. Tại hàm Main cũng không có, nên CLR sẽ gọi trình mặc định xử lý ngoại lệ, việc này đơn giản là xuất ra một thông điệp lỗi.

Trong C#, một trình xử lý ngoại lệ hay một đoạn chương trình xử lý các ngoại lệ được gọi là một khối catch và được tạo ra với từ khóa catch.

Trong ví dụ sau, câu lệnh throw được thực thi bên trong khối try, và một khối catch được sử dụng để công bố rằng một lỗi đã được xử lý.

Bắt giữ ngoại lệ.

-----------------------------------------------------------------------------

namespace Programming_CSharp

{

using System;

public class Test

{

public static void Main()

{

Console.WriteLine("Enter Main..."); 

Test t = new Test();

t.Func1();

Console.WriteLine("Exit Main...");

}

public void Func1()

{

Console.WriteLine("Enter Func1..."); 

Func2();

Console.WriteLine("Exit Func1...");

}

public void Func2()

{

Console.WriteLine("Enter Func2...");

try

{

Console.WriteLine("Entering try block..."); 

throw new System.Exception(); 

Console.WriteLine("Exiting try block...");

}

catch

{

Console.WriteLine("Exception caught and handled.");

}

Console.WriteLine("Exit Func2...");

}

}

}

-----------------------------------------------------------------------------

Kết quả: Enter Main... Enter Func1... Enter Func2...

Entering try block... Exception caught and handled. Exit Func2...

Exit Func1... Exit Main...

-----------------------------------------------------------------------------

Ví dụ trên cũng tương tự như ví dụ minh họa trước ngoại trừ chương trình thêm vào trong một khối try/catch. Thông thường chúng ta cũng co thể đặt khối try bao quanh những đoạn chương trình tiềm ẩn gây ra sự nguy hiểm, như là việc truy cập file, cấp phát bộ nhớ...

Theo sau khối try là câu lệnh catch tổng quát. Câu lệnh catch trong ví dụ này là tổng quát bởi vì chúng ta không xác định loại ngoại lệ nào mà chúng ta bắt giữ. Trong trường hợp tổng quát này thì khối catch này sẽ bắt giữ bất cứ ngoại lệ nào được phát sinh. Sử dụng câu lệnh catch để bắt giữ ngoại lệ xác định sẽ được thảo luận trong phần sau của chương.

Trong ví dụ này, khối catch đơn giản là thông báo ra một ngoại lệ được bắt giữ và được xử lý. Trong ví dụ của thế giới thực, chúng ta có thể đưa hành động đúng để sửa chữa vấn đề mà gây ra sự ngoại lệ. Ví dụ, nếu người sử dụng đang cố mở một tập tin có thuộc tính chỉ đọc, chúng ta có thể gọi một phương thức cho phép người dùng thay đổi thuộc tính của tập tin. Nếu chương trình thực hiện thiếu bộ nhớ, chúng ta có thể phát sinh cho người dùng cơ hội để đóng bớt các ứng dụng khác lại. Thậm chí trong trường hợp xấu nhất ta không khắc phục được thì khối catch này có thể in ra thông điệp lỗi để người dùng biết.

Thử kiểm tra kỹ lại chương trình trên, chúng ta sẽ thấy xuất hiện đoạn mã đi vào từng hàm như Main(), Func1(), Func2(), và cả khối try. Chúng ta không bao giờ thấy nó thoát khối lệnh try (tức là in ra thông báo “Exiting try block...”, hay thực hiện lệnh này), mặc dù nó vẫn thoát ra theo thứ tự Func2(), Func1(), và Main(). Chuyện gì xảy ra?

Khi một ngoại lệ được phát sinh, việc thi hành ngay lập tức sẽ bị tạm dừng và việc thi hành sẽ được chuyển qua khối lệnh catch. Nó không bao giờ trả về luồng thực hiện ban đầu, tức là các lệnh sau khi phát ra ngoại lệ trong khối try không được thực hiện. Trong trường hợp này chúng ta sẽ không bao giờ nhận được thông báo “Exiting try block....”. Khối lệnh catch xử lý lỗi và sau đó chuyển việc thực thi chương trình đến các lệnh tiếp sau khối catch.

Ở đây không có việc quay lại cuộc gọi hàm trước trong stack. Ngoại lệ bây giờ được xử lý, không có vấn đề gì xảy ra, và chương trình tiếp tục hoạt động bình thường. Điều này trở nên rõ ràng hơn nếu chúng ta di chuyển khối try/catch lên hàm Func1 như trong ví dụ minh họa bên dưới.

Catch trong hàm gọi.

-----------------------------------------------------------------------------

namespace Programming_CSharp

{

using System;

public class Test

{

public static void Main()

{

Console.WriteLine("Enter Main..."); 

Test t = new Test();

t.Func1();

Console.WriteLine("Exit Main...");

}

public void Func1()

{

Console.WriteLine("Enter Func1...");

try

{

Console.WriteLine("Entering try block..."); 

Func2();

Console.WriteLine("Exiting try block...");

}

catch

{

Console.WriteLine("Exception caught and handled.");

}

Console.WriteLine("Exit Func1...");

}

public void Func2()

{

Console.WriteLine("Enter Func2..."); 

throw new System.Exception(); 

Console.WriteLine("Exit Func2...");

}

}

}

-----------------------------------------------------------------------------

Kết quả: Enter Main... Enter Func1...

Entering try block... Enter Func2...

Exception caught and handled. Exit Func1...

Exit Main...

-----------------------------------------------------------------------------

Lúc này ngoại lệ không được xử lý bên trong hàm Func2(), mà nó được xử lý bên trong hàm Func1(). Khi hàm Func2() được gọi, nó in câu lệnh thông báo vào hàm rồi phát sinh một ngoại lệ. Việc thực hiện chương trình bị ngưng, CLR tìm kiếm phần xử lý ngoại lệ, nhưng trong hàm này không có và CLR vào stack lấy hàm gọi trong trường hợp này là Func1(). Câu lệnh catch sẽ được gọi, và việc thực thi tiếp tục thực hiện bình thường sau câu lệnh catch.

Hãy chắc chắn rằng chúng ta đã hiểu rõ tại sao câu lệnh “Exiting try block” và “Exit Func2” không được in ra. Chúng ta có thể dùng cách cũ để kiểm tra việc này bằng cách dùng chương trình debug cho chương trình chạy từng bước để tìm hiểu rõ hơn.

Tạo một khối catch xác định:

Cho đến bây giờ chúng ta chỉ dùng khối catch tổng quát, tức là với bất cứ ngoại lệ nào cũng được. Tuy nhiên chúng ta có thể tạo ra khối catch xác định để xử lý chỉ một vài các ngoại lệ chứ không phải toàn bộ ngoại lệ, dựa trên kiểu của ngoại lệ phát sinh. Ví dụ sau minh họa cách xác định loại ngoại lệ mà chúng ta xử lý.

Xác định ngoại lệ để bắt.

-----------------------------------------------------------------------------

namespace Programming_CSharp

{

using System;

public class Test

{

public static void Main()

{

Test t = new Test();

t.TestFunc();

}

// ta thử chia hai phần xử lý ngoại lệ riêng

public void TestFunc()

{

try

{ double a = 5;

double b = 0;

Console.WriteLine("{0} / {1} = {2}", a, b, DoDivide(a,b));

}

catch (System.DivideByZeroException)

{

Console.WriteLine("DivideByZeroException caught!");

}

catch (System.ArithmeticException)

{

Console.WriteLine("ArithmeticException caught!");

}

catch

{

Console.WriteLine("Unknown exception caught");

}

}

// thực hiện phép chia hợp lệ

public double DoDivide(double a, double b)

{

if ( b == 0)

throw new System.DivideByZeroException();

if ( a == 0)

throw new System.ArithmeticException();

return a/b;

}

}

}

-----------------------------------------------------------------------------

Kết quả:

DivideByZeroException caught!

--------------------------------------------------------------------------

Trong ví dụ này, phương thức DoDivide() sẽ không cho phép chúng ta chia cho zero bởi một số khác, và cũng không cho phép chia số zero. Nó sẽ phát sinh một đối tượng của Divide- ByzeroException nếu chúng ta thực hiện chia với zero. Trong toán học việc lấy zero chia cho một số khác là được phép, nhưng trong ví dụ minh họa của chúng ta không cho phép thực hiện việc này, nếu thực hiện sẽ phát sinh ra một ngoại lệ ArithmeticException.

Khi một ngoại lệ được phát sinh, CLR sẽ kiểm tra mỗi khối xử lý ngoại lệ theo thứ tự và sẽ lấy khối đầu tiên thích hợp. Khi chúng ta thực hiện với a=5 và b=7 thì kết quả như sau:

5 / 7 = 0.7142857142857143

Như chúng ta mong muốn, không có ngoại lệ được phát sinh. Tuy nhiên, khi chúng ta thay đổi giá trị của a là 0, thì kết quả là:

ArithmeticException caught!

Ngoại lệ được phát sinh, và CLR sẽ kiểm tra ngoại lệ đầu tiên: DivideByZeroException. Bởi vì không phù hợp, nên nó sẽ tiếp tục đi tìm và khối xử lý ArithmeticException được chọn.

Cuối cùng, giả sử chúng ta thay đổi giá trị của b là 0. Khi thực hiện điều này sẽ dẫn đến ngoại lệ DivideByZeroException.

Chúng ta phải cẩn thận thứ tự của câu lệnh catch, bởi vì DivideByZero- Exception được dẫn xuất từ ArithmeticException. Nếu chúng ta đảo thứ tự của câu lệnh catch, thì ngoại lệ DivideByZeroException sẽ được phù hợp với khối xử lý ngoại lệ Arith- meticException. Và việc xử lý ngoại lệ sẽ không bao giờ được giao cho khối xử lý DivideByZeroException. Thật vậy, nếu thứ tự này được đảo, nó sẽ không cho phép bất cứ ngoại lệ nào được xử lý bởi khối xử lý ngoại lệ DivideByZeroException. Trình biên dịch sẽ nhận ra rằng DivideByZeroException không được thực hiện bất cứ khi nào và nó sẽ thông báo một lỗi biên dịch.

Chúng ta có thể phân phối câu lệnh try/ catch, bằng cách bắt giữ những ngoại lệ xác định trong một hàm và nhiều ngoại lệ tổng quát trong nhiều hàm. Mục đích của thực hiện này là đưa ra các thiết kế đúng. Giả sử chúng ta có phương thức A, phương thức này gọi một phương thức khác tên là phương thức B, đến lượt mình phương thức B gọi phương thức C. Và phương thức C tiếp tục gọi phương thức D, cuối cùng phương thức D gọi phương thức E. Phương thức E ở mức độ sâu nhất trong chương trình của chúng ta, phương thức A, B ở mức độ cao hơn. Nếu chúng ta đoán trước phương thức E có thể phát sinh ra ngoại lệ, chúng ta có thể tạo ra khối try/catch để bắt giữ những ngoại lệ này ở chỗ gần nơi phát sinh ra ngoại lệ nhất. Chúng ta cũng có thể tạo ra nhiều khối xử lý ngoại lệ chung ở trong đoạn chương trình ở mức cao trong trường hợp những ngoại lệ không đoán trước được.

Trong một số tình huống, việc phát sinh ngoại lệ và unwind stack có thể tạo ra một số vấn đề. Ví dụ như nếu chúng ta mở một tập tin hay trường hợp khác là xác nhận một tài nguyên, chúng ta có thể cần thiết một cơ hội để đóng một tập tin hay là giải phóng bộ nhớ đệm mà chương trình đã chiếm giữ trước đó.

Trong ngôn ngữ C#, vấn đề này ít xảy ra hơn do cơ chế thu dọn tự động của C# ngăn ngừa những ngoại lệ phát sinh từ việc thiếu bộ nhớ.

Tuy nhiên, có một số hành động mà chúng ta cần phải quan tâm bất cứ khi nào một ngoại lệ được phát sinh ra, như việc đóng một tập tin, chúng ta có hai chiến lược để lựa chọn thực hiện. Một hướng tiếp cận là đưa hành động nguy hiểm vào trong khối try và sau đó thực hiện việc đóng tập tin trong cả hai khối catch và try. Tuy nhiên, điều này gây ra đoạn chương trình không được đẹp do sử dụng trùng lắp lệnh. Ngôn ngữ C# cung cấp một sự thay thế tốt hơn trong khối finally.

Đoạn chương trình bên trong khối catch được đảm bảo thực thi mà không quan tâm đến việc khi nào thì một ngoại lệ được phát sinh. Phương thức TestFunc() trong ví dụ 13.5 minh họa việc mở một tập tin như là hành động đầu tiên của nó, sau đó phương thức thực hiện một vài các phép toán toán học, và sau đó là tập tin được đóng. Có thể trong quá trình mở tập tin cho đến khi đóng tập tin chương trình phát sinh ra một ngoại lệ. Nếu xuất hiện ngoại lệ, và khi đó tập tin vẫn còn mở. Người phát triển biết rằng không có chuyện gì xảy ra, và cuối của phương thức này thì tập tin sẽ được đóng. Do chức năng đóng tập tin được di chuyển vào trong khối finally, ở đây nó sẽ được thực thi mà không cần quan tâm đến việc có phát sinh hay không một ngoại lệ trong chương trình.

Sử dụng khối finally.

-----------------------------------------------------------------------------

namespace Programming_CSharp

{

using System;

public class Test

{

public static void Main()

{

Test t = new Test();

t.TestFunc();

}

// chia hai số và xử lý ngoại lệ nếu có 

public void TestFunc()

{

try

{

Console.WriteLine("Open file here");

double a = 5;

double b = 0;

Console.WriteLine("{0} /{1} = {2}", a, b, DoDivide(a,b)); 

Console.WriteLine("This line may or not print");

}

catch (System.DivideByZeroException)

{

Console.WriteLine("DivideByZeroException caught!");

}

catch

{

Console.WriteLine("Unknown exception caught");

}

Finally

{

Console.WriteLine("Close file here.");

}

}

// thực hiện chia nếu hợp lệ

public double DoDivide(double a, double b)

{

if ( b == 0)

{

throw new System.DivideByZeroException();

}

if ( a == 0)

{

throw new System.ArithmeticException();

}

return a/b;

}

}

}

-----------------------------------------------------------------------------

Kết quả:

Open file here

DivideByZeroException caught! Close file here.

Kết quả trong trường hợp b = 12

Open file here

5/ 12 = 0.416666666666

Close file here

Trong ví dụ này một khối catch được loại bỏ và thêm vào khối finally. Bất cứ khi một ngoại lệ có được phát sinh ra hay không thì khối lệnh bên trong finally cũng được thực thi. Do vậy nên trong cả hai trường hợp ta cũng thấy xuất hiện thông điệp “Close file here”.

0