Tutorial

Cloning Object บน Python กับเรื่องลึกลับที่น้อยคนจะรู้

By Arnon Puitrakul - 06 กันยายน 2021 - 2 min read min(s)

Cloning Object บน Python กับเรื่องลึกลับที่น้อยคนจะรู้

บทความนี้เกิดจากการนั่งคุยขำ ๆ กับเพื่อนอีกแล้ว ในหัวเรื่องที่ว่า เราจะ Copy หรือที่บางคนเรียกว่า Clone Object ของเราอย่างไรได้บ้างใน Python ถ้าเกิดในภาษาเช่นพวก C เราจะพอเข้าใจ Concept ในเรื่องของการทำ Copy by value และ Copy by reference (จุ๊บ Address ผ่าน Pointer มา) ซึ่งมันมีความแตกต่างกันอย่างมาก ๆ โดยเฉพาะ ถ้าเราทำงานกับพวก Parallel ทั้งหลาย ทำผิดชีวิตเปลี่ยนได้เลย วันนี้เราเลยอยากมาเล่าว่า ในฝั่ง Python มันทำยังไงได้บ้าง

Cloning Object

a = [ [1,2,3], [4,5,6], [7,8,9] ]
b = a

บน Python เราสามารถ Clone Object ได้ง่าย ๆ เลยโดยการที่เราลองทำตาม Code ด้านบนได้เลย คือ เราสามารถ Assign ตัวแปรใหม่เป็นค่าของตัวแปรที่เราต้องการจะ Clone ได้เลยด้วยความที่เรา Clone แล้วนั่นแปลว่า a และ b มันควรจะเป็นอิสระต่อกัน ทำให้ ถ้าเราแก้ a ตัว b ต้องไม่เปลี่ยน เราลองมาทดสอบสมมุติฐานนี้พร้อม ๆ กันดีกว่า

