copy กับ deepcopy ใน Python: ทำไม "สำเนา" ถึงไม่ใช่สำเนาจริง
บทเรียนนี้แปลจากต้นฉบับภาษาอังกฤษRead in EnglishLeer en españolЧитать на русскомLer em portuguêsแจ้งข้อผิดพลาดในการแปล
ลองนึกว่าคุณมีแฟ้มที่เต็มไปด้วยรูปถ่าย คุณทำสำเนาแฟ้มนั้นแล้วให้เพื่อน แต่มีจุดที่ต้องระวัง: มีแค่ตัวแฟ้มเท่านั้นที่เป็นของใหม่ รูปถ่ายข้างในยังเป็นรูปใบเดิมจริง ๆ ที่ทั้งสองแฟ้มใช้ร่วมกัน ถ้าเพื่อนวาดหนวดลงบนรูปใบหนึ่ง รูปของคุณก็จะมีหนวดด้วย
นี่คือวิธีที่การคัดลอกแบบปกติของ Python ทำงานทุกประการ คุณคิดว่าได้สำเนาที่แยกเป็นอิสระแล้ว แต่ออบเจกต์ที่อยู่ข้างในยังใช้ร่วมกันอยู่ นี่เป็นหนึ่งในบั๊กที่ชวนสับสนที่สุดใน Python และทำให้ทั้งผู้เริ่มต้นและนักพัฒนาที่มีประสบการณ์พลาดได้เหมือนกัน
ในบทเรียนนี้ คุณจะได้เรียนรู้ความแตกต่างระหว่างการกำหนดค่า (assignment) สำเนาแบบตื้น (shallow copy) และสำเนาแบบลึก (deep copy) เมื่อจบบทเรียน คุณจะรู้ชัดว่าควรใช้แบบไหนเมื่อไร และจะไม่โดนบั๊ก "การอ้างอิงร่วมกัน" (shared reference) เล่นงานอีก
ทำไมการเปลี่ยน "สำเนา" จึงเปลี่ยนต้นฉบับไปด้วย
มาเริ่มจากเรื่องที่ทำให้แปลกใจบ่อยที่สุด เมื่อเขียน b = a คุณ ไม่ได้ สร้างสำเนา แต่กำลังสร้างชื่อที่สองให้ออบเจกต์ตัวเดิม ทั้ง a และ b ชี้ไปที่ข้อมูลเดียวกันในหน่วยความจำ
[1, 2, 3, 4] [1, 2, 3, 4]
ที่เป็นแบบนี้เพราะ a และ b เป็นป้ายสองป้ายที่ติดอยู่บนกล่องใบเดียวกัน เมื่อใส่ของใหม่ลงในกล่องผ่านป้าย b คุณก็จะเห็นของนั้นเมื่อดูผ่านป้าย a ด้วย
คุณตรวจได้ว่าตัวแปรสองตัวชี้ไปที่ออบเจกต์เดียวกันหรือไม่ โดยใช้ตัวดำเนินการ is หรือดูค่า id() ของตัวแปรทั้งสอง
True 80036304 80036304
สำเนาแบบตื้นคืออะไร
สำเนาแบบตื้นสร้างคอนเทนเนอร์ชั้นนอกขึ้นมาใหม่ แต่ ไม่ได้ คัดลอกออบเจกต์ที่อยู่ข้างใน ลิสต์ใหม่แยกเป็นอิสระในระดับบนสุด แต่ออบเจกต์ที่ซ้อนอยู่ข้างในยังใช้ร่วมกับต้นฉบับ
การสร้างสำเนาแบบตื้นของลิสต์ทำได้หลายวิธี
[1, 2, 3] [1, 2, 3, 99]
สำหรับดิกชันนารี ให้ใช้ .copy() หรือ dict()
{'name': 'Alice', 'age': 25}
{'name': 'Alice', 'age': 30}ดูเหมือนจะสมบูรณ์แบบ แต่นี่คือกับดัก สำเนาแบบตื้นคัดลอกลงไปแค่ชั้นเดียว ถ้าลิสต์ของคุณมีลิสต์อื่น (หรือดิกชันนารี) อยู่ข้างใน ออบเจกต์ชั้นในเหล่านั้นยังใช้ร่วมกันอยู่
3 4 [1, 2, 99] [1, 2, 99]
original = [[1, 2], [3, 4]]
shallow = original.copy()
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]]import copy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0].append(99)
print(original) # [[1, 2], [3, 4]]สำเนาแบบลึกคืออะไร และต้องใช้เมื่อไร
สำเนาแบบลึกสร้างโคลนที่แยกเป็นอิสระโดยสมบูรณ์ โดยคัดลอกออบเจกต์ชั้นนอก และคัดลอกทุกออบเจกต์ที่เปลี่ยนแปลงได้ซึ่งอยู่ข้างในแบบเรียกซ้ำ (recursive) ไม่ว่าจะซ้อนกันลึกแค่ไหน สำเนาจะไม่ใช้สิ่งใดที่แก้ไขได้ร่วมกับต้นฉบับเลย มีเพียงค่าที่เปลี่ยนแปลงไม่ได้ เช่น ตัวเลขและสตริง ที่ถูกนำกลับมาใช้ซ้ำ
การสร้างสำเนาแบบลึกต้องใช้โมดูล copy จากไลบรารีมาตรฐานของ Python
[[1, 2], [3, 4], [5, 6]] [[1, 2, 99], [3, 4], [5, 6], [7, 8]]
สำเนาแบบลึกใช้กับดิกชันนารีได้ด้วย รวมถึงโครงสร้างที่ซ้อนกันหลายชั้น
Alice [90, 85, 92] ['star', 'gold'] Bob [90, 85, 92, 100] ['star', 'gold', 'diamond']
เบื้องหลัง: การอ้างอิงใน Python ทำงานอย่างไร
การจะเข้าใจการคัดลอกอย่างแท้จริง ต้องเข้าใจก่อนว่า Python เก็บข้อมูลอย่างไร ข้อมูลทุกชิ้นอยู่ที่ไหนสักแห่งในหน่วยความจำในรูปของ ออบเจกต์ ส่วนตัวแปรเป็นเพียงชื่อที่ อ้างอิง (ชี้ไปยัง) ออบเจกต์เหล่านั้น
การกำหนดค่าคัดลอกการอ้างอิง สำเนาแบบตื้นสร้างคอนเทนเนอร์ใหม่ที่มีการอ้างอิงชุดเดิมอยู่ข้างใน ส่วนสำเนาแบบลึกสร้างคอนเทนเนอร์ใหม่ที่การอ้างอิงข้างในชี้ไปยังสำเนาใหม่ของทุกออบเจกต์ที่เปลี่ยนแปลงได้ซึ่งอยู่ข้างใน
True True False
ตารางสรุปทั้งสามวิธีมีดังนี้
Assignment: same outer, same inner Shallow copy: new outer, same inner Deep copy: new outer, new inner
ควรใช้การคัดลอกแต่ละแบบเมื่อไร
ใช้ การกำหนดค่า (b = a) เมื่อตั้งใจให้มีสองชื่อสำหรับข้อมูลเดียวกัน กรณีนี้พบบ่อยเมื่อส่งข้อมูลให้ฟังก์ชัน ซึ่งปกติคุณต้องการให้ฟังก์ชันทำงานกับต้นฉบับ
ใช้ สำเนาแบบตื้น เมื่อมีลิสต์หรือดิกชันนารีชั้นเดียว (ไม่มีออบเจกต์ที่เปลี่ยนแปลงได้ซ้อนอยู่) และต้องการสำเนาที่แยกเป็นอิสระ ซึ่งครอบคลุมกรณีส่วนใหญ่ในการใช้งานทั่วไป
ใช้ สำเนาแบบลึก เมื่อมีออบเจกต์ที่เปลี่ยนแปลงได้ซ้อนกัน (ลิสต์ของลิสต์ ดิกชันนารีที่มีค่าเป็นลิสต์ ฯลฯ) และต้องการโคลนที่แยกเป็นอิสระทั้งหมด ซึ่งจำเป็นมากเมื่อต้องการแก้ไขสำเนาโดยไม่มีความเสี่ยงที่จะกระทบต้นฉบับ
แบบฝึกหัด
อ่านโค้ดอย่างละเอียด โค้ดจะแสดงผลอะไร อย่าลืมว่า = กับลิสต์จะสร้างชื่อแฝง ไม่ใช่สำเนา
พิมพ์ผลลัพธ์ให้ตรงทุกตัวอักษร
โค้ดด้านล่างพยายามสำรองลิสต์ต้นฉบับไว้ก่อนแก้ไข แต่ข้อมูลสำรองกลับเปลี่ยนตามไปด้วย ให้แก้โค้ดเพื่อให้ข้อมูลสำรองไม่เปลี่ยนแปลง
ผลลัพธ์ที่ต้องการ:
Original: [1, 2, 3, 4]
Backup: [1, 2, 3]โค้ดนี้ใช้สำเนาแบบตื้นกับข้อมูลที่ซ้อนกัน อ่านอย่างละเอียดแล้วทายว่าจะแสดงผลอะไร
พิมพ์ผลลัพธ์ให้ตรงทุกตัวอักษร
สร้างสำเนาของข้อมูล original ที่แยกเป็นอิสระทั้งหมดด้วยสำเนาแบบลึก จากนั้นเพิ่มคะแนน 100 เข้าไปในลิสต์ scores ของสำเนา แสดงคะแนนของต้นฉบับและคะแนนของสำเนาคนละบรรทัด เพื่อพิสูจน์ว่าทั้งสองแยกจากกัน
เกมนี้บันทึกจุดเซฟ (checkpoint) ของสถานะผู้เล่นไว้ จากนั้นผู้เล่นเก็บไอเทมชิ้นหนึ่ง แต่เมื่อโหลดจุดเซฟ ไอเทมนั้นกลับอยู่ในนั้นแล้ว ให้แก้โค้ดเพื่อให้จุดเซฟเป็นภาพสถานะที่แยกเป็นอิสระจริง ๆ
ผลลัพธ์ที่ต้องการ:
Checkpoint inventory: ['sword', 'shield']
Current inventory: ['sword', 'shield', 'potion']