ข้ามไปยังเนื้อหา

Dunder Methods

Dunder methods (ย่อจาก double-underscore) คือ method พิเศษอย่าง __repr__, __eq__ และ __add__ ที่ Python เรียกให้อัตโนมัติเมื่อตอบสนองต่อ syntax และ function ที่เป็น built-in มันคือกลไกเบื้องหลัง Python Data Model — สัญญาที่ทำให้ object ของคุณทำงานเหมือน built-in type

ทั้งคู่สร้าง string representation แต่รับใช้ผู้ใช้ต่างกัน:

Methodเรียกเมื่อจุดประสงค์
__repr__repr(obj), interactive shell, element ภายใน containerDeveloper representation ที่ไม่กำกวม
__str__str(obj), print(obj), f-string {obj}แสดงผลให้มนุษย์อ่านง่าย

ถ้ากำหนดแค่ __repr__ Python จะใช้มันทั้งสองกรณี Convention ของ __repr__ คือ return string ที่ถ้าส่งเข้า eval() แล้วจะสร้าง object กลับมาได้เหมือนเดิม

class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Point({self.x}, {self.y})"
def __str__(self) -> str:
return f"({self.x}, {self.y})"

รายละเอียดที่ทำให้คนที่กำหนดทั้งสองตัวแปลกใจ: print([Point(1, 2)]) พิมพ์ [Point(1, 2)] ไม่ใช่ [(1, 2)] list ไม่เคยเรียก __str__ ของ element ของมันเลย — มันเรียก __repr__ เสมอ แม้ว่า print() บน list เองจะใช้ __str__ ของ list (ซึ่งสำหรับ list แล้วสร้างจาก repr ของแต่ละ element) __str__ จะถูกเรียกก็ต่อเมื่อคุณ convert หรือ print object นั้น โดยตรง เท่านั้น

Python เรียก __eq__ เมื่อคุณเขียน a == b ถ้าไม่มี __eq__, == จะ fallback เป็นการเทียบ identity (is) — สอง instance ที่แยกกันแต่มี data เดียวกันจะไม่เคยเท่ากันเลย

def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y

__eq__ ทำให้ __hash__ เป็น None — TypeError ตัวจริง และวิธีแก้สองแบบ

หัวข้อที่มีชื่อว่า “__eq__ ทำให้ __hash__ เป็น None — TypeError ตัวจริง และวิธีแก้สองแบบ”

การกำหนด __eq__ บน class จะปิดการใช้ __hash__ default ที่สืบทอดจาก object — Python ตั้ง __hash__ = None ให้อัตโนมัติ เพราะสอง object ที่เท่ากันต้อง hash เท่ากัน และ __eq__ ที่คุณเขียนเองทำลาย guarantee นั้นที่ interpreter เคยสมมติได้ instance ตอนนี้เป็น unhashable:

class User:
def __init__(self, uid: int) -> None:
self.uid = uid
def __eq__(self, other: object) -> bool:
return isinstance(other, User) and self.uid == other.uid
cache = {}
cache[User(1)] = "active"

รันบน 3.14 บรรทัดนี้ raise TypeError: cannot use 'User' as a dict key (unhashable type: 'User') — และ hash(User(1)) ที่เรียกตรงๆ raise TypeError: unhashable type: 'User' วิธีแก้สองแบบ:

  1. กำหนด __hash__ เอง สร้างจาก field เดียวกับที่ __eq__ เทียบ: def __hash__(self) -> int: return hash(self.uid)
  2. ใช้ frozen dataclass (@dataclass(frozen=True)) — มันสร้างทั้ง __eq__ ที่ถูกต้องและ __hash__ ที่เข้าคู่กันให้อัตโนมัติ ตราบใดที่ทุก field เป็น hashable

วิธีไหนก็ตามจะคืน hash(obj) ให้ใช้ได้ และให้ instance อยู่ใน set หรือเป็น dict key ได้อีกครั้ง

