Python cached_property: คำนวณครั้งเดียว แต่อย่าลืมออกแบบจังหวะล้าง cache
property ที่ต้อง parse ไฟล์หรือคำนวณสถิติซ้ำทุกครั้งทำให้ object ใช้ง่ายแต่เสียเวลา functools.cached_property เปลี่ยน method ให้คำนวณครั้งแรก แล้วเขียนผลลง attribute ชื่อเดียวกันใน __dict__ ครั้งต่อไปจึงอ่านค่าที่เก็บไว้ตรงๆ
ความต่างสำคัญจาก property คือ attribute นี้เขียนทับได้ และลบด้วย del เพื่อบังคับคำนวณใหม่ได้ เราจึงต้องออกแบบ invalidation พร้อม cache ตั้งแต่ต้น ไม่ใช่รอให้ข้อมูลเก่ากลายเป็น incident
from __future__ import annotations
import unittest
from functools import cached_property
class Invoice:
def __init__(self, amounts: list[int]):
self.amounts = amounts
self.calculations = 0
@cached_property
def total(self) -> int:
self.calculations += 1
return sum(self.amounts)
def add(self, amount: int) -> None:
self.amounts.append(amount)
self.__dict__.pop("total", None)
class CachedPropertyTests(unittest.TestCase):
def test_computes_once_then_invalidates(self):
invoice = Invoice([10, 20])
self.assertEqual(invoice.total, 30)
self.assertEqual(invoice.total, 30)
self.assertEqual(invoice.calculations, 1)
invoice.add(5)
self.assertEqual(invoice.total, 35)
self.assertEqual(invoice.calculations, 2)
if __name__ == "__main__":
unittest.main()
กับดักที่ต้องรู้
ตั้งแต่ Python 3.12 ตัว decorator ไม่มี lock ภายในแล้ว ถ้าหลาย thread อ่านครั้งแรกพร้อมกัน getter อาจทำงานมากกว่าหนึ่งครั้ง ดังนั้น getter ควร idempotent หรือมี synchronization ของตัวเองเมื่อการคำนวณซ้ำมีผลข้างเคียง นอกจากนี้ class ต้องมี __dict__ ที่แก้ได้ จึงใช้ตรงๆ กับ __slots__ ที่ไม่ประกาศ __dict__ ไม่ได้
ใช้กับผลที่ขึ้นกับสถานะค่อนข้างนิ่ง เช่น metadata ที่ parse แล้ว หากข้อมูลเปลี่ยนบ่อยหรือมี TTL ให้ใช้ cache ที่มี lifecycle ชัดเจนกว่า
Recap
- ระบุ mutation ทุกจุดที่ต้องล้างค่า cache
- ทำ getter ให้คำนวณซ้ำได้อย่างปลอดภัย
- อย่าใช้กับ
slotsที่ไม่มี__dict__ - เลือก cache แบบมี TTL เมื่อความสดของข้อมูลสำคัญ