Bem-vindo à aula 74 do curso de Python. Hoje vamos mergulhar em um dos recursos mais avançados e elegantes da linguagem: os descritores (descriptors). Se você já usou a função property para criar atributos gerenciados, já usou descritores sem saber. Entender como eles funcionam por baixo dos panos vai elevar seu nível de compreensão do Python e permitir que você crie abstrações poderosas e reutilizáveis.

Um descritor é um objeto que define como um atributo de uma classe é acessado, alterado ou deletado. Ele é um protocolo que permite que você controle o comportamento de atributos em nível de classe, não de instância. Nesta aula, vamos desvendar o protocolo de descritores, implementar nossos próprios descritores e ver como o Python usa esse mecanismo para implementar property, e também explorar casos de uso práticos.

O protocolo

O protocolo de descritores é um conjunto de métodos especiais que um objeto pode implementar para controlar o acesso a um atributo. Quando um atributo de uma classe é um objeto que implementa esses métodos, o Python redireciona as operações de acesso (get, set, delete) para os métodos do descritor. Os métodos são:

  • __get__(self, obj, objtype=None) — chamado quando o atributo é acessado.
  • __set__(self, obj, value) — chamado quando o atributo é atribuído um valor.
  • __delete__(self, obj) — chamado quando o atributo é deletado.

Um descritor pode implementar apenas __get__ (descritor não-dados) ou também __set__ e/ou __delete__ (descritor de dados). A diferença é crucial: descritores de dados têm prioridade sobre o dicionário da instância (o __dict__), enquanto descritores não-dados são sobrepostos por atributos da instância.

Para que um objeto seja considerado um descritor, ele deve implementar pelo menos um dos métodos acima. O Python verifica se a classe do objeto tem esses métodos (via __getattribute__). A ordem de resolução de atributos é: primeiro a __getattribute__ da classe, que procura na classe e nas superclasses por um descritor de dados; se não encontrar, procura no __dict__ da instância; e só então procura por descritores não-dados.

Vamos ver um exemplo simples de um descritor não-dados que apenas registra o acesso:

class TraceAccess:
    def __get__(self, obj, objtype=None):
        print(f"Acessando atributo via descritor")
        return "valor padrão"

class MinhaClasse:
    atributo = TraceAccess()

obj = MinhaClasse()
print(obj.atributo)  # Acessando atributo via descritor\nvalor padrão

Nesse exemplo, TraceAccess implementa apenas __get__, então é um descritor não-dados. Se atribuirmos um valor ao atributo na instância, ele será armazenado no __dict__ e o descritor será ignorado:

obj.atributo = 42
print(obj.atributo)  # 42 (não chama o descritor)

Já um descritor de dados (com __set__) sobrescreve o __dict__ da instância, como veremos a seguir.

__get__/__set__

Os métodos __get__ e __set__ são os pilares do protocolo. Eles recebem a instância da classe dona (obj) e, no caso de __get__, também a classe (objtype). O parâmetro obj é None quando o acesso é feito via classe (ex.: MinhaClasse.atributo), o que permite implementar comportamentos diferentes para acesso via instância ou via classe.

Vamos criar um descritor de dados que valida e armazena um valor inteiro positivo:

class PositiveNumber:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # acesso via classe
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        if not isinstance(value, int) or value <= 0:
            raise ValueError("Valor deve ser um inteiro positivo")
        obj.__dict__[self.name] = value

    def __delete__(self, obj):
        del obj.__dict__[self.name]

class Pessoa:
    idade = PositiveNumber("idade")

p = Pessoa()
p.idade = 25
print(p.idade)  # 25
# p.idade = -5  # ValueError

No exemplo, usamos obj.__dict__ para armazenar o valor real, evitando recursão infinita (se usássemos setattr dentro de __set__, chamaria o descritor novamente). O nome do atributo é passado ao construtor para que o descritor saiba onde armazenar o valor no dicionário da instância.

