Articolo originale: https://www.freecodecamp.org/news/python-property-decorator/

🔹 Le proprietà

In quest'articolo, parleremo di come funziona il decoratore @property in Python.

Imparerai:

  • I vantaggi di lavorare con le proprietà in Python.
  • Le basi dei decoratori: cosa sono e come sono collegate a @property.
  • Come puoi usare @property per definire getter, setter e deleter.

1️⃣ Vantaggi delle proprietà in Python

Iniziamo col cercare di capire perché potresti voler utilizzare le proprietà in Python.

Le proprietà vengono considerate il modo di lavorare con gli attributi proprio di Python:

  • La sintassi utilizzata per definire le proprietà è molto concisa e leggibile.
  • Puoi accedere agli attributi di istanze esattamente come se fossero attributi pubblici utilizzando gli intermediari "magici" (getter e setter) per validare i nuovi valori ed evitare di accedere o modificare direttamente ai dati.
  • Usando @property, puoi "riutilizzare" il nome di una proprietà per evitare di creare nuovi nomi per i getter, setter e deleter.

Questi vantaggi rendono le proprietà uno strumento fantastico per aiutarti a scrivere in modo più conciso e leggibile.

2️⃣ Introduzione ai decoratori

Una decoratore è essenzialmente una funzione che aggiunge nuove funzionalità a una funzione che gli viene passata come argomento. Usare un decoratore è un po' come aggiungere delle gocce di cioccolato su un gelato. Ci permette di aggiungere nuove funzionalità a una funzione esistente senza modificarla.

Nell'esempio qui sotto, puoi vedere un tipico decoratore in Python:

def decorator(f):
    def new_function():
        print("Extra Functionality")
        f()
    return new_function

@decorator
def initial_function():
    print("Initial Functionality")

initial_function()

Analizziamo questi elementi nel dettaglio:

  • Prima di tutto, troviamo la funzione def decorator(f) (le gocce di cioccolato ✨) che accetta la funzione f come argomento.
def decorator(f):
    def new_function():
        print("Extra Functionality")
        f()
    return new_function
  • Questo decoratore ha una funzione annidata, new_function. Nota che f viene chiamata all'interno di new_function per raggiungere la stessa funzionalità mentre ne aggiungiamo una nuova prima di chiamare la funzione (possiamo aggiungere una nuova funzionalità dopo aver chiamato la funzione).
  • Il decoratore restituisce la funzione annidata new_function.
  • Poi (vedi esempio in basso), troviamo la funzione che verrà decorata (il gelato) initial_function. Nota la sintassi peculiare (@decorator) sopra l'intestazione della funzione.
@decorator
def initial_function():
    print("Initial Functionality")

initial_function()

Eseguendo il codice, otterremo l'output:

Extra Functionality
Initial Functionality

Osserva come il decoratore viene eseguito anche se stiamo chiamando soltanto initial_function(). Questa è la magia data dall'aggiunta di @decorator.

💡Nota: in generale, scriveremo @<decorator_function_name>, inserendo il nome del decoratore dopo il simbolo @.

Probabilmente ti stai chiedendo: cosa c'entra tutto questo con @property? @property è un decoratore integrato di Python, che viene usato per conferire funzionalità "speciali" a certi metodi e farli agire come dei getter, setter, o deleter quando definiamo delle proprietà in una classe.

Adesso che abbiamo familiarità con i decoratori, vediamo come utilizzare praticamente @property!

🔸 Real-World Scenario: @property

Supponiamo che la classe nell'esempio qui sotto sia parte del tuo programma. Stai modellando una casa con la classe House (al momento, nella classe è definito soltanto l'attributo di istanza price):

class House:

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

Questo attributo di istanza è pubblico perché il nome non inizia con un trattino basso. Siccome l'attributo è attualmente pubblico, è molto probabile che tu e il tuo team di sviluppatori possiate accedervi e modificarlo direttamente in altre parti del programma usando la dot notation, in questo modo:

# Access value
obj.price

