Wednesday, June 10, 2026

Explain the concept of the metaobject protocol in Python


All Questions From This Chapter    « Previously    Next »

In Python, the Metaobject Protocol (MOP) refers to the set of hooks and mechanisms that let you control how the language’s object system itself behaves — things like how classes are created, how attributes are accessed, or how instances are instantiated. Instead of treating the object model as fixed, Python exposes many of its internals so you can customize the rules from within your code. The MOP is not a single class or function; it’s the collection of special methods, metaclasses, and protocols that together make the object system programmable.


What a Metaobject Protocol does

A metaobject protocol lets you answer questions like:

  • “What happens when I access obj.attr?”

  • “How is a class created when I write class Foo:?”

  • “Can I change what it means for an object to be callable or iterable?”

By overriding certain magic methods or using metaclasses, you can rewrite the behaviour that the interpreter normally provides behind the scenes.


The core building blocks in Python

1. Metaclasses — controlling class creation

The default class factory is type. When you write

python
Copy
Download
class Dog:
    pass

Python roughly does:

python
Copy
Download
Dog = type('Dog', (), {})

You can replace type with your own metaclass by passing the metaclass keyword:

python
Copy
Download
class MyMeta(type):
    def __new__(mcs, name, bases, namespace):
        # Add a class attribute automatically
        namespace['created_by'] = 'MyMeta'
        return super().__new__(mcs, name, bases, namespace)

class Cat(metaclass=MyMeta):
    pass

print(Cat.created_by)   # 'MyMeta'

Metaclasses give you control over:

  • The namespace of the class body (via __prepare__)

  • The creation of the class object (__new__)

  • The initialisation of the class object (__init__)

  • How instances of the metaclass (i.e., classes) behave when called (__call__)

Example: a singleton metaclass

python
Copy
Download
class SingletonMeta(type):
    _instances = {}
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Database(metaclass=SingletonMeta):
    pass

db1 = Database()
db2 = Database()
assert db1 is db2

Here, __call__ intercepts instance creation (Database()), letting us enforce the singleton pattern.


2. Attribute access — controlling obj.attr

Python’s attribute machinery is fully customisable through these methods, which belong to every object (or its class):

  • __getattr__(self, name) – called only when normal lookup fails.

  • __getattribute__(self, name) – called unconditionally on every attribute read.

  • __setattr__(self, name, value) – called on obj.x = value.

  • __delattr__(self, name) – called on del obj.x.

Example: logging all attribute access

python
Copy
Download
class Logged:
    def __getattribute__(self, name):
        print(f"Accessing {name}")
        return super().__getattribute__(name)

    def __setattr__(self, name, value):
        print(f"Setting {name} = {value}")
        super().__setattr__(name, value)

obj = Logged()
obj.x = 10       # prints "Setting x = 10"
print(obj.x)     # prints "Accessing x" then 10

3. Descriptors — reusable attribute behaviour

Descriptors are objects that define __get__, __set__, or __delete__. They are the magic behind property, staticmethod, classmethod, and many ORM fields.

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

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.private_name, 0)

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError("Must be non-negative")
        setattr(obj, self.private_name, value)

class BankAccount:
    balance = PositiveNumber()

acc = BankAccount()
acc.balance = 100   # ok
# acc.balance = -50 # raises ValueError

Descriptors let you intercept attribute access at the class level, making the protocol even more granular.


4. Customising instance creation and deletion

Beyond the class‑level __call__, you can also modify:

  • __new__ – allocate a new instance (rarely overridden).

  • __init__ – initialise it.

  • __del__ – define cleanup when the object is garbage collected.


5. The namespace dictionary and __slots__

Every instance normally has a __dict__ for attributes. You can replace it by defining __slots__, which trades dynamic attributes for memory efficiency. This is another MOP hook: Python changes how attribute storage works based on that declaration.


6. Method resolution order and __mro__

The MRO is computed when a class is created, but you can read cls.__mro__ and even affect it through metaclasses (by passing a custom mro() method). Libraries sometimes do this to inject mixins or enforce traits.


Why the MOP matters

The Metaobject Protocol makes Python incredibly flexible. It enables:

  • Frameworks like Django – model fields are descriptors; metaclasses turn model classes into SQL tables.

  • ORM systems – attribute access is transparently converted to database queries.

  • Mock libraries – easily replace any object behaviour for testing.

  • Aspect‑Oriented Programming – add logging, locking, or caching to whole families of objects without touching their source code.

  • Domain‑Specific Languages – create classes that look and behave exactly like your problem domain needs.


A note of caution

With great power comes great complexity. Changing fundamental behaviour can make code harder to read, debug, and maintain. Use the MOP sparingly — when you genuinely need to alter the rules of the language rather than just implementing business logic. Often a simple decorator or composition is a better fit. But when you do need it, Python’s open protocol is one of its most elegant features.


All Questions From This Chapter    « Previously    Next »

No comments:

Post a Comment