โหมดมืด
บทที่ 8 — Exception Handling
C# มี exception model (รูปแบบการจัดการข้อผิดพลาด — exception คือข้อผิดพลาดที่เกิดตอนรัน) ที่คล้าย Java
แต่ C# ไม่มี checked exception (exception ที่บังคับให้ผู้เรียกต้องประกาศหรือดักจับ — Java ใช้ throws แบบนี้ แต่ C# ไม่ต้อง)
ผลคือ C# เขียนง่ายกว่า แต่สไตล์การจัดการ error (idiomatic = สไตล์ที่ภาษาออกแบบมาให้ใช้) ต่างจาก Java อยู่บ้าง
1. Throw + Try-Catch
พื้นฐานการจัดการ error ใน C# — throw โยน exception เมื่อเจอปัญหา ส่วนผู้เรียกห่อด้วย try/catch เพื่อดักจับและจัดการ (เลือก catch เฉพาะชนิดที่จัดการได้):
csharp
// โค้ดนี้เขียนเป็น top-level statement ได้เลย (บท 1) หรือใส่ใน method ก็ได้
public decimal Divide(decimal a, decimal b)
{
if (b == 0)
throw new DivideByZeroException("b cannot be zero");
return a / b;
}
try
{
var r = Divide(10, 0);
}
catch (DivideByZeroException ex)
{
Console.WriteLine($"caught: {ex.Message}");
}key:
- ทุก exception inherit
System.Exception throw new SomeException("msg", innerException)catch (T)จับ T และ subclass (คลาสลูก) ของ T
2. Exception Hierarchy
ที่ throw บ่อย:
ArgumentException/ArgumentNullException/ArgumentOutOfRangeException— input validationInvalidOperationException— state ผิดNotSupportedException— feature ที่ implementation นี้ไม่รองรับ
💡 เวลาเขียน custom exception ของตัวเอง ให้ inherit จาก
Exceptionตรง ๆ ไม่ใช่ApplicationException— เดิมตั้งใจให้แยก system vs app error แต่ guideline เลิกแนะนำตั้งแต่ .NET Framework 2.0 แล้ว (ไม่ได้ใช้จริงในชีวิตจริง)
3. Multiple catch
csharp
try
{
DoSomething();
}
catch (FileNotFoundException ex)
{
Console.WriteLine("file missing");
}
catch (UnauthorizedAccessException ex)
{
Console.WriteLine("no permission");
}
catch (IOException ex) // catch ทั่ว ๆ IO หลัง
{
Console.WriteLine($"IO error: {ex.Message}");
}
catch (Exception ex) // catch all — สุดท้ายเสมอ
{
Console.WriteLine($"unexpected: {ex}");
throw; // re-throw — สำคัญมาก
}ลำดับ: specific → general
⚠️ catch แล้วไม่ทำอะไร (swallow exception = ดักแล้วเงียบ ไม่แจ้ง ไม่ log ไม่ throw ต่อ) คือ ข้อผิดพลาดร้ายแรงที่สุดของ junior dev — ทำให้ตอน production มีปัญหาแล้ว debug ไม่ออก เพราะ error หายไปเฉย ๆ ไม่เหลือร่องรอย
4. finally block
csharp
FileStream? f = null;
try
{
f = File.OpenRead("data.txt");
// ...
}
catch (IOException ex)
{
Console.WriteLine($"io: {ex.Message}");
}
finally
{
f?.Dispose(); // run always — clean up resource
}⚠️ ตัวอย่างนี้คือ legacy pattern สำหรับเข้าใจหลักการเท่านั้น — โค้ดจริงควรใช้
using(หัวข้อถัดไป) ที่สั้นกว่าและพลาดยากกว่า
finally รัน:
- เมื่อ try จบปกติ
- เมื่อ catch จบ
- แม้ throw ที่ catch handler
แต่ใน practice — ใช้ using ดีกว่า (ดูหัวข้อ 5 ถัดไป)
5. using for cleanup
เวลาเกิด exception ทรัพยากร (ไฟล์, connection คือการเชื่อมต่อ) ต้องถูกปิดเสมอ — using คือ try/finally แบบย่อที่รับประกันว่า Dispose() (method คืนทรัพยากรของ IDisposable = object ที่ถือทรัพยากรไว้และต้องคืนเมื่อเลิกใช้) ถูกเรียกเมื่อจบ scope (ขอบเขตบล็อก) ไม่ว่าจะ error หรือไม่:
csharp
// Try-finally pattern แบบ syntactic sugar
using var f = File.OpenRead("data.txt");
// f.Dispose() ที่ end of scope อัตโนมัติทุก IDisposable ใช้ using ได้:
- FileStream, StreamReader/Writer
DbConnection,SqlCommand(ADO.NET — การเชื่อมต่อฐานข้อมูลโดยตรง),DbContext(Entity Framework — สอนในส่วน dotnet/02)- response object จาก HttpClient (
HttpResponseMessage)
⚠️ ข้อยกเว้นสำคัญ:
HttpClientเอง — อย่าใส่usingแม้จะ implementIDisposableก็ตามเหตุผล: การสร้าง
HttpClientใหม่แล้วทิ้งบ่อย ๆ (เช่นสร้างทุกครั้งที่เรียก method) ทำให้ช่องเชื่อมต่อเครือข่าย (socket) ค้างอยู่ในสถานะรอปิดนานผิดปกติ (เรียกว่า TIME_WAIT) พอสะสมมากเข้าช่องก็เต็มหมด — เรียกอาการนี้ว่า socket exhaustion (ช่องเชื่อมต่อหมด) ผลคือโปรแกรมบน production จะเชื่อมต่อเครือข่ายใหม่ไม่ได้เลยทางเลือกตามสถานการณ์:
- Console app / โปรแกรมเล็ก ๆ: ใช้
static readonly HttpClientแชร์ตัวเดียวทั้งโปรแกรม —private static readonly HttpClient _http = new();- ASP.NET Core: ใช้
IHttpClientFactory(ลงทะเบียนผ่าน DI ด้วยservices.AddHttpClient()) แล้วขอ instance มาใช้แต่ละครั้ง — factory จัดการ pooling ให้ (สอนในส่วน dotnet/05)
6. Custom Exception
csharp
public class InsufficientBalanceException : Exception
{
public decimal Available { get; }
public decimal Required { get; }
public InsufficientBalanceException(decimal available, decimal required)
: base($"Need {required} but have {available}")
{
Available = available;
Required = required;
}
}
// throw
if (balance < amount)
throw new InsufficientBalanceException(balance, amount);
// catch + use data
try { Withdraw(100); }
catch (InsufficientBalanceException ex)
{
Console.WriteLine($"short by {ex.Required - ex.Available}");
}Custom exception ดีเมื่อ:
- domain-specific error (ข้อผิดพลาดที่เฉพาะกับโดเมนธุรกิจ เช่น ยอดเงินไม่พอ) ที่ caller ควรจัดการต่างจาก generic exception ทั่วไป
- ใส่ structured data (Available, Required ฯลฯ)
7. Re-throw — สำคัญมาก
จุดที่มือใหม่ผิดบ่อยมาก: เมื่อ catch แล้วอยากโยน exception ต่อ ให้ใช้ throw; (เปล่า ๆ) ไม่ใช่ throw ex; — เพราะ throw ex; รีเซ็ต stack trace ทำให้ตามหาต้นตอ error ไม่เจอ:
csharp
// ❌ ผิด — ทำลาย stack trace
catch (Exception ex)
{
Log(ex);
throw ex; // reset stack trace ที่ point นี้
}
// ✅ ถูก
catch (Exception ex)
{
Log(ex);
throw; // keep original stack trace
}
// ✅ wrap as new exception
catch (Exception ex)
{
throw new BusinessException("failed to ...", ex);
}throw; ต่างจาก throw ex; — เก็บ original stack trace
8. when filter (C# 6+)
when (C# 6+) เพิ่มเงื่อนไขให้ catch — จับ exception เฉพาะเมื่อเข้าเกณฑ์ (เช่น HTTP 404 เท่านั้น) ถ้าไม่เข้าก็ปล่อยผ่านไป catch อื่น ทำให้แยกจัดการตามรายละเอียดของ error ได้สะอาด:
csharp
using System.Net; // ต้องมีเพื่อใช้ HttpStatusCode
try { /*...*/ }
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// ใช้ Thread.Sleep เพื่อความง่าย — ใน async code จริงใช้ await Task.Delay(1000) (สอนในบท 11)
Thread.Sleep(1000);
Retry();
}
catch (HttpRequestException ex) when (ex.StatusCode is >= HttpStatusCode.InternalServerError)
{
// ใช้ pattern `is >=` เพราะ StatusCode เป็น HttpStatusCode? (nullable) — ถ้าเป็น null จะไม่ match
Backoff();
}filter ไม่ unwind stack — performance ดีกว่า catch แล้ว throw
9. Patterns ในการใช้ exception
9.1 Validation — throw early
csharp
public void Set(int n)
{
if (n < 0)
throw new ArgumentOutOfRangeException(nameof(n), "must be non-negative");
if (n > 100)
throw new ArgumentOutOfRangeException(nameof(n), "max 100");
_value = n;
}nameof(n) = compile-time string ของชื่อ parameter — refactor-safe (ปลอดภัยเวลา refactor: ถ้า rename n เป็นชื่ออื่น compiler error ทันที ไม่หลุดเป็น string ผิดใน error message)
9.2 Guard helper (.NET 6+/7+)
csharp
public void Process(string input)
{
ArgumentNullException.ThrowIfNull(input); // .NET 6+
ArgumentException.ThrowIfNullOrEmpty(input); // .NET 7+
ArgumentException.ThrowIfNullOrWhiteSpace(input); // .NET 7+
// ...
}9.3 Try-pattern (ไม่ throw)
csharp
// แทน
int n = int.Parse(s); // throws
// ใช้
if (int.TryParse(s, out int n))
{
// use n
}ใน method ของคุณก็เช่นกัน:
csharp
public bool TryGet(string key, out string? v)
{
v = null;
if (!_dict.TryGetValue(key, out var raw)) return false;
v = raw;
return true;
}10. Exception vs Result Pattern
csharp
// Exception (ทั่วไป)
// _db คือ repository/data access object เช่น IUserRepository
public User GetUser(string id)
{
var u = _db.Find(id);
if (u is null) throw new NotFoundException();
return u;
}
// Result pattern (functional style)
// record Result<T> คือ type เก็บข้อมูลสั้น ๆ — บท 10 สอนเต็ม ๆ ตอนนี้แค่รู้ว่าเป็น type เก็บผลลัพธ์
// public record Result<T>(bool Ok, T? Value, string? Error);
public Result<User> GetUser(string id)
{
var u = _db.Find(id);
if (u is null) return new Result<User>(false, null, "not found");
return new Result<User>(true, u, null);
}💡 ในงานจริงนิยมเพิ่ม static helper ให้ readable ขึ้น:
Result<User>.Ok(u)/Result<User>.Fail("not found")— เป็น factory method ที่ห่อ constructor ไว้
เลือกอย่างไร:
- Exception ดี: รุนแรง, unexpected, control flow ที่ "ไม่ปกติ"
- Result ดี: validation/business rule ที่ "ปกติ" + ทำ pipeline ได้ลื่น
แต่เดิม C# นิยมใช้ Exception แม้กระทั่งสำหรับ validation แต่ปัจจุบันหลายทีมที่ทำ API ที่ต้องรับ traffic สูงเริ่มหันมาใช้ Result pattern สำหรับ error ที่ "คาดได้" มากขึ้น (เก็บ Exception ไว้กับเคสที่ "ผิดปกติจริง ๆ" เท่านั้น) — ทั้งสองสไตล์พบได้ในโค้ด production จริง แล้วแต่ทีม (ASP.NET Core แปลง exception เป็น HTTP 400 Bad Request — รหัสบอก client ว่า request ผิดพลาด — ได้ผ่านกลไกที่เรียกว่า Problem Details middleware แต่ต้องตั้งค่าก่อน ไม่ได้ automatic โดย default; รายละเอียดเรื่อง middleware และ ASP.NET Core อยู่ในเล่ม dotnet/ ถัดไป ตอนนี้รู้แค่ว่ามันแปลง exception เป็น HTTP error ที่ client เข้าใจได้ก็พอ)
11. Async + Exception
⏭️ มือใหม่ข้ามหัวข้อนี้ไปก่อนได้ — กลับมาอ่านหลังเรียนบท 11 (async/await) จะเข้าใจมากกว่า
💡 ถ้าอยากดูก่อน: หัวข้อนี้ใช้
async/await(การทำงานแบบไม่บล็อกรอ I/O) ที่ สอนเต็มบท 11 — ตอนนี้รู้แค่ว่าawaitคือ "รอผลของงานที่ทำในเบื้องหลังเสร็จก่อนค่อยไปต่อ" ก็พอ ดูแค่ว่า exception ในโค้ด async ดักด้วยtry/catchได้ปกติ
csharp
public async Task<User> GetAsync(string id)
{
// GetFromJsonAsync = idiomatic: stream JSON ตรงจาก response → deserialize
// (ไม่ alloc string ทั้งก้อนใน memory) + throw HttpRequestException ถ้า non-2xx
return await _http.GetFromJsonAsync<User>($"/users/{id}")
?? throw new InvalidOperationException("null body");
}
// ทางเลือก low-level (ถ้าต้องการคุม response เอง)
public async Task<User> GetAsyncLowLevel(string id)
{
var resp = await _http.GetAsync($"/users/{id}");
resp.EnsureSuccessStatusCode(); // throw ถ้าได้ status ที่ไม่ใช่ 2xx (สำเร็จ)
return await resp.Content.ReadFromJsonAsync<User>()
?? throw new InvalidOperationException("null body");
}
try
{
var u = await GetAsync("123");
}
catch (HttpRequestException ex)
{
// handle
}ใน async — exception ถูก wrap (ห่อ) ไว้ใน Task (ตัวแทนงานที่จะเสร็จในอนาคต) แต่ await แกะ (unwrap) ให้ → catch ได้ปกติ
สำคัญ: ห้าม async void (method async ที่คืน void แทน Task) — caller await ไม่ได้ แปลว่าไม่มีใครรอจับ exception
ผลคือ exception จะหลุดออกไปที่ตัวจัดการ error ระดับบนสุดของ process (unhandled exception handler) ซึ่งมักทำให้โปรแกรม crash ทั้งตัว
csharp
public async void Click() { ... } // ❌ ไม่ catch ได้
public async Task Click() { ... } // ✅ยกเว้น event handler ที่จำเป็น
12. AggregateException — Task.WaitAll, Parallel ⏭️ (ข้ามได้ — อ่านหลังบท 11)
csharp
// ✅ async — ใช้ Task.WhenAll (await แบบไม่บล็อก thread)
var whenAll = Task.WhenAll(task1, task2, task3);
try
{
await whenAll;
}
catch
{
// await throw แค่ exception ตัวแรก — เข้าถึงทุกตัวผ่าน Task.Exception
foreach (var ex in whenAll.Exception!.InnerExceptions)
Console.WriteLine(ex.Message);
}
// ⚠️ sync context เท่านั้น — Task.WaitAll บล็อก thread (อย่าใช้ในโค้ด async)
try
{
Task.WaitAll(task1, task2, task3);
}
catch (AggregateException agg)
{
foreach (var ex in agg.InnerExceptions)
Console.WriteLine(ex.Message);
// flatten ลึก
agg.Flatten().Handle(ex => true /* handled */);
}💡 มือใหม่ยังไม่ต้องเข้าใจตอนนี้ — ถ้ามีหลาย Task fail พร้อมกัน exception จะถูกรวมใน
AggregateExceptionอ่านเพิ่มในบท 11 แค่จำไว้ว่า:
await Task.WhenAll(...)= idiomatic สำหรับ async code (ไม่บล็อก thread)Task.WaitAll(...)= บล็อก thread ใช้เฉพาะใน entry point ที่ไม่ใช่ async (เช่นMainแบบเก่า)AggregateExceptionคือ exception ที่รวมหลาย exception ไว้ด้วยกัน — เกิดเพราะหลาย task อาจ fail พร้อมกัน
13. Logging Exception
เวลา log exception ให้ส่ง object ex เป็น argument แรก (ไม่ใช่แค่ ex.Message) เพื่อให้ logger เก็บ stack trace ครบ และใช้ structured placeholder ({OrderId}) แทนการต่อ string เพื่อให้ค้นหา log ได้:
csharp
catch (Exception ex)
{
// _logger คือ object สำหรับบันทึก log มาจาก ILogger ของ Microsoft.Extensions.Logging
// ซึ่งได้รับผ่าน Dependency Injection (สอนในส่วน dotnet/)
// ตอนนี้แค่รู้ว่าควรส่ง ex ทั้งตัวเข้าไป ไม่ใช่แค่ ex.Message
_logger.LogError(ex, "Failed to process order {OrderId}", orderId);
throw;
}ใช้ structured logging — {OrderId} กลายเป็น field ใน JSON log → search/filter ได้
💡 รายละเอียดเชิงลึกเรื่อง structured logging, log levels, และ correlation จะแยกไปอธิบายในชุดเอกสาร observability (ถ้ามี) — ตอนนี้พอแค่จำว่า "ส่ง
exตัวเต็มเข้าเป็น argument แรก + ใช้ placeholder แทนการต่อ string"
14. Common Pitfalls (จุดพลาดบ่อย)
catch (Exception ex)แล้วกลืนเงียบ (swallow) → bug หาต้นตอ (root cause) ไม่เจอthrow ex;→ ทำลาย stack trace (รอยทาง error) → ใช้throw;- ลืม
usingสำหรับ FileStream, HttpClient response → resource leak (ทรัพยากรรั่ว ไม่ถูกคืน) async voidที่ไม่ใช่ event handler (ตัวจัดการเหตุการณ์) → exception หลุด- ใช้ exception เป็น control flow (ควบคุมการไหลของโปรแกรม) — เช่น
try { return Get(); } catch { return null; }ถ้าอยู่ใน hot path (โค้ดที่ถูกเรียกถี่ ๆ) จะช้ามาก- เหตุผล: การ throw exception หนึ่งครั้งแพงกว่าการเช็คเงื่อนไข if ปกติเป็นหลักร้อยถึงหลักพันเท่า (ตัวเลขจริงแล้วแต่ runtime/JIT — JIT คือตัวคอมไพล์โค้ดตอนรันของ .NET)
- หลักการ: ใช้ exception สำหรับเคสที่ "ไม่ปกติ" เท่านั้น ไม่ใช่สำหรับ logic ที่คาดเดาได้ล่วงหน้า
- NullReferenceException = bug 99% — เปิด NRT + ใช้
?./?? - ใช้
Parseทุกที่ → ใช้TryParseแทน
🛠️ Checkpoint 8 — ลงมือทำ
- สร้าง
BankAccountที่ throwInsufficientBalanceException(custom + structured data) - ใช้
whenfilter จับเฉพาะ HttpStatusCode 429 → retry 3 ครั้ง - method
Result<User> ParseUser(string json)ที่คืน Result แทน throw - ลอง
async Taskกับasync voidใน method ที่ throw — สังเกตว่าจับได้/ไม่ได้ - ทำ extension
LogAndRethrowที่ log + throw; → call ด้วยcatch (Ex ex) { ex.LogAndRethrow(_logger); }
สรุปบทที่ 8
- C# ไม่มี checked exception — ยืดหยุ่น (flexible) แต่ต้องระวัง (discipline)
- Hierarchy: SystemException + ApplicationException (เก่า)
- ใช้
usingแทน try/finally สำหรับ IDisposable throw;(keep stack) vsthrow ex;(reset stack)whenfilter = catch ที่ specific โดยไม่ unwind stack- Try-pattern + Result pattern = ทางเลือกของ throw
- ห้าม
async void(ยกเว้น event handler) - ห้าม swallow exception