# Modify value
obj.price = 40000

💡 Suggerimento: obj rappresenta una variabile che fa riferimento a un'istanza di House.

Finora tutto bene, vero? Ma immagina che ti venga richiesto di rendere protetto (non-pubblico) questo attributo e validare il nuovo valore prima di assegnarlo. Specificamente, hai bisogno di verificare che il valore sia un float positivo. Come potresti farlo? Vediamo.

Cambiare il tuo codice

A questo punto, se decidi di aggiungere getter e setter, tu e il tuo team potreste andare in panico. Questo perché ogni linea di codice che ha accesso o modifica il valore dell'attributo dovrà essere modificata per chiamare il getter o il setter, rispettivamente. In caso contrario, il codice non funzionerà ⚠️.

# Changed from obj.price
obj.get_price()

# Changed from obj.price = 40000
obj.set_price(40000)

Ma... Le proprietà vengono a salvarvi! Con @property, tu e il tuo team non avrete bisogno di modificare nessuna di quelle righe perché potrete aggiungere getter e setter "dietro le quinte" senza influenzare la sintassi che avete usato per accedere o modificare l'attributo quando era pubblico.

Non è meraviglioso?

🔹 @property: sintassi e logica

Se decidi di utilizzare @property, la tua classe somiglierà a quella dell'esempio qui sotto:

class House:

	def __init__(self, price):
		self._price = price

	@property
	def price(self):
		return self._price
	
	@price.setter
	def price(self, new_price):
		if new_price > 0 and isinstance(new_price, float):
			self._price = new_price
		else:
			print("Please enter a valid price")

	@price.deleter
	def price(self):
		del self._price

Puoi definire tre metodi per una proprietà:

  • Un getter - per accedere al valore dell'attributo.
  • Un setter - per modificare il valore dell'attributo.
  • Un deleter - per cancellare l'attributo di istanza.

Price adesso è "protetto"
Nota che l'attributo price adesso viene considerato "protetto" perché abbiamo aggiunto un trattino basso prima del suo nome in self._price:

self._price = price

In Python, per convenzione, quando aggiungi un trattino basso prima di un nome, stai comunicando agli altri sviluppatori che non dovrebbe essere accessibile o modificabile direttamente al di fuori della classe. Bisogna accedervi soltanto attraverso intermediari (getter e setter) se sono disponibili.

🔸 Getter

Ecco l'esempio di un metodo getter:

@property
def price(self):
	return self._price

Nota la sintassi:

  • @property - Viene usato per indicare che stiamo per definire una proprietà. Come puoi vedere, questa sintassi migliora la leggibilità perché l'intento di questo metodo è chiaramente visibile.
  • def price(self) - L'intestazione. Nota come il getter ha lo stesso nome della proprietà che stiamo definendo: price. Questo è lo stesso nome che utilizzeremo per accedere e modificare l'attributo fuori dalla classe. Il metodo accetta formalmente solo un parametro, self, che fa riferimento all'istanza.
  • return self._price - Questa riga è esattamente ciò che puoi aspettarti da un getter ordinario e restituisce il valore dell'attributo protetto.

Ecco un esempio dell'utilizzo del metodo getter:

>>> house = House(50000.0) # Create instance
>>> house.price            # Access value
50000.0

Nota come accediamo all'attributo price come se fosse un attributo pubblico. Non stiamo cambiando per nulla la sintassi, ma stiamo comunque usando il getter come un intermediario per evitare di accedere al dato direttamente.

🔹 Setter

Ed ecco il metodo setter:

@price.setter
def price(self, new_price):
	if new_price > 0 and isinstance(new_price, float):
		self._price = new_price
	else:
		print("Please enter a valid price")