NotImplemented เป็น singleton พิเศษ — ไม่ใช่ exception — ที่ dunder method return เพื่อบอกว่า “ฉันไม่รู้วิธีทำสิ่งนี้กับ type อื่นนั้น ให้ลองถามตัวอื่นดู” จากนั้น Python จะลอง method แบบ reflected (__radd__ สำหรับ +, ในรูปแบบเดียวกันสำหรับ operator อื่น) บน operand อีกฝั่ง ถ้าทุกตัวเลือก return NotImplemented หมด Python จะ fallback ไปค่า default (== และ != fallback เป็น identity; operator อื่น raise TypeError)

def __radd__(self, other: object) -> "Vector":
# supports sum([...]), which starts by computing 0 + first_item
if other == 0:
return self
return NotImplemented

การสับสน NotImplemented กับ NotImplementedError (exception จริง มีไว้สำหรับ abstract method) เป็น bug ที่พบบ่อย: Good(1) == "not a Good" return False เพราะ NotImplemented กระตุ้น fallback ปกติของ Python แต่ class ที่ raise NotImplementedError จาก __eq__ แทน จะทำให้การเปรียบเทียบทั้งหมดระเบิด — Bad(1) == "not a Bad" raise NotImplementedError: cannot compare แทนที่จะ return False เงียบๆ ยืนยันแล้วบน 3.14: ทั้งสอง behavior เป็นไปตามที่อธิบายเป๊ะ

Implement __len__ เพื่อรองรับ len(obj) Implement __getitem__ เพื่อรองรับการเข้าถึงแบบ subscript obj[index]

def __len__(self) -> int:
return 2
def __getitem__(self, index: int) -> float:
return (self.x, self.y)[index]

Iterator protocol จริงๆ คือสอง method: __iter__ return iterator (บ่อยครั้งคือ self) และ __next__ return ค่าถัดไปหรือ raise StopIteration เพื่อบอกจุดจบ:

class Countdown:
def __iter__(self) -> "Countdown":
self.current = self.start
return self
def __next__(self) -> int:
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1

__getitem__ เพียงตัวเดียวก็เพียงพอที่จะทำให้ object iterable ได้เหมือนกัน ผ่าน fallback แบบ legacy ที่แยกออกไป: ถ้า class ไม่มี __iter__ for loop ของ Python จะเรียก __getitem__ ด้วย 0, 1, 2, ... จนกว่าจะ raise IndexError แล้วหยุดตรงนั้น — __len__ ไม่เกี่ยวข้องกับ fallback นี้เลย list(LegacySequence([10, 20, 30])) รันได้ปกติและพิมพ์ [10, 20, 30] บน CPython แต่ ทั้ง uvx mypy --strict และ uvx pyright ปฏิเสธมัน — ทั้งสองตัว model แค่ Iterable protocol แบบใหม่ (__iter__) ไม่รู้จัก __getitem__ fallback แบบ legacy: mypy รายงาน No overload variant of "list" matches argument type "LegacySequence" [call-overload] และ pyright รายงาน "LegacySequence" is incompatible with protocol "Iterable[_T@list]" — "__iter__" is not present นี่คือช่องว่างจริงระหว่างสิ่งที่ CPython รันได้กับสิ่งที่ type checker ปัจจุบันเข้าใจ — ใช้ __iter__/__next__ เมื่อคุณต้องการให้ type checker ยอมรับ

def __len__(self) -> int:
return len(self.items)
def __bool__(self) -> bool:
return len(self.items) > 0
def __contains__(self, item: object) -> bool:
return item in self.items

bool(obj) เรียก __bool__ ถ้ามี ถ้าไม่มีจะ fallback เป็น __len__ (ความยาว 0 เป็น falsy) in เรียก __contains__ ถ้ามี ถ้าไม่มีจะ fallback เป็นการ iterate ด้วย __iter__/__getitem__

การกำหนด __call__ ทำให้เรียก instance เหมือน function ได้ — obj(...) กลายเป็น obj.__call__(...):

