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

Properties and Class Methods

@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._celsius

จับคู่ @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

Property คำนวณค่าสดๆ ได้โดยไม่มีที่เก็บข้อมูล:

@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32

t.fahrenheit ดูเหมือน attribute ธรรมดาสำหรับผู้เรียกใช้ แต่มันถูกคำนวณใหม่ทุกครั้ง

@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 @property

t.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 ที่ทำงานอยู่ ทุกครั้ง

@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 instance

r.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 ซึ่งบังคับให้การเข้าถึงครั้งถัดไปคำนวณใหม่

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.0

Subclass สืบทอด 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 Self

k เป็น Kelvin จริงตอน runtime ไม่ว่า annotation จะเป็นอะไร — แต่มีแค่ Self annotation เท่านั้นที่ทำให้ทั้ง uvx mypy --strict และ uvx pyright ยืนยันว่า k.to_kelvin() ถูกต้องโดยไม่ต้อง cast เอง

Static method ไม่รับทั้งinstance และ class ใช้กับ utility ที่เป็นของ class ในเชิง logic แต่ไม่ต้องการเข้าถึง class หรือ instance state:

@staticmethod
def absolute_zero() -> float:
return -273.15
print(Temperature.absolute_zero()) # -273.15
from functools import cached_property
from 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.0
print(t.celsius)
try:
t.celsius = -300
except 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 = 0
print(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())
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"]
Descriptor อยู่ระหว่าง attribute lookup ธรรมดากับ instance __dict__

@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 จะรันอีกครั้ง

สิ่งที่ได้ประโยชน์ต้นทุน
@propertyValidation และ 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 + SelfAlternative constructor ที่ทำงานถูกต้องกับ subclassAnnotate 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 จะ raise AttributeError ไม่ใช่แค่เงียบไปเฉยๆ
  • cached_property รัน function ของมันทุกครั้ง แค่เร็วกว่า มันรันแค่ครั้งเดียว — การเข้าถึงครั้งแรกเขียนค่าลง instance __dict__ และการเข้าถึงหลังจากนั้นทุกครั้งเป็นแค่ dict lookup ธรรมดาที่ไม่เรียก function อีกเลย
  • การ annotate return type ของ classmethod เป็นชื่อ class แบบ literal (-> "Temperature") ถูกต้องพอๆ กับ Self Type checker ยึด literal name ตรงตัวแม้ cls จะเป็น subclass ทำให้เกิด attr-defined error ปลอมบน method ที่มีแค่ใน subclass; Self ติดตาม class ที่ถูกเรียกจริง
  • @staticmethod กับ module-level function ต่างกันอย่างมีนัยสำคัญตอน runtime พวกมันทำงานเหมือนกันตอนถูกเรียก — ความต่างมีแค่เรื่อง namespace (ClassName.util() เทียบกับ free function) และการค้นหาเจอง่ายแค่ไหน

ก่อนเชื่อ 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 — จะ raise AttributeError: 'ClassName' object has no attribute '__dict__' ตอนเข้าถึงครั้งแรก เพราะไม่มีที่เก็บ cache
  • Business logic หรือ side effect (network call, การแก้ state ที่ไม่เกี่ยวข้อง) ซ่อนอยู่ใน @property getter — ผู้เรียกใช้ที่อ่าน obj.attr ไม่มีสัญญาณทาง syntax เลยว่ามันอาจช้าหรือ fail ได้
  • __get__ ของ descriptor ที่เขียนเองที่ไม่จัดการ obj is None (กรณีที่ descriptor ถูกเข้าถึงบน class เอง เช่น Thermostat.celsius) — มันควร return self (หรือ 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

@property ทำอะไรกับ method?
ทำให้ @property เขียนได้ (writable) อย่างไร?
Parameter แรกของ @classmethod คืออะไร?
@staticmethod เหมาะกับกรณีไหน?
@property มีความสัมพันธ์กับ descriptor protocol อย่างไร?
โค้ดนี้พิมพ์อะไร?
class Report:
    def __init__(self, rows):
        self.rows = rows
        self.calls = 0

    @cached_property
    def total(self):
        self.calls += 1
        return sum(self.rows)

r = Report([1, 2, 3])
r.total
r.total
print(r.calls)
uvx mypy --strict รายงานอะไร?
class Base:
    @classmethod
    def make(cls) -> "Base":
        return cls()

class Sub(Base):
    def extra(self) -> str:
        return "hi"

s = Sub.make()
s.extra()
ทำไม __get__ ของ descriptor ที่เขียนเองถึงถูกเรียกด้วย obj=None ได้?