Curso
Descritores em Python — ou, de forma mais geral, descritores — oferecem uma técnica poderosa para escrever código reutilizável que pode ser compartilhado entre classes. Eles podem parecer semelhantes a herança, mas tecnicamente não são. São uma forma genérica de interceptar o acesso a atributos. Descritores são o mecanismo por trás de propriedades, métodos estáticos, métodos de classe, super, etc.
Os descritores foram adicionados ao Python na versão 2.2 e, desde então, são vistos como uma peça “mágica” que trouxe um novo estilo às classes tradicionais. São classes que permitem gerenciar propriedades em outra classe. Especificamente, implementam a interface dos métodos __get__(), __set__() e __delete__(), o que os torna interessantes por vários motivos. Por exemplo, decoradores de classe e de propriedade que você já deve ter visto em Python.
Em poucas palavras, uma classe que implementa os métodos __get__(), __set()__ ou __delete()__ para um objeto é chamada de "Descriptor". Citando diretamente a documentação oficial do Python, um descritor é um atributo de objeto com binding behavior, cujo acesso foi sobrescrito por métodos do protocolo de descritor. Esses métodos são __get__(), __set__() e __delete__() (Fonte).
Binding behavior, no contexto de descritores, significa vincular a forma como um valor pode ser definido, consultado (get) ou excluído para uma variável, objeto ou conjunto de dados. Toda essa interação fica vinculada àquele pedaço de dado, pois só se aplica ao dado no qual você a configurou.
Os descritores podem ser categorizados em descritores de dados e não de dados. Se o descritor que você escrever tiver apenas o método __get__(), ele é um descritor não de dados; por outro lado, implementações que envolvem os métodos __set__() e __delete__() são chamadas de descritores de dados. Descritores não de dados são apenas legíveis, enquanto os de dados são legíveis e graváveis.
É importante notar que descritores são atribuídos a uma classe, não a uma instância. Modificar a classe sobrescreve ou remove o próprio descritor, em vez de disparar o código dele (IBM Developer).
Por fim, a classe do descritor não se limita a esses três métodos; ela pode conter quaisquer outros atributos além de get, set e delete.
Vamos entender os métodos get, set e delete em mais detalhes, com base nesta página da IBM Developer:
selfé a instância do descritor que você cria (Real Python).objecté a instância do objeto ao qual seu descritor está anexado (Real Python).typeé o tipo do objeto ao qual o descritor está anexado (Real Python).valueé o valor atribuído ao atributo do descritor. get(self, object, type) set(self, object, value) delete(self, object)-
__get__()acessa o atributo, ou seja, quando você quer extrair alguma informação. Ele retorna o valor do atributo ou lança a exceção AttributeError se o atributo solicitado não existir. -
__set__()é chamado em uma atribuição de atributo que define o valor de um atributo. Não retorna nada, mas pode lançar a exceção AttributeError. -
__delete__()controla a operação de exclusão, isto é, quando você quer remover o atributo de um objeto. Não retorna nada.
Agora vamos entender o propósito dos descritores com alguns exemplos!
Propósito dos descritores
Vamos definir uma classe car com três atributos: make, model e fuel_cap. Você usará o método __init__() para inicializar os atributos da classe. Depois, usará a função mágica __str__(), que simplesmente retorna uma string com os três atributos passados à classe ao criar o objeto.
Observe que o método __str__() retorna a representação em string do objeto. Ele é chamado quando a função print() ou str() é invocada em um objeto da classe.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
Como você viu acima, tudo certo até aqui!
Agora vamos mudar a capacidade de combustível do carro para menos 40.
car2 = Car("BMW","X7",-40)
print(car2)
BMW model X7 with a fuel capacity of -40 ltr.
Opa, tem algo errado, não é? A capacidade do tanque nunca pode ser negativa. Mesmo assim, o Python aceita a entrada sem erro. Isso acontece porque Python é uma linguagem dinâmica e não faz checagem de tipos explicitamente.
Para evitar isso, vamos adicionar uma condição if no método __init__() para verificar se a capacidade informada é válida. Se não for, lançamos um ValueError.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car("BMW","X7",-40)
print(car2)
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-22-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
2 print(car2)
<ipython-input-20-1e154783588d> in __init__(self, make, model, fuel_cap)
5 self.fuel_cap = fuel_cap
6 if self.fuel_cap < 0:
----> 7 raise ValueError("Fuel Capacity can never be less than zero")
8
9 def __str__(self):
ValueError: Fuel Capacity can never be less than zero
Pelo resultado acima, tudo funciona, já que o programa lança um ValueError se a capacidade for abaixo de zero.
Mas há outro problema: e se a capacidade for um float ou uma string? E não só a capacidade — o make e o model também podem ser inteiros. Em todos esses casos, o programa não levantará exceção.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car(-40,"X7",40)
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.
Para tratar esse caso, você pode pensar em adicionar outro if ou usar isinstance para checagem de tipo.
Desta vez, vamos usar o método embutido isinstance para tratar o erro.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if isinstance(self.make, str):
print(self.make)
else:
raise ValueError("Make of the car can never be an integer")
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car(-40,"X7",40)
print(car2)
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-34-75b08cba454f> in <module>
----> 1 car2 = Car(-40,"X7",40)
2 print(car2)
<ipython-input-31-175690bf3b98> in __init__(self, make, model, fuel_cap)
7 print(self.make)
8 else:
----> 9 raise ValueError("Make of the car can never be an integer")
10
11 if self.fuel_cap < 0:
ValueError: Make of the car can never be an integer
Ótimo! Assim você tratou esse erro também.
Porém, e se você decidir alterar a capacidade do tanque para -40 mais tarde? Não vai funcionar, pois a checagem de tipo ocorre só uma vez, dentro de __init__(). Como você sabe, __init__() é um construtor chamado apenas na criação do objeto. Depois disso, sua verificação personalizada não será aplicada.
Veja o exemplo:
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if isinstance(self.make, str):
print(self.make)
else:
raise ValueError("Make of the car can never be an integer")
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2.make = -40
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.
Pronto: você conseguiu burlar a checagem de tipo.
Agora pense que você tem vários outros atributos — consumo, preço, acessórios etc. — que também precisam de validação, e alguns deles deveriam ter apenas acesso de leitura. Não seria bem chato manter tudo isso?
Para resolver esses problemas, entram em cena os descritores do Python!
Como vimos, qualquer classe que implemente os métodos mágicos __get__(), __set()__ ou __delete()__ do protocolo de descritor é um descritor. Eles também dão controle extra sobre como um atributo deve funcionar, como restringir leitura e escrita.
Agora vamos estender o exemplo acima adicionando os métodos de descritor do Python.
class Descriptor:
def __init__(self):
self.__fuel_cap = 0
def __get__(self, instance, owner):
return self.__fuel_cap
def __set__(self, instance, value):
if isinstance(value, int):
print(value)
else:
raise TypeError("Fuel Capacity can only be an integer")
if value < 0:
raise ValueError("Fuel Capacity can never be less than zero")
self.__fuel_cap = value
def __delete__(self, instance):
del self.__fuel_cap
class Car:
fuel_cap = Descriptor()
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
40
BMW model X7 with a fuel capacity of 40 ltr.
Se a classe Descriptor parece obscura, calma. Vamos destrinchar cada método.
- O método
__init__()da classe Descriptor define a variável local__fuel_capcomo zero. Odunder(duplo sublinhado) no início indica que a variável é privada. Aqui, ele também ajuda a distinguir o atributo de capacidade da classe Descriptor do da classe Car.
- Como você já sabe, o método
__get__()é usado para recuperar atributos e retorna a capacidade de combustível. Ele recebe três argumentos: o próprio objeto descritor; a instância da classe que contém o descritor (por exemplo,car2); e o owner, que é a classe à qual a instância pertence (a classeCar). Nesse método, você simplesmente retorna o atributo cujo valor é definido em__set__().
- O método
__set__()é invocado quando um valor é atribuído ao atributo e, ao contrário de__get__(), não retorna nada. Além do descritor, ele recebe a instância (como em__get__()) e o valor que você quer atribuir. Aqui, verificamos se o valor parafuel_capé um inteiro; se não for, levantamosTypeError. Depois verificamos se é menor que zero; se for, levantamosValueError. Por fim, atualizamosfuel_capcom o valor válido.
- Por último,
__delete__()é chamado quando o atributo é removido do objeto e, assim como__set__(), não retorna nada.
A classe Car permanece praticamente igual, com a diferença de que adicionamos a instância fuel_cap da classe Descriptor(). Repare: a instância do descritor deve ser adicionada como atributo de classe, não como atributo de instância.
Assim que você define fuel_cap no __init__(), o método __set__() do descritor é invocado.
Agora vamos alterar a capacidade para um valor negativo e ver se o programa levanta um ValueError.
car2 = Car("BMW","X7",-40)
print(car2)
-40
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-115-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
2 print(car2)
<ipython-input-107-3e1f3e97d3c7> in __init__(self, make, model, fuel_cap)
4 self.make = make
5 self.model = model
----> 6 self.fuel_cap = fuel_cap
7
8 def __str__(self):
<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
11
12 if value < 0:
---> 13 raise ValueError("Fuel Capacity can never be less than zero")
14
15 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Se você lembrar deste ponto, a checagem falhava quando o atributo era alterado depois, já que a validação estava apenas em __init__(). Vamos agora definir fuel_cap como string e ver se dá erro.
car2.fuel_cap = -1
-1
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-120-dea9dbe96ebe> in <module>
----> 1 car2.fuel_cap = -1
<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
11
12 if value < 0:
---> 13 raise ValueError("Fuel Capacity can never be less than zero")
14
15 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
car2.fuel_cap = "BMW"
----------------------------------------
TypeErrorTraceback (most recent call last)
<ipython-input-121-0b316a9872c6> in <module>
----> 1 car2.fuel_cap = "BMW"
<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
8 print(value)
9 else:
---> 10 raise TypeError("Fuel Capacity can only be an integer")
11
12 if value < 0:
TypeError: Fuel Capacity can only be an integer
Perfeito! Como você viu, funciona mesmo ao atualizar o atributo depois.
Existe, porém, um pequeno problema com descritores: quando você cria uma nova instância da classe, o valor anterior pode ser sobrescrito. O motivo é que descritores estão ligados à classe, não à instância.
Veja:
car3 = Car("BMW","X7",48) # criou uma nova instância 'car3' com valores diferentes
48
Ao imprimir a instância car2, você notará que os valores foram sobrescritos por car3.
print(car2)
BMW model X7 with a fuel capacity of 48 ltr.
Conclusão
Parabéns por concluir o tutorial.
Este conteúdo é voltado para quem já tem familiaridade com Python e quer dominar recursos avançados. Como exercício, vale investigar como resolver o problema de sobrescrita entre instâncias que discutimos hoje.
Se tiver qualquer dúvida sobre este tutorial, fique à vontade para perguntar nos comentários abaixo.
Para aprender mais sobre Python, faça a trilha de habilidades completa Python Programming da DataCamp.
Confira também nosso tutorial de funções em Python.