Chuyển đến nội dung chính

Hướng dẫn về Descriptor trong Python

Tìm hiểu Descriptor trong Python là gì, khi nào nên dùng và vì sao nên dùng.
· 13 phút đọc

Khám phá cùng AI

Python descriptors, hay nói chung là descriptor, cung cấp cho bạn một kỹ thuật mạnh mẽ để viết mã có thể tái sử dụng và chia sẻ giữa các lớp. Chúng có vẻ giống với khái niệm kế thừa, nhưng về mặt kỹ thuật thì không phải. Descriptor là một cách dùng chung để chặn việc truy cập thuộc tính. Descriptor là cơ chế đứng sau property, static method, class method, super method, v.v.

Descriptor được bổ sung trong Python phiên bản 2.2 và từ đó được coi như những thứ “ma thuật” mang đến phong cách mới cho các lớp truyền thống. Chúng là những lớp cho phép bạn quản lý thuộc tính trong một lớp khác. Cụ thể, chúng triển khai giao diện cho các phương thức __get__(), __set__() và __delete__(), khiến chúng trở nên thú vị vì nhiều lý do. Ví dụ như class decorator và property decorator mà bạn thường thấy trong Python.

Nói đơn giản, một lớp triển khai phương thức __get__(), __set()__ hoặc __delete()__ cho một đối tượng được gọi là “Descriptor”. Trích dẫn trực tiếp từ tài liệu chính thức của Python, một descriptor là một thuộc tính của đối tượng có hành vi ràng buộc (binding behavior), trong đó việc truy cập thuộc tính đã bị ghi đè bởi các phương thức trong giao thức descriptor. Những phương thức đó là __get__(), __set__() và __delete__() (Nguồn).

Binding behavior đối với descriptor có nghĩa là ràng buộc cách giá trị có thể được đặt (set), truy vấn (get) hoặc xóa đối với một biến, một đối tượng hay một tập dữ liệu nhất định. Toàn bộ tương tác này được ràng buộc với phần dữ liệu đó vì nó chỉ áp dụng cho dữ liệu mà bạn đã gắn vào, do đó ràng buộc nó với đúng phần dữ liệu cụ thể đó.

Descriptor có thể được phân loại tiếp thành data descriptor và non-data descriptor. Nếu descriptor bạn viết chỉ có phương thức __get__() thì đó là non-data descriptor; ngược lại, triển khai có __set__() và __delete__() được gọi là data descriptor. Non-data descriptor chỉ có thể đọc, trong khi data descriptor vừa đọc vừa ghi được.

Lưu ý rằng descriptor được gán cho lớp, không phải cho thể hiện của lớp. Việc sửa đổi lớp sẽ ghi đè hoặc xóa chính descriptor, thay vì kích hoạt mã của nó (IBM Developer).

Cuối cùng, lớp descriptor không bị giới hạn chỉ có ba phương thức này, nghĩa là nó cũng có thể chứa bất kỳ thuộc tính nào khác ngoài các phương thức get, set và delete.

Hãy tìm hiểu chi tiết hơn về các phương thức get, set và delete dựa trên trang IBM Developer này:

  • self là thể hiện của chính descriptor bạn tạo (Real Python).
  • object là thể hiện của đối tượng mà descriptor của bạn được gắn vào (Real Python).
  • type là kiểu của đối tượng mà descriptor được gắn vào (Real Python).
  • value là giá trị được gán cho thuộc tính của descriptor. get(self, object, type) set(self, object, value) delete(self, object)
  • __get__() truy cập thuộc tính hoặc khi bạn muốn lấy một số thông tin. Nó trả về giá trị của thuộc tính hoặc phát sinh ngoại lệ AttributeError nếu thuộc tính được yêu cầu không tồn tại.

  • __set__() được gọi trong thao tác gán thuộc tính, đặt giá trị cho một thuộc tính. Không trả về gì, nhưng có thể phát sinh ngoại lệ AttributeError.

  • __delete__() kiểm soát thao tác xóa, tức là khi bạn muốn xóa thuộc tính khỏi một đối tượng. Không trả về gì.

Bây giờ hãy hiểu mục đích của Descriptor qua một vài ví dụ!

Mục đích của Descriptor

Hãy định nghĩa lớp car có ba thuộc tính là make, model và fuel_cap. Bạn sẽ dùng phương thức __init__() để khởi tạo các thuộc tính của lớp. Sau đó, bạn dùng hàm “ma thuật” __str__(), hàm này đơn giản trả về chuỗi kết quả gồm ba thuộc tính bạn truyền vào lớp khi tạo đối tượng.

Lưu ý rằng phương thức __str__() trả về biểu diễn dạng chuỗi của đối tượng. Nó được gọi khi hàm print() hoặc str() được gọi trên một đối tượng của lớp.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.

Như bạn thấy từ kết quả trên, mọi thứ đều ổn!