Nota la sintassi:

  • @price.setter - Viene usato per indicare che questo è un metodo setter per la proprietà price. Presta attenzione al fatto che non stiamo usando @property.setter, ma @price.setter. Il nome della proprietà deve essere incluso prima di .setter.
  • def price(self, new_price): - L'intestazione e la lista dei parametri. Nota come il nome della proprietà viene usato come nome del setter. È presente anche il secondo parametro formale (new_price), che rappresenta il nuovo valore che verrà assegnato all'attributo price (se è valido).
  • Infine, abbiamo il corpo del setter dove validiamo l'argomento per verificare se è un float positivo e, se risulta valido, aggiorniamo il valore dell'attributo. Se il valore non è valido, viene visualizzato un messaggio descrittivo. Puoi scegliere come gestire i valori invalidi a seconda delle necessità del tuo programma.

Questo è un esempio dell'uso del metodo setter  con @property:

>>> house = House(50000.0)  # Create instance
>>> house.price = 45000.0   # Update value
>>> house.price             # Access value
45000.0

Puoi notare come non cambiamo la sintassi, ma adesso stiamo utilizzando un intermediario (il setter) per valutare l'argomento prima che venga assegnato. Il nuovo valore (45000.0) viene passato come argomento del setter :

house.price = 45000.0

Se proviamo ad assegnare un valore non valido, vedremo un messaggio che descrive il problema. Possiamo anche verificare che il valore non è stato aggiornato:

>>> house = House(50000.0)
>>> house.price = -50
Please enter a valid price
>>> house.price
50000.0

💡 Suggerimento: il metodo setter lavora come intermediario, operando un aggiornamento "dietro le quinte" e il messaggio descrittivo viene visualizzato quando immettiamo un valore non valido.

🔸 Deleter

Infine, abbiamo il metodo deleter:

@price.deleter
def price(self):
	del self._price

Analizziamo la sintassi:

  • @price.deleter - Viene usato per indicare che questo è un metodo deleter per la proprietà price. Questa riga è molto simile a @price.setter, ma adesso stiamo definendo un metodo deleter, quindi scriviamo @price.deleter.
  • def price(self): - L'intestazione. Questo metodoha soltanto un parametro formale definito, self.
  • del self._price - Il corpo, dove eliminiamo l'attributo d'istanza.

💡 Suggerimento: il nome della proprietà viene "riutilizzato" per tutti e tre  i metodi.

Ecco un esempio dell'uso del metodo deleter con @property:

# Create instance
>>> house = House(50000.0)

# The instance attribute exists
>>> house.price
50000.0

# Delete the instance attribute
>>> del house.price

# The instance attribute doesn't exist
>>> house.price
Traceback (most recent call last):
  File "<pyshell#35>", line 1, in <module>
    house.price
  File "<pyshell#20>", line 8, in price
    return self._price
AttributeError: 'House' object has no attribute '_price'

L'attributo di istanza è stato eliminato con successo. Se proviamo ad accedervi di nuovo, otterremo un messaggio di errore perché l'attributo non esiste più.

🔹 Qualche ultimo consiglio

Non devi necessariamente definire tutti e tre i metodi per ogni proprietà. Puoi definire le proprietà di sola lettura includendo un metodo getter. Puoi anche decidere di definire un getter e un setter senza un deleter.

Se pensi che un attributo debba essere impostato solo quando viene creata l'istanza, o che debba essere modificato solo internamente alla classe, puoi omettere il setter.

Puoi anche scegliere quali metodi includere a seconda del contesto in cui stai lavorando.

🔸 In conclusione

  • Puoi definire delle proprietà con la sintassi @property, che è più compatta e leggibile.
  • @property può essere considerato il modo di definire getter, setter, e deleter proprio di Python.
  • Definendo delle proprietà, puoi cambiare l'attuazione interna di una classe senza avere un impatto sul programma, quindi puoi aggiungere getter, setter e deleter che agiscono come intermediari "dietro le quinte" per evitare di accedere o modificare direttamente dei dati.

Spero davvero che quest'articolo ti sia piaciuto e che ti sia stato d'aiuto. Se vuoi imparare dell'altro sulle proprietà e la programmazione orientata agli oggetti in Python, visita il mio corso online, che include più di 6 ore di videolezioni, esercizi di programmazione e mini progetti.