b.append([10, 11, 12])
print('a', a)
print('b', b)
'a', [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
'b', [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]

อ้าวไหงบอกว่า เรา Clone แล้วไง ทำไมเราแก้ b แล้ว a ก็เปลี่ยนด้วยละ นั่นเป็นเพราะใน Python การที่เราใช้เท่ากับตรง ๆ เลย มันไม่ได้เป็นการ Clone แต่มันแค่การ Assign Address ให้เท่านั้น

print(hex(id(a)) == hex(id(b)))
True

เพื่อเป็นการทดสอบสมมุติฐานนี้ เราลองเอา Memory Address ของทั้ง a และ b ออกมาเทียบกันเลยว่ามันตรงกันมั้ย เราจะเห็นได้เลยว่ามันตรงกันเป๊ะ ๆ เลย ทำให้จริง ๆ แล้ว การที่เราเข้าไปแก้ไข a มันก็จะส่งผลถึง b กลับกัน ถ้าแก้ b ตัว a ก็จะไม่รอดด้วยเช่นกัน เพราะไส้ในมันไปแก้ใน Memory ที่จุดเดียวกัน ดังนั้น เราจะทำยังไงได้บ้างละ

b = list(a)
b.append([10, 11, 12])
print('a', a)
print('b', b)
'a', [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
'b', [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]

เราเปลี่ยนวิธีใหม่ ถ้าเรา Cast ให้มันเป็น List อีกทีละ จะเห็นจากผลได้เลยว่า เย้ ~ ตอนนี้ถ้าเรา Append Item ลงไปใน b ตัว a ก็จะไม่เปลี่ยนอะไร

print(hex(id(a)) == hex(id(b)))
False

เพื่อให้มั่นใจ เราลองเช็ค Memory Address ของทั้ง a และ b ก็จะเห็นได้เลยว่า มันใช้ Memory คนละ Address กัน ทำให้ทั้ง a และ b ก็จะแยกออกจากกันจริง ๆ นั่นเอง

ลองแก้ข้างในดูสิ๊

ก่อนหน้านี้ เราลองเพิ่ม Item ลงไปใน b ได้แล้ว ถ้าเราบอกว่า a และ b มัน Clone จริง ๆ นั่นแปลว่า ไม่ว่าเราจะแก้อะไรใน b ฝั่งของ a จะไม่เปลี่ยนเลย เรามาทำการทดลองเพิ่มกัน

b[1][1] = 6
print('a', a)
print('b', b)
'a', [[1, 2, 3], [4, 6, 6], [7, 8, 9]]
'b', [[1, 2, 3], [4, 6, 6], [7, 8, 9], [10, 11, 12]]

เราทดลองแก้ Item ที่ Index 1,1 ของ b จาก 5 เป็น 6 ถ้าเราดูที่ b มันก็แสดงผลได้อย่างถูกต้องนะว่า มันจะกลายเป็น 6 แต่ ถ้าเราลองดูที่ a ด้วย เราจะเห็นว่า อ้าวเห้ย ทำไมมันเปลี่ยนด้วยละ เราลอง เทียบ Memory Address ของ Index ที่ 1 ใน a และ b กัน

hex(id(a[1])) == hex(id(b[1]))
True

เห็นผลลัพธ์แล้วเอ๊ะมั้ยฮ่ะ เราบอกว่า เรา Clone แล้วนะ แต่ทำไม Address ของ Element ข้างในมันถึงมี Memory Address อันเดียวกันละ ทั้ง ๆ ที่เราเช็ค Memory Address ของตัว List ใหญ่แล้วนิ นี่มัน Clone ทิพย์ป่ะนิ

Shallow vs Deep Cloning

ปรากฏการที่เราแสดงให้ดู มันเป็นเรื่องของความแตกต่าง ระหว่างการทำ Shallow Cloning และ Deep Cloning

ในครั้งแรก ที่เรา Cast List ให้เป็น List มันเป็นการบังคับให้ Python ทำการสร้าง List ใหม่โดยใช้ Item เดิม เราเรียกการทำแบบนี้ว่า การทำ Shallow Cloning หรือแบบตื้น ๆ นั่นเอง ซึ่งอย่างที่เราเห็นกันว่า เราได้ List ใหม่จริง ๆ อยู่คนละ Address จริง ๆ และเพิ่ม Item แล้วมันไม่ไปเพิ่มในตัวเก่าจริง แต่ Item เดิม มันไม่ได้ถูกสร้างใหม่ มันเป็นการ Reference Address มาเหมือนกับตอนที่เราใช้เครื่องหมายเท่ากับในตอนแรกเลย เราเรียกการ Clone แบบนี้ว่า Shallow Cloning

ทำให้ ถ้าเราต้องการที่จะแยกของทั้งหมดออกมาจริง ๆ เราจะต้องทำสิ่งที่เรียกว่า Deep Cloning สิ่งที่มันทำคือ มันก็จะสร้าง List ใหม่ขึ้นมา และทุก Object ในนั้น ก็จะถูกสร้างขึ้นมาเป็น Instance ใหม่เช่นกัน ไม่ได้ Reference Address มา ทำให้เมื่อเราแก้ มันก็จะเปลี่ยนแค่ที่ปลายทางเท่านั้น ตัวต้นทางไม่เปลี่ยน

import copy
b = copy.deepcopy(a)
hex(id(a[1])) == hex(id(b[1]))
False

ซึ่งการทำ Deep Copy ใน Python ไม่ได้ยากเลย ตัว Python เตรียมคำสั่งมาให้เราเรียบร้อยแล้ว ผ่าน Function ที่ชื่อว่า deepcopy() ที่อยู่ใน Module copy ซึ่งมันเป็น Standard Library ของ Python เองไม่ต้องลงเพิ่ม จากตัวอย่างเราลองให้ดูเลยว่ามันจริงมั้ย เราลอง Deep Clone และ เทียบ Address อีกครั้ง ปรากฏว่า Address มันไม่ตรงกันซะแล้ว ก็ถือว่าเราได้สิ่งที่เราต้องการแล้ว

ทำไมต้องมีทั้ง Shallow และ Deep Cloning ?

อ่านมาถึงตรงนี้แล้ว น่าจะเข้าใจแล้วว่า Shallow และ Deep Cloning ใน Python มันต่างกันยังไง และทำยังไง แต่อีกคำถามที่น่าจะเกิดขึ้นมาคือ แล้วเราจะมีทั้ง 2 แบบทำไม ทำไมเราไม่ทำ Deep Cloning ไปให้หมดเลยละ ทั้ง ๆ ที่ Deep Cloning การันตีเลยนะว่า เราจะได้สิ่งที่เหมือนกับต้นฉบับทุกประการ และ ไม่ว่าเราจะแก้ หรือทำอะไรก็ตาม ต้นฉบับจะไม่เปลี่ยนเลย เราสามารถมองได้จาก 2 มุมใหญ่ ๆ ด้วยกัน คือ การจัดสรร Memory และ เวลาในการทำงาน

ถ้าเรามองเรื่องของ Memory ถ้าเราเล่นทำ Deep Cloning ทั้งหมดเลย มันเป็นการเปลือง Memory เท่าตัวเลย ถ้ามองในมุมของ Object ตัวเล็ก ๆ มันอาจจะไม่มีอะไร แต่ถ้าเราเล่นทำงานกับข้อมูลขนาดใหญ่เลยละ แล้วเราอาจจะแค่อยากจะ Append ข้อมูลเพิ่มแค่นั้นเลย อาจจะเพื่อเป็น Reference อะไรบางอย่าง แต่เราต้อง Deep Copy หมดเลย เราว่ามันก็เป็นการสิ้นเปลืองมากไปหน่อย ทำให้จริง ๆ แล้วบางเคส การทำ Shallow Copy ก็เป็นการ Optimise เรื่องของการจัดการ Memory ได้เป็นอย่างดีเลยทีเดียว

หรือถ้าเรามองในมุมของ เวลา การทำ Shallow Clone เร็วกว่าแน่นอน เพราะมันต้องสร้าง Object ใหม่แค่ตัวเดียว ส่วนที่เหลือมันจะ Reference ไปเลย เมื่อเทียบกับ Deep Clone ที่มันต้องไล่เข้าไปอ่านแต่ละ Object แล้วไล่ Clone ทีละตัว ทำให้การทำ Deep Cloning มันเลยมีราคาในเรื่องของเวลาที่มากกว่า

ไม่ว่าจะเป็นเรื่องไหน เราบอกก่อนว่า มันอาจจะไม่เห็นผลเวลาเราทำงานกับข้อมูลขนาดไม่ใหญ่เมื่อเทียบกับระบบที่เราทำงาน แต่ถ้าข้อมูลมันใหญ่ขึ้น เช่น เราทำงานกับ Image เราโหลดเข้ามา มันเป็น 3D Array และในนั้นมีหลายล้าน Element นึกสภาพว่า ถ้าเราต้อง Deep Clone ทุกครั้ง ก็คือ Memory แตก กับรันนานแน่นอน

สรุป

Python อนุญาติให้เราทำงานกับ Object ในหลากหลายวิธีมาก ๆ ตั้งแต่การ Refer ไปที่ Address เลย ทำให้ทั้ง 2 ตัวแปร อ้างอิงถึงที่เดียวกัน ดีขึ้นมาอีก ก็ทำ Shallow Cloning ที่จะสร้าง Object ใหม่ในชั้นแรกเท่านั้น ส่วนไส้ข้างในก็จะเอาแค่ Address มาใส่ ทำให้เมื่อเราแก้ หรือ Mutate Element ที่อยู่ด้านใน ก็จะเปลี่ยนไปหมดเลย เพื่อเป็นการแก้ปัญหานี้ การทำ Deep Cloning จึงเป็น Solution ที่ดี โดยการบังคับให้ Python ไล่สร้าง Object ทั้งหมดตั้งแต่ตัวมันเอง จนไปถึง Element ภายในใหม่ทั้งหมด ทำให้เป็นการ Isolate Object ออกจากกันได้ 100% เลย การเลือกใช้ก็ขึ้นกับงานของเราแล้วว่า เราต้องการที่จะ Mutate Object นั้น ๆ มากแค่ไหน ต้องการสุด ๆ เลย ก็ต้องไปเล่น Deep Cloning เลย แต่ถ้าเราอาจจะแก้แค่ในชั้นแรก ๆ การทำ Shallow Cloning ก็มีราคาน้อยกว่ามาก คุ้มกว่าในเชิงของ Performance