Note: see the link below for the English version of this article.

https://duongnt.com/deep-copy

Python deep copy in action

Việc tạo deep copy cho object là tương đối phức tạp. Thật may là Python có hàm deepcopy trong module copy để giúp ta đơn giản hóa quá trình này. Trong bài hôm nay, chúng ta sẽ phân tích hàm deepcopy và làm rõ cách thức hoạt động của nó.

Giới thiệu hàm deepcopy

Từ cái tên, ta có thể đoán được hàm này có thể tạo deep copy cho object. Có nghĩa là nếu trong object của ta lại chứa các object khác thì deepcopy sẽ tạo bản sao của các object con thay vì chỉ copy reference tới các object con đó.

from copy import deepcopy

inner = [1, 2, 3]
outer = [inner, 'a', 'b']

outer_copy = deepcopy(outer)
outer_copy[0] is outer[0] # False

Tuy nhiên, nếu nhiều object con có cùng reference tới một object thì deepcopy cũng chỉ tạo một bản sao.

inner = [1, 2, 3]
outer = [inner, inner]
outer[0] is outer[1] # True

outer_copy = deepcopy(outer)
outer_copy[0] is outer[0] # False
outer_copy[0] is outer_copy[1] # True

Và hàm này cũng đủ thông minh để xử lý các object chứa reference về lại chính nó.

a = [1,2,3]
b = [1, a]
a[0] = b
a[0][1] is a # True

a_copy = deepcopy(a) # Không bị lỗi
a_copy[0][1] is a_copy # True

Vậy deepcopy đã làm những điều này bằng cách nào? Chúng ta sẽ tìm câu trả lời bằng cách đọc mã nguồn.

Phương thức hoạt động của hàm deepcopy

Các bước để tạo một bản sao

Từ docstring của module copy, ta phần nào đoán được cách deepcopy xử lý các object có chứa reference về chính nó.

(tạm dịch)
Trong quá trình tạo deepcopy, Python tránh các vấn đề trên bằng cách sau:
 a) lưu lại các object đã được copy trước đó
 b) cho phép người dùng tự định nghĩa lại bước copy trong các lớp
    họ tự tạo

Để hiểu rõ hơn các điểm trên, ta cần xem phần code của hàm deepcopy tại đây. Tổng quan các bước của quá trình copy là như sau.

  • Tạo một dict để làm cache lưu các object đã được copy.
  • Dùng hàm id để tính identity của object cần copy rồi tìm ID đó trong cache. Nếu trong cache đã chứa bản sao tương ứng thì ta trả lại bản sao đó. Còn nếu không thì ta thực hiện tiếp các bước dưới.
    d = id(x)
    y = memo.get(d, _nil)
    
  • Tìm trong một dict khác gọi là _deepcopy_dispatch để xem ta đã biết cách copy kiểu object hiện tại chưa. Nếu đã biết rồi thì ta trả lại hàm copy tương ứng. Còn nếu không thì ta thực hiện tiếp các bước dưới.
    cls = type(x)
    copier = _deepcopy_dispatch.get(cls)
    
  • Nếu object cần copy có định nghĩa hàm deepcopy thì ta dùng nó để tạo bản sao (đây là cách ta định nghĩa lại quá trình copy). Còn nếu không thấy hàm đó thì ta thực hiện tiếp các bước dưới.
    copier = getattr(x, "__deepcopy__", None)
    
  • Nếu tất cả các bước trên đều thất bại thì ta sử dụng logic của quá trình pickling/unpickling để tạo bản sao.
  • Bước cuối cùng là lưu bản sao vào cache.
    if y is not x:
        memo[d] = y
    

Cách copy các kiểu dữ liệu mặc định

Từ đoạn này, ta có thể thấy value type (và một vài reference type đặc biệt) có thể được copy bằng hàm _deepcopy_atomic. Và hàm này chỉ đơn giản là trả lại đúng giá trị của object gốc. Đó là vì với value type, ta không thể phân biệt 2 object cùng giá trị. Và việc thay đổi một object hoàn toàn không ảnh hưởng đến các object khác cùng giá trị.

def _deepcopy_atomic(x, memo):
    return x

Với kiểu list/tuple/dictionary, ta dùng hàm _deepcopy_list, _deepcopy_tuple, hoặc _deepcopy_dict. Mặc dù có một vài khác biệt nhỏ giữa các hàm này, nhưng nói chung là chúng tạo collection rỗng, gọi hàm deepcopy trên từng thành phần của object ban đầu, rồi nhét các bản sao vào trong collection.

