杰瑞科技汇

Python descriptor是什么?如何实现与使用?

Of course! Let's dive deep into Python descriptors. They are a powerful and often misunderstood feature, but understanding them is key to unlocking a lot of "magic" in Python's standard library and frameworks like Django and SQLAlchemy.

Python descriptor是什么?如何实现与使用?-图1
(图片来源网络,侵删)

What is a Descriptor?

At its core, a descriptor is a simple protocol. Any object that defines at least one of the following special methods can be considered a descriptor:

  • __get__(self, instance, owner)
  • __set__(self, instance, value)
  • __delete__(self, instance)

These methods allow an object to control how its attribute is accessed, set, or deleted. When you access an attribute on an object (e.g., obj.my_attr), Python looks for that attribute in a specific order. If it finds a descriptor in the class dictionary, it calls the descriptor's methods instead of just returning the object directly.

The Lookup Resolution Order

This is the most important concept to understand. When you access obj.x, Python performs this search:

  1. The instance dictionary: obj.__dict__['x']
  2. The class dictionary: obj.__class__.__dict__['x']
  3. The class's parent classes' dictionaries: (MRO search)
  4. If it finds a descriptor in step 2 or 3, it calls its __get__ method.

A descriptor only takes effect when it is accessed via the class or an instance of the class. If you access it directly from the class's dictionary, it behaves like a normal object.

Python descriptor是什么?如何实现与使用?-图2
(图片来源网络,侵删)

The Three "Flavors" of Descriptors

Based on which methods are implemented, descriptors fall into three categories:

  1. Non-Data Descriptor: Only implements __get__(). It's "passive" and doesn't manage storage. Functions are classic examples.
  2. Data Descriptor: Implements both __get__() and __set__(). It's "active" and controls access, including setting. Properties are the most common example.
  3. Non-Descriptor: If an object defines neither __get__ nor __set__, it's just a normal object and follows the standard attribute lookup rules.

A crucial rule: Data descriptors always take precedence over non-data descriptors in the lookup order. This is why a property can override a method with the same name.


Let's Build Some Examples

Example 1: The Classic property (A Data Descriptor)

You might think @property is magic, but it's just a convenient way to create a data descriptor. Let's build our own version.

class MyProperty:
    """A simple re-implementation of the built-in property."""
    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
    def __get__(self, instance, owner):
        print("MyProperty __get__ called!")
        if instance is None:
            # Accessed on the class itself (e.g., Person.age)
            return self
        if self.fget is None:
            raise AttributeError(f"unreadable attribute")
        return self.fget(instance)
    def __set__(self, instance, value):
        print("MyProperty __set__ called!")
        if self.fset is None:
            raise AttributeError(f"can't set attribute")
        self.fset(instance, value)
    def setter(self, fset):
        print("MyProperty.setter called!")
        self.fset = fset
        return self # Return self to allow decorator chaining
    def deleter(self, fdel):
        self.fdel = fdel
        return self
# --- Usage ---
class Person:
    def __init__(self, name, age):
        self.name = name
        # The descriptor is set here
        self.age = age
    # Use our custom property descriptor
    age = MyProperty()
    @age.setter
    def age(self, value):
        if not isinstance(value, int):
            raise ValueError("Age must be an integer")
        if value < 0:
            raise ValueError("Age cannot be negative")
        print(f"Setting age to {value}")
        # We need to store the actual value somewhere else
        # A common pattern is to use a unique name in the instance's __dict__
        self._age = value
    @property
    def name(self):
        print("MyProperty __get__ called for name!")
        return self._name
    @name.setter
    def name(self, value):
        print("MyProperty __set__ called for name!")
        self._name = value
p = Person("Alice", 30)
# Accessing p.age triggers MyProperty.__get__
print(f"Person's age: {p.age}") # Output: MyProperty __get__ called! ... Person's age: 30
# Setting p.age triggers MyProperty.__set__
p.age = 31
print(f"Person's age: {p.age}") # Output: MyProperty __set__ called! ... Setting age to 31 ... Person's age: 31
# This demonstrates that the descriptor controls access, but the data is stored
# in a different attribute (_age) to avoid recursion.
print(f"Instance __dict__: {p.__dict__}") # Output: {'name': 'Alice', '_age': 31}

