Custom Errors
ทำไม custom exception ถึงสำคัญ
หัวข้อที่มีชื่อว่า “ทำไม custom exception ถึงสำคัญ”built-in exception ของ Python (ValueError, TypeError, RuntimeError, …) เป็นแบบทั่วไป
เมื่อโค้ดของคุณ raise ValueError caller ไม่มีทางรู้ว่ามันมาจาก input ของตัวเอง, config file ที่พัง หรือ network response
Custom exception class ให้ error ของคุณมี type ที่ชัดเจนที่ caller เลือกจับได้, เพิ่ม attribute ที่มีโครงสร้าง (เช่น HTTP status code หรือ field name) และ document ว่าแต่ละส่วนของ application ผิดพลาดได้อย่างไรบ้าง
Subclass Exception
หัวข้อที่มีชื่อว่า “Subclass Exception”custom exception ที่น้อยที่สุดคือ class ที่ inherit จาก Exception:
class AppError(Exception): """Base class for all application errors."""แค่นี้ก็ raise และจับมันโดยระบุชื่อได้แล้ว:
raise AppError("something went wrong")เพิ่ม attribute เอง
หัวข้อที่มีชื่อว่า “เพิ่ม attribute เอง”override __init__ เพื่อแนบ context เพิ่มเข้าไปใน exception:
class AppError(Exception): def __init__(self, message: str, code: int = 0) -> None: super().__init__(message) # pass message to Exception self.code = codecaller สามารถอ่าน err.code เพื่อได้ข้อมูลที่มีโครงสร้างโดยไม่ต้อง parse message string
สร้าง hierarchy
หัวข้อที่มีชื่อว่า “สร้าง hierarchy”สร้าง subclass สำหรับแต่ละหมวดของความล้มเหลว NotFoundError ทุกตัวก็เป็น AppError ด้วย ทำให้ caller จับได้ในระดับความเจาะจงที่ต้องการ
class ValidationError(AppError): def __init__(self, field: str, message: str) -> None: super().__init__(f'Validation failed on "{field}": {message}', code=400) self.field = field
class NotFoundError(AppError): def __init__(self, resource: str, resource_id: int) -> None: super().__init__(f"{resource} id={resource_id} not found", code=404) self.resource = resource self.resource_id = resource_idflowchart TD EXC["Exception"] --> APP["AppError (code: int)"] APP --> VAL["ValidationError (field: str)"] APP --> NF["NotFoundError (resource: str, resource_id: int)"]
จับที่ระดับต่างกัน:
try: raise ValidationError("email", "must contain @")except ValidationError as e: print(f"Field {e.field!r} invalid: {e}") # specificexcept AppError as e: print(f"App error [{e.code}]: {e}") # broadpackage ที่มีหลาย module มักนิยาม root exception หนึ่งตัวต่อ package (หรือหนึ่งตัวต่อ subsystem) แล้วให้ exception อื่นทุกตัวใน package นั้น inherit จากมัน — caller ที่สนใจแค่ “package นี้ล้มเหลวหรือเปล่า” จับที่ root ได้ ส่วน caller ที่ต้องการรายละเอียดต่อความล้มเหลวจับ subclass ที่เจาะจง
except X as e: ลบ e เมื่อ block จบ
หัวข้อที่มีชื่อว่า “except X as e: ลบ e เมื่อ block จบ”CPython รัน del e แบบ implicit เมื่อ except ... as e: block จบ — ไม่ว่าจะจบแบบปกติหรือผ่าน exception อื่น
นี่ไม่ใช่ทางเลือกเรื่อง style แต่เป็นพฤติกรรมของภาษา และมันหมายความว่าชื่อนั้นหายไปทันทีหลังจากนั้น:
try: raise ValueError("boom")except ValueError as e: print(e) # fine -- e is bound hereprint(e) # NameError: name 'e' is not definedสิ่งนี้มีไว้เพื่อตัด reference cycle: traceback ของ exception object ถือ reference ไปยัง frame และ local ของ
frame นั้นก็จะถือ reference กลับไปที่ exception ทำให้ทั้งคู่มีชีวิตอยู่ตลอดไปถ้าไม่มีการลบอัตโนมัตินี้ ผลในทาง
ปฏิบัติ: อย่าวางแผนใช้ชื่อที่ผูกไว้หลัง except block เลย — คัดลอกสิ่งที่ต้องใช้ (message, attribute) ไปเป็นชื่อ
อื่นก่อน อย่างที่ demo ด้านล่างทำโดยใช้ sample แทนการใช้ e ซ้ำ
error ที่เห็นจริงขึ้นอยู่กับ scope: ที่ระดับ module การอ่านชื่อที่ถูกลบไปแล้ว raise NameError: name 'e' is not defined อย่างที่เห็นข้างบน ส่วนภายใน function e เป็นตัวแปร local และ compiler ของ Python รู้ตั้งแต่ตอน
compile time แล้วว่าชื่อนี้เป็น local ของ function นั้น การอ่านมันหลัง implicit del จะ raise
UnboundLocalError: cannot access local variable 'e' where it is not associated with a value แทน ถึงแม้โค้ด
จะ “ดูเหมือนกัน” กับกรณีระดับ module ก็ตาม ทั้งสองคือการลบแบบเดียวกัน ต่างกันแค่ class ของ error ตาม scope
args, str() และ repr()
หัวข้อที่มีชื่อว่า “args, str() และ repr()”BaseException ทุกตัวเก็บ positional argument ที่ส่งเข้ามาไว้ใน .args — super().__init__(message) คือสิ่งที่
เติม self.args ด้วย (message,)
str(e) คืน message ที่ส่งเข้าไปใน super().__init__() นี่คือสิ่งที่พิมพ์ออกมาเมื่อ print(e) หรือใส่ exception ใน f-string
repr(e) คืน ExceptionClass(message) — มีประโยชน์สำหรับ logging เพราะมันบอกด้วยว่า exception type ไหนสร้าง message นี้
err = AppError("disk full", code=507)str(err) # 'disk full'repr(err) # "AppError('disk full')"err.args # ('disk full',)err.code # 507 -- custom attributes are NOT part of .argsattribute เองอย่าง code อยู่บน instance ไม่ใช่ใน .args — มันถูก set โดย __init__ ของคุณเอง ไม่ใช่โดย Exception.__init__
Implicit chaining — __context__ เทียบกับ __cause__
หัวข้อที่มีชื่อว่า “Implicit chaining — __context__ เทียบกับ __cause__”การ raise exception ใหม่ใน except block จะบันทึก exception ที่กำลังถูก handle อยู่เสมอ แม้ไม่มี from —
Python set __context__ ให้อัตโนมัติ raise ... from original set __cause__ เพิ่มเข้ามาด้วย และ traceback
printer default ของ Python เปลี่ยนคำพูดระหว่างสองแบบนี้ (“during handling of the above exception” สำหรับ
context แบบ implicit เทียบกับ “the direct cause of the following exception” สำหรับ cause แบบ explicit)
try: int("nope")except ValueError: raise RuntimeError("second failure") # no 'from' -- __context__ set implicitly, __cause__ is None
try: int("nope")except ValueError as original: raise RuntimeError("second failure") from original # __cause__ set explicitlyraise ... from None เป็นวิธีเดียวที่ suppress ทั้งคู่ ได้ — มันบอก Python ว่า exception ใหม่นี้ไม่เกี่ยวข้อง
กับสิ่งที่กำลังถูก handle อยู่จริง ๆ
add_note() — แนบ context หลังสร้าง exception แล้ว (Python 3.11+)
หัวข้อที่มีชื่อว่า “add_note() — แนบ context หลังสร้าง exception แล้ว (Python 3.11+)”BaseException.add_note(note) เพิ่ม string เข้าไปใน __notes__ list ของ exception ซึ่ง traceback printer
default จะรวมมันให้อัตโนมัติ มีประโยชน์สำหรับแนบ context ที่รู้ หลังจาก exception object ถูกสร้างไปแล้ว —
เช่น ระหว่างที่มัน propagate ผ่าน retry loop:
err = AppError("upstream timeout", code=504)err.add_note("retry attempt 1 of 3")err.add_note("upstream host: api.internal:8443")raise errต่างจากการสร้าง message string ที่ยาวขึ้นตั้งแต่แรก note ถูกเพิ่มแบบ incremental และปรากฏเป็นบรรทัดแยกใน traceback ทำให้ message เดิมยังคงอยู่ครบสำหรับอะไรก็ตามที่ parse หรือเทียบมัน
เมื่อไหร่ควร subclass builtin เทียบกับนิยาม base ของตัวเอง
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควร subclass builtin เทียบกับนิยาม base ของตัวเอง”การ subclass builtin (class NegativeAmountError(ValueError):) หมายความว่า caller เดิมทุกตัวที่จับ
ValueError อยู่แล้วยังทำงานต่อได้โดยไม่เปลี่ยนแปลง ในขณะที่ caller ที่ต้องการ attribute เพิ่มของคุณจับ subclass
ที่เจาะจงได้ การนิยาม base ของตัวเอง (class AppError(Exception):) เหมาะเมื่อความล้มเหลวนั้นเฉพาะกับ domain ของ
application คุณจริง ๆ และไม่มี builtin ตัวไหนที่สื่อความหมายที่ถูกต้องอยู่แล้ว — บังคับให้ caller ทุกตัว ทั้งเก่า
และใหม่ ผ่าน hierarchy ของคุณโดยตั้งใจ
class NegativeAmountError(ValueError): """A ValueError with extra context -- callers who only know ValueError still catch it.""" def __init__(self, amount: float) -> None: super().__init__(f"amount must be non-negative, got {amount}") self.amount = amountexcept ValueError: แบบทั่วไปยังจับ NegativeAmountError ได้เหมือนเดิม ส่วนโค้ดที่ต้องการ .amount
โดยเฉพาะจับ NegativeAmountError ตรง ๆ เลือก subclass builtin ทุกครั้งที่ error ของคุณ เป็น กรณีเจาะจงกว่าของ
error ที่มีอยู่แล้วจริง ๆ ใช้ base เฉพาะของ application ใหม่เฉพาะเมื่อไม่ใช่กรณีนั้น
Demo แบบ runnable เต็มรูปแบบ
หัวข้อที่มีชื่อว่า “Demo แบบ runnable เต็มรูปแบบ”# --- base application error ---class AppError(Exception): """Base class for all application errors.""" def __init__(self, message: str, code: int = 0) -> None: super().__init__(message) self.code = code
# --- specific subclasses ---class ValidationError(AppError): """Raised when input validation fails.""" def __init__(self, field: str, message: str) -> None: super().__init__( f'Validation failed on field "{field}": {message}', code=400, ) self.field = field
class NotFoundError(AppError): """Raised when a requested resource does not exist.""" def __init__(self, resource: str, resource_id: int) -> None: super().__init__( f"{resource} with id={resource_id} not found", code=404, ) self.resource = resource self.resource_id = resource_id
# --- catching at different levels ---errors = [ ValidationError("email", "must contain @"), NotFoundError("User", 42), AppError("generic app failure", code=500),]
for err in errors: try: raise err except ValidationError as e: print(f"[{e.code}] Validation on {e.field!r}: {e}") except NotFoundError as e: print(f"[{e.code}] {e.resource} not found (id={e.resource_id})") except AppError as e: print(f"[{e.code}] AppError: {e}")
# --- isinstance hierarchy ---# 'e' is deleted when the except block ends (CPython) — use a new name hereprint()sample = ValidationError("name", "too short")print(f"ValidationError? {isinstance(sample, ValidationError)}")print(f"AppError? {isinstance(sample, AppError)}")print(f"Exception? {isinstance(sample, Exception)}")print(f"BaseException? {isinstance(sample, BaseException)}")Loading Python runtime (first run only)…
Depth demo: ทุกอย่างข้างบน รันจริง
หัวข้อที่มีชื่อว่า “Depth demo: ทุกอย่างข้างบน รันจริง”# --- except X as e: deletes e when the block ends ---print("=== except ... as e deletes e ===")try: raise ValueError("boom")except ValueError as e: print(f" inside the except block: {e}")print(" now outside the except block:")try: print(e) # NameError: 'e' was deleted by an implicit del e when the block endedexcept NameError as ne: print(f" NameError: {ne}")
# --- args and repr ---print("=== args and repr ===")class AppError(Exception): def __init__(self, message: str, code: int = 0) -> None: super().__init__(message) self.code = code
err = AppError("disk full", code=507)print(f" str(err) = {str(err)!r}")print(f" repr(err) = {repr(err)!r}")print(f" err.args = {err.args}")print(f" err.code = {err.code}")
# --- implicit chaining: __context__ vs __cause__ ---print("=== implicit chaining (__context__) vs explicit (__cause__) ===")try: try: int("nope") except ValueError: raise RuntimeError("second failure") # no 'from' -- __context__ set implicitlyexcept RuntimeError as e: print(f" no 'from': __cause__={e.__cause__!r} __context__={e.__context__!r}")
try: try: int("nope") except ValueError as original: raise RuntimeError("second failure") from original # __cause__ set explicitlyexcept RuntimeError as e: print(f" with 'from': __cause__={e.__cause__!r}")
# --- add_note() (3.11+) ---print("=== add_note() ===")try: err2 = AppError("upstream timeout", code=504) err2.add_note("retry attempt 1 of 3") err2.add_note("upstream host: api.internal:8443") raise err2except AppError as e: print(f" {e}") print(f" notes: {e.__notes__}")
# --- when to subclass a builtin vs your own base ---print("=== builtin subclass vs custom base ===")class NegativeAmountError(ValueError): """A ValueError with extra context -- callers who only know ValueError still catch it.""" def __init__(self, amount: float) -> None: super().__init__(f"amount must be non-negative, got {amount}") self.amount = amount
def withdraw(amount: float) -> float: if amount < 0: raise NegativeAmountError(amount) return amount
try: withdraw(-10.0)except ValueError as e: # generic callers that only know about ValueError still catch this print(f" caught as plain ValueError: {e}")try: withdraw(-10.0)except NegativeAmountError as e: # callers who need the extra .amount attribute catch the specific type print(f" caught as NegativeAmountError, amount={e.amount}")Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Exception class คือ class ธรรมดาของ Python ดังนั้นทุกอย่างที่รู้เกี่ยวกับ class ใช้ได้หมด: __init__ ตั้งค่า
attribute, isinstance เดินตาม MRO และการ inherit หมายถึง “จับได้ในนาม” สิ่งเดียวที่พิเศษเฉพาะของ
except ... as e: คือ del e อัตโนมัติของ CPython ตอนจบ block — statement แบบ implicit ตัวเดียวนี้คือต้นเหตุ
ทั้งพฤติกรรมตัด reference cycle และความประหลาดใจแบบ NameError ข้างบน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| Custom exception hierarchy | caller จับได้ที่ระดับความเจาะจงที่ถูกต้องพอดี | ต้องนิยาม class เพิ่มและดูแลจัดระเบียบ |
Subclass builtin (ValueError) | catcher ทั่วไปที่มีอยู่แล้วยังทำงานต่อได้ | ไม่ explicit เท่า application base เฉพาะ |
Custom attribute (.code, .field) | ข้อมูลมีโครงสร้างโดยไม่ต้อง parse message | boilerplate __init__ เพิ่มต่อ exception class |
add_note() | แนบ context ที่รู้หลังสร้างได้ โดยไม่ต้องสร้าง message ใหม่ | note เป็นแค่ text ต่อท้าย ไม่ใช่ structured data |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ใช้
eต่อหลังexceptblock ได้เหมือนตัวแปรอื่น” — CPython ลบมันอัตโนมัติเมื่อ block จบ แม้จะจบแบบสำเร็จ ก็ตาม — ใช้มันทีหลัง raiseNameError - “attribute เองอย่าง
.codeถูกเก็บใน.args” — ไม่ใช่ —.argsเก็บแค่สิ่งที่ส่งให้Exception.__init__(หรือBaseException.__init__) attribute เองเป็น instance attribute แยกที่คุณ set เอง - “raise exception ใหม่โดยไม่มี
fromจะทิ้งตัวเดิมไป” — ไม่จริง — Python ยังบันทึกมันไว้ใน__context__อัตโนมัติ มีแค่raise ... from Noneเท่านั้นที่ทิ้งมันจริง ๆ - “custom error ทุกตัวต้องมี base class ใหม่ของตัวเอง” — ถ้าความล้มเหลวเป็น
ValueError(หรือKeyError,TypeError, …) ที่เจาะจงกว่าจริง ๆ ให้ subclass builtin — handler ทั่วไปที่มีอยู่แล้วยังทำงานต่อได้ฟรี ๆ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ custom-exception hierarchy ที่ AI generate ให้ตรวจสิ่งเหล่านี้:
- มีโค้ดที่อ่าน binding ของ
except ... as e:หลัง block จบไปแล้วหรือเปล่า? มันจะ raiseNameErrorทันทีที่ path นั้นถูกรันจริง - attribute เองอย่าง
.codeหรือ.fieldถูก set เป็น instance attribute จริงหรือเปล่า หรือโค้ดพยายามดึงมันจาก.args? - hierarchy subclass
BaseExceptionตรง ๆ แทนที่จะเป็นExceptionหรือเปล่า? นั่นเสี่ยงต่อการจับKeyboardInterruptหรือSystemExitโดยไม่ตั้งใจทุกที่ที่ base ใหม่นี้ถูกจับแบบกว้าง ๆ - มี exception class ใหม่ถูกนิยามสำหรับสิ่งที่จริง ๆ แล้วเป็นแค่
ValueErrorหรือKeyErrorแฝงตัวอยู่หรือเปล่า? เช็คว่าการ subclass builtin จะทำให้ caller เดิมยังทำงานต่อได้หรือไม่ - การ re-raise ใน
exceptblock ต้องการfrom Noneหรือเปล่า แต่โค้ดที่ generate ปล่อยให้ chain__context__แบบ implicit อยู่ พิมพ์ traceback ซ้อนที่สับสนให้ end user เห็น?
🐛 หา bug ในโค้ดนี้
def parse_amount(raw: str) -> float: try: return float(raw) except ValueError as e: log_message = f"failed to parse {raw!r}: {e}" print(log_message) print(e) # meant to log the original exception object too return 0.0
parse_amount("not-a-number")เฉลย
e ถูกผูกไว้แค่ระหว่างที่ except ValueError as e: block กำลังทำงานเท่านั้น CPython รัน del e แบบ implicit ทันทีที่ block นั้นจบ ไม่ว่าจะจบแบบปกติหรือไม่ก็ตาม เพราะ e เป็นตัวแปร local ภายใน parse_amount การ print(e) สองบรรทัดถัดมาจึง raise UnboundLocalError: cannot access local variable 'e' where it is not associated with a value — ไม่ใช่ NameError (ซึ่งจะเกิดเฉพาะกับ e ระดับ module เท่านั้น) — ถึงแม้ log_message (ที่สร้างไว้ข้างใน block) จะยังอ่านได้ปกติดี ทางแก้คือ capture สิ่งที่ต้องใช้ทีหลัง — str(e), e.args หรือตัว exception object เอง — ไปเป็นชื่ออื่นก่อนที่ block จะจบ