🔹 Propiedades en Python

¡Hola! En este artículo veremos cómo puedes usar el decorador @property en Python y sus ventajas.

Aprenderás:

  • Las ventajas de usar propiedades en Python.
  • Los fundamentos de los decoradores (qué son y cómo están relacionados con @property).
  • Cómo puedes usar @property para definir getters, setters y deleters.

🔹 Ventajas de las propiedades en Python

Comencemos con un poco de contexto. ¿Por qué deberías usar propiedades en Python?

Las propiedades se pueden considerar como la forma "Pythonica" de trabajar con atributos y programación orientada a objetos en Python porque:

  • La sintaxis para definir propiedades es muy concisa y fácil de leer.
  • Puedes acceder al valor de los atributos de instancias como si fueran atributos públicos y usar la magia de los intermediarios (getters y setters) para validar valores nuevos y para evitar acceder o modificar los valores directamente.
  • Puedes "reutilizar" el nombre de una propiedad para evitar crear nuevos nombres para el getter, setter y deleter.

Estas ventajas hacen que las propiedades sean herramientas geniales en Python para escribir código más conciso y fácil de leer.  

🔸 Introducción a los decoradores

Una función decoradora ("decorator function" en inglés) es una función que agrega funcionalidad a otra función que recibe como argumento.

Usar una función decoradora es como añadir chispas de chocolate a un helado. Nos permite agregar funcionalidad a una función existente sin modificarla.

En el siguiente ejemplo puedes ver la estructura general de una función decoradora en Python:

def decorador(f):
    def funcion_nueva():
        print("Funcionalidad extra")
        f()
    return funcion_nueva

@decorador
def funcion_inicial():
    print("Funcionalidad inicial")

funcion_inicial()

Analicemos estos elementos en más detalle:

  • Primero encontramos la función decoradora def decorador(f) (las chispas de chocolate ✨) que toma una función f como argumento.
def decorador(f):
    def funcion_nueva():
        print("Funcionalidad extra")
        f()
    return funcion_nueva
  • Esta función decoradora tiene una función anidada, funcion_nueva. Nota cómo f es llamada dentro de funcion_nueva para obtener la misma funcionalidad y agregar funcionalidad nueva antes de la llamada a la función (también podemos añadir funcionalidad nueva luego de la llamada a la función).
  • La función decoradora retorna la función anidada funcion_nueva.
  • Luego (abajo), encontramos la función que será decorada (el helado) funcion_inicial. Nota la sintaxis muy peculiar (@decorador) antes de la primera línea de la función.
@decorador
def funcion_inicial():
    print("Funcionalidad inicial")

funcion_inicial()

Si ejecutamos el código, podemos ver el siguiente resultado:

Funcionalidad extra
Funcionalidad inicial

Nota cómo la función decoradora se ejecuta incluso si solo llamamos a la función funcion_inicial. Esta es la magia de agregar @decorador en Python.

💡 Dato: en general, escribimos @<nombre_de_la_función_decoradora>, reemplazando el nombre de la función decoradora luego del símbolo @.

Sé que debes estar preguntándote: ¿cómo se relaciona este concepto con @property? El decorador @property es un decorador incorporado (built-in) para la función property() en Python.

Se usa para asignarle una funcionalidad "especial" a ciertos métodos para que actúen como getters, setters o deleters al momento de definir y usar propiedades en una clase.

Ahora que ya conoces más sobre los decoradores, veamos una situación real en la cual usarías @property.

🔸 Escenario real: @property

Digamos que la siguiente clase es parte de tu programa. Estás modelando una casa con una clase Casa (en este momento, la clase solo tiene un atributo de instancia precio):

class Casa:

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

Este atributo de instancia es público porque su nombre no tiene un guión bajo al inicio.

Como el atributo es público, es muy probable que tú y otros desarrolladores en tu equipo usen y modifiquen el atributo directamente en otras partes del programa usando notación de punto.

De esta forma:

# Acceder al valor
obj.precio

