Menu

Ném ngoại lệ trong C#: guard clause, throw expression, ngoại lệ tự tạo

Cách và thời điểm ném ngoại lệ trong C#. Tìm hiểu câu lệnh throw, kiểu ngoại lệ có sẵn nào hợp với lỗi nào, throw expression với ?? và ?:, các helper ThrowIfNull, và cách viết lớp ngoại lệ tự tạo có dữ liệu riêng và inner exception.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Bắt ngoại lệ (exception) mới là một nửa của việc xử lý lỗi; nửa còn lại là ném đúng ngoại lệ. Một ngoại lệ được chọn kỹ cho nơi gọi biết chính xác điều gì đã sai, và đó là lỗi của họ hay do trạng thái của chương trình. Trang này nói về phía ném; phần xử lý nằm ở try catch.

Câu lệnh throw và guard clause

throw nhận một đối tượng ngoại lệ. Phương thức dừng chạy tại đó, và ngoại lệ đi ngược lên call stack để tìm trình xử lý. Cách dùng phổ biến nhất là guard clause: các bước kiểm tra ở đầu phương thức, từ chối đầu vào không hợp lệ trước khi làm bất cứ việc gì.

Đầu ra:

ArgumentOutOfRangeException for parameter 'amount'
InvalidOperationException: The account is frozen.
Balance: 100

nameof(amount) tạo ra chuỗi "amount" và vẫn đúng nếu tham số được đổi tên. Các ngoại lệ về đối số lưu chuỗi này trong ParamName, để công cụ và log chỉ ra đúng đối số bị sai.

Guard clause giúp phần còn lại của phương thức đơn giản: qua được các bước kiểm tra, code có thể coi đầu vào là hợp lệ. Chúng cũng báo lỗi ngay tại chỗ sai, thay vì để một giá trị hỏng trôi tiếp và gây ra một NullReferenceException khó hiểu ba phương thức sau đó.

Nên ném kiểu ngoại lệ nào

Hãy dùng lại một kiểu có sẵn khi nó mô tả đúng tình huống; nơi gọi đã biết cách xử lý nó.

Tình huốngNém
Một đối số bắt buộc là nullArgumentNullException
Một đối số nằm ngoài khoảng cho phép (số lượng âm, chỉ số vượt quá cuối)ArgumentOutOfRangeException
Một đối số không hợp lệ theo cách khác (tên rỗng, mã sai định dạng)ArgumentException
Lời gọi không hợp lệ với trạng thái hiện tại của đối tượngInvalidOperationException
Kiểu này không bao giờ hỗ trợ thao tác đó (Add của một collection chỉ đọc)NotSupportedException
Phương thức chưa được viếtNotImplementedException
Một đối tượng bị dùng sau khi đã DisposeObjectDisposedException
Một thao tác có giới hạn thời gian đã hết giờTimeoutException

Ranh giới giữa ba hàng đầu và InvalidOperationException nằm ở chỗ ai cần thay đổi. Ngoại lệ về đối số nói "hãy gọi theo cách khác". InvalidOperationException nói "lời gọi không sai, nhưng chưa phải lúc".

Đừng ném trực tiếp Exception, SystemException hay ApplicationException: nơi gọi không thể bắt chúng mà không bắt luôn mọi thứ khác. Cũng đừng tự ném NullReferenceException, IndexOutOfRangeException hay StackOverflowException; runtime dành riêng các kiểu này cho bug thật.

Throw expression

Trước C# 7, throw chỉ là một câu lệnh. Từ C# 7, nó còn có thể xuất hiện như một biểu thức ở ba chỗ, giúp các bước kiểm tra quen thuộc gọn lại thành một dòng:

Đầu ra:

Ana <ana@example.com>
Null: name
ArgumentException: email

Lưu ý ArgumentNullException kế thừa từ ArgumentException, nên một catch (ArgumentException) đặt trước sẽ bắt luôn trường hợp null. Cũng vì vậy mà thứ tự các mệnh đề catch quan trọng khi bạn xử lý cả hai.

ThrowIfNull và các helper tương tự (.NET 6 trở lên)

.NET hiện đại có thêm các helper tĩnh viết sẵn bước kiểm tra và lệnh ném cho bạn, tên tham số được lấy tự động:

public void Ship(Order order, int quantity, string address)
{
    ArgumentNullException.ThrowIfNull(order);                   // .NET 6
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); // .NET 8
    ArgumentException.ThrowIfNullOrWhiteSpace(address);          // .NET 8
    ObjectDisposedException.ThrowIf(disposed, this);             // .NET 7

    // ...
}

Chúng hoạt động giống hệt if và throw viết tay, và giữ mỗi guard clause trong một dòng. Với các phiên bản cũ hơn, hãy viết theo dạng if ở trên.

Viết lớp ngoại lệ tự tạo

Hãy tạo kiểu ngoại lệ riêng khi nơi gọi cần bắt riêng lỗi cụ thể này, hoặc khi trình xử lý cần dữ liệu mà một chuỗi thông báo khó chứa cho gọn.

Đầu ra:

Cannot withdraw 25 from a balance of 15.
Short by 10

Các quy ước:

  • Tên kết thúc bằng Exception.
  • Lớp kế thừa từ Exception (hoặc từ một kiểu có sẵn cụ thể hơn khi nó là trường hợp đặc biệt của kiểu đó, chẳng hạn InvalidOperationException).
  • Có ba hàm khởi tạo chuẩn: không tham số, nhận thông báo, và nhận thông báo cùng inner exception. Bạn có thể thêm hàm khởi tạo riêng bên cạnh.
  • Dữ liệu thêm nằm trong các thuộc tính chỉ đọc, được gán trong hàm khởi tạo. Nhờ đó trình xử lý có thể dựa vào e.Requested thay vì phải phân tích chuỗi thông báo.