O método __get__ também pode retornar um objeto totalmente diferente, como uma versão transformada do valor. Por exemplo, um descritor que converte temperatura:

class Celsius:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        obj.__dict__[self.name] = value

class Termometro:
    celsius = Celsius("celsius")

    @property
    def fahrenheit(self):
        return self.celsius * 9/5 + 32

t = Termometro()
t.celsius = 100
print(t.fahrenheit)  # 212.0

Aqui, o descritor Celsius simplesmente armazena o valor, mas a property fahrenheit usa o valor para calcular a conversão. Isso mostra como descritores podem ser combinados com properties.

Como properties funcionam

A função property é, na verdade, uma classe que implementa o protocolo de descritores. Quando você define:

class Retangulo:
    def __init__(self, largura, altura):
        self._largura = largura
        self._altura = altura

    @property
    def area(self):
        return self._largura * self._altura

o decorador @property cria um descritor de dados que chama o método area quando o atributo é acessado. O objeto property tem métodos getter, setter e deleter que permitem definir os métodos correspondentes. Na prática, property é um descritor que armazena referências a funções e as invoca no momento do acesso.

Você pode criar seu próprio descritor que imite o comportamento de property:

class MinhaProperty:
    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("atributo não legível")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("atributo não gravável")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("atributo não deletável")
        self.fdel(obj)

    def getter(self, fget):
        self.fget = fget
        return self

    def setter(self, fset):
        self.fset = fset
        return self

    def deleter(self, fdel):
        self.fdel = fdel
        return self

Usando nossa implementação:

class Exemplo:
    def __init__(self, valor):
        self._valor = valor

    @MinhaProperty
    def valor(self):
        return self._valor

    @valor.setter
    def valor(self, novo):
        self._valor = novo

e = Exemplo(10)
print(e.valor)  # 10
e.valor = 20
print(e.valor)  # 20

Isso demonstra que property é apenas um descritor conveniente. Entender isso ajuda a ver que descritores são a base de muitas funcionalidades do Python, como classmethod, staticmethod e até mesmo __slots__ (que usa descritores para criar atributos com acesso mais rápido).

Casos de uso

Os descritores são extremamente úteis quando você precisa reutilizar lógica de validação, transformação ou monitoramento em vários atributos de uma ou mais classes. Em vez de repetir o mesmo código em várias properties, você pode criar um descritor genérico e aplicá-lo a vários atributos.

Validação de dados: Um descritor pode validar tipos, faixas, formatos, etc. Por exemplo, um descritor para garantir que um atributo seja um email válido:

import re

class Email:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", value):
            raise ValueError("Email inválido")
        obj.__dict__[self.name] = value

class Usuario:
    email = Email("email")

u = Usuario()
u.email = "teste@exemplo.com"  # ok
# u.email = "invalido"  # ValueError

Transformação de dados: Um descritor pode converter o valor armazenado em outro formato, como um descritor que armazena strings em minúsculas:

class LowerCase:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        obj.__dict__[self.name] = value.lower()

class Produto:
    nome = LowerCase("nome")

p = Produto()
p.nome = "Notebook Dell"
print(p.nome)  # notebook dell

Cache e memoização: Descritores podem ser usados para implementar cache de valores computados. Por exemplo, um descritor que calcula um valor uma vez e o armazena:

class CachedProperty:
    def __init__(self, func):
        self.func = func
        self.name = func.__name__

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.name not in obj.__dict__:
            obj.__dict__[self.name] = self.func(obj)
        return obj.__dict__[self.name]

class Circulo:
    def __init__(self, raio):
        self.raio = raio

    @CachedProperty
    def area(self):
        print("Calculando área...")
        return 3.14159 * self.raio ** 2

c = Circulo(5)
print(c.area)  # Calculando área...\n78.53975
print(c.area)  # 78.53975 (não recalcula)

