Exception ใน Python: try, except, else, finally, raise และ exception ของตัวเอง
Exception ใน Python คือออบเจกต์ที่บอกว่าเกิดข้อผิดพลาด และหยุดการทำงานปกติของโค้ดจนกว่าบล็อก try/except จะดักจับได้ else ทำงานเมื่อไม่มีข้อผิดพลาด ส่วน finally ทำงานเสมอ สามารถเรียกข้อผิดพลาดเองด้วยคำสั่ง raise เช่น raise ValueError("จำนวนเงินติดลบ") และสร้าง exception ของตัวเองได้โดยสืบทอดคลาส Exception
Exception ใน Python คือออบเจกต์ที่บอกว่าเกิดข้อผิดพลาดและหยุดการทำงานปกติของโปรแกรม หากไม่มีใครดักจับ Python จะแสดง traceback และจบการทำงาน การดักจับทำด้วยโครงสร้าง try/except ส่วนการเรียกข้อผิดพลาดเองทำด้วยคำสั่ง raise บทความนี้จะอธิบายการทำงานของระบบนี้ ข้อผิดพลาดที่พบบ่อย และวิธีจัดการข้อผิดพลาดโดยไม่ไปซ่อนปัญหา
Exception ใน Python คืออะไร
เมื่อการดำเนินการทำต่อไม่ได้ เช่น หารด้วยศูนย์ เข้าถึงคีย์ที่ไม่มีในดิกชันนารี หรือแปลงคำให้เป็นตัวเลข Python จะสร้างออบเจกต์ exception และ "โยน" ออกมา การทำงานของฟังก์ชันปัจจุบันจะหยุด exception จะถูกส่งต่อขึ้นไปตามลำดับการเรียกฟังก์ชัน จนกว่าจะเจอตัวจัดการที่เหมาะสม หากไม่เจอ โปรแกรมจะหยุด
>>> 10 / 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
ตอบคำถามที่พบบ่อย: เมื่อหารด้วยศูนย์ใน Python จะเกิด exception ชนิด ZeroDivisionError ซึ่งรวมถึงตัวดำเนินการ / // และ % ด้วย
วิธีอ่าน traceback
Traceback คือรายงานว่าโปรแกรมไปถึงข้อผิดพลาดได้อย่างไร อ่านจากล่างขึ้นบนจะสะดวกกว่า บรรทัดสุดท้ายสำคัญที่สุด คือชนิดของ exception และข้อความ บรรทัดด้านบนบอกว่าเกิดขึ้นที่โค้ดบรรทัดใด และด้านบนสุดบอกว่าใครเป็นคนเรียกมา ไล่ขึ้นไปจนถึงจุดเริ่มต้นของโปรแกรม
Traceback (most recent call last):
File "tracker.py", line 9, in <module>
total = parse_amount(row)
File "tracker.py", line 4, in parse_amount
return float(raw.strip())
~~~~~^^^^^^^^^^^^^
ValueError: could not convert string to float: 'สิบ'
ตั้งแต่ Python 3.11 จะมีเครื่องหมาย ~~~^^^ วาดอยู่ใต้บรรทัดโค้ด (PEP 657) เพื่อชี้ว่านิพจน์ใดในบรรทัดนั้นที่เกิดข้อผิดพลาด ซึ่งมีประโยชน์มากเมื่อบรรทัดเดียวมีการเรียกฟังก์ชันหลายครั้งต่อกัน
คำแนะนำแบบ "Did you mean"
ตั้งแต่ Python 3.10 เมื่อพิมพ์ชื่อผิด ข้อผิดพลาดจะแนะนำชื่อที่ใกล้เคียง ใน Python 3.15 คำแนะนำฉลาดขึ้น โดยรู้ถึงนิสัยจากภาษาอื่น เช่น เรียก list.push จะแนะนำ append และ "กาแฟ".toUpperCase() จะแนะนำเมธอด upper คำแนะนำยังดูลงไปถึงฟิลด์ซ้อนได้ เช่น หากดิกชันนารีอยู่ในแอตทริบิวต์ totals ของอ็อบเจกต์ แล้วคุณเรียก items() กับตัวอ็อบเจกต์ เอง ข้อผิดพลาดจะแนะนำ .totals.items นอกจากนี้ delattr() ก็แนะนำชื่อที่ใกล้เคียงได้แล้ว
ชนิดของ exception ใน Python
มี exception ในตัวหลายสิบชนิด แต่ช่วงเริ่มต้นจะเจอชนิดเหล่านี้บ่อยที่สุด:
| Exception | เกิดขึ้นเมื่อ | ตัวอย่าง |
|---|---|---|
NameError | ไม่รู้จักชื่อนี้ มักเกิดจากพิมพ์ผิด | print(totl) |
TypeError | การดำเนินการไม่เหมาะกับชนิดข้อมูลนี้ | "5" + 3 |
ValueError | ชนิดถูกต้องแต่ค่าไม่ถูกต้อง | int("สิบ") |
KeyError | ไม่มีคีย์นี้ในดิกชันนารี | d["missing"] |
IndexError | ดัชนีอยู่นอกขอบเขตของลิสต์ | [1, 2][5] |
AttributeError | อ็อบเจกต์ไม่มีเมธอดหรือฟิลด์นี้ | [].push(1) |
ZeroDivisionError | หารด้วยศูนย์ | 1 / 0 |
FileNotFoundError | ไม่พบไฟล์ | open("nope.txt") |
ส่วน SyntaxError (และกรณีย่อย IndentationError) เกิดขึ้นตั้งแต่ตอนแปลไฟล์ ก่อนโค้ดจะรัน จึงดักจับด้วย try ในไฟล์เดียวกันไม่ได้
ลำดับชั้นของ exception
Exception เป็นคลาส และมีการจัดเป็นลำดับชั้น ที่ยอดคือ BaseException ซึ่งมี Exception (ข้อผิดพลาดทั่วไปแทบทั้งหมด) และคลาสระบบบางตัวสืบทอดอยู่ เช่น KeyboardInterrupt SystemExit และ GeneratorExit
BaseException
├── KeyboardInterrupt
├── SystemExit
└── Exception
├── ArithmeticError
│ └── ZeroDivisionError
├── LookupError
│ ├── KeyError
│ └── IndexError
├── OSError
│ └── FileNotFoundError
├── TypeError
└── ValueError
ลำดับชั้นมีผลเมื่อดักจับ except LookupError จะดักได้ทั้ง KeyError และ IndexError ส่วน KeyboardInterrupt ไม่ใช่ข้อผิดพลาดในโค้ด แต่เป็นสัญญาณว่าผู้ใช้กด Ctrl+C มันไม่ได้สืบทอดจาก Exception โดยเจตนา เพื่อไม่ให้ except Exception ไปขัดขวางการหยุดโปรแกรม
การจัดการ exception: try/except/else/finally
โค้ดที่อาจเกิดข้อผิดพลาดจะถูกวางไว้ในบล็อก try ส่วน except ระบุชนิดของข้อผิดพลาดและกำหนดสิ่งที่จะทำ except คือบล็อกที่ Python ใช้จัดการ exception
def parse_amount(raw):
try:
value = float(raw)
except ValueError as e:
print(f"ข้ามบรรทัดนี้: {e}")
return None
else:
return value # ทำงานเมื่อไม่มีข้อผิดพลาด
finally:
print("แยกข้อมูลเสร็จแล้ว") # ทำงานเสมอ
print(parse_amount("250"))
ผลลัพธ์จะแสดง "แยกข้อมูลเสร็จแล้ว" ก่อน แล้วตามด้วย 250.0 คำสั่ง return ในบล็อก else จะจำค่าไว้ก่อน จากนั้น Python ทำ finally ให้เสร็จเสมอ แล้วจึงคืนค่า ดังนั้น finally จึงเชื่อถือได้สำหรับการเก็บกวาด เช่น ปิดไฟล์หรือคืนการเชื่อมต่อ
หากเขียน return ของตัวเองใน finally ค่านี้จะทับค่าเดิม และยังอาจ "กลืน" exception ได้ด้วย ตั้งแต่ Python 3.14 โค้ดแบบนี้จะแสดง SyntaxWarning (PEP 765) อย่าเขียน return ใน finally
หลาย exception พร้อมกัน
สามารถเขียนหลายบล็อก except สำหรับชนิดต่างกัน หรือระบุหลายชนิดในทูเพิลเดียวได้:
try:
amount = int(data["amount"])
except KeyError:
amount = 0
except (ValueError, TypeError) as e:
print("ตัวเลขผิดรูปแบบ:", e)
amount = 0
บล็อกจะถูกตรวจจากบนลงล่าง และทำงานเฉพาะบล็อกแรกที่ตรง ดังนั้นชนิดที่เจาะจงกว่าต้องวางไว้เหนือชนิดทั่วไป ตั้งแต่ Python 3.14 (PEP 758) สามารถระบุหลายชนิดได้โดยไม่ต้องใส่วงเล็บ เช่น except ValueError, TypeError: แต่หากใช้ as ยังต้องมีวงเล็บเหมือนเดิม
วิธีแสดงข้อความและดูชนิดของ exception
ออบเจกต์ exception เข้าถึงได้ผ่าน as e โดย str(e) จะได้ข้อความ และ type(e).__name__ จะได้ชื่อชนิด:
try:
int("สิบ")
except ValueError as e:
print(type(e).__name__) # ValueError
print(e) # invalid literal for int() with base 10: 'สิบ'
โมดูล traceback: ข้อความผิดพลาดแบบเต็ม
บางครั้งต้องดักข้อผิดพลาดไว้ แต่ยังต้องเก็บ traceback ทั้งหมดไว้ เช่น สำหรับบันทึกล็อกหรือรายงาน โมดูล traceback ทำหน้าที่นี้:
import traceback
try:
1 / 0
except ZeroDivisionError:
traceback.print_exc() # พิมพ์ traceback ไปยัง stderr
text = traceback.format_exc() # ข้อความเดียวกันในรูปสตริง
หากใช้โมดูล logging เขียนล็อก ให้เรียก logging.exception("คำนวณไม่สำเร็จ") ภายใน except จะได้ traceback แนบไปกับข้อความโดยอัตโนมัติ
raise ใน Python: เรียก exception เอง
คำสั่ง raise โยน exception ด้วยตัวเอง ฟังก์ชันใช้บอกว่าไม่สามารถทำงานกับข้อมูลแบบนี้ได้:
def add_expense(amount):
if amount < 0:
raise ValueError(f"จำนวนเงินติดลบไม่ได้: {amount}")
...
add_expense(-5)
# ValueError: จำนวนเงินติดลบไม่ได้: -5
raise เป็นคำสั่ง ไม่ใช่ฟังก์ชัน จึงไม่ต้องมีวงเล็บหลังคำนี้ อาร์กิวเมนต์ที่ใส่ในวงเล็บคือค่าที่ส่งเข้า constructor ของ exception เลือกชนิดให้ตรงความหมาย ค่าที่ไม่ถูกต้องใช้ ValueError ชนิดข้อมูลที่ไม่ถูกต้องใช้ TypeError
raise เปล่า ๆ และ raise from
ภายใน except การเขียน raise เปล่า ๆ จะส่ง exception ปัจจุบันต่อไปโดยไม่เปลี่ยนแปลง ใช้เมื่อต้องการบันทึกบางอย่างแต่ให้โค้ดที่เรียกใช้เป็นผู้จัดการต่อ
หากต้องการแทนที่ exception หนึ่งด้วยอีกตัวหนึ่ง ใช้ raise ... from e เพื่อเก็บ exception เดิมไว้ในสายโซ่ และ traceback จะแสดงทั้งสองตัว:
def load_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError as e:
raise RuntimeError(f"ไม่พบไฟล์คอนฟิก {path}") from e
add_note: เพิ่มบริบทให้ข้อผิดพลาด
ตั้งแต่ Python 3.11 (PEP 678) สามารถเพิ่มหมายเหตุให้ exception ด้วยเมธอด add_note() แล้วส่งต่อได้เลย ชนิดข้อผิดพลาดไม่เปลี่ยน และหมายเหตุจะแสดงอยู่ใต้ข้อความ:
def parse(raw, line_no):
try:
return float(raw)
except ValueError as e:
e.add_note(f"ไฟล์ expenses.csv บรรทัดที่ {line_no}")
raise
parse("สิบ", 17)
# ValueError: could not convert string to float: 'สิบ'
# ไฟล์ expenses.csv บรรทัดที่ 17
สร้าง exception ของตัวเองใน Python
Exception ของตัวเองมีประโยชน์กับข้อผิดพลาดเฉพาะโดเมน เช่น เกินงบประมาณ ออเดอร์จ่ายเงินแล้ว หรือผู้ใช้ถูกบล็อก เพียงสร้างคลาสที่สืบทอดจาก Exception:
class BudgetExceeded(Exception):
def __init__(self, category, limit, spent):
super().__init__(f"{category}: วงเงิน {limit} ใช้ไป {spent}")
self.category = category
self.limit = limit
self.spent = spent
def check_budget(category, limit, spent):
if spent > limit:
raise BudgetExceeded(category, limit, spent)
try:
check_budget("อาหาร", 10000, 12500)
except BudgetExceeded as e:
print("คำเตือน:", e) # คำเตือน: อาหาร: วงเงิน 10000 ใช้ไป 12500
ตอนนี้โค้ดที่เรียกใช้จะดักเฉพาะกรณีเกินงบประมาณ และไม่สับสนกับการพิมพ์ชื่อผิด ในโปรเจกต์ขนาดใหญ่ควรสร้างคลาสฐานร่วม (เช่น class TrackerError(Exception)) แล้วให้ exception ทั้งหมดสืบทอดจากคลาสนี้ เพื่อดักทั้งหมดด้วย except TrackerError เพียงบล็อกเดียว และควรสืบทอดจาก Exception ไม่ใช่จาก BaseException
กลุ่ม exception และ except*
บางครั้งเกิดข้อผิดพลาดหลายตัวพร้อมกัน เช่น ตรวจทั้งไฟล์ หรือในงานคู่ขนานของ asyncio.TaskGroup ตั้งแต่ Python 3.11 สามารถรวมไว้ใน ExceptionGroup และจัดการแยกตามชนิดด้วย except*:
def check_all(rows):
errors = []
for r in rows:
try:
float(r)
except ValueError as e:
errors.append(e)
if errors:
raise ExceptionGroup("ข้อผิดพลาดในไฟล์", errors)
try:
check_all(["1", "a", "b"])
except* ValueError as eg:
print(f"บรรทัดที่ผิดรูปแบบ: {len(eg.exceptions)}") # บรรทัดที่ผิดรูปแบบ: 2
try except ดักทุกข้อผิดพลาด: ทำไมไม่ควร
นิสัยที่เป็นอันตรายที่สุดคือดักทุกอย่าง เช่น except: pass โปรแกรมจะ "ไม่ล่ม" แต่จริง ๆ แล้วกลับไม่รายงานปัญหาใดเลย การใช้ except เปล่า ๆ จะดัก KeyboardInterrupt และ SystemExit ด้วย ทำให้กด Ctrl+C แล้วหยุดโปรแกรมไม่ได้ ส่วน except Exception: pass ดีกว่าเล็กน้อย แต่ก็ยังเงียบไปกับการพิมพ์ชื่อผิดและข้อผิดพลาดทางตรรกะ
วิธีจัดการ exception ที่ถูกต้องใน Python:
- ดักเฉพาะชนิดที่รู้วิธีรับมือ เช่น
except ValueErrorไม่ดักทั้งหมด - ทำบล็อก
tryให้สั้น ใส่เฉพาะบรรทัดที่อาจเกิดข้อผิดพลาดแบบนั้นจริง ๆ - หากดักกว้าง (
except Exception) ที่ระดับบนสุดของโปรแกรม ต้องบันทึก traceback ทุกครั้ง - อย่าใช้ exception สำหรับการแตกแขนงธรรมดาเมื่อการตรวจสอบเพียงพอ เช่น ใช้
dict.get()แทนtry/except KeyErrorในกรณีง่าย ๆ - สำหรับทรัพยากร ใช้
withเพราะมันจะปิดไฟล์ให้แม้เกิดข้อผิดพลาด
สรุป
Exception ใน Python เป็นกลไกสำหรับแจ้งข้อผิดพลาด ไม่ใช่เหตุผลที่จะซ่อนมัน อ่าน traceback จากล่างขึ้นบน ดักชนิดที่เจาะจงด้วย try/except ใช้ else สำหรับโค้ด "เมื่อทุกอย่างผ่าน" และ finally สำหรับการเก็บกวาด raise ใช้แจ้งข้อผิดพลาดด้วยตัวเอง raise from และ add_note เก็บบริบทไว้ และ exception ของตัวเองควรสืบทอดจาก Exception หัวข้อนี้ถูกสอนแบบลงมือทำในคอร์สเรื่อง Python โดยโปรแกรมติดตามค่าใช้จ่ายจะไม่หยุดทำงานเพราะบรรทัด CSV ที่ผิดรูปแบบบรรทัดเดียว และแสดงการเกินงบประมาณด้วย exception ของตัวเอง
คำถามที่พบบ่อย
ข้อผิดพลาดกับ exception ใน Python ต่างกันอย่างไร?
ในการพูดทั่วไปทั้งสองคำมีความหมายเดียวกัน ในทางเทคนิค SyntaxError เกิดขึ้นตั้งแต่ตอนแปลโค้ด ส่วนข้อผิดพลาดอื่นเกิดขณะรันโปรแกรมในรูปของออบเจกต์ exception ซึ่งสามารถดักจับได้
หลัง exception แล้วจะกลับไปทำต่อจากจุดที่เกิดได้หรือไม่?
ไม่ได้ หลังจากดักจับแล้ว โปรแกรมจะทำงานต่อจากหลังบล็อก try/except ไม่ใช่จากบรรทัดที่เกิดข้อผิดพลาด หากต้องการประมวลผลทีละบรรทัดของไฟล์ ให้วาง try ไว้ภายในลูป
หาก exception เกิดขึ้นภายในบล็อก except จะเป็นอย่างไร?
exception ใหม่จะถูกส่งต่อขึ้นไป และ exception เดิมจะถูกเก็บไว้ในแอตทริบิวต์ __context__ ใน traceback จะปรากฏข้อความ During handling of the above exception, another exception occurred
จะทดสอบว่าฟังก์ชันโยน exception จริงในเทสต์ได้อย่างไร?
ใน pytest ใช้ with pytest.raises(ValueError): ใน unittest ใช้ self.assertRaises(ValueError) เทสต์จะผ่านเฉพาะเมื่อมี exception ชนิดที่ระบุเกิดขึ้นในบล็อก
assert ต่างจาก raise อย่างไร?
assert เงื่อนไข, "ข้อความ" จะโยน AssertionError เมื่อเงื่อนไขเป็นเท็จ เป็นเครื่องมือตรวจสอบระหว่างพัฒนา เมื่อรันด้วยแฟล็ก -O จะถูกปิด ดังนั้นการตรวจข้อมูลจากผู้ใช้ต้องใช้ if กับ raise
หัวข้อนี้สอนอย่างละเอียดพร้อมฝึกปฏิบัติในคอร์ส “Python 3.15: от новичка до профи”
ไปที่คอร์ส