class Multiplier:
def __init__(self, factor: int) -> None:
self.factor = factor
def __call__(self, value: int) -> int:
return value * self.factor
double = Multiplier(2)
double(21) # 42
callable(double) # True

นี่คือวิธีที่ decorator แบบ class และ callback ที่ config ได้ทำงาน — object พก state (factor) ที่ function ธรรมดาเก็บไม่ได้โดยไม่มี closure

__getattribute__ รันสำหรับทุกการเข้าถึง attribute บน instance ไม่ว่าจะเจอหรือไม่ — override มันหายากและผิดพลาดง่าย (recursion ไม่มีที่สิ้นสุดถ้ามันเรียก self.name แทนที่จะเป็น object.__getattribute__(self, name)) __getattr__ เป็น hook ที่ปลอดภัยกว่า: มันรันเฉพาะเมื่อ lookup ปกติล้มเหลวไปแล้วเท่านั้น (ทั้ง instance __dict__ และ MRO ของ class ไม่เจอ) ทำให้เหมาะกับ fallback ที่คำนวณได้หรือ error ที่เป็นมิตรกว่า

def __getattr__(self, name: str) -> str:
# only called when normal attribute lookup FAILS
return f"<no setting {name!r}>"

cfg.loaded (attribute จริง) ไม่ไปถึง __getattr__ เลย ส่วน cfg.missing_setting ไปถึง เพราะ lookup ปกติล้มเหลวก่อน

__del__ รันเมื่อ reference count ของ object ลดเหลือศูนย์ — สำหรับ object ธรรมดาที่ไม่มี cycle บน CPython มันเกิดขึ้นทันทีที่ reference สุดท้ายถูกลบ ดูเหมือน deterministic แต่จะหยุดเป็น deterministic ทันทีที่สอง object อ้างอิงกันเป็น cycle: reference counting อย่างเดียวไม่มีทางทำให้ count ของ cycle เป็นศูนย์ได้ ดังนั้น __del__ จะไม่รันจนกว่า cyclic garbage collector (gc.collect() หรือการรันอัตโนมัติเป็นระยะ) จะตัด cycle นั้น — ซึ่งอาจช้ากว่ามาก หรือในกรณีเลวร้ายที่สุด (exception ตอน interpreter shutdown หรือปิด gc) อาจไม่รันเลย ยืนยันแล้ว: การลบ node สองตัวที่อ้างอิงกันไม่พิมพ์อะไรเลยจนกว่า gc.collect() จะรัน ส่วน object ที่ไม่มี cycle จะพิมพ์จาก __del__ ทันทีตอน del อย่าพึ่ง __del__ เพื่อปล่อย resource ภายนอก (file, socket, lock) — ใช้ context manager (__enter__/__exit__) แทน

การ implement comparison operator ทุกตัว (__lt__, __le__, __gt__, __ge__) ด้วยมือซ้ำซาก @total_ordering เติมส่วนที่เหลือให้จากแค่ __eq__ กับอีกหนึ่งตัว:

from functools import total_ordering
@total_ordering
class Money:
def __eq__(self, other: object) -> bool:
...
def __lt__(self, other: "Money") -> bool:
return self.cents < other.cents

sorted(prices) และ Money(100) <= Money(200) ทำงานได้ทั้งคู่ — <= ไม่เคยถูกเขียนด้วยมือ total_ordering สร้างมันมาจาก __eq__ และ __lt__

Arithmetic operator ผูกกับ dunder method:

