Properties and Class Methods
Decorator @property
หัวข้อที่มีชื่อว่า “Decorator @property”@property เปลี่ยน method ให้เป็น accessor แบบ attribute
ผู้เรียกใช้ obj.celsius แทน obj.get_celsius() ทำให้ public API สะอาดในขณะที่ validation logic ยังทำงานอยู่เบื้องหลัง
class Temperature: def __init__(self, celsius: float = 0.0) -> None: self._celsius = celsius # underscore = internal storage
@property def celsius(self) -> float: return self._celsiusSetters
หัวข้อที่มีชื่อว่า “Setters”จับคู่ @property กับ @name.setter เพื่อดัก assignment:
@celsius.setter def celsius(self, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") self._celsius = valueตอนนี้ t.celsius = -300 raise ValueError อัตโนมัติ
ถ้าไม่มี setter property จะ read-only — การพยายาม assign จะ raise AttributeError
Computed (derived) properties
หัวข้อที่มีชื่อว่า “Computed (derived) properties”Property คำนวณค่าสดๆ ได้โดยไม่มีที่เก็บข้อมูล:
@property def fahrenheit(self) -> float: return self._celsius * 9 / 5 + 32t.fahrenheit ดูเหมือน attribute ธรรมดาสำหรับผู้เรียกใช้ แต่มันถูกคำนวณใหม่ทุกครั้ง
property คือ descriptor — เขียนของตัวเองใน 10 บรรทัด
หัวข้อที่มีชื่อว่า “property คือ descriptor — เขียนของตัวเองใน 10 บรรทัด”@property ไม่ใช่ syntax พิเศษที่ interpreter hard-code ไว้ — มันคือ class ชื่อ property ที่ implement descriptor protocol: __get__, __set__ และ __delete__ (ถ้าต้องการ) Class ไหนก็ตามที่ implement method เหล่านี้และถูกเก็บเป็น attribute ระดับ class จะทำงานแบบเดียวกัน นี่คือ descriptor ที่เขียนเองที่ทำ celsius property แบบ validate ใหม่ตั้งแต่ต้น ในราวสิบบรรทัด:
class Celsius: def __set_name__(self, owner: type, name: str) -> None: self.name = f"_{name}" # remembers the attribute name it's bound to
def __get__(self, obj: object, objtype: type | None = None) -> float: if obj is None: return self # accessed on the class itself, e.g. Thermostat.celsius return getattr(obj, self.name, 0.0)
def __set__(self, obj: object, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") setattr(obj, self.name, value)
class Thermostat: celsius = Celsius() # a descriptor instance, used exactly like @propertyt.celsius = 21.0 เรียก Celsius.__set__; t.celsius เรียก Celsius.__get__ — behavior เหมือนกับ @property ที่มี setter ทุกประการ เพราะนั่นคือสิ่งที่ property เป็นจริงๆ เบื้องหลัง: hasattr(property, "__get__") และ hasattr(property, "__set__") ล้วนเป็น True การเข้าใจสิ่งนี้อธิบาย behavior “แปลกๆ” อีกหลายอย่างใน Python: classmethod, staticmethod และแม้แต่ function เอง (bound method) ล้วนเป็น descriptor ด้วยกันทั้งหมด — การเข้าถึง attribute บน instance จริงๆ แล้วคือ descriptor protocol ที่ทำงานอยู่ ทุกครั้ง
functools.cached_property
หัวข้อที่มีชื่อว่า “functools.cached_property”@property คำนวณใหม่ทุกครั้งที่เข้าถึง @cached_property (จาก functools) คำนวณค่าครั้งเดียวตอนเข้าถึงครั้งแรก แล้วเก็บเป็น entry ธรรมดาใน __dict__ ของ instance — การเข้าถึงหลังจากนั้นทุกครั้งเป็นแค่ attribute lookup ธรรมดา (เร็ว) ไม่ใช่การรัน method ใหม่:
from functools import cached_property
class Report: def __init__(self, rows: list[int]) -> None: self.rows = rows
@cached_property def total(self) -> int: return sum(self.rows) # only runs once per instancer.total ที่เข้าถึงสองครั้งรัน method รวมยอดแค่ครั้งเดียว r.__dict__["total"] แสดงผลลัพธ์ที่ cache ไว้เป็น attribute ธรรมดาหลังการเข้าถึงครั้งแรก เพราะ cache อยู่ใน __dict__ cached_property จึงต้องการให้ instance มี __dict__ (จะไม่ทำงานบน class slots=True เว้นแต่ "total" จะถูกระบุเป็น slot) — และเพราะ cache เป็นแค่ instance attribute การ invalidate มันคือ del obj.total ซึ่งบังคับให้การเข้าถึงครั้งถัดไปคำนวณใหม่
@classmethod — alternative constructor
หัวข้อที่มีชื่อว่า “@classmethod — alternative constructor”Class method รับ class (cls) เป็น argument แรกแทน instance
การใช้งานที่พบบ่อยที่สุดคือทำ alternative constructor:
@classmethod def from_fahrenheit(cls, f: float) -> Self: return cls((f - 32) * 5 / 9)boiling = Temperature.from_fahrenheit(212)print(boiling.celsius) # 100.0Subclass สืบทอด class method อัตโนมัติและ cls จะอ้างถึง subclass — ทำให้ factory สร้าง instance ของ type ที่ถูกต้อง
Annotate return type ด้วย typing.Self (from typing import Self) ไม่ใช่ string literal "Temperature" — string annotation ทำให้ type checker infer เป็น base class แม้ cls จะเป็น subclass ก็ตาม ด้วย annotation แบบเก่า uvx mypy --strict บน subclass ที่เพิ่ม method จะรายงานตรงตัวว่า: "Temperature" has no attribute "to_kelvin" [attr-defined] Self แก้ปัญหานี้เพราะมันอ้างถึง class ที่ถูกเรียกจริงเสมอ — ยืนยันด้วย subclass จริง:
class Kelvin(Temperature): def to_kelvin(self) -> float: return self.celsius + 273.15
k = Kelvin.from_fahrenheit(32)print(type(k)) # <class '__main__.Kelvin'>print(k.to_kelvin()) # 273.15 -- only type-checks because from_fahrenheit's return type is Selfk เป็น Kelvin จริงตอน runtime ไม่ว่า annotation จะเป็นอะไร — แต่มีแค่ Self annotation เท่านั้นที่ทำให้ทั้ง uvx mypy --strict และ uvx pyright ยืนยันว่า k.to_kelvin() ถูกต้องโดยไม่ต้อง cast เอง
@staticmethod — utility function
หัวข้อที่มีชื่อว่า “@staticmethod — utility function”Static method ไม่รับทั้งinstance และ class ใช้กับ utility ที่เป็นของ class ในเชิง logic แต่ไม่ต้องการเข้าถึง class หรือ instance state:
@staticmethod def absolute_zero() -> float: return -273.15print(Temperature.absolute_zero()) # -273.15Demo รันได้จริง
หัวข้อที่มีชื่อว่า “Demo รันได้จริง”from functools import cached_propertyfrom typing import Self
class Celsius: def __set_name__(self, owner: type, name: str) -> None: self.name = f"_{name}"
def __get__(self, obj: object, objtype: type | None = None) -> float: if obj is None: return self # type: ignore[return-value] return getattr(obj, self.name, 0.0)
def __set__(self, obj: object, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") setattr(obj, self.name, value)
class Thermostat: celsius = Celsius() # a descriptor instance, used exactly like @property
t = Thermostat()t.celsius = 21.0print(t.celsius)try: t.celsius = -300except ValueError as e: print(f"ValueError: {e}")
print(hasattr(Celsius, "__get__"), hasattr(Celsius, "__set__"))print(hasattr(property, "__get__"), hasattr(property, "__set__"))
class Report: def __init__(self, rows: list[int]) -> None: self.rows = rows self.builds = 0
@cached_property def total(self) -> int: self.builds += 1 return sum(self.rows)
r = Report([1, 2, 3])print(r.total)print(r.total)print(r.builds) # computed once -- cached_property stores the result in the instance __dict__print(r.__dict__["total"]) # the cache IS an ordinary instance attribute now
class Temperature: def __init__(self, celsius: float = 0.0) -> None: self._celsius = celsius
@property def celsius(self) -> float: return self._celsius
@celsius.setter def celsius(self, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero!") self._celsius = value
@property def fahrenheit(self) -> float: return self._celsius * 9 / 5 + 32
@classmethod def from_fahrenheit(cls, f: float) -> Self: return cls((f - 32) * 5 / 9)
@staticmethod def absolute_zero() -> float: return -273.15
t2 = Temperature(100)print(t2.celsius)print(t2.fahrenheit)t2.celsius = 0print(t2.fahrenheit)t3 = Temperature.from_fahrenheit(212)print(t3.celsius)print(Temperature.absolute_zero())
class Kelvin(Temperature): def to_kelvin(self) -> float: return self.celsius + 273.15
k = Kelvin.from_fahrenheit(32)print(type(k))print(k.to_kelvin())Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD
A["obj.attr ="] --> B{"type(obj) defines attr as a data descriptor? (has __set__)"}
B -->|"yes"| C["descriptor.__set__(obj, value) runs -- property setter, custom descriptor"]
B -->|"no"| D["plain assignment into obj.__dict__['attr']"]
E["obj.attr"] --> F{"type(obj) defines attr as a descriptor? (has __get__)"}
F -->|"yes, and it's a DATA descriptor (__set__ too)"| G["descriptor wins even over obj.__dict__"]
F -->|"yes, but non-data (only __get__, e.g. a plain method)"| H["obj.__dict__ wins if present, else descriptor.__get__ runs"]
F -->|"no"| I["obj.__dict__, then the class's MRO"] @property, cached_property, classmethod, staticmethod และแม้แต่ function ธรรมดาที่เก็บบน class ล้วนเป็น descriptor — กลไกที่ Mental Model เรื่อง attribute lookup ในบท classes พูดกลบไว้ว่าเป็นแค่ “class __dict__” Data descriptor (มีทั้ง __get__ และ __set__ เหมือน property) มีความสำคัญเหนือ __dict__ ของ instance เอง — นั่นคือเหตุผลที่ @property ที่ compute ได้ไม่สามารถถูก shadow ด้วย obj.attr = x แบบที่ class attribute ธรรมดาถูก shadow ได้ Non-data descriptor (มีแค่ __get__ เหมือน cached_property ก่อนเข้าถึงครั้งแรก หรือ method ธรรมดา) แพ้ให้ entry ใน instance __dict__ ที่ชื่อเดียวกัน — นี่คือวิธีที่ cached_property “cache” จริงๆ: หลังเข้าถึงครั้งแรกมันเขียน entry ลง obj.__dict__ และการ lookup ครั้งถัดไปทุกครั้งจะเจอ entry นั้นก่อนที่ descriptor จะรันอีกครั้ง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
@property | Validation และ computed value ในรูป plain-attribute syntax | คำนวณใหม่ทุกครั้งที่เข้าถึง |
| Descriptor เอง | Validation/logic ที่ reuse ได้ข้าม class และ attribute หลายตัว | Boilerplate มากกว่า @property ตัวเดียว; ต้องเรียนรู้ protocol (__get__/__set__/__set_name__) ครั้งหนึ่ง |
@cached_property | คำนวณครั้งเดียว จากนั้นเร็วเหมือน attribute ธรรมดา | ต้องการ instance __dict__ (ใช้กับ slots=True ไม่ได้ เว้นแต่ชื่อนั้นเป็น slot); cache ค้างถ้าข้อมูลต้นทางเปลี่ยน |
@classmethod + Self | Alternative constructor ที่ทำงานถูกต้องกับ subclass | Annotate return type เป็น literal ผิด ("Temperature" แทน Self) ทำลาย type-checking ของ subclass แบบเงียบๆ |
@staticmethod | จัดกลุ่ม utility function ไว้ใต้ namespace ของ class | ไม่มีสิทธิ์เข้าถึง self หรือ cls — แยกไม่ออกจาก module-level function ยกเว้นเรื่อง namespace |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”@propertyเป็น syntax พิเศษของ interpreter ไม่เกี่ยวกับ class ธรรมดา มันเป็น built-in class ธรรมดาที่ implement descriptor protocol (__get__/__set__) — คุณเขียนเวอร์ชันของตัวเองได้ในราวสิบบรรทัด- Property ที่ไม่มี setter ยังคง assign ได้ แค่ไม่มี validation มัน assign ไม่ได้เลย — การพยายาม
obj.attr = xบน property ที่ read-only จะ raiseAttributeErrorไม่ใช่แค่เงียบไปเฉยๆ cached_propertyรัน function ของมันทุกครั้ง แค่เร็วกว่า มันรันแค่ครั้งเดียว — การเข้าถึงครั้งแรกเขียนค่าลง instance__dict__และการเข้าถึงหลังจากนั้นทุกครั้งเป็นแค่ dict lookup ธรรมดาที่ไม่เรียก function อีกเลย- การ annotate return type ของ classmethod เป็นชื่อ class แบบ literal (
-> "Temperature") ถูกต้องพอๆ กับSelfType checker ยึด literal name ตรงตัวแม้clsจะเป็น subclass ทำให้เกิดattr-definederror ปลอมบน method ที่มีแค่ใน subclass;Selfติดตาม class ที่ถูกเรียกจริง @staticmethodกับ module-level function ต่างกันอย่างมีนัยสำคัญตอน runtime พวกมันทำงานเหมือนกันตอนถูกเรียก — ความต่างมีแค่เรื่อง namespace (ClassName.util()เทียบกับ free function) และการค้นหาเจอง่ายแค่ไหน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ property และ class method ที่ generate มา ตรวจสิ่งเหล่านี้:
@classmethodแบบ alternative constructor ที่ annotate-> "ClassName"(string / forward reference ไปยัง literal class) แทน-> Self— ทำลาย type-checking แบบเงียบๆ ทันทีที่มีคน subclass มัน@cached_propertyบน class ที่มีslots=Trueโดยไม่มี slot ตั้งชื่อไว้สำหรับ attribute ที่ cache — จะ raiseAttributeError: 'ClassName' object has no attribute '__dict__'ตอนเข้าถึงครั้งแรก เพราะไม่มีที่เก็บ cache- Business logic หรือ side effect (network call, การแก้ state ที่ไม่เกี่ยวข้อง) ซ่อนอยู่ใน
@propertygetter — ผู้เรียกใช้ที่อ่านobj.attrไม่มีสัญญาณทาง syntax เลยว่ามันอาจช้าหรือ fail ได้ __get__ของ descriptor ที่เขียนเองที่ไม่จัดการobj is None(กรณีที่ descriptor ถูกเข้าถึงบน class เอง เช่นThermostat.celsius) — มันควร returnself(หรือ sentinel คล้ายกัน) แทนที่จะ crash@staticmethodที่อ้างถึงselfหรือclsเลย — นั่นเป็นสัญญาณว่าควรเป็น instance หรือ class method แทน
🐛 หา bug ในโค้ดนี้
class Config: def __init__(self) -> None: self._data = {"retries": 3}
@property def retries(self) -> int: return self._data["retries"]
@classmethod def from_env(cls) -> "Config": return cls()
class StrictConfig(Config): def validate(self) -> bool: return self.retries > 0
cfg = StrictConfig.from_env()cfg.validate()เฉลย
from_env annotate เป็น -> "Config" แทนที่จะเป็น -> Self ตอน runtime StrictConfig.from_env() return StrictConfig จริง ดังนั้น cfg.validate() รันได้ปกติ — แต่ type checker ยึด annotation แบบ literal ตรงตัวและ infer cfg: Config ซึ่งไม่มี method validate ยืนยันแล้ว: uvx mypy --strict บนไฟล์นี้รายงาน "Config" has no attribute "validate" [attr-defined] และ uvx pyright รายงาน reportAttributeAccessIssue ที่เทียบเท่ากัน แม้โค้ดจะรันถูกต้องก็ตาม แก้โดย import Self จาก typing แล้ว annotate from_env(cls) -> Self