🐍 Python Q55 / 170

Explain Python descriptors.

AI-Powered Answer ✓ Answered

Python descriptors are objects that allow you to customize attribute access (getting, setting, and deleting) on other objects. They are a powerful, low-level mechanism that underpins many common Python features like properties, methods, `staticmethod`, and `classmethod`.

What are Descriptors?

At their core, a descriptor is an object that implements at least one of the __get__, __set__, or __delete__ special methods. When an attribute is accessed on an object, Python's attribute lookup machinery checks if the attribute's value is an object that implements the descriptor protocol. If so, the appropriate descriptor method is called instead of simply returning or setting the attribute directly.

The Descriptor Protocol

  • __get__(self, instance, owner): Called when the attribute is accessed (e.g., obj.attr). instance is the object the attribute was accessed through (or None if accessed directly on the class), and owner is the class of instance.
  • __set__(self, instance, value): Called when the attribute is assigned (e.g., obj.attr = value). instance is the object the attribute was accessed through, and value is the new value.
  • __delete__(self, instance): Called when the attribute is deleted (e.g., del obj.attr). instance is the object the attribute was accessed through.

Why Use Descriptors?

  • Reusability: Define attribute behavior once and apply it to multiple attributes or classes.
  • Validation: Enforce rules on attribute values (e.g., type checking, range validation) upon assignment.
  • Lazy Loading: Defer the computation or loading of an attribute's value until it's first accessed.
  • Computed Attributes: Create attributes whose value is dynamically computed each time it's accessed.
  • Attribute Management: Implement advanced access patterns, like ORM fields or mocking objects.

Example: A Simple Integer Descriptor

Let's create a descriptor that ensures an attribute is always an integer and allows setting and getting its value. The __set_name__ method (introduced in Python 3.6) is often used to get the name of the attribute in the owning class.

python
class Integer:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        # Access the value from the instance's private attribute
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if not isinstance(value, int):
            raise ValueError(f"Expected an integer for {self.public_name}, got {type(value).__name__}")
        # Store the value in the instance's private attribute
        setattr(obj, self.private_name, value)

    def __delete__(self, obj):
        delattr(obj, self.private_name)

class MyClass:
    x = Integer() # x is an instance of Integer, which is a descriptor
    y = Integer()

    def __init__(self, x_val, y_val):
        self.x = x_val # This calls Integer.__set__
        self.y = y_val

# Usage
try:
    obj = MyClass(10, 20)
    print(f"obj.x: {obj.x}") # Calls Integer.__get__
    obj.x = 30 # Calls Integer.__set__
    print(f"obj.x after change: {obj.x}")
    del obj.y # Calls Integer.__delete__
    # obj.y = "hello" # This would raise a ValueError
except ValueError as e:
    print(e)
except AttributeError as e:
    print(e) # Will catch 'obj has no attribute _y' after deletion

Data vs. Non-Data Descriptors

Python distinguishes between two types of descriptors based on the methods they implement, which affects attribute lookup precedence:

  • Data Descriptors: Define __set__ or __delete__ (and optionally __get__). These descriptors take precedence over an instance's __dict__ entries. Examples include property() objects and the Integer descriptor shown above.
  • Non-Data Descriptors: Only define __get__. These descriptors have lower precedence; if an attribute with the same name exists in an instance's __dict__, that instance attribute will be returned instead. Examples include functions (methods) before they are bound to an instance, staticmethod, and classmethod.

Understanding this distinction is crucial for predicting how attribute access works. For a data descriptor, obj.attr will always call the descriptor's __get__ method, even if attr exists in obj.__dict__. For a non-data descriptor, obj.attr will first check obj.__dict__; if attr is not found there, then the descriptor's __get__ is called.

Common Built-in Descriptors

  • property(): A built-in class that implements the data descriptor protocol, allowing attributes to be managed like object-oriented getters, setters, and deleters.
  • staticmethod(): A non-data descriptor that wraps a method so it can be accessed without an instance, acting like a regular function attached to a class.
  • classmethod(): A non-data descriptor that wraps a method so it receives the class as its first argument (instead of the instance).
  • Functions: Regular Python functions are non-data descriptors when they are defined inside a class, becoming bound methods when accessed via an instance.

Key Takeaways

  • Descriptors are Python objects implementing __get__, __set__, or __delete__.
  • They provide fine-grained control over attribute access, enabling powerful customization.
  • They are central to many core Python features like property, staticmethod, classmethod, and methods themselves.
  • The distinction between data (has __set__ or __delete__) and non-data (only __get__) descriptors affects attribute lookup precedence.