# Modificar el valor
obj.precio = 40000

💡 Dato: obj representa una variable que contiene una referencia a una instancia de Casa.

Hasta ahora todo está funcionando correctamente, ¿cierto?

Pero digamos que te piden que hagas ciertos cambios en el código, específicamente debes hacer que este atributo no sea público y que se valide el valor nuevo antes de asignarlo.

Específicamente, debes verificar si el valor es un número decimal (float) positivo. ¿Cómo lo harías? Veamos.

Cambiando tu código

En este momento, si decides agregar getters y setters, tú y tu equipo probablemente entrarían en pánico.

Esto ocurriría porque cada línea de código que usa o modifica el valor del atributo tendrá que ser modificada para llamar al getter y al setter. Si no hacemos estos cambios, el código ya no funcionará. ⚠️

# Antes: obj.precio
obj.get_precio()

# Antes: obj.precio = 40000
obj.set_precio(40000)

Pero... ¡las propiedades vienen al rescate! Con @property, tú y tu equipo no necesitarán modificar ninguna de estas líneas porque podrás agregar getters y setters "detrás de escenas" sin afectar la sintaxis que usabas para usar y modificar los atributos cuando eran públicos.

Genial, ¿no?

🔹 @property: sintaxis y lógica

Si decides usar @property, tu clase se verá como el siguiente ejemplo:

class Casa:

	def __init__(self, precio):
		self._precio = precio

	@property
	def precio(self):
		return self._precio
	
	@precio.setter
	def precio(self, precio_nuevo):
		if precio_nuevo > 0 and isinstance(precio_nuevo, float):
			self._precio = precio_nuevo
		else:
			print("Por favor ingrese un precio valido.")

	@precio.deleter
	def precio(self):
		del self._precio

Específicamente, puedes definir tres métodos para una propiedad:

  • Un getter - para acceder al valor del atributo.
  • Un setter - para actualizar el valor del atributo.
  • Un deleter - para eliminar el atributo de la instancia.

Precio ahora está "protegido"
El atributo precio ahora se considera "protegido" o no público porque agregamos un guión bajo al inicio de su nombre en self._precio:

self._precio = precio

En Python, por convenio, cuando agregas un guión bajo a un nombre, le dices a otros desarrolladores que no debe ser usado o modificado directamente fuera de la clase. Solo debe ser usado a través de los intermediarios (getters y setters) si están definidos.

🔸 Getter

Aquí tenemos el método getter:

@property
def precio(self):
	return self._precio

Veamos la sintaxis:

  • @property - Se usa para indicar que se va a definir una propiedad. Nota cómo inmediatamente esto logra que el código sea más fácil de leer porque podemos saber el propósito de este método.
  • def precio(self) - La primera línea de la definición. Nota cómo el getter tiene el mismo nombre de la propiedad que vamos a definir: precio. Este es el nombre que usaremos para acceder y modificar el atributo fuera de la clase. El método solo toma un parámetro formal, self, una referencia a la instancia.
  • return self._precio - esta línea es exactamente lo que esperarías en un getter. Se retorna el valor del atributo.

En este ejemplo podemos ver cómo se usa el getter detrás de escenas con notación de punto:

>>> casa = Casa(50000.0)   # Crear instancia
>>> casa.precio            # Acceder al valor
50000.0

Nota cómo accedemos al atributo precio como si fuera un atributo público. No cambiamos la sintaxis pero usamos el getter como intermediario para evitar acceder al valor directamente.

🔹 Setter

Ahora tenemos el método setter:

@precio.setter
def precio(self, precio_nuevo):
	if precio_nuevo > 0 and isinstance(precio_nuevo, float):
		self._precio = precio_nuevo
	else:
		print("Por favor ingrese un valor valido")