Example 2: A Verbose Logging Descriptor (A Non-Data Descriptor)

This descriptor will log every time an attribute is accessed, but it won't prevent the attribute from being set or deleted.

Python descriptor是什么?如何实现与使用?-图3
(图片来源网络,侵删)
class VerboseAttribute:
    """A non-data descriptor that logs attribute access."""
    def __init__(self, name):
        self.name = name
        print(f"VerboseAttribute for '{self.name}' created.")
    def __get__(self, instance, owner):
        if instance is None:
            return self
        print(f"*** ACCESSING {self.name} of {owner.__name__} instance ***")
        # The actual value is stored in the instance's __dict__
        return instance.__dict__[self.name]
    # No __set__ or __del__, so it's a non-data descriptor.
class Point:
    x = VerboseAttribute('x')
    y = VerboseAttribute('y')
    def __init__(self, x, y):
        self.x = x
        self.y = y
p = Point(10, 20)
print("\nAccessing p.x:")
val_x = p.x
# Output:
# VerboseAttribute for 'x' created.
# VerboseAttribute for 'y' created.
#
# Accessing p.x:
# *** ACCESSING x of Point instance ***
print("\nAccessing p.y:")
val_y = p.y
# Output:
# *** ACCESSING y of Point instance ***
print(f"\nPoint coordinates: ({val_x}, {val_y})")
# Output: Point coordinates: (10, 20)

Example 3: A Smart Attribute for Validation (A Data Descriptor)

This is a very practical use case. We want an attribute that can only hold integers.

class IntegerAttribute:
    """A data descriptor that only accepts integers."""
    def __init__(self, name):
        # We use a unique internal name to store the value
        self.internal_name = f"_{name}"
    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError(f"{self.internal_name[1:]} must be an integer")
        setattr(instance, self.internal_name, value)
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.internal_name)
class BankAccount:
    balance = IntegerAttribute("balance")
    def __init__(self, initial_balance):
        # This assignment triggers the descriptor's __set__
        self.balance = initial_balance
    def deposit(self, amount):
        self.balance += amount
# --- Usage ---
acc = BankAccount(100)
print(f"Initial balance: {acc.balance}") # Output: Initial balance: 100
try:
    acc.balance = "hello" # This will raise TypeError
except TypeError as e:
    print(f"\nError: {e}") # Output: Error: _balance must be an integer
acc.deposit(50)
print(f"Balance after deposit: {acc.balance}") # Output: Balance after deposit: 150

When to Use Descriptors?

Descriptors are not for everyday use. They are a tool for building frameworks and reusable components. Here are the primary use cases:

  1. Managing Attribute Access: This is the most common use.

    • Properties: As shown, @property is a data descriptor.
    • Read-Only Attributes: Create a descriptor with only __get__.
    • Computed Attributes: Dynamically calculate a value when it's accessed.
  2. Validation: As in the IntegerAttribute example, you can centralize validation logic for a specific attribute type.

  3. Lazy Loading: Only perform an expensive operation (like a database query) the first time an attribute is accessed. The descriptor can check if the value has been loaded and, if not, load it and store it in the instance's __dict__.

  4. Caching: Store the result of a function call in the instance's __dict__ so subsequent calls are fast.

  5. Deprecation: Create a descriptor that warns the user when an old, deprecated attribute is accessed.

When NOT to Use Descriptors?

  • For simple instance-specific state. If you just need to store data on an instance, do it directly in __init__. The overhead of the descriptor protocol is unnecessary.
  • To replace simple if/else logic inside a method. A descriptor is too heavyweight for this.

Summary

Feature Description
Core Idea A protocol (__get__, __set__, __delete__) that lets an object control another object's attribute access.
Lookup Order Instance dict -> Class dict -> Parent dicts. If a descriptor is found in the class/parent dict, its method is called.
Data Descriptor Has __get__ and __set__. Takes precedence over non-data descriptors. (e.g., @property)
Non-Data Descriptor Has only __get__. Does not manage storage. (e.g., a method, a function)
Use Cases Properties, validation, lazy loading, caching, building frameworks.
Don't Use For Simple instance data storage or replacing basic logic.

By mastering descriptors, you gain a much deeper understanding of how Python works under the hood and can write more elegant, powerful, and reusable code.

分享:
扫描分享到社交APP
上一篇
下一篇