07 · Exception Handling¶
Exceptions represent errors detected at runtime. C# lets you handle them explicitly instead of crashing the whole program.
try / catch / finally¶
int a = 10;
int b = 0;
try
{
int result = a / b; // throws DivideByZeroException
Console.WriteLine(result);
}
catch (DivideByZeroException e)
{
Console.WriteLine("Cannot divide by zero: " + e.Message);
}
finally
{
Console.WriteLine("This always runs, error or not.");
}
Console.WriteLine("Program continues normally.");
// Cannot divide by zero: Attempted to divide by zero.
// This always runs, error or not.
// Program continues normally.
finally runs whether or not an exception was thrown — used for cleanup
(closing files, releasing resources). Integer division by zero throws
DivideByZeroException in C# — unlike double division by zero, which
produces Infinity/NaN instead of throwing.
Catching multiple exception types¶
string?[] inputs = { "42", "oops", null };
foreach (var input in inputs)
{
try
{
int value = int.Parse(input!);
Console.WriteLine("Parsed: " + value);
}
catch (FormatException)
{
Console.WriteLine("Not a number: " + input);
}
catch (ArgumentNullException)
{
Console.WriteLine("Input was null");
}
}
// Parsed: 42
// Not a number: oops
// Input was null
int.Parse throws FormatException on unparseable text and
ArgumentNullException on null — that's why there are two catch blocks.
(The ! after input is the null-forgiving operator, telling the compiler
"trust me, I'm handling the null case myself" — more on nullable reference
types in Level 2.) The TryParse pattern from Module 4 avoids exceptions
entirely and is usually preferred when failure is an expected, common case
rather than truly exceptional.
Exception filters with when¶
try
{
throw new InvalidOperationException("boom");
}
catch (FormatException)
{
Console.WriteLine("format");
}
catch (Exception e) when (e.Message == "boom")
{
Console.WriteLine("Caught filtered: " + e.Message);
}
// Caught filtered: boom
A when clause on a catch lets you match an exception type and an
additional condition — if the condition is false, the exception keeps
propagating to the next catch (or up the call stack) instead of being
swallowed.
Throwing your own exceptions¶
public class AgeValidator
{
public static void Validate(int age)
{
if (age < 0 || age > 150)
throw new ArgumentOutOfRangeException(nameof(age), $"Age out of range: {age}");
}
}
try
{
AgeValidator.Validate(-5);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine("Validation failed: " + e.Message);
}
// Validation failed: Age out of range: -5 (Parameter 'age')
nameof(age) yields the literal string "age" — it stays correct even if
you rename the parameter later, since the compiler checks it. C#'s built-in
ArgumentException, ArgumentNullException, and ArgumentOutOfRangeException
cover most "bad input to this method" cases; prefer them over a generic
Exception so callers can catch precisely.
Custom exception types¶
public class InsufficientFundsException : Exception
{
public InsufficientFundsException(string message) : base(message) { }
}
try
{
throw new InsufficientFundsException("Not enough balance");
}
catch (InsufficientFundsException e)
{
Console.WriteLine("Custom exception: " + e.Message);
}
// Custom exception: Not enough balance
A custom exception is just a class inheriting from Exception (or a more
specific subclass), forwarding its message to the base constructor with
: base(message). This lets callers catch your domain-specific error type
distinctly from generic framework exceptions.
using — deterministic cleanup¶
using (var writer = new System.IO.StringWriter())
{
writer.WriteLine("cleanup demo");
Console.WriteLine(writer.ToString().Trim());
}
// cleanup demo
A using block guarantees Dispose() is called on the object when the
block exits — even if an exception is thrown inside it — for any type
implementing IDisposable (files, streams, database connections). This is
C#'s equivalent of try-with-resources; Module 9 covers file I/O with using
in more depth.
| Kind | Base | Typical cause |
|---|---|---|
DivideByZeroException |
ArithmeticException |
Integer division by zero |
FormatException |
SystemException |
Unparseable string (int.Parse) |
ArgumentNullException |
ArgumentException |
Null passed where not allowed |
ArgumentOutOfRangeException |
ArgumentException |
Value outside a valid range |
InvalidOperationException |
SystemException |
Object in a state that doesn't support the call |
IndexOutOfRangeException |
SystemException |
Array/string index out of bounds |
Custom (: Exception) |
Your choice | Domain-specific failure |
How It Actually Works¶
- Throwing an exception is expensive — much more than a
returnor a bool-basedTryXfailure. Whenthrowexecutes, the CLR captures a stack trace by walking the call stack frame by frame, allocates the exception object on the heap, and then performs two-pass exception handling: pass one walks up the stack looking for a matchingcatchfilter (evaluatingwhenclauses as it goes, without unwinding anything yet), and only once a handler is found does pass two actually unwind the stack, runningfinallyblocks along the way, down to that handler. This two-pass design is whycatch (Exception e) when (...)can inspect state from deeper frames before the stack is torn down — those frames are still alive during the search pass. It's also why hot-path "expected failure" logic (parsing user input, probing a dictionary) should preferTryParse/TryGetValueovertry/catch— the exception path costs orders of magnitude more CPU than a bool check. finallyblocks are protected regions in the CLR's exception table, not something the JIT re-derives from control flow — the compiled method carries metadata describing which IL ranges aretryregions and whichfinally/catchhandler each maps to. This is also why you can't safelyreturnout of afinallyin most languages that support this pattern — C# disallows a baregoto/returnjumping into a try region, and any jump out of one triggers the runtime to still run interveningfinallyblocks before completing.usingcompiles to atry/finallycallingDispose(). The compiler desugarsusing (var writer = ...) { ... }intotry { ... } finally { if (writer != null) writer.Dispose(); }— literally the same IL you'd get writing that by hand.Dispose()is a deterministic, synchronous cleanup hook the CLR itself does not call automatically; it exists specifically for unmanaged resources (file handles, sockets, database connections) that the garbage collector doesn't know how to reclaim promptly, since the GC only tracks managed memory pressure, not OS handles.- Custom exceptions add virtual dispatch overhead to
catchmatching. When the CLR searches for a matching handler, it checks the exception's runtime type against eachcatchclause's declared type using the sameisinsttype-check machinery as pattern matching (Module 3) — walking the inheritance chain from yourInsufficientFundsExceptionup throughExceptionuntil it finds (or fails to find) a match, in source order, top to bottom.
🔀 See this in another language¶
Exercise¶
Write a method SafeDivide(int a, int b) that returns the division result,
but catches DivideByZeroException internally and returns 0 instead of
crashing when dividing by zero (print a warning message when this happens).
Then write a custom InvalidAgeException : Exception, and a method
ParseAge(string input) that parses a string to an int with int.Parse,
catches FormatException, and throws InvalidAgeException with a clearer
message ("Invalid age: " + input) if parsing fails.