Ta còn thấy một trường hợp thú vị khác, đó là việc copy hàm. Như ta đã biết, trong Python hàm cũng là một object. Tức là ta cũng có thể chứa hàm trong list/tuple/dictionary… Vậy khi ta copy một hàm thì bản sao đó sẽ được gắn với object nào? Câu trả lời là nó sẽ được gắn với một bản sao của object gốc, và bản sao đó cũng được tạo bởi chính deepcopy.

def _deepcopy_method(x, memo): # Copy instance methods
    return type(x)(x.__func__, deepcopy(x.__self__, memo))

Ở đây, ta có thể thấy deepcopy sử dụng types.MethodType. Đây là một tính năng ít dùng trong Python.

Nếu ta không tìm được hàm copy nào phù hợp thì sao?

Nếu kiểu object cần copy là do người dùng tự định nghĩa thì sao? Lúc này deepcopy sẽ tạo bản sao bằng cách tái sử dụng quá trình serialize/deserialize như trong module pickle. Tùy theo object cần copy và phiên bản Python đang được sử dụng, ta sẽ dùng pickle protocol phiên bản 1 hoặc phiên bản 4. Nhưng phiên bản 4 được ưu tiên hơn.

Giả sử ta có object dưới đây và nó hỗ trợ pickle protocol phiên bản 4, ta sẽ copy nó như thế nào?

class Person:
    def __init__(self, name):
        self.name = name

    def introduce(self):
        print(f'{id(self)}: My name is {self.name}')

p = Person('John Doe')

Bước đầu tiên là gọi hàm __reduce_ex__ của object đó.

func, args, state, _, _ = d.__reduce_ex__(4)

Các bạn có thể tham khảo ý nghĩa của từng giá trị trả về tại đây (đường link trên là của hàm __reduce__, nhưng hàm __reduce_ex__ hầu như tương tự). Vì lớp Person không kế thừa list hay dict, ta chỉ cần quan tâm tới 3 giá trị đầu tiên.

func
<function __newobj__ at 0x000002392D419280>
args
(<class '__main__.Person'>,)
state
{'name': 'John Doe'}

Ta dùng funcargs để tạo một object mới với kiểu là Person.

p1 = func(*args)

Rồi ta dùng state để phục hồi lại giá trị cho các attribute trong bản copy. Vì ta đang thực hiện deep copy nên ta phải tạo bản sao cho chính state.

state = deepcopy(state, memo) # Chú ý ta gọi deepcopy một cách đệ quy tại đây
p1.__dict__.update(state)

Tất cả các bước ở trên được thực hiện trong một hàm private của module copy với tên gọi là _reconstruct.

Thử một vài tính năng của hàm deepcopy

Truyền memo khi sử dụng hàm deepcopy

Ta sẽ tạo một ví dụ để xem chuyện gì xảy ra nếu ta truyền vào tham số memo khi gọi hàm deepcopy.

first_inner = [1, 2, 3]
second_inner = [4, 5, 6]
outer = [first_inner, second_inner]

memo = { id(second_inner): second_inner }
outer_copy = deepcopy(outer, memo)

Cần nhớ là đầu tiên deepcopy sẽ tìm bản sao của object cần copy trong memo. Vì trong tham số memo ta đã định nghĩa sẵn một entry cho second_inner, deepcopy không cần copy list này nữa.

outer[0] is outer_copy[0] # False
outer[1] is outer_copy[1] # True do sử dụng luôn giá trị trong memo
outer[1].append(1989)
print(outer_copy[1]) # [4, 5, 6, 1989]

Tự định nghĩa lại quá trình deep copy

Như đã nói ở trên, nếu object cần copy đã định nghĩa sẵn hàm __deepcopy__ thì Python sẽ sử dụng luôn hàm đó. Ta sẽ tạo một object mà người khác không copy được.

class Uncopyable:
    def __init__(self, attributes):
        self.attribute = attributes

    def __deepcopy__(self, memo):
        raise NotImplementedError('Không được copy object này.')

    def show(self):
        return self.attribute

Ta có thể tạo shallow copy cho Uncopyable, nhưng ta không thể tạo deep copy.

from copy import copy

u = Uncopyable([1,2,3,4,5])
u_shallow = copy(u) # OK
u_deep = deepcopy(u) # Exception has occurred: NotImplementedError: Không được copy object này.

Kết thúc

Tôi tin rằng việc đọc mã nguồn của các module hệ thống là cách hiệu quả để hiểu rõ hơn về một ngôn ngữ lập trình. Ở đây, tôi không ngờ rằng khi nghiên cứu về deepcopy tôi lại biết thêm một số điều thú vị về module pickle.

A software developer from Vietnam and is currently living in Japan.

One Thought on “Deep copy trong Python hoạt động như thế nào?”

Leave a Reply