Artigo original: https://www.freecodecamp.org/news/python-property-decorator/

🔹 Conheça as propriedades

Boas vindas! Neste artigo, você saberá como trabalhar com o decorator @property em Python.

Você aprenderá:

  • As vantagens de se trabalhar com propriedades em Python.
  • O básico sobre as funções do tipo decorator: o que são e como estão relacionadas a @property.
  • Como você pode usar @property para definir getters, setters e deleters.

1️⃣ Vantagens das propriedades em Python

Vamos começar contextualizando um pouco. Para que usaríamos propriedades em Python?

As propriedades podem ser consideradas a maneira em Python de se trabalhar com atributos, pois:

  • A sintaxe usada para definir as propriedades é muito concisa e legível.
  • Você pode acessar atributos de instância exatamente como se fossem atributos públicos e, ao mesmo tempo, usar a "magia" dos intermediadores (getters e setters) para validar novos valores e evitar o acesso ou a modificação dos dados diretamente.
  • Ao usar @property, você pode "reutilizar" o nome de uma propriedade para evitar criar nomes novos para os getters, setters e deleters.

Essas vantagens tornam as propriedades ótimas ferramentas para ajudá-lo a escrever código mais conciso e legível.

2️⃣ Introdução aos decorators

Uma função decorator é, basicamente, uma função que adiciona uma nova funcionalidade a uma função que é passada como argumento. Usar uma função decorator é como adicionar granulado de chocolate a um sorvete. Ela dá  uma nova funcionalidade a uma função existente sem modificá-la.

No exemplo abaixo, você pode ver a aparência de uma função decorator típica em Python:

def decorator(f):
    def nova_funcao():
        print("Funcionalidade extra")
        f()
    return nova_funcao

@decorator
def funcao_inicial():
    print("Funcionalidade inicial")

funcao_inicial()

Vamos analisar esses elementos em detalhes:

  • Primeiro, encontramos a função decorator def decorator(f) (o granulado de chocolate ✨) que recebe uma função f como um argumento.
def decorator(f):
    def nova_funcao():
        print("Funcionalidade extra")
        f()
    return nova_funcao
  • Essa função decorator tem uma função aninhada, nova_funcao. Perceba como f é chamada dentro de nova_funcao para obter a mesma funcionalidade ao mesmo tempo em que outra funcionalidade é acrescentada antes da chamada da função (também podíamos adicionar uma nova funcionalidade após a chamada da função).
  • A própria função decorator retorna a função aninhada nova_funcao.
  • Em seguida, abaixo, encontramos a função que será 'decorada' (o sorvete?) funcao_inicial. Perceba a sintaxe bastante peculiar (@decorator) acima do cabeçalho da função.
@decorator
def funcao_inicial():
    print("Funcionalidade inicial")

funcao_inicial()

Se executarmos o código, vemos esse resultado:

Funcionalidade extra
Funcionalidade inicial

Perceba como a função decorator executa, mesmo que estivéssemos apenas chamando funcao_inicial(). Essa é a mágica de adicionar @decorator.

💡Observação: em geral, escreveríamos @<nome_da_funcao_decorator>, substituindo o nome da função decorator após o símbolo @.

Sei que você pode estar se perguntando: como isso está relacionado a @property? A @property é um decorator integrado à função property() em Python (texto em inglês). Ela é usada para dar uma funcionalidade "especial" a certos métodos para fazer com que ajam como getters, setters ou deleters quando definimos as propriedades em uma classe.

Agora que nos familiarizamos com os decorators, vamos ver um cenário real de uso de @property!

🔸 Cenário real: @property

Vamos dizer que a classe abaixo é parte do seu programa. Você está modelando uma casa com a classe Casa (no momento, a classe tem apenas um atributo de instância preço definido):

class Casa:

	def __init__(self, preco):
		self.preco = preco

Este atributo de instância é público, pois seu nome não tem uma sublinha precedente. Como o atributo é público no momento, é bem provável que você e que outros desenvolvedores de sua equipe tenham acessado e modificado o atributo diretamente em outras partes do programa usando a notação ponto, assim:

