Exceptions
Exception คืออะไร
หัวข้อที่มีชื่อว่า “Exception คืออะไร”Exception คือสัญญาณที่บอกว่ามีบางอย่างผิดพลาดระหว่างการรัน
เมื่อ Python raise exception จะ unwind call stack จนกว่าจะเจอ except clause ที่ตรงกัน หรือหยุดโปรแกรม
Exception คือ object — instance ของ class ที่สืบทอดจาก BaseException Exception ที่ใช้งานในโค้ดทั่วไปมักสืบทอดจาก Exception
Hierarchy ของ exception
หัวข้อที่มีชื่อว่า “Hierarchy ของ exception”Exception ทุกตัวคือ class และ exception class ทุกตัวสืบทอด — ทางตรงหรือทางอ้อม — จาก BaseException
Exception คือแขนงหนึ่งของ tree นั้น ส่วนสัญญาณควบคุม process อีกไม่กี่ตัวอยู่คนละแขนงที่ Exception ไม่ครอบคลุม:
flowchart TD BE["BaseException"] --> KI["KeyboardInterrupt"] BE --> SE["SystemExit"] BE --> GE["GeneratorExit"] BE --> EXC["Exception"] EXC --> VE["ValueError"] EXC --> TE["TypeError"] EXC --> LE["LookupError"] LE --> KE["KeyError"] LE --> IE["IndexError"] EXC --> OSE["OSError"] EXC --> RTE["RuntimeError"] EXC --> EG["ExceptionGroup"]
นี่คือเหตุผลที่ except Exception, bare except: และ except BaseException เป็นสามสิ่งที่ต่างกัน:
except SpecificError:— จับเฉพาะ class นั้นและ subclass ของมัน ควรใช้แบบนี้เสมอexcept Exception:— จับทุกอย่างที่เป็น application-level error แต่ ไม่ จับKeyboardInterrupt,SystemExitหรือGeneratorExit— สิ่งเหล่านั้นเป็นสัญญาณควบคุม process ไม่ใช่ bug- bare
except:— เหมือนกับexcept BaseException:เป๊ะ มันจับทุกอย่างจริง ๆ รวมถึง Ctrl+C และsys.exit()ที่สะอาด นี่แทบไม่ใช่สิ่งที่ต้องการเลย: มันทำให้โปรแกรมขัดจังหวะไม่ได้ และกลืนสัญญาณ shutdown ที่ตั้งใจให้ propagate ไปแบบเงียบ ๆ
except: pass — กลืน bug แบบเงียบ ๆ
หัวข้อที่มีชื่อว่า “except: pass — กลืน bug แบบเงียบ ๆ”การรวม except แบบกว้าง ๆ กับ body ที่ว่างเปล่าคือวิธีที่พบบ่อยที่สุดที่โค้ดที่ AI สร้างซ่อน bug จริงไว้หลังค่า
fallback ที่ดูสมเหตุสมผล:
def risky_lookup(data: dict, key: str) -> int: try: return data[key] except: # catches EVERYTHING, not just KeyError pass return -1key ที่ไม่มีอยู่จะคืน -1 — ซึ่งน่าจะเป็นสิ่งที่ตั้งใจไว้ แต่การเรียก risky_lookup(None, "x") ก็เช่นกัน
ซึ่งจะ raise TypeError: 'NoneType' object is not subscriptable — เป็น programming error จริง ไม่ใช่ key ที่หาย —
และถูกกลืนไปแบบเดียวกันเป๊ะ คืนค่า -1 ราวกับไม่มีอะไรเกิดขึ้น ทางแก้คือระบุ exception ที่คาดไว้ให้เจาะจงเสมอ
(except KeyError:) เพื่อให้ exception อื่น ยังคงโผล่ออกมา
โครงสร้าง try statement ครบรูปแบบ
หัวข้อที่มีชื่อว่า “โครงสร้าง try statement ครบรูปแบบ”# illustrative — names below are placeholderstry: result = risky_operation()except SomeError as e: # runs only if SomeError (or a subclass) was raised handle(e)else: # runs only if NO exception was raised in try use(result)finally: # ALWAYS runs — exception or not cleanup()Block else เป็น feature Python ที่มีประโยชน์: ช่วยแยกแยะ “การดำเนินการสำเร็จ” ออกจาก “เราอยู่ใน cleanup” โค้ดใน else ทำงานเฉพาะเส้นทาง happy path เท่านั้น
อย่า return ออกจาก finally
หัวข้อที่มีชื่อว่า “อย่า return ออกจาก finally”return, break หรือ continue ใน finally จะทิ้ง exception ที่กำลังลอยอยู่ทั้งหมด — function จะ return
ตามปกติและ error ก็หายไปเฉย ๆ Python 3.14 (PEP 765) เตือนกรณีนี้แล้ว:
def risky(): try: raise ValueError("lost") finally: return "swallowed" # SyntaxWarning in 3.14 — the ValueError never escapesให้ finally ทำแค่ cleanup แล้วปล่อยให้ exception ไหลต่อไป
การจับ exception ตามประเภทเฉพาะ
หัวข้อที่มีชื่อว่า “การจับ exception ตามประเภทเฉพาะ”จับ exception ที่เฉพาะเจาะจงที่สุดเสมอ การจับ Exception เป็นทางเลือกสุดท้าย ส่วน except: เปล่าจะกลืน KeyboardInterrupt และ SystemExit ไปด้วย
try: n = int(user_input)except ValueError: print("Not a valid integer")except TypeError: print("Expected a string, not None")จับหลายประเภทใน clause เดียวด้วย tuple:
except (ValueError, TypeError) as e: print(f"Input error: {e}")ตั้งแต่ Python 3.14 (PEP 758) วงเล็บเป็นตัวเลือกได้เมื่อ clause ไม่มี as:
except ValueError, TypeError: print("Input error")ถ้ามี as ยังต้องใส่วงเล็บอยู่ — except (ValueError, TypeError) as e: — เพราะไม่งั้น parser จะแยกไม่ออกว่าเป็น tuple หรือ binding
EAFP กับ LBYL และ race แบบ TOCTOU
หัวข้อที่มีชื่อว่า “EAFP กับ LBYL และ race แบบ TOCTOU”วัฒนธรรม Python ชอบ EAFP (“easier to ask forgiveness than permission” — ลองทำแล้วจับ failure) มากกว่า LBYL (“look before you leap” — เช็ค precondition ก่อนแล้วค่อยทำ):
def lbyl(d: dict[str, int], key: str) -> int: if key in d: # check return d[key] # use -- something could remove `key` between these two lines return 0
def eafp(d: dict[str, int], key: str) -> int: try: return d[key] # single operation -- no gap for anything else to run in between except KeyError: return 0ทั้งสองแบบให้คำตอบเหมือนกันสำหรับ dict ธรรมดาแบบ single-threaded ความต่างจะสำคัญขึ้นทันทีที่ thread อื่น, signal
handler หรือจุด await สามารถทำงานคั่นระหว่างการ check กับการ use ได้ — LBYL มีช่องโหว่แบบ TOCTOU
(“time-of-check to time-of-use”) ที่เงื่อนไขที่เช็คไว้อาจกลายเป็นเท็จก่อนที่จะลงมือทำ ตัวอย่างจริงแบบคลาสสิก:
if os.path.exists(path): os.remove(path) ยังสามารถ raise FileNotFoundError ได้ถ้า process อื่นลบไฟล์นั้นไปก่อน
EAFP ที่เป็น operation เดียวไม่มีช่องโหว่แบบนี้
Raising exceptions
หัวข้อที่มีชื่อว่า “Raising exceptions”Use raise to signal an error explicitly:
def divide(a: float, b: float) -> float: if b == 0: raise ValueError("divisor cannot be zero") return a / braise กับ raise e — เฟรมที่เพิ่มมา
หัวข้อที่มีชื่อว่า “raise กับ raise e — เฟรมที่เพิ่มมา”การ re-raise exception เดียวกัน ใน except block ดูเหมือนใช้แทนกันได้ระหว่าง bare raise กับ raise e
แต่ไม่ใช่:
def a(): b()
def b(): raise ValueError("deep failure")
def reraise_bare(): try: a() except ValueError: raise # preserves the original traceback -- adds no frame
def reraise_e(err: ValueError): raise err # adds a frame pointing at THIS lineการเรียก reraise_bare() ให้ traceback ที่จบที่ b() เหมือนเดิม การเรียก a(), จับ, แล้วเรียก
reraise_e(caught) เพิ่ม frame เข้าไปอีกหนึ่งใน traceback — บรรทัดข้างใน reraise_e — ถึงแม้จะเป็น exception
object “ตัวเดียวกัน” ก็ตาม ยืนยันได้โดยนับจำนวน frame ของ traceback.extract_tb ใน demo ที่รันได้ด้านล่าง:
bare raise คงจำนวน frame เดิม; raise e เพิ่มมาหนึ่ง ควรใช้ bare raise เมื่อ re-raise แบบไม่เปลี่ยนแปลง —
มันคง traceback ให้ชี้ไปที่จุดกำเนิดจริง
Exception chaining ด้วย raise ... from
หัวข้อที่มีชื่อว่า “Exception chaining ด้วย raise ... from”เมื่อจับ exception หนึ่งแล้ว raise อีกตัว ใช้ raise NewError(...) from original เพื่อเก็บตัวเดิมไว้เป็น cause การทำแบบนี้เก็บ traceback เต็มไว้ใน error chain
try: value = int(raw_string)except ValueError as original: raise RuntimeError("Failed to parse config") from originalattribute __cause__ บน exception ใหม่เก็บตัวเดิมไว้ Python แสดง traceback ทั้งสองให้อัตโนมัติ
ถ้าต้องการทิ้ง cause อย่างชัดเจน (suppress chained traceback) ใช้ raise ... from None ถ้าไม่มีทั้ง
from original และ from None การ raise exception ใหม่ใน except block ยังคงบันทึกตัวเดิมไว้ใน __context__
(implicit chaining ซึ่งอยู่ในบท Custom Errors) — from None เป็นวิธีเดียวที่ suppress สิ่งนั้นได้ทั้งหมด
assert ไม่ใช่ validation — ถูกตัดทิ้งด้วย -O
หัวข้อที่มีชื่อว่า “assert ไม่ใช่ validation — ถูกตัดทิ้งด้วย -O”assert มีไว้สำหรับเช็คตัวเองภายในและ test ไม่ใช่สำหรับ validate ข้อมูลที่โปรแกรมของคุณไม่ได้ควบคุม การรัน Python
ด้วย -O (optimized mode) จะเอา statement assert ทุกตัวออกจาก bytecode ที่ compile แล้วทั้งหมด — เงื่อนไขไม่ถูก
evaluate เลยแม้แต่ครั้งเดียว:
def validate(value: int) -> int: assert value > 0, "value must be positive" return value
print(validate(-1))$ python3.14 validate.pyTraceback (most recent call last): ...AssertionError: value must be positive
$ python3.14 -O validate.py-1source เดียวกันเป๊ะให้ crash หรือรับเลขติดลบไปแบบเงียบ ๆ ขึ้นอยู่กับ flag ที่เลือกจากภายนอกไฟล์ทั้งหมด ใช้
if/raise (มักเป็น ValueError) สำหรับอะไรก็ตามที่ต้องถูกเช็คเสมอ และเก็บ assert ไว้สำหรับเงื่อนไขที่บ่งบอกว่า
มี bug ใน โค้ดของคุณเอง ไม่ใช่ external input ที่ไม่ดี
except* และ exception groups (Python 3.11+)
หัวข้อที่มีชื่อว่า “except* และ exception groups (Python 3.11+)”บาง operation — concurrent task, batch validation — สามารถล้มเหลวด้วย exception ที่ไม่เกี่ยวข้องกันมากกว่าหนึ่งตัว
พร้อมกัน ExceptionGroup รวม exception หลายตัวเป็น object เดียว except* แกะ group ออกโดยจับ exception แต่ละตัว
ในนั้นเทียบกับ type ที่ระบุ อาจรัน handler มากกว่าหนึ่งตัวสำหรับการ raise ครั้งเดียว:
def run_tasks() -> None: errors: list[Exception] = [ValueError("bad input"), TypeError("bad type")] raise ExceptionGroup("task failures", errors)
try: run_tasks()except* ValueError as eg: print(f"ValueError group: {[str(e) for e in eg.exceptions]}")except* TypeError as eg: print(f"TypeError group: {[str(e) for e in eg.exceptions]}")ทั้ง except* ValueError และ except* TypeError ทำงานที่นี่ แต่ละตัวได้รับ sub-group ของตัวเอง except ValueError ธรรมดาจับ ExceptionGroup ไม่ได้เลย — มันไม่ match เว้นแต่จะระบุ ExceptionGroup เอง (หรือ
superclass ของมัน) PEP 758 (3.14) คือการเปลี่ยน syntax ที่ทำให้ except และ except* clause ตัดวงเล็บรอบ type
หลายตัวออกได้อย่างที่แสดงไว้ก่อนหน้าในบทนี้ มันไม่ได้เปลี่ยนวิธีที่ exception group เองทำงาน
contextlib.suppress — ทางเลือกที่เจาะจงกว่า try/except/pass
หัวข้อที่มีชื่อว่า “contextlib.suppress — ทางเลือกที่เจาะจงกว่า try/except/pass”สำหรับกรณีเฉพาะเจาะจงและถูกต้องของ “จงใจเมิน exception type นี้” contextlib.suppress ชัดเจนกว่า
try/except/pass block เพราะมันระบุชัดว่ากำลัง suppress อะไรอยู่ ในบรรทัดเดียว:
import contextlib
with contextlib.suppress(FileNotFoundError): open("does-not-exist.txt")# execution continues here either wayต่างจาก bare except: pass, suppress รับรายการ exception type ที่ชัดเจน — มันไม่มีทางกลืน
KeyboardInterrupt หรือ TypeError ที่เกิดจาก typo แบบที่ except: ที่ไม่ระบุ type ทำได้โดยไม่ตั้งใจ
logging.exception กับ print(e)
หัวข้อที่มีชื่อว่า “logging.exception กับ print(e)”print(e) (หรือ print(f"{e}")) แสดงแค่ message ของ exception — traceback, chain ของ cause และบรรทัดที่แน่นอน
หายไปหมด logging.exception(...) ที่เรียกจากภายใน except block บันทึก traceback เต็มให้อัตโนมัติ:
import logging
try: 1 / 0except ZeroDivisionError as e: print(f"print(e) shows only: {e}") # "division by zero" -- no location, no traceback logging.exception("division failed") # full traceback, at ERROR level, plus your messagelogging.exception เป็นตัวย่อของ logging.error(..., exc_info=True) — ต้องถูกเรียกระหว่างที่กำลัง handle
exception นั้นอยู่ (ภายใน except block) เพื่อให้ exc_info=True มีอะไรให้แนบ
sys.exit และ SystemExit
หัวข้อที่มีชื่อว่า “sys.exit และ SystemExit”sys.exit(code) ไม่ได้บังคับหยุด process ทันที — มันจะ raise SystemExit ซึ่ง propagate เหมือน exception
ทั่วไป (รัน finally block ระหว่างทางด้วย) เว้นแต่มีอะไรจับมันไว้:
import sys
def cli_entry(code: int) -> None: sys.exit(code)
try: cli_entry(2)except SystemExit as e: print(f"SystemExit caught, code={e.code}")เพราะ SystemExit สืบทอดจาก BaseException ไม่ใช่ Exception ทำให้ except Exception: ไม่เคย intercept
การเรียก sys.exit() ที่สะอาดได้เลย — มีแค่ bare except: หรือ except SystemExit:/except BaseException:
ที่ชัดเจนเท่านั้นที่ทำได้ นี่เป็นความตั้งใจ: handler ระดับ except Exception ของ application ไม่ควรสามารถ
ขวางไม่ให้โปรแกรม exit ได้
อ่าน traceback จากล่างขึ้นบน
หัวข้อที่มีชื่อว่า “อ่าน traceback จากล่างขึ้นบน”Traceback ของ Python แสดง frame จากบนลงล่างตามลำดับที่ เข้า มา แต่ลำดับที่อ่านแล้วมีประโยชน์คือจากล่างขึ้นบน: บรรทัดสุดท้ายบอกชื่อและ message ของ exception, frame เหนือขึ้นไปคือจุดที่ exception นั้น raise จริง ๆ, และแต่ละ frame ที่สูงขึ้นไปคือหนึ่งระดับที่ไกลออกไปใน call chain ที่นำไปสู่จุดนั้น
def outer(): middle()
def middle(): inner()
def inner(): raise ValueError("innermost failure")
outer()Traceback (most recent call last): File "demo.py", line 9, in <module> outer() File "demo.py", line 2, in outer middle() File "demo.py", line 5, in middle inner() File "demo.py", line 8, in inner raise ValueError("innermost failure")ValueError: innermost failureเริ่มที่ ValueError: innermost failure (สิ่งที่พัง) แล้วดู frame ที่อยู่เหนือขึ้นไปทันที (inner จุดที่มันพัง)
แล้วค่อยไล่ขึ้นผ่าน middle กับ outer เฉพาะเมื่อต้องรู้ว่า execution มาถึงจุดนั้นได้อย่างไร การอ่านจากบนลงล่าง
จะล่อให้เริ่ม debug ที่ outer ทั้งที่มันไม่ได้พังจริง ๆ
Demo แบบ runnable เต็มรูปแบบ
หัวข้อที่มีชื่อว่า “Demo แบบ runnable เต็มรูปแบบ”def safe_divide(a: float, b: float) -> float: """Divide a by b, raising ValueError if b is zero.""" if b == 0: raise ValueError("divisor cannot be zero") return a / b
# --- try / except / else / finally ---print("=== basic try/except/else/finally ===")for b in [2.0, 0]: try: result = safe_divide(10.0, b) except ValueError as e: print(f" ValueError: {e}") else: print(f" Result: {result}") finally: print(" finally ran")
# --- catching specific types ---print("\n=== specific exception types ===")vals: list[str | None] = ["42", "abc", None]for val in vals: try: if val is None: raise TypeError("expected str, got None") n = int(val) print(f" parsed {val!r} -> {n}") except (TypeError, ValueError) as e: print(f" {type(e).__name__} for {val!r}: {e}")
# --- exception chaining ---print("\n=== exception chaining ===")try: try: raw = "not-a-number" value = int(raw) except ValueError as original: raise RuntimeError("Failed to parse config value") from originalexcept RuntimeError as e: print(f" RuntimeError: {e}") print(f" __cause__: {e.__cause__}")Loading Python runtime (first run only)…
Depth demo: ทุกอย่างข้างบน รันจริง
หัวข้อที่มีชื่อว่า “Depth demo: ทุกอย่างข้างบน รันจริง”import contextlibimport loggingimport sysimport traceback
# --- hierarchy: except Exception vs bare except vs BaseException ---print("=== hierarchy ===")for kind in (ValueError("bad value"), KeyboardInterrupt(), SystemExit(1)): try: raise kind except Exception as e: print(f" except Exception caught: {type(e).__name__}") except BaseException as e: print(f" except Exception missed it; only BaseException caught: {type(e).__name__}")
# --- except: pass swallows real bugs silently ---print("=== except: pass ===")def risky_lookup(data: dict, key: str) -> int: try: return data[key] except: # deliberately bad: swallows EVERYTHING, including a bad argument type below pass return -1
print(f" risky_lookup({{}}, 'x') = {risky_lookup({}, 'x')}") # KeyError silently becomes -1result = risky_lookup(None, "x") # TypeError: 'NoneType' is not subscriptable -- ALSO silently swallowedprint(f" risky_lookup(None, 'x') = {result} <- a real bug (wrong argument type) hidden as -1")
# --- raise vs raise e vs raise ... from ---print("=== raise vs raise e ===")def a(): b()
def b(): raise ValueError("deep failure")
def reraise_bare(): try: a() except ValueError: raise # preserves the original traceback -- no new frame for this line
def reraise_e(err: ValueError): raise err # adds THIS line as a new traceback frame
try: reraise_bare()except ValueError: frames_bare = len(traceback.extract_tb(sys.exc_info()[2])) print(f" bare raise traceback frames: {frames_bare}")
try: try: a() except ValueError as caught: reraise_e(caught)except ValueError: frames_e = len(traceback.extract_tb(sys.exc_info()[2])) print(f" raise e traceback frames: {frames_e}")
# --- from / from None ---try: try: int("not-a-number") except ValueError as original: raise RuntimeError("config parse failed") from originalexcept RuntimeError as e: print(f" __cause__ set by 'from': {e.__cause__!r}")
try: try: int("not-a-number") except ValueError: raise RuntimeError("config parse failed") from Noneexcept RuntimeError as e: print(f" __cause__ suppressed by 'from None': {e.__cause__!r}")
# --- finally overriding return ---print("=== finally overriding return ===")def leaky() -> str: try: raise ValueError("lost forever") finally: return "swallowed" # SyntaxWarning in 3.14 (PEP 765) -- the ValueError never escapes
print(f" leaky() = {leaky()!r} <- no exception visible at all")
# --- EAFP vs LBYL ---print("=== EAFP vs LBYL ===")config: dict[str, int] = {"retries": 3}
def lbyl(d: dict[str, int], key: str) -> int: if key in d: return d[key] return 0
def eafp(d: dict[str, int], key: str) -> int: try: return d[key] except KeyError: return 0
print(f" lbyl(config, 'retries') = {lbyl(config, 'retries')}")print(f" eafp(config, 'retries') = {eafp(config, 'retries')}")
# --- except* and exception groups (3.11+) ---print("=== except* / exception groups ===")def run_tasks() -> None: errors: list[Exception] = [ValueError("bad input"), TypeError("bad type")] raise ExceptionGroup("task failures", errors)
try: run_tasks()except* ValueError as eg: print(f" except* ValueError caught {len(eg.exceptions)}: {[str(e) for e in eg.exceptions]}")except* TypeError as eg: print(f" except* TypeError caught {len(eg.exceptions)}: {[str(e) for e in eg.exceptions]}")
# --- contextlib.suppress ---print("=== contextlib.suppress ===")with contextlib.suppress(FileNotFoundError): open("does-not-exist.txt")print(" suppress(FileNotFoundError) let execution continue past the failed open()")
# --- logging.exception vs print(e) ---print("=== logging.exception vs print(e) ===")logging.basicConfig(stream=sys.stdout, level=logging.INFO, format=" LOG: %(message)s")try: 1 / 0except ZeroDivisionError as e: print(f" print(e) shows only: {e}") logging.exception("division failed") # includes the full traceback, not just the message
# --- sys.exit / SystemExit ---print("=== sys.exit / SystemExit ===")def cli_entry(code: int) -> None: sys.exit(code)
try: cli_entry(2)except SystemExit as e: print(f" SystemExit caught, code={e.code} -- inherits BaseException, not Exception")
# --- assert is stripped by -O ---print("=== assert and -O ===")def validate(value: int) -> int: assert value > 0, "value must be positive" # removed entirely when Python runs with -O return value
print(f" validate(5) = {validate(5)}")try: validate(-1)except AssertionError as e: print(f" validate(-1) raised AssertionError: {e} <- gone under -O, never validated")
# --- reading a traceback bottom-up ---print("=== reading a traceback bottom-up ===")def outer(): middle()
def middle(): inner()
def inner(): raise ValueError("innermost failure")
try: outer()except ValueError: tb_text = traceback.format_exc() print(" " + tb_text.strip().splitlines()[-1]) print(" ^ read bottom-up: this line names the error; frames above show the call chain that led here")Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การเดินทางของ exception คือเส้นตรงเส้นเดียวขึ้นไปตาม call stack: raise สร้างมันขึ้นที่ frame ในสุด จากนั้น
Python จะ unwind ทีละ frame เช็คแต่ละ try ที่ครอบอยู่หา except ที่ตรงกัน จนกว่าจะเจอหรือ stack หมด (ไม่มีใครจับ
→ interpreter พิมพ์ traceback แล้ว exit) finally block ตลอดทางจะรันเสมอระหว่าง unwind นี้ ไม่ว่าจะเป็นตัวที่จับ
อะไรได้หรือไม่ก็ตาม การอ่าน traceback ก็คือการอ่าน log ของการ unwind นี้เอง — ซึ่งเป็นเหตุผลว่าทำไมบรรทัดล่างสุด
(จุดที่มันเริ่ม) ถึงมีประโยชน์กว่าบรรทัดบนสุด (โปรแกรมมาถึงจุดนั้นได้อย่างไร) ในฐานะสิ่งแรกที่ควรอ่าน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
except clause แบบเจาะจง | จัดการเฉพาะ failure ที่ตั้งใจไว้ bug อื่นยัง crash ให้เห็นชัด | ต้องเขียนหลาย clause กว่า except กว้าง ๆ ตัวเดียว |
except Exception | handler เดียวสำหรับ “application error ใดก็ตาม” | ยังพลาดสัญญาณควบคุม process โดยตั้งใจ อาจซ่อน bug ที่ควรแก้ |
EAFP (try/except) | ไม่มีช่องโหว่ TOCTOU มักเร็วกว่าบน path ปกติ | exception handling มี overhead บน path ที่ล้มเหลว |
except* / exception groups | การ raise ครั้งเดียวพก failure ที่ไม่เกี่ยวข้องกันหลายตัวได้ | syntax และ mental model ใหม่ except ธรรมดาจับ group ไม่ได้ |
assert สำหรับ validation | ไม่ต้องเขียนโค้ดเพิ่มเลย | หายไปหมดภายใต้ -O — ไม่ validate อะไรเลยใน production ถ้าใช้ flag นั้น |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
except Exceptionจับทุกอย่าง” — มันตั้งใจไม่จับKeyboardInterrupt,SystemExitหรือGeneratorExit— สิ่งเหล่านั้นสืบทอดจากBaseExceptionโดยตรง มีแค่ bareexcept:หรือexcept BaseException:เท่านั้นที่จับ พวกนั้นด้วย - “
raiseกับraise eในexceptblock คือสิ่งเดียวกัน” —raise eเพิ่ม traceback frame ใหม่ที่บรรทัดraise eเอง ส่วน bareraisere-raise exception เดิมโดยคง traceback เดิมไว้ไม่แตะต้อง - “
assertคือวิธีเบา ๆ ในการ validate user input” — มันหายไปทั้งหมดภายใต้python -O— อย่าใช้กับอะไรก็ตาม ที่ต้องถูกเช็คเสมอ - “
sys.exit()ฆ่า process ทันที” — มัน raiseSystemExitซึ่งเป็น exception ธรรมดา (แม้จะแปลกไปสักหน่อย) ที่รันfinallyblock และถูกจับได้ เหมือน exception อื่น ๆ - “ควรอ่าน traceback จากบนลงล่าง เหมือนอ่านโค้ด” — บรรทัดล่างสุดคือ error จริง อ่านขึ้นไปจากตรงนั้นเพื่อไล่ว่า execution มาถึงจุดนั้นได้อย่างไร
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ exception handling ที่ AI generate ให้ตรวจสิ่งเหล่านี้:
- มี bare
except:(หรือexcept BaseException:) ที่ไม่ re-raise เลยหรือเปล่า? มันจะกลืนKeyboardInterruptกับSystemExitไปด้วย รวมถึง typo ใด ๆ ที่ raise exception ที่ไม่เกี่ยวข้อง - มี
except: pass(หรือexcept Exception: pass) ใช้ “จัดการ” error โดยไม่ log อะไรเลยหรือเปล่า? นั่นคือ bug ที่หายไปแบบเงียบ ๆ ไม่ใช่การจัดการ error - โค้ดใช้
assertvalidate external input (user input, API response, file content) แทนที่จะเป็น internal invariant หรือเปล่า? มันหายไปภายใต้-O - มี
raise eปรากฏในexcept ... as e:block ที่ตั้งใจใช้ bareraiseหรือเปล่า? เช็คว่า frame ที่เพิ่มมามีผล จริง ๆ ที่นี่หรือไม่ — ปกติแล้วไม่มีผล และ bareraiseสะอาดกว่า - โค้ด
print(e)แทนที่จะเป็นlogging.exception(...)ภายในexceptblock ในอะไรก็ตามที่มากกว่า script เร็ว ๆ หรือเปล่า? traceback หายไปทันทีที่ต้องใช้มัน debug
🐛 หา bug ในโค้ดนี้
def process_order(quantity: int) -> int: assert quantity > 0, "quantity must be positive" return quantity * 10
print(process_order(-5))เฉลย
โค้ดนี้ดูเหมือน input validation และเมื่อรันปกติมันก็ raise AssertionError: quantity must be positive จริง — จึงเชื่อถือได้ง่าย แต่ assert ไม่ใช่ validation: รันไฟล์เดียวกันเป๊ะด้วย python3.14 -O process_order.py แล้ว assertion จะถูกตัดออกจาก bytecode ทั้งหมด process_order(-5) จะคืน -50 แบบเงียบ ๆ และปริมาณ order ที่ติดลบจะไหลเข้าสู่ส่วนที่เหลือของโปรแกรมตรง ๆ ทางแก้คือ if quantity <= 0: raise ValueError("quantity must be positive") ซึ่งไม่มี interpreter flag ไหนเอาออกได้