Descriptors
Esta aula explora os descritores em Python, um mecanismo poderoso que permite controlar o acesso a atributos de objetos. Você aprenderá o protocolo de descritores, como implementar __get__ e __set__, como properties são construídas sobre esse conceito e casos de uso práticos, incluindo validação e reutilização de lógica.
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 Noneem__get__para permitir acesso via classe. - Documente bem seus descritores, pois eles podem esconder complexidade.
- Prefira
propertypara 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
- Python Data Model - Descriptors
- Descriptor HowTo Guide (oficial)
- Python property()
- object.__getattribute__
- Real Python - Descriptors
- GeeksforGeeks - Descriptors in Python
Exercícios
- Crie um descritor chamado
MaxLengthque valide se o valor atribuído a um atributo é uma string com comprimento máximo de 10 caracteres. Se exceder, levanteValueError. Aplique-o a uma classeProdutocom um atributonome. - Implemente um descritor
ReadOnlyque permita apenas a leitura do atributo. Ao tentar atribuir um valor, levanteAttributeError. - Escreva um descritor
TimeStampedque, quando o atributo for definido, registre o horário da última modificação em um atributo_timestampda instância. - Utilize o descritor
CachedPropertyda aula para implementar uma classePessoacom um métodonome_completoque concatenaprimeiro_nomeesobrenome, e demonstre que ele é calculado apenas uma vez. - Explique a diferença entre descritores de dados e não-dados, e dê um exemplo de cada.
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
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
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
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)
__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).