# Acessar o valor
obj.preco

# Modificar o valor
obj.preco = 40000

💡 Dica: obj representa uma variável que referencia uma instância de Casa.

Até o momento, tudo está ótima, certo? Digamos, porém, que peçam a você que torne esse atributo protegido (não público) e valide o novo valor antes de atribui-lo. Especificamente, você precisa verificar se o valor é de ponto flutuante e positivo. Como você faria isso? Vamos ver.

Alterando o código

Nesse ponto, se você decidir adicionar getters e setters, você e sua equipe provavelmente entrarão em pânico, pois cada linha do código que acessa ou modifica o valor do atributo terá de ser modificada para chamar o getter ou o setter, respectivamente. Do contrário, o código dará problemas ⚠️.

# Alteração de obj.preco
obj.get_preco()

# Alteração de obj.preco = 40000
obj.set_preco(40000)

Mas... lá vem as propriedades para o resgate! Com @property, você e sua equipe não precisarão modificar qualquer uma dessas linhas, pois você poderá adicionar getters e setters "internamente" sem afetar a sintaxe usada para acessar ou modificar o atributo quando ele era público.

Incrível, não?

🔹 @property: sintaxe e lógica

Se decidir usar @property, sua classe terá a aparência do exemplo abaixo:

class Casa:

	def __init__(self, preco):
		self._preco = preco

	@property
	def preco(self):
		return self._preco
	
	@preco.setter
	def preco(self, new_price):
		if novo_preco > 0 and isinstance(novo_preco, float):
			self._preco = novo_preco
		else:
			print("Insira um preço válido")

	@preco.deleter
	def preco(self):
		del self._preco

Especificamente, você pode definir três métodos para uma propriedade:

  • Um getter - para acessar o valor do atributo.
  • Um setter - para definir o valor do atributo.
  • Um deleter - para excluir o atributo de instância.

O preço, agora, está "protegido"
Observe que o atributo preco agora está "protegido", pois adicionamos uma sublinha precedente ao seu nome em self._preco:

self._preco = preco

Em Python, por convenção (texto em inglês), quando você adiciona uma sublinha precedendo o nome de uma propriedade, você está dizendo aos outros desenvolvedores que ela não deve ser acessada ou modificada diretamente de fora da classe. Ela somente deve ser acessada por intermediadores (getters e setters), caso estejam disponíveis.

🔸 Getter

Aqui temos o método getter:

@property
def preco(self):
	return self._preco

Observe a sintaxe:

  • @property - Usado para indicar que vamos definir uma propriedade. Observe como isso melhora imediatamente a legibilidade, pois conseguimos ver com clareza a finalidade desse método.
  • def preco(self) - O cabeçalho. Observe como o getter tem o nome exato da propriedade que estamos definindo: preco. Esse é o nome que vamos usar para acessar e modificar o atributo fora da classe. O método recebe apenas um parâmetro formal, self, que é uma referência da instância.
  • return self._preco - Essa linha é exatamente o que você esperaria de um getter normal. O valor do atributo protegido é retornado.

Aqui temos um exemplo de uso do método getter:

>>> casa = Casa(50000.0) # Criar instância
>>> casa.preco            # Acessar o valor
50000.0

Observe como acessamos o atributo preco como se ele fosse um atributo público. Não estamos mudando a sintaxe de modo algum, mas estamos, de fato, usando o getter como intermediador para evitar o acesso direto aos dados.

🔹 Setter

Agora, temos o método setter:

@preco.setter
def preco(self, novo_preco):
	if novo_preco > 0 and isinstance(novo_preco, float):
		self._preco = novo_preco
	else:
		print("Insira um preço válido")