OperatorMethod
a + ba.__add__(b)
a - ba.__sub__(b)
a * ba.__mul__(b)
a == ba.__eq__(b)
a < ba.__lt__(b)
def __add__(self, other: "Vector") -> "Vector":
return Vector(self.x + other.x, self.y + other.y)
from functools import total_ordering
from dataclasses import dataclass
class Vector:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
def __str__(self) -> str:
return f"({self.x}, {self.y})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __add__(self, other: "Vector") -> "Vector":
return Vector(self.x + other.x, self.y + other.y)
def __radd__(self, other: object) -> "Vector":
if other == 0:
return self
return NotImplemented
def __len__(self) -> int:
return 2
def __getitem__(self, index: int) -> float:
return (self.x, self.y)[index]
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(repr(v1))
print(str(v1))
print(v1)
print([v1, v2]) # containers always use repr for elements, even though __str__ exists
print(v1 == Vector(1, 2))
print(v1 + v2)
print(sum([v1, v2])) # 0 + v1 -> Vector.__radd__ since int.__add__(0, v1) fails
print(len(v1))
print(v1[0], v1[1])
try:
hash(v1)
except TypeError as e:
print(f"TypeError: {e}")
class HashableVector:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __eq__(self, other: object) -> bool:
if not isinstance(other, HashableVector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self) -> int: # Fix 1: define __hash__ alongside __eq__
return hash((self.x, self.y))
hv1 = HashableVector(1, 2)
hv2 = HashableVector(1, 2)
print(hash(hv1) == hash(hv2))
print(len({hv1, hv2})) # equal + same hash -> the set dedupes them to 1
@dataclass(frozen=True)
class FrozenVector: # Fix 2: frozen=True generates a working __hash__ for free
x: float
y: float
print(hash(FrozenVector(1, 2)) == hash(FrozenVector(1, 2)))
class Countdown:
def __init__(self, start: int) -> None:
self.start = start
def __iter__(self) -> "Countdown":
self.current = self.start
return self
def __next__(self) -> int:
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
print(list(Countdown(3)))
class LegacySequence:
def __init__(self, data: list[int]) -> None:
self._data = data
def __getitem__(self, index: int) -> int:
return self._data[index] # raises IndexError once index runs out -- for loop relies on that
print(list(LegacySequence([10, 20, 30])))
class Basket:
def __init__(self, items: list[str]) -> None:
self.items = items
def __len__(self) -> int:
return len(self.items)
def __bool__(self) -> bool:
return len(self.items) > 0
def __contains__(self, item: object) -> bool:
return item in self.items
empty = Basket([])
full = Basket(["apple"])
print(bool(empty), bool(full))
print("apple" in full)
print(len(full))
class Multiplier:
def __init__(self, factor: int) -> None:
self.factor = factor
def __call__(self, value: int) -> int:
return value * self.factor
double = Multiplier(2)
print(double(21))
print(callable(double))
class LazyConfig:
def __init__(self) -> None:
self.loaded = {"debug": False}
def __getattr__(self, name: str) -> str:
# only called when normal attribute lookup FAILS
return f"<no setting {name!r}>"
def __getattribute__(self, name: str) -> object:
# called for EVERY attribute access, found or not
print(f"looking up {name!r}")
return object.__getattribute__(self, name)
cfg = LazyConfig()
print(cfg.loaded)
print(cfg.missing_setting)
@total_ordering
class Money:
def __init__(self, cents: int) -> None:
self.cents = cents
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return self.cents == other.cents
def __lt__(self, other: "Money") -> bool:
return self.cents < other.cents
prices = [Money(300), Money(100), Money(200)]
print([m.cents for m in sorted(prices)])
print(Money(100) <= Money(200)) # generated by total_ordering from __eq__ + __lt__
flowchart TD
A["a + b"] --> B{"a.__add__(b) returns NotImplemented?"}
B -->|"no"| R1["use a.__add__(b)'s result"]
B -->|"yes"| C{"b.__radd__(a) exists and returns NotImplemented?"}
C -->|"no"| R2["use b.__radd__(a)'s result"]
C -->|"yes"| E["TypeError: unsupported operand type(s)"]
NotImplemented ขับเคลื่อนการ dispatch operator ของ Python โดยเริ่มจาก operand ซ้ายก่อน

