Swiftorial Logo
Home
Swift Lessons
Tutorials
Learn More
Career
Resources

Python FAQ: Top Questions

34. What is a descriptor in Python? Give an example.

In Python, a **descriptor** is an object attribute that has "binding behavior," meaning its attribute access (getting, setting, or deleting) has been overridden by methods in the descriptor protocol. These methods are `__get__`, `__set__`, and `__delete__`.

Descriptors are a fundamental part of how many Python features work under the hood, including properties (`@property`), methods (which are essentially descriptors), class methods (`@classmethod`), static methods (`@staticmethod`), and even `__slots__`.

When you access an attribute on an object (`obj.attribute`), Python's lookup mechanism follows a specific order. If `attribute` is a descriptor, its defined behavior takes precedence. The exact behavior depends on whether the descriptor implements `__get__`, `__set__`, or `__delete__`.

Descriptor Protocol Methods:

  • `__get__(self, instance, owner)`:
    • Called when the descriptor's attribute is accessed (read from).
    • `self`: The descriptor instance itself.
    • `instance`: The instance of the class on which the attribute was accessed (e.g., `obj` in `obj.attribute`). If accessed via the class (`Class.attribute`), this will be `None`.
    • `owner`: The class of `instance` (e.g., `MyClass` in `obj.attribute`). If accessed via the class (`Class.attribute`), this is `Class`.
    • Must return the value of the attribute.
  • `__set__(self, instance, value)`:
    • Called when the descriptor's attribute is assigned to (written to).
    • `self`: The descriptor instance.
    • `instance`: The instance of the class on which the attribute was assigned.
    • `value`: The new value being assigned to the attribute.
  • `__delete__(self, instance)`:
    • Called when the descriptor's attribute is deleted (using `del`).
    • `self`: The descriptor instance.
    • `instance`: The instance of the class from which the attribute was deleted.

Types of Descriptors:

  • **Data Descriptors:** Implement `__get__` and `__set__` (and optionally `__delete__`). They take precedence over instance dictionaries. Properties (`@property`) are data descriptors.
  • **Non-Data Descriptors:** Implement only `__get__` (and optionally `__delete__`). They are overridden by entries in the instance's dictionary. Methods (`func.__get__` creates a bound method) are non-data descriptors.

When to use Descriptors (Beyond `@property`):

While `@property` is the most common and convenient way to use descriptors for getter/setter-like behavior, direct descriptor implementation is useful for:

  • **Type Validation/Coercion:** Automatically validate or transform values when assigned to an attribute.
  • **Lazy Loading:** Loading an attribute's value only when it's first accessed.
  • **Cached Properties:** Computing a value once and caching it for subsequent access.
  • **Access Control:** Implementing read-only attributes or attributes that can only be set once.
  • **Logging Attribute Access:** Logging whenever an attribute is read or written.
  • **ORMs/Frameworks:** Used extensively in ORMs (like Django, SQLAlchemy) to map database columns to Python object attributes, handling conversion and database interactions.
  • **Managed Attributes:** Any scenario where you need custom logic to run upon attribute access, assignment, or deletion.

Understanding descriptors is key to grasping Python's object model and how its powerful built-in features operate.


import datetime

# --- Example 1: A simple Data Descriptor for type validation and storage ---
print("--- Data Descriptor: Type and Value Validation ---")

