Skip to content

    Chapter 45, Advanced

    Descriptors, __slots__, and metaclasses

    The mechanism that makes `property`, methods, and `dataclass` work is public, and you can use it. But the most important thing in this chapter is knowing when **not** to use it.

    Code for this chapter: avancado/cap45_descritores_slots.py

    The descriptor protocol

    A descriptor is an object, stored in a attribute, that defines __get__, __set__ or __delete__. When you read or write the attribute, Python delegates to this object. The __set_name__ notifies the descriptor of the name it was assigned:

    avancado/cap45_descritores_slots.pylinhas 10 a 40
    class Positivo:
        def __set_name__(self, dono, nome):
            self.nome = "_" + nome
    
        def __get__(self, instancia, dono=None):
            if instancia is None:
                return self
            return getattr(instancia, self.nome)
    
        def __set__(self, instancia, valor):
            if valor <= 0:
                raise ValueError(f"{self.nome[1:]} deve ser positivo")
            setattr(instancia, self.nome, valor)
    
    
    class Item:
        preco = Positivo()
        quantidade = Positivo()
    
        def __init__(self, preco, quantidade):
            self.preco = preco
            self.quantidade = quantidade
    
    
    item = Item(10, 2)
    print(item.preco * item.quantidade)
    try:
        item.quantidade = 0
    except ValueError as erro:
        print(erro)
    print(type(Item.preco).__name__)
    
    Output
    20
    quantidade deve ser positivo
    Positivo
    

    The advantage over @property is the reuse: the "positive" rule was written once and applied to two attributes. With property, there would be two pairs of getter and setter.

    Descriptors are everywhere. The property itself is one. And the functions as well: it is the __get__ of the function that turns it into a method bound to the object:

    avancado/cap45_descritores_slots.pylinhas 42 a 48
    class A:
        def metodo(self):
            pass
    
    
    bruto = A.__dict__["metodo"]
    print(type(bruto).__name__, hasattr(bruto, "__get__"))
    
    Output
    function True
    

    __slots__

    By default, each instance stores attributes in a dictionary (__dict__). Declaring __slots__ replaces this dictionary with a fixed space, which saves memory when there are many instances, and prevents the creation of new attributes:

    avancado/cap45_descritores_slots.pylinhas 53 a 82
    import tracemalloc
    
    
    class Comum:
        def __init__(self, x, y):
            self.x, self.y = x, y
    
    
    class Enxuta:
        __slots__ = ("x", "y")
    
        def __init__(self, x, y):
            self.x, self.y = x, y
    
    
    def medir(classe):
        tracemalloc.start()
        objetos = [classe(i, i) for i in range(50_000)]
        atual, _ = tracemalloc.get_traced_memory()
        tracemalloc.stop()
        return atual
    
    
    print(medir(Enxuta) < medir(Comum))
    e = Enxuta(1, 2)
    print(hasattr(e, "__dict__"))
    try:
        e.z = 3
    except AttributeError as erro:
        print(erro)
    
    Output
    True
    False
    'Enxuta' object has no attribute 'z'
    

    I only use __slots__ when I measure a memory problem with millions of objects. For the rest, the savings do not compensate for the rigidity. The simplest way to gain the benefit is @dataclass(slots=True).

    Metaclasses

    In Python, a class is also an object, and the one who creates it is the metaclass, by default type. It is possible to create classes dynamically and intercept the creation of instances:

    avancado/cap45_descritores_slots.pylinhas 87 a 105
    Ponto = type("Ponto", (), {"x": 0, "ola": lambda self: "oi"})
    print(Ponto().ola(), type(Ponto).__name__)
    
    
    class Singleton(type):
        _instancias = {}
    
        def __call__(cls, *args, **kwargs):
            if cls not in Singleton._instancias:
                Singleton._instancias[cls] = super().__call__(*args, **kwargs)
            return Singleton._instancias[cls]
    
    
    class Configuracao(metaclass=Singleton):
        def __init__(self):
            self.valores = {}
    
    
    print(Configuracao() is Configuracao())
    
    Output
    oi type
    True
    

    Avoid metaclasses

    I almost never write one. To register subclasses, use __init_subclass__. To validate attributes, use descriptors or dataclass. For a single object, use an instance at the module level (modules are already loaded only once). The metaclass is the hardest resource to debug and to combine with others, and is rarely the right tool.

    Exercise 1

    A short text descriptor

    Write the descriptor TextoCurto(limite) that raises ValueError if the text exceeds the limit, and use it in Perfil.bio with a limit of 5.