Syntax ที่เป็น built-in ทุกตัวจริงๆ แล้วคือ lookup-แล้ว-call: a + b คือ type(a).__add__(a, b) ถูกลองก่อน มีแค่ตอนที่ return NotImplemented เท่านั้น Python จึงจะลอง type(b).__radd__(b, a) รูปแบบเดียวกัน — “ลอง dunder ตรงก่อน แล้ว fallback ไปอีกตัวที่เกี่ยวข้อง มีแค่ตอนนั้นถึงจะยอมแพ้” — ปรากฏซ้ำทั่วทั้งบทนี้: bool(x) ลอง __bool__ แล้ว fallback เป็น __len__; for ลอง __iter__/__next__ แล้ว fallback เป็น __getitem__ protocol; การเข้าถึง attribute ลอง lookup ปกติแล้ว fallback เป็น __getattr__ การอ่าน class ที่เต็มไปด้วย dunder คือการถามทุกครั้งว่า: syntax แต่ละแบบรัน method ไหนก่อน และ Python ทำอะไรถ้ามันปฏิเสธ?

สิ่งที่ได้ประโยชน์ต้นทุน
__eq__ ที่เขียนเองEquality แบบ value-based แทน identityปิด __hash__ — ต้องเพิ่มกลับเองถ้าต้องการ hashing
__iter__/__next__Iterator protocol จริง type checker เข้าใจต้องเขียนสอง method แทนหนึ่ง
Iteration แบบ __getitem__ อย่างเดียวทำงานได้ด้วย method เดียว เหมาะกับข้อมูลที่ index ได้อยู่แล้วmypy/pyright ไม่นับเป็น Iterable เฉพาะข้อมูลที่ index เป็นจำนวนเต็มและจบด้วย IndexError เท่านั้นที่ใช้ได้
__call__Instance ทำตัวเหมือน function และพก state ได้ใช้เกินความจำเป็นได้ง่ายในจุดที่ function หรือ method ธรรมดาอ่านง่ายกว่า
__getattr__Fallback สะอาดสำหรับ attribute ที่หายไปหรือคำนวณได้การตอบให้เงียบๆ กับชื่อ attribute ที่พิมพ์ผิดอาจซ่อน bug จริง
@total_orderingเขียน comparison แค่ 2 ตัว ได้ครบ 4 ตัวช้ากว่า operator ที่เขียนเองเล็กน้อย (มี function call เพิ่มต่อการเปรียบเทียบหนึ่งครั้ง)
  • __str__ ถูกใช้ทุกที่ที่ __repr__ ไม่ได้ใช้ Container (list, dict, tuple) ใช้ __repr__ สำหรับ element ของมันเสมอ — print([obj]) ไม่เคยเรียก obj.__str__()
  • Class ที่มี __eq__ ใช้ใน set หรือเป็น dict key ได้เหมือนเดิม การกำหนด __eq__ ทำให้ __hash__ เป็น None เว้นแต่คุณจะกำหนด __hash__ ด้วย — class กลายเป็น unhashable โดย TypeError เกิดตอนใช้งาน ไม่ใช่ตอน define
  • การ return False กับการ raise NotImplementedError จาก __eq__ เป็นวิธี “ปลอดภัย” ที่บอกปฏิเสธคล้ายกัน NotImplemented (ไม่มีวงเล็บ ไม่ใช่ exception) ให้ Python ลอง operand อีกฝั่งแล้ว fallback อย่างนุ่มนวล ส่วน NotImplementedError เป็น exception จริงที่ทำให้การเปรียบเทียบทั้งหมดพัง
  • __len__ จำเป็นสำหรับให้ for loop ทำงาน มันไม่เกี่ยวกับ iteration เลย — __iter__/__next__ หรือ __getitem__ fallback ต่างหากที่ for ใช้จริง
  • __del__ เป็นที่ปล่อย resource ที่น่าเชื่อถือ เหมือน destructor ใน C++ มันรันทันทีเฉพาะ object ที่ไม่มี cycle บน CPython’s refcounting เท่านั้น reference cycle จะทำให้มันรันช้าลงจนกว่า cyclic GC จะทำงาน และอาจไม่รันเลยก่อน interpreter shutdown ในบางกรณี ใช้ context manager แทน