class Integer:
    """A descriptor that ensures the assigned value is an integer and within a range."""
    def __init__(self, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value
        self.name = None # Will be set by the owner class

    def __set_name__(self, owner, name):
        """Called automatically by Python when the descriptor is assigned to an attribute."""
        self.name = name

    def __get__(self, instance, owner):
        if instance is None: # Accessing via class (e.g., MyClass.attribute)
            return self
        # Get the value from the instance's __dict__ (where it's stored)
        # Use getattr with a default to avoid KeyError if not set
        return instance.__dict__.get(self.name, None)

    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError(f"'{self.name}' must be an integer.")
        if self.min_value is not None and value < self.min_value:
            raise ValueError(f"'{self.name}' must be at least {self.min_value}.")
        if self.max_value is not None and value > self.max_value:
            raise ValueError(f"'{self.name}' cannot exceed {self.max_value}.")
        
        # Store the validated value directly in the instance's dictionary
        instance.__dict__[self.name] = value
        print(f"Set {self.name} to {value} (validated).")

    def __delete__(self, instance):
        if self.name in instance.__dict__:
            del instance.__dict__[self.name]
            print(f"Deleted {self.name} from instance.")
        else:
            print(f"{self.name} not found to delete.")


class Product:
    # Use our Integer descriptor for 'price' and 'stock'
    price = Integer(min_value=0, max_value=1000)
    stock = Integer(min_value=0)

    def __init__(self, name, price, stock):
        self.name = name
        self.price = price # This assignment calls Integer.__set__
        self.stock = stock # This assignment calls Integer.__set__

p = Product("Laptop", 800, 50)
print(f"Product: {p.name}, Price: {p.price}, Stock: {p.stock}")

# Test validation
try:
    p.price = 1200 # Too high
except (TypeError, ValueError) as e:
    print(f"Error setting price: {e}")

try:
    p.stock = -5 # Too low
except (TypeError, ValueError) as e:
    print(f"Error setting stock: {e}")

try:
    p.price = "invalid" # Not an integer
except (TypeError, ValueError) as e:
    print(f"Error setting price: {e}")

p.price = 750 # Valid update
print(f"New price: {p.price}")

# Test deletion
del p.stock
print(f"After deleting stock, trying to access p.stock: {p.stock}") # Will be None
try:
    del p.stock # Delete again
except AttributeError as e:
    print(f"Error deleting stock again: {e}")


# --- Example 2: Non-Data Descriptor (e.g., a simple method) ---
print("\n--- Non-Data Descriptor: Simple Method ---")

class Greeter:
    def greet(self, name): # 'greet' is a non-data descriptor when accessed via class
        return f"Hello, {name}!"

g = Greeter()
print(f"Calling instance method: {g.greet('Alice')}")

# How Python works behind the scenes for methods:
# When you do g.greet, Python finds Greeter.greet, which is a function (non-data descriptor).
# It then calls Greeter.greet.__get__(g, Greeter)
# This __get__ method returns a "bound method" that automatically passes 'g' as 'self'.
bound_method = Greeter.greet.__get__(g, Greeter)
print(f"Calling bound method directly: {bound_method('Bob')}")


# --- Example 3: Property (which uses descriptors internally) ---
print("\n--- Property (Built-in Descriptor) ---")

class Celsius:
    def __init__(self, temperature=0):
        self._temperature = temperature # Private attribute

    def get_temperature(self):
        print("Getting temperature...")
        return self._temperature

    def set_temperature(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below -273.15 C (absolute zero).")
        print(f"Setting temperature to {value}...")
        self._temperature = value

    def del_temperature(self):
        print("Deleting temperature...")
        del self._temperature

    # Using @property is syntactic sugar for creating a data descriptor
    temperature = property(get_temperature, set_temperature, del_temperature, "Temperature in Celsius")

c = Celsius(25)
print(f"Initial temp: {c.temperature}") # Calls get_temperature

c.temperature = 30 # Calls set_temperature
print(f"Updated temp: {c.temperature}")

try:
    c.temperature = -300 # Invalid value
except ValueError as e:
    print(f"Error setting temperature: {e}")

del c.temperature # Calls del_temperature
try:
    print(c.temperature) # Access after deletion
except AttributeError as e:
    print(f"Error accessing temperature after deletion: {e}")
        

Explanation of the Example Code:

  • **Data Descriptor (`Integer` class):**
    • The `Integer` class is a descriptor. It implements `__set_name__` (Python 3.6+ feature to get the attribute name), `__get__`, `__set__`, and `__delete__`.
    • When `Product.price = Integer(...)` is defined, `Integer.__set_name__` is automatically called, setting `self.name` to `'price'` or `'stock'`.
    • When `p = Product("Laptop", 800, 50)` runs:
      • `self.price = 800` internally calls `Integer.__set__(p, 800)`. Our descriptor validates the value and then stores it directly in `p.__dict__['price']`.
      • `self.stock = 50` similarly calls `Integer.__set__(p, 50)`.
    • When `print(p.price)` runs, it calls `Integer.__get__(p, Product)`, which retrieves the value from `p.__dict__['price']`.
    • The `try...except` blocks demonstrate that the `Integer` descriptor successfully enforces type and range validation by raising `TypeError` or `ValueError` during assignment.
    • `del p.stock` calls `Integer.__delete__(p)`.
  • **Non-Data Descriptor (Simple Method):**
    • A regular method like `greet` in `Greeter` is a non-data descriptor.
    • When you access `g.greet`, Python calls `Greeter.greet.__get__(g, Greeter)`. This `__get__` method (built-in for functions) returns a "bound method" object. This bound method is what you actually call when you do `g.greet('Alice')`, and it automatically passes `g` as the `self` argument to the original `greet` function.
  • **Property (`Celsius` class):**
    • The `@property` decorator (or directly using `property()` as shown) is a high-level way to create data descriptors.
    • `temperature = property(get_temperature, set_temperature, del_temperature)` creates a descriptor object.
    • When `c.temperature` is accessed, `get_temperature` is called.
    • When `c.temperature = value` is used, `set_temperature` is called.
    • When `del c.temperature` is used, `del_temperature` is called.
    • This provides a clean interface for controlled attribute access without direct exposure of the underlying private attribute (`_temperature`), which is a common use case for descriptors.

These examples illustrate how descriptors enable custom logic for attribute access, assignment, and deletion, both explicitly through implementing the descriptor protocol and implicitly through Python's built-in features like properties and methods.