งูต้นฉบับเฝ้ากล่องผลลัพธ์ที่มีลูกศรวนกลับไปยังเครื่องคำนวณบนโต๊ะสีมิ้นต์

Python cached_property: คำนวณครั้งเดียว แต่อย่าลืมออกแบบจังหวะล้าง cache

พี่โชว์Engineering

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 เมื่อความสดของข้อมูลสำคัญ