Controle de acesso e observação: Você pode criar descritores que registram mudanças, controlam permissões ou emitem eventos. Por exemplo, um descritor que notifica quando um atributo é alterado:

class Observable:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        print(f"Atributo {self.name} mudou para {value}")
        obj.__dict__[self.name] = value

class Conta:
    saldo = Observable("saldo")

c = Conta()
c.saldo = 1000  # Atributo saldo mudou para 1000

Esses exemplos mostram a versatilidade dos descritores. Eles são uma ferramenta de metaprogramação que permite escrever código mais limpo e DRY (Don't Repeat Yourself).

Boas práticas e observações finais

Ao trabalhar com descritores, lembre-se de:

  • Use obj.__dict__ para armazenar valores, evitando recursão infinita.
  • Trate o caso obj is None em __get__ para permitir acesso via classe.
  • Documente bem seus descritores, pois eles podem esconder complexidade.
  • Prefira property para casos simples; use descritores quando houver lógica reutilizável.
  • Considere o impacto na performance: chamadas de descritores têm um overhead, mas geralmente é aceitável.

Referências

Exercícios

  1. Crie um descritor chamado MaxLength que valide se o valor atribuído a um atributo é uma string com comprimento máximo de 10 caracteres. Se exceder, levante ValueError. Aplique-o a uma classe Produto com um atributo nome.
  2. Implemente um descritor ReadOnly que permita apenas a leitura do atributo. Ao tentar atribuir um valor, levante AttributeError.
  3. Escreva um descritor TimeStamped que, quando o atributo for definido, registre o horário da última modificação em um atributo _timestamp da instância.
  4. Utilize o descritor CachedProperty da aula para implementar uma classe Pessoa com um método nome_completo que concatena primeiro_nome e sobrenome, e demonstre que ele é calculado apenas uma vez.
  5. Explique a diferença entre descritores de dados e não-dados, e dê um exemplo de cada.

✓ Resposta:
class MaxLength:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        if not isinstance(value, str) or len(value) > 10:
            raise ValueError("String deve ter no máximo 10 caracteres")
        obj.__dict__[self.name] = value

class Produto:
    nome = MaxLength("nome")

p = Produto()
p.nome = "Notebook"  # ok
# p.nome = "Notebook muito longo"  # ValueError

✓ Resposta:
class ReadOnly:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        raise AttributeError("Atributo é somente leitura")

class Config:
    versao = ReadOnly("versao")

c = Config()
c.versao = "1.0"  # AttributeError

✓ Resposta:
import time

class TimeStamped:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        obj.__dict__[self.name] = value
        obj.__dict__["_timestamp"] = time.time()

class Documento:
    conteudo = TimeStamped("conteudo")

d = Documento()
d.conteudo = "Olá"
print(d._timestamp)  # hora atual

✓ Resposta:
class CachedProperty:
    def __init__(self, func):
        self.func = func
        self.name = func.__name__

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.name not in obj.__dict__:
            obj.__dict__[self.name] = self.func(obj)
        return obj.__dict__[self.name]

class Pessoa:
    def __init__(self, primeiro_nome, sobrenome):
        self.primeiro_nome = primeiro_nome
        self.sobrenome = sobrenome

    @CachedProperty
    def nome_completo(self):
        print("Calculando nome completo...")
        return f"{self.primeiro_nome} {self.sobrenome}"

p = Pessoa("João", "Silva")
print(p.nome_completo)  # Calculando...\nJoão Silva
print(p.nome_completo)  # João Silva (sem recalcular)

✓ Resposta:Descritores de dados implementam pelo menos __set__ (ou __delete__), e têm precedência sobre o __dict__ da instância. Descritores não-dados implementam apenas __get__, e são sobrepostos por atributos da instância. Exemplo de dados: property. Exemplo de não-dados: classmethod (na verdade, classmethod é um descritor não-dados).