Observe a sintaxe:

  • @preco.setter - Usado para indicar que esse é o método setter para a propriedade preco. Observe que não usamos @property.setter, mas, sim, @preco.setter. O nome da propriedade é incluído antes de .setter.
  • def preco(self, novo_preco): - O cabeçalho e a lista de parâmetros. Observe como o nome da propriedade é usado como o nome do setter. Também temos um segundo parâmetro formal (novo_preco), que é o novo valor que será atribuído ao atributo preco (se o valor for válido).
  • Por fim, temos o corpo do método setter, onde validamos o argumento para verificar se é um número de ponto flutuante e positivo. Em seguida, se o argumento for válido, atualizamos o valor do atributo. Se não for válido, uma mensagem descritiva é impressa na tela. Você pode escolher como lidar com valores inválidos de acordo com as necessidades do seu programa.

Aqui temos um exemplo de uso do método setter com @property:

>>> casa = Casa(50000.0)  # Cria instância
>>> casa.preco = 45000.0  # Atualizar valor
>>> casa.preco            # Acessar valor
45000.0

Observe como não estamos alterando a sintaxe, mas agora estamos usando um intermediador (o setter) para validar o argumento antes de atribui-lo. O novo valor (45000.0) é passado como um argumento ao setter:

casa.preco = 45000.0

Se estivermos tentando atribuir um valor inválido, veremos uma mensagem descritiva. Podemos verificar que o valor também não foi atualizado:

>>> casa = Casa(50000.0)
>>> casa.preco = -50
Insira um valor válido
>>> casa.preco
50000.0

💡 Dica: Isso prova que o método setter está funcionando como intermediador. Ele está sendo chamado "internamente" quando tentamos atualizar o valor. Assim, a mensagem descritiva é exibida quando o valor não for válido.

🔸 Deleter

Por fim, temos o método deleter:

@preco.deleter
def preco(self):
	del self._preco

Observe a sintaxe:

  • @preco.deleter - Usado para indicar que esse é o método deleter para a propriedade preco. Observe que essa linha é muito semelhante a @preco.setter, mas agora estamos definindo o método deleter, por isso escrevemos @preco.deleter.
  • def preco(self): - O cabeçalho. Esse método tem apenas um parâmetro formal definido, self.
  • del self._preco - O corpo do método, onde excluímos o atributo de instância.

💡 Dica: Observe que o nome da propriedade é "reutilizado" para todos os três métodos.

Este é um exemplo de uso do método deleter com @property:

# Criar instância
>>> casa = Casa(50000.0)

# O atributo de instância existe
>>> casa.preco
50000.0

# Excluir o atributo de instância
>>> del house.price

# O atributo de instância não existe
>>> casa.preco
Traceback (most recent call last):
  File "<pyshell#35>", line 1, in <module>
    casa.preco
  File "<pyshell#20>", line 8, in price
    return self._preco
AttributeError: 'Casa' object has no attribute '_preco'

O atributo de instância foi excluído com sucesso. Quando tentamos acessá-lo novamente, surge um erro, pois o atributo já não existe mais.

🔹 Algumas dicas finais

Você não precisa, necessariamente, definir todos os três métodos para cada propriedade. Você pode definir propriedades somente para leitura incluindo apenas um método getter. Você também pode optar por definir um getter e um setter sem um deleter.

Se você achar que um atributo deva apenas ser definido quando a instância é criada ou que ele deva apenas ser modificado internamente na classe, é possível omitir o setter.

Você pode escolher quais métodos vai incluir, dependendo do contexto no qual está trabalhando.

🔸 Em resumo

  • É possível definir propriedades com a sintaxe @property, que é mais compacta e legível.
  • @property pode ser considerada a maneira do Python de definir getters, setters e deleters.
  • Ao definir as propriedades, você pode alterar a implementação interna de uma classe sem afetar o programa. Desse modo, você pode adicionar os getters, setters e deleters que agem como intermediadores "internamente" para evitar o acesso ou a modificação direta dos dados.

Espero realmente que você tenha gostado do artigo e que o tenha achado útil. Para saber mais sobre propriedades e sobre Programação Orientada a Objetos em Python, confira o curso on-line da autora, que inclui mais de 6 horas de aulas em vídeo, exercícios de programação e miniprojetos.