Bây giờ hãy đổi dung tích bình nhiên liệu của xe thành âm 40.

car2 = Car("BMW","X7",-40)
print(car2)
BMW model X7 with a fuel capacity of -40 ltr.

Khoan đã, có gì đó sai sai phải không? Dung tích bình nhiên liệu của xe không bao giờ có thể âm. Tuy nhiên, Python vẫn chấp nhận đầu vào này mà không báo lỗi. Đó là vì Python là ngôn ngữ lập trình động, không hỗ trợ kiểm tra kiểu một cách tường minh.

Để tránh vấn đề này, hãy thêm điều kiện if trong phương thức __init__() và kiểm tra xem dung tích nhiên liệu nhập vào hợp lệ hay không. Nếu không hợp lệ thì phát sinh ngoại lệ ValueError.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car("BMW","X7",-40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-22-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-20-1e154783588d> in __init__(self, make, model, fuel_cap)
      5         self.fuel_cap = fuel_cap
      6         if self.fuel_cap < 0:
----> 7             raise ValueError("Fuel Capacity can never be less than zero")
      8
      9     def __str__(self):


ValueError: Fuel Capacity can never be less than zero

Từ kết quả trên, bạn có thể thấy mọi thứ vẫn ổn vì chương trình phát sinh ValueError nếu dung tích nhiên liệu nhỏ hơn 0.

Tuy nhiên, có thể có vấn đề khác, ví dụ fuel capacity nhập vào là số thực hoặc chuỗi. Không chỉ dung tích nhiên liệu mà cả make và model của xe cũng có thể là số nguyên. Trong tất cả các trường hợp này, chương trình sẽ không phát sinh ngoại lệ.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car(-40,"X7",40)
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

Để xử lý trường hợp trên, bạn có thể nghĩ đến việc thêm một điều kiện if khác hoặc dùng phương thức isinstance để kiểm tra kiểu.

Lần này hãy dùng hàm dựng sẵn isinstance để xử lý lỗi.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car(-40,"X7",40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-34-75b08cba454f> in <module>
----> 1 car2 = Car(-40,"X7",40)
      2 print(car2)


<ipython-input-31-175690bf3b98> in __init__(self, make, model, fuel_cap)
      7             print(self.make)
      8         else:
----> 9             raise ValueError("Make of the car can never be an integer")
     10
     11         if self.fuel_cap < 0:


ValueError: Make of the car can never be an integer

Tuyệt! Bạn cũng đã xử lý được lỗi này.

Nhưng nếu sau này bạn muốn đổi thuộc tính dung tích nhiên liệu thành âm 40 một cách tường minh thì sao? Trong trường hợp này sẽ không hoạt động, vì việc kiểm tra kiểu chỉ được thực hiện một lần trong phương thức __init__(). Như bạn biết, __init__() là hàm khởi tạo và chỉ được gọi một lần khi bạn tạo đối tượng của lớp. Do đó, việc kiểm tra kiểu tùy biến sẽ không còn hiệu lực về sau.

Hãy hiểu điều đó qua ví dụ.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2.make = -40
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

Đó, bạn đã “vượt rào” kiểm tra kiểu.

Bây giờ thử nghĩ xem, nếu bạn có nhiều thuộc tính khác của xe như mức tiêu thụ, giá, phụ kiện, v.v. cũng cần kiểm tra kiểu, và bạn còn muốn một số thuộc tính chỉ có quyền đọc. Chẳng phải sẽ rất phiền phức sao?

Để giải quyết tất cả vấn đề trên, Python có Descriptor để cứu nguy!

Như bạn đã học ở trên, bất kỳ lớp nào triển khai các phương thức “ma thuật” __get__(), __set()__ hoặc __delete()__ cho một đối tượng theo giao thức descriptor đều được gọi là Descriptor. Chúng cũng cho bạn quyền kiểm soát bổ sung về cách một thuộc tính nên hoạt động, như chỉ đọc hay có thể ghi.

Bây giờ hãy mở rộng ví dụ ở trên bằng cách thêm các phương thức Descriptor trong Python.

class Descriptor:
    def __init__(self):
        self.__fuel_cap = 0
    def __get__(self, instance, owner):    
        return self.__fuel_cap
    def __set__(self, instance, value):
        if isinstance(value, int):
            print(value)
        else:
            raise TypeError("Fuel Capacity can only be an integer")

        if value < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

        self.__fuel_cap = value

    def __delete__(self, instance):
        del self.__fuel_cap
class Car:
    fuel_cap = Descriptor()
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
40
BMW model X7 with a fuel capacity of 40 ltr.

Đừng lo nếu lớp Descriptor có vẻ khó hiểu. Hãy chia nhỏ ra và xem mỗi phương thức làm gì.

  • Phương thức __init__() của lớp Descriptor có biến cục bộ __fuel_cap bằng 0. dunder hay hai dấu gạch dưới ở đầu có nghĩa là biến ở phạm vi riêng tư. Có dunder ở đầu chỉ nhằm phân biệt thuộc tính dung tích nhiên liệu của lớp Descriptor với lớp Car.
  • Như bạn đã biết, phương thức __get__() dùng để lấy thuộc tính và trả về biến dung tích nhiên liệu. Nó nhận ba đối số: đối tượng descriptor, thể hiện của lớp chứa đối tượng descriptor, tức là car2, và cuối cùng là owner, lớp mà thể hiện thuộc về, tức là lớp Car. Trong phương thức này, bạn chỉ cần trả về thuộc tính giá trị, tức fuel_cap, giá trị của nó được đặt trong phương thức __set__().
  • Phương thức __set__() được gọi khi gán giá trị cho thuộc tính và khác với __get__(), nó không trả về gì. Nó có hai đối số ngoài chính đối tượng descriptor, là instance giống như trong __get__() và đối số value, tức giá trị bạn gán cho thuộc tính. Trong phương thức này, bạn kiểm tra xem giá trị muốn gán cho thuộc tính fuel_cap có phải số nguyên không. Nếu không, phát sinh ngoại lệ TypeError. Sau đó, cũng trong phương thức này, bạn kiểm tra xem giá trị có nhỏ hơn 0 không; nếu có, phát sinh ngoại lệ ValueError. Sau khi kiểm tra lỗi, bạn cập nhật thuộc tính fuel_cap bằng giá trị đó.
  • Cuối cùng, phương thức __delete__() được gọi khi thuộc tính bị xóa khỏi một đối tượng và tương tự __set__(), nó không trả về gì.

Lớp Car vẫn như trước. Thay đổi duy nhất là bạn thêm thể hiện fuel_cap của lớp Descriptor(). Lưu ý như đã nói, thể hiện của lớp descriptor phải được thêm vào lớp như một thuộc tính lớp, không phải thuộc tính thể hiện.

Ngay khi bạn đặt biến cục bộ fuel_cap trong phương thức __init__() cho thể hiện fuel_cap, nó sẽ gọi phương thức __set__() của lớp Descriptor.

Bây giờ hãy đổi dung tích nhiên liệu thành một giá trị âm và xem chương trình có phát sinh ValueError hay không.

car2 = Car("BMW","X7",-40)
print(car2)
-40



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-115-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-107-3e1f3e97d3c7> in __init__(self, make, model, fuel_cap)
      4         self.make = make
      5         self.model = model
----> 6         self.fuel_cap = fuel_cap
      7
      8     def __str__(self):


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero

Nếu bạn còn nhớ ở đây, việc kiểm tra kiểu đã thất bại khi sau đó bạn đổi giá trị thuộc tính thành số âm vì kiểm tra kiểu chỉ áp dụng một lần trong phương thức __init__(). Hãy cập nhật giá trị fuel_cap thành một chuỗi và xem có lỗi không.

car2.fuel_cap = -1
-1



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-120-dea9dbe96ebe> in <module>
----> 1 car2.fuel_cap = -1


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero
car2.fuel_cap = "BMW"
----------------------------------------

TypeErrorTraceback (most recent call last)

<ipython-input-121-0b316a9872c6> in <module>
----> 1 car2.fuel_cap = "BMW"


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
      8             print(value)
      9         else:
---> 10             raise TypeError("Fuel Capacity can only be an integer")
     11
     12         if value < 0:


TypeError: Fuel Capacity can only be an integer

Hoàn hảo! Như bạn thấy, nó hoạt động khi bạn cập nhật thuộc tính dung tích nhiên liệu về sau.

Tuy nhiên, Descriptor có một vấn đề nhỏ: khi bạn tạo một thể hiện mới hoặc thể hiện thứ hai của lớp, giá trị của thể hiện trước đó bị ghi đè. Lý do là Descriptor được liên kết với lớp chứ không phải với từng thể hiện.

Hãy hiểu điều này bằng ví dụ dưới đây.

car3 = Car("BMW","X7",48) #created a new instance 'car3' with different values
48

Khi bạn in thể hiện car2, bạn sẽ thấy các giá trị đã bị car3 ghi đè.

print(car2)
BMW model X7 with a fuel capacity of 48 ltr.

Kết luận

Chúc mừng bạn đã hoàn thành hướng dẫn.

Hướng dẫn này dành cho những ai đã quen với Python và muốn chinh phục mức độ nâng cao. Bài tập hay là bạn thử tìm cách giải quyết vấn đề ghi đè giữa các thể hiện đã được thảo luận hôm nay.

Vui lòng đặt câu hỏi liên quan đến hướng dẫn này trong phần bình luận bên dưới.

Nếu bạn muốn học thêm về Python, hãy tham gia lộ trình kỹ năng Python Programming đầy đủ của DataCamp.

Xem thêm Hướng dẫn về Hàm trong Python của chúng tôi.

Chủ đề

Liên quan