Veamos la sintaxis:

  • @precio.setter - se usa para indicar que este es el método setter para la propiedad precio. Nota que no estamos usando @property.setter sino @precio.setter. El nombre de la propiedad se incluye antes de .setter.
  • def precio(self, precio_nuevo): - La primera línea de la definición del método y la lista de parámetros. Nota cómo el nombre de la propiedad se usa como el nombre del setter. También tenemos un segundo parámetro formal (precio_nuevo), el cual es el nuevo valor que será asignado al atributo precio (si es válido).
  • Finalmente, tenemos el cuerpo del setter en el cual validamos el argumento para verificar si es un float positivo y luego, si el argumento es válido, actualizamos el valor del atributo. Si el valor no es válido, se muestra un mensaje. Puedes escoger cómo manejar valores inválidos de acuerdo a las necesidad de tu programa.

Este es un ejemplo del uso del método setter con @property:

>>> casa = Casa(50000.0)    # Crear instancia
>>> casa.precio = 45000.0   # Actualizar valor
>>> casa.precio             # Acceder al valor
45000.0

Nota cómo no estamos cambiando la sintaxis pero ahora usamos un intermediario (el setter) para validar el argumento antes de asignarlo. El valor nuevo (45000.0) se pasa como argumento para el setter:

casa.precio = 45000.0

Si intentamos asignar un valor inválido, podemos ver el mensaje. También podemos verificar que el valor del atributo no se actualizó.

>>> casa = Casa(50000.0)
>>> casa.precio = -50
Por favor ingrese un valor valido
>>> casa.precio
50000.0

💡 Dato: esto prueba que el método setter sí está actuando como intermediario. Se llama "detrás de escenas" cuando intentamos actualizar el valor y el mensaje se muestra cuando el valor no es válido.

🔸 Deleter

Finalmente, tenemos el método deleter:

@precio.deleter
def precio(self):
	del self._precio

Veamos la sintaxis:

  • @precio.deleter - se usa para indicar que este es el método deleter para la propiedad precio. Nota que esta línea es muy parecida a @precio.setter pero ahora estamos definiendo un método deleter, así que escribimos @precio.deleter.
  • def precio(self): - La primera línea de la definición. Este método solo tiene un parámetro formal, self.
  • del self._precio - El cuerpo, en el cual eliminamos el atributo de instancia.

💡 Dato: nota que el nombre de la propiedad se "reusa" para los tres métodos.

Este es un ejemplo del uso de un método deleter con @property:

# Crear instancia
>>> casa = Casa(50000.0)

# El atributo de instancia existe
>>> casa.precio
50000.0

# Eliminar el atributo de instancia
>>> del casa.precio

# El atributo de instancia ya no existe
>>> casa.precio
Traceback (most recent call last):
  File "<pyshell#35>", line 1, in <module>
    casa.precio
  File "<pyshell#20>", line 8, in precio
    return self._precio
AttributeError: 'Casa' object has no attribute '_precio'

El atributo de instancia fue eliminado exitosamente. Cuando intentamos usarlo nuevamente, se genera un error porque el atributo ya no existe.

🔹 Tips útiles

No necesariamente tienes que definir los tres métodos para cada propiedad. Puedes definir propiedades que solo pueden ser usadas pero no modificadas (read-only) si solo defines un método getter.

También puedes definir un getter y un setter sin un deleter.

Si crees que un atributo solo debe ser inicializado cuando la instancia es creada o solo debe modificarse internamente dentro de la clase, puedes omitir el setter.

Puedes escoger los métodos que deseas incluir en base al contexto en el cual estás trabajando.

🔸 En resumen

  • Puedes definir propiedades con el decorador @property. Esta opción es más concisa y fácil de leer.
  • @property puede ser considerado como la forma "Pythonica" de definir getters, setters y deleters.
  • Al definir propiedades, puedes cambiar la implementación interna de una clase sin afectar el programa. Así que puedes agregar getters, setters y deleters que actúan como intermediarios "detrás de escenas" para evitar acceder a los valores o modificarlos directamente.

Espero que te haya gustado mi tutorial y que te haya sido de utilidad. Te invito a seguirme en Twitter (@EstefaniaCassN). ⭐