Python copy.replace: อัปเดต Value Object โดยไม่แก้ของเดิม
การแก้ object เดิมกลาง workflow ทำให้ log และ retry ตอบยากว่า “ค่าตอนนั้นคืออะไร” ครับ copy.replace สร้าง object ชนิดเดิมพร้อมเปลี่ยนเฉพาะฟิลด์ที่ระบุ จึงเหมาะกับ value object ที่อยากให้แต่ละสถานะมีตัวตนชัดเจน
WHY: การ Copy ต้องรักษา Invariant
copy.copy ทำ shallow copy และ deepcopy เดินคัดลอกโครงสร้าง แต่สองฟังก์ชันนี้ไม่ได้สื่อว่าเราอยากเปลี่ยน field ใด copy.replace จำกัดโจทย์ให้แคบกว่า รองรับ named tuple, dataclass และชนิดที่ประกาศ __replace__
สำหรับ dataclass การสร้างค่าทดแทนจะเรียก constructor ตามปกติ จึงให้ __post_init__ ตรวจ invariant อีกครั้ง นี่สำคัญกว่าการคัดลอก __dict__ แล้วแก้ค่า เพราะวิธีหลังอาจข้าม validation
HOW: เก็บต้นฉบับแล้วสร้างสถานะใหม่
copy.replace เริ่มใช้ได้ใน Python 3.13 ตัวอย่างจึงตรวจ version boundary ให้ชัด: บน 3.12 จะยืนยันว่า API ยังไม่มีและข้ามเฉพาะ test ที่เรียกฟีเจอร์ ส่วน 3.13 ขึ้นไปจะรันพฤติกรรมจริงทั้งหมด
import unittest
import copy
import sys
from dataclasses import dataclass
COPY_REPLACE_AVAILABLE = sys.version_info >= (3, 13)
@dataclass(frozen=True)
class Order:
item: str
quantity: int
status: str = "draft"
def __post_init__(self):
if self.quantity < 1:
raise ValueError("quantity must be positive")
class ReplaceTests(unittest.TestCase):
def test_runtime_requirement_is_explicit(self):
self.assertEqual(
hasattr(copy, "replace"),
COPY_REPLACE_AVAILABLE,
)
@unittest.skipUnless(
COPY_REPLACE_AVAILABLE,
"copy.replace requires Python 3.13+",
)
def test_replaces_one_field_without_mutating_original(self):
draft = Order("sensor", 2)
confirmed = copy.replace(draft, status="confirmed")
self.assertEqual(draft.status, "draft")
self.assertEqual(confirmed.status, "confirmed")
self.assertEqual(confirmed.item, draft.item)
@unittest.skipUnless(
COPY_REPLACE_AVAILABLE,
"copy.replace requires Python 3.13+",
)
def test_constructor_validation_still_runs(self):
with self.assertRaises(ValueError):
copy.replace(Order("sensor", 2), quantity=0)
if __name__ == "__main__":
unittest.main()
ชื่อ field ใน changes ต้องมีจริง มิฉะนั้นจะได้ TypeError สำหรับ dataclass ที่มี init=False เอกสารเตือนว่าฟิลด์นั้นไม่ถูกคัดลอกจากต้นฉบับ แต่จะถูกสร้างใหม่ตาม initialization path จึงควรทดสอบค่า derived โดยเฉพาะ
ไม่ใช่ Deep Copy
replace เปลี่ยนเฉพาะชั้นที่ขอ สมาชิก mutable ภายในที่ไม่ได้แทนยังอาจเป็น object เดียวกัน ถ้า value object มี list แล้วผู้เรียกแก้ list ได้ ความเป็น immutable เป็นเพียงเปลือก ใช้ tuple, frozen dataclass ซ้อน หรือ copy collection ที่ boundary ตามสัญญา
อย่าใช้ replace เพื่อจำลอง event history โดยไม่เก็บเหตุผล การมีค่าก่อนและหลังช่วย debug แต่ระบบ audit ยังต้องบันทึก actor, เวลา และคำสั่งที่ทำให้เปลี่ยน
Recap
- ใช้
copy.replaceเมื่ออยากประกาศ field ที่เปลี่ยนอย่างตรงไปตรงมา - ให้ constructor หรือ
__post_init__ตรวจ invariant ซ้ำ - ระวังสมาชิก mutable เพราะนี่ไม่ใช่ deep copy
- ทดสอบว่าต้นฉบับไม่เปลี่ยนและค่าที่ผิดถูกปฏิเสธ
พรุ่งนี้ลองเปลี่ยน mutation ของ value object หนึ่งจุดให้คืนค่าใหม่ แล้วดูว่า test กับ log อ่านง่ายขึ้นหรือไม่ครับ