ก่อนเชื่อ class ที่เต็มไปด้วย dunder ที่ generate มา ตรวจสิ่งเหล่านี้:

  • __eq__ ถูกกำหนดโดยไม่มี __hash__ ที่เข้าคู่กันไหม? Class จะกลายเป็น unhashable แบบเงียบๆ ไม่มีปัญหาจนกว่าจะมีคนเอา instance ไปใส่ set หรือเป็น dict key ซึ่งจะ raise TypeError ห่างไกลจากจุดที่เขียน __eq__
  • Comparison dunder raise NotImplementedError แทนที่จะ return NotImplemented ไหม? นั่นเปลี่ยน “ลอง operand อีกฝั่ง” ให้กลายเป็น crash
  • __getitem__ ถูกใช้เป็นกลไก iteration อย่างเดียว ในโค้ดที่จะถูก type-check ด้วยไหม? mypy --strict/pyright ปฏิเสธการส่ง object แบบนี้ให้ list(...) หรือ parameter ที่เป็น Iterable[...] ใดๆ แม้ว่ามันจะรันได้บน CPython
  • __getattribute__ (ไม่ใช่ __getattr__) ถูก override โดยไม่จบด้วย object.__getattribute__(self, name) ไหม? การเรียก self.name หรือ getattr(self, name) แทนจะทำให้ recursion ไม่มีที่สิ้นสุด
  • Logic การ cleanup (ปิด file, ปล่อย lock) อยู่แค่ใน __del__ เท่านั้นไหม? Resource นั้นอาจเปิดค้างนานกว่าที่ตั้งใจ หรือไม่ถูกปิดเลยถ้าอยู่ใน cycle

🐛 หา bug ในโค้ดนี้

class User:
def __init__(self, uid: int) -> None:
self.uid = uid
def __eq__(self, other: object) -> bool:
return isinstance(other, User) and self.uid == other.uid
cache = {}
u = User(1)
cache[u] = "active"
print(cache)
เฉลย

การกำหนด __eq__ โดยไม่กำหนด __hash__ ด้วย ทำให้ __hash__ เป็น None บน class — instance กลายเป็น unhashable cache[u] = "active" raise TypeError: cannot use 'User' as a dict key (unhashable type: 'User') บน 3.14 ที่จุด assignment เลย ไม่ใช่ตอน define class แก้โดยเพิ่ม __hash__ ที่เข้าคู่กัน: def __hash__(self) -> int: return hash(self.uid) — สร้างจาก field เดียวกับที่ __eq__ เทียบ — หรือเปลี่ยนไปใช้ @dataclass(frozen=True) ซึ่งสร้างคู่ที่ถูกต้องให้อัตโนมัติ

อะไรคือความต่างระหว่าง __repr__ และ __str__?
__eq__ ควร return NotImplemented แทน False เมื่อไหร่?
v1 + v2 เรียก dunder method ไหนบน v1?
for loop รู้ได้อย่างไรว่าเมื่อไหร่ควรหยุด?
class Item:
    def __getitem__(self, i):
        return [10, 20, 30][i]

for v in Item():
    print(v)
เกิดอะไรขึ้น?
class Thing:
    def __eq__(self, other):
        return True

t = Thing()
d = {}
d[t] = 1
โค้ดนี้พิมพ์ (หรือ raise) อะไร?
class V:
    def __eq__(self, other):
        if not isinstance(other, V):
            raise NotImplementedError
        return True

print(V() == "x")
Class หนึ่งกำหนด __len__ แต่ไม่มี __bool__ bool(obj) ทำอะไรเมื่อ len(obj) == 0?
ความต่างหลักระหว่าง __getattr__ กับ __getattribute__ คืออะไร?