Bọc ngoại lệ bằng inner exception

Khi một lỗi ở tầng thấp cần hiện ra như một lỗi ở tầng cao hơn, hãy bọc nó lại. Ngoại lệ gốc được giữ trong InnerException, nên không mất thông tin nào:

Đầu ra:

Setting 'port' must be a number, got '80a'.
Caused by: FormatException

Giờ nơi gọi làm việc với khái niệm cấu hình, thứ nó hiểu được, còn khi ghi log e.ToString() sẽ in ra cả chuỗi lỗi, bao gồm FormatException và stack trace của nó. Chỉ bọc khi bạn thêm được ý nghĩa; bọc mọi ngoại lệ trong một MyAppException chung chung chỉ khiến trình xử lý phải đào bới InnerException.

Ném ngoại lệ hay trả về kết quả

Ngoại lệ dành cho những lỗi mà nơi gọi không lường trước trong lúc chạy bình thường. Với những kết quả thường gặp, như một lần tra cứu hay không tìm thấy gì hoặc dữ liệu người dùng nhập hay sai, quy ước của .NET là Try pattern: trả về bool và đưa giá trị ra qua một tham số out.

public bool TryWithdraw(decimal amount, out string error)
{
    if (amount > Balance) { error = "Insufficient funds."; return false; }
    Balance -= amount;
    error = null;
    return true;
}

Nhiều kiểu có cả hai: int.Parse ném ngoại lệ, int.TryParse trả về false; dict[key] ném ngoại lệ, dict.TryGetValue trả về false. Ném một ngoại lệ tốn hơn nhiều so với trả về một giá trị, nên đừng đặt nó trên một đường chạy hàng nghìn lần mỗi giây. Xem ref và out để biết về tham số out.

Viết thông báo lỗi tốt

Thông báo của ngoại lệ là thứ lập trình viên đọc khi xem log. Hãy nói rõ điều gì sai và, khi an toàn, giá trị gây lỗi: "Quantity must be between 1 and 99, got 0." tốt hơn "Invalid input." Viết thành câu đầy đủ, và đừng để bí mật như mật khẩu hay token lọt vào thông báo, vì chúng sẽ nằm lại trong tệp log.

Lỗi thường gặp

  • Ném chính Exception. Nơi gọi không thể bắt riêng nó; hãy dùng một kiểu cụ thể.
  • Truyền thông báo vào chỗ của tên tham số. new ArgumentNullException("name") nhận tên tham số; thông báo là đối số thứ hai.
  • Viết cứng tên tham số. Dùng nameof(param) để tên luôn đúng khi đổi tên.
  • Ngoại lệ tự tạo không mang thêm ý nghĩa gì. Nếu một kiểu có sẵn phù hợp, hãy dùng nó.
  • Làm mất lỗi gốc khi bọc. Luôn truyền nó vào làm inner exception.

Câu hỏi thường gặp

Làm sao ném một ngoại lệ trong C#?

Tạo một đối tượng ngoại lệ rồi ném nó: throw new ArgumentException("Amount must be positive", nameof(amount));. Chương trình dừng ở dòng đó và ngoại lệ đi ngược lên call stack tới khối catch phù hợp gần nhất. Hãy chọn kiểu có sẵn cụ thể nhất mô tả đúng vấn đề, hoặc một kiểu tự tạo khi nơi gọi cần xử lý riêng trường hợp này.

Làm sao tạo ngoại lệ tự định nghĩa trong C#?

Tạo một lớp kế thừa từ Exception có tên kết thúc bằng Exception, rồi thêm các hàm khởi tạo chuẩn: một hàm không có đối số, một hàm nhận thông báo, và một hàm nhận thông báo cùng inner exception, mỗi hàm gọi hàm khởi tạo base(...) tương ứng. Thêm các thuộc tính chỉ đọc cho dữ liệu mà trình xử lý cần, chẳng hạn mã đơn hàng hay số dư.

Khi nào nên ném ArgumentException, khi nào ném InvalidOperationException?

Ném ArgumentException (hoặc ArgumentNullException / ArgumentOutOfRangeException) khi nơi gọi truyền vào một giá trị sai: cách sửa là gọi phương thức theo cách khác. Ném InvalidOperationException khi đối số không có vấn đề nhưng đối tượng đang ở trạng thái không cho phép lời gọi đó, chẳng hạn đọc từ một kết nối đã đóng hoặc rút tiền từ một tài khoản đã bị khóa.

Throw expression trong C# là gì?

Từ C# 7, throw có thể dùng như một biểu thức ở ba chỗ: sau ??, ở một trong hai nhánh của ?:, và làm thân của một thành viên dạng expression-bodied hoặc lambda. Ví dụ, _name = name ?? throw new ArgumentNullException(nameof(name)); gán giá trị hoặc ném ngoại lệ chỉ trong một dòng.

ArgumentNullException.ThrowIfNull dùng để làm gì?

Đây là một helper tĩnh có từ .NET 6: ArgumentNullException.ThrowIfNull(customer); ném ArgumentNullException với tên tham số được điền tự động khi customer là null, và không làm gì trong trường hợp ngược lại. Các phiên bản sau thêm những helper tương tự như ArgumentException.ThrowIfNullOrEmpty (.NET 7) và ArgumentOutOfRangeException.ThrowIfNegative (.NET 8).

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu