def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10))
print(fibonacci(21))55
10946
Los tres principios fundamentales de la programación orientada a objetos son la encapsulación, la herencia y el polimorfismo.
A lo largo de los próximos apuntes vamos a explorar cada uno en detalle, entendiendo los conceptos que los sustentan y analizando ejemplos concretos de cómo se aplican en Python. En líneas generales, estos principios se pueden describir de la siguiente manera:
Supongamos que nos encontramos manejando un auto por el centro rosarino y al llegar a la esquina vemos que por la calle perpendicular se aproxima otro vehículo que no aparenta intenciones de frenar.
Todo indica que tendremos que detener el auto completamente.
Instantáneamente, presionamos el embrague casi al mismo tiempo que el pedal de freno y colocamos la palanca de cambio en la posición de punto muerto. El auto responde de la manera que esperamos y se detiene.
Una vez que el otro vehículo cruza, nos disponemos a continuar nuestra marcha. Como aún no soltamos el pie del embrague, movemos la palanca de cambios a la posición de primera, suavemente soltamos el embrague mientras comenzamos a presionar el acelerador, y finalmente cruzamos.
¿Y qué tiene que ver toda esta escena automovilística con el encapsulamiento? Más de lo que podríamos imaginarnos.
Para detener el auto, tuvimos que interactuar con los pedales y eventualmente con la palanca de cambios. Todo un esfuerzo, sí.
Sin embargo, no necesitamos saber en realidad como funciona el proceso de frenado de un auto: desconocemos como funcionan los discos, la hidráulica y mucho menos podríamos describir como funciona una caja de cambios. Todos estos mecanismos internos permanecen ocultos dentro del sistema (el auto). Lo único visible es una interfaz sencilla que nos permite lograr nuestro objetivo sin necesidad de saber qué ocurre detrás.
En programación ocurre lo mismo: la encapsulación consiste en mantener el estado interno y la lógica de un objeto fuera del alcance del exterior, exponiendo únicamente una forma clara y controlada de interactuar con él. De este modo, el código que interactua con el objeto no necesita conocer sus detalles internos y puede seguir funcionando incluso si estos cambian.
En programación no existe una única manera de aplicar el encapsulamiento, es decir, de aislar y proteger los detalles internos de cómo algo funciona. A continuación, veremos cómo esta idea aparece y se utiliza en distintos niveles: funciones, objetos y clases.
Las funciones ofrecen un ejemplo clarísimo de encapsulación: para usarlas, no hace falta conocer como funcionan internamente. De hecho, una función bien diseñada se caracteriza por cumplir una única tarea y tener un nombre que la describa con claridad. De esta manera, con solo leer su nombre podemos anticipar qué hace, sin preocuparnos por los detalles de su implementación.
Por ejemplo, consideremos la siguiente función que devuelve el número \(n\)-ésimo en la secuencia de Fibonacci:
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10))
print(fibonacci(21))55
10946
Si está clara la interfaz de la función (cantidad y tipos de entradas y salidas), no es necesario conocer detalles de la implementación, ni si el código es largo o complejo. Incluso si se encuentra un mejor algoritmo para resolver el mismo problema, la función puede reescribirse sin cambiar su uso externo, siempre que la interfaz no cambie.
Esta modularización hace que el código sea más fácil de mantener y adaptar a futuros cambios.
En la programación orientada a objetos hay una distinción clave entre el interior y el exterior de una clase u objeto.
Desde el interior, al diseñar una clase o implementar sus métodos, debemos cuidar cómo interactúan con los atributos, la eficiencia de los algoritmos y el diseño de la interfaz. El objetivo es construir una estructura coherente y fácil de mantener.
Desde el exterior, lo que importa no son los detalles internos sino la interfaz pública: qué hace cada método, qué argumentos necesita y qué valores devuelve. Mientras esa interfaz se mantenga, la clase puede usarse sin conocer su implementación.
Las clases favorecen el encapsulamiento porque:
Y, gracias a ello, permiten cambiar la implementación sin afectar el código que las utiliza.
Ahora bien, ¿cómo es que las clases en Python definen una interfaz clara y protegen el estado interno?
¿Cómo podemos saber cuántos argumentos debemos pasar y de qué tipo al inicializar una clase? ¿Y cómo saberlo al llamar a uno de sus métodos?
Una primera opción sería leer directamente su implementación, pero eso es precisamente lo que queremos evitar.
Una alternativa mucho mejor es consultar la documentación. Para ello, la clase y sus métodos deben contar con docstrings adecuados que describan su uso.
Veamos el siguiente ejemplo:
class CuentaBancaria:
"""Cuenta bancaria simple con operaciones básicas.
Esta clase implementa un modelo básico de cuenta bancaria que permite
depositar y retirar dinero, así como consultar el saldo actual.
"""
def __init__(self, titular, saldo_inicial=0.0):
"""Inicializa una nueva cuenta bancaria.
Parameters
----------
titular : str
Nombre del titular de la cuenta.
saldo_inicial : float, optional
Saldo inicial de la cuenta. Por defecto es 0.0.
"""
self.titular = titular
self.saldo = saldo_inicial
def depositar(self, monto):
"""Depositar dinero en la cuenta.
Parameters
----------
monto : float
Monto a depositar. Debe ser un número positivo.
"""
if monto <= 0:
print("Error: El monto a depositar debe ser positivo.")
return
self.saldo += monto
def retirar(self, monto):
"""Extrae dinero de la cuenta si hay fondos suficientes.
Parameters
----------
monto : float
Monto a retirar.
"""
if monto > self.saldo:
print("Error: Fondos insuficientes.")
return
self.saldo -= monto
def consultar_saldo(self):
"""Devuelve el saldo actual.
Returns
-------
float
Saldo disponible en la cuenta.
"""
return self.saldoError: Fondos insuficientes.
9700
Para consultar la documentación, podemos usar la función help de Python.
Si la ejecutamos en Positron, se abrirá una ventana a la derecha que muestra la información disponible. Si, en cambio, la usamos desde una terminal, obtendremos un resultado similar al siguiente:
Ayuda para toda clase:
Help on class CuentaBancaria in module __main__:
class CuentaBancaria(builtins.object)
| CuentaBancaria(titular, saldo_inicial=0.0)
|
| Cuenta bancaria simple con operaciones básicas.
|
| Esta clase implementa un modelo básico de cuenta bancaria que permite
| depositar y retirar dinero, así como consultar el saldo actual.
|
| Methods defined here:
|
| __init__(self, titular, saldo_inicial=0.0)
| Inicializa una nueva cuenta bancaria.
|
| Parameters
| ----------
| titular : str
| Nombre del titular de la cuenta.
| saldo_inicial : float, optional
| Saldo inicial de la cuenta. Por defecto es 0.0.Ayuda para el método depositar, que recibe un flotante y no devuelve nada:
Help on function depositar in module __main__:
depositar(self, monto)
Depositar dinero en la cuenta.
Parameters
----------
monto : float
Monto a depositar. Debe ser un número positivo.Ayuda para el método consultar_saldo, que no recibe ningún parametro y devuelve un flotante:
Help on function consultar_saldo in module __main__:
consultar_saldo(self)
Devuelve el saldo actual.
Returns
-------
float
Saldo disponible en la cuenta.Si bien en algunos casos puede resultar necesario consultar la ayuda con la función help, los editores de código suelen mostrar automáticamente una pequeña ventana junto al código mientras escribimos, donde aparece la documentación de clases, métodos y funciones.
Si bien Python es un lenguaje de tipado dinámico e implícito —es decir, no es necesario especificar el tipo de las variables y este puede cambiar durante la ejecución—, es posible utilizar anotaciones de tipo (del inglés type annotations) para indicar qué tipo de dato se espera. Estas anotaciones son opcionales, pero ayudan a que el código sea más claro, fácil de entender y detectar errores antes de ejecutar el programa.
Para los parámetros de una función o método, las anotaciones de tipo se escriben con el formato <nombre_variable>: <tipo>. En el caso la salida, el tipo se indica después de una flecha (-> <tipo>) al final de la definición.
Por ejemplo, en la siguiente función se especifica que el parámetro nombre debe ser de tipo str y que la función devuelve también un objeto de tipo str:
En nuestra clase CuentaBancaria, la anotación de tipos se ve de la siguiente manera:
class CuentaBancaria:
1 def __init__(self, titular: str, saldo_inicial: float = 0.0):
self.titular = titular
self.saldo = saldo_inicial
2 def depositar(self, monto: float):
self.saldo += monto
3 def retirar(self, monto: float):
self.saldo -= monto
4 def consultar_saldo(self) -> float:
return self.saldotitular de tipo str y otro saldo_inicial de tipo float.
depositar espera recibir un argumento de tipo float.
retirar también espera recibir un argumento de tipo float.
consultar_saldo devuelve un valor de tipo float.
Si se eligen nombres representativos para los métodos y se utilizan anotaciones de tipo en sus parámetros, es muy probable que no sea necesario escribir un docstring para que el usuario comprenda cómo funciona la clase.
Sin embargo, ni la documentación mediante docstrings ni el uso de anotaciones de tipo garantizan que una función o método se utilice con los tipos de datos adecuados. Por ejemplo, podríamos pasarle un número a nuestra función saludar sin que Python lo impida:
O incluso podríamos inicializar el saldo_inicial de la cuenta bancaria con una lista y luego intentar “depositar” otra lista.
['Cosas', 'Otras cosas', 'Aún más cosas']
En resumen, si bien Python nos permite especificar la interfaz de funciones y métodos mediante docstrings y anotaciones de tipo, al ser un lenguaje de tipado dinámico nada impide que se utilicen con tipos de datos para los que no fueron diseñados. En algunos casos esto puede resultar en comportamientos inesperados y, en otros, simplemente generar un error en tiempo de ejecución.
En lenguajes dinámicos como Python, muchas veces no importa de qué tipo es un objeto, sino qué puede hacer. Lo relevante no es su clase, sino si se comporta como necesitamos.
Por ejemplo, en una función como saludar, el parámetro no tiene que ser necesariamente un str, siempre que pueda usarse dentro de una f-string.
Este enfoque, donde importa más el comportamiento que el tipo, se llama duck typing y suele expresarse así:
Si camina como un pato y hace cuac como un pato, entonces probablemente es un pato.
Ahora que sabemos qué estrategias podemos usar para que la interfaz de una clase sea clara, veamos cómo las clases definen y protegen su estado interno.
Consideremos a la siguiente clase que sirve para representar a estudiantes de la Facultad de Ciencias Económicas y Estadística de la UNR.
Cada objeto mantiene sus propias variables de instancia, como nombre, ingreso y carrera, con valores independientes de los de otros objetos de la misma clase. Esto implica que modificar los datos de una instancia no afecta en absoluto a las demás: cada objeto gestiona y conserva su propio estado interno, es decir, los objetos son dueños de sus variables.
Gracias al método resumen, podemos obtener una representación clara e intuitiva de cada objeto.
Aunque también es posible interactuar con los atributos de cada instancia de manera individual.
Mariano González: Contador Público
Leticia Gallardo: Licenciatura en Estadística
Esta interacción no solo implica que podemos acceder a los valores individuales de los atributos, sino también que tenemos la posibilidad de modificarlos.
'Estudiante(nombre=Mariano González, ingreso=2019, carrera=Contador Público)'
Tenemos tanta flexibilidad al modificar los atributos de una instancia que incluso podemos asignarles valores que no tienen sentido dentro del contexto de la clase.
'Estudiante(nombre=Mariano González, ingreso=Cualquier cosa, carrera=Contador Público)'
En programación orientada a objetos, los getters y setters son métodos especiales que permiten acceder y modificar el estado interno de un objeto de forma segura y controlada. Su objetivo principal es proteger los atributos, evitando que se acceda o se cambien directamente desde el exterior de la clase.
En particular:
Nuestra clase Estudiante, incorporando ahora estos métodos para acceder y modificar sus atributos, se vería de la siguiente manera:
class Estudiante:
def __init__(self, nombre):
1 self.setNombre(nombre)
2 def setNombre(self, nombre):
if isinstance(nombre, str):
self.nombre = nombre
else:
print("El nombre debe ser de tipo 'str'")
3 def getNombre(self):
return self.nombre
def resumen(self):
return f"Estudiante(nombre={self.getNombre()})"Creemos un nuevo objeto de tipo Estudiante.
Luego, obtenemos el nombre de la estudiante mediante su getter.
Si queremos modificarlo, no asignamos el valor directamente a una variable del objeto, sino que llamamos a su método setter. Este método se encarga de validar el dato y evitar que se asignen valores de tipos no permitidos.
Sin embargo, Python no impide que, como usuarios, accedamos y modifiquemos directamente los atributos del objeto. Por ejemplo, podemos asignar un valor de cualquier tipo directamente a la variable nombre:
En ese caso, las ventajas de usar setter y getter dejan de tener efecto si el usuario decide “romper” el objeto ignorando su interfaz.
En muchos lenguajes de programación existe una distinción clara entre atributos públicos y privados. Los públicos pueden ser accedidos tanto desde dentro como desde fuera de la clase, mientras que los privados solo pueden usarse internamente. Es decir, un método de la clase puede acceder a un atributo o método privado, pero el código externo que usa la clase no.
En Python no existe una separación estricta entre atributos públicos y privados: todos son técnicamente públicos. Sin embargo, por convención, si el nombre de un atributo o método comienza con un guion bajo (_), esto indica que no debería ser accedido ni modificado desde el exterior de la clase, ya que está protegido.
Siguiendo esta convención, en nuestro ejemplo con la clase Estudiante podemos usar un atributo llamado _nombre para almacenar el nombre del estudiante.
class Estudiante:
def __init__(self, nombre):
self.setNombre(nombre)
def setNombre(self, nombre):
if isinstance(nombre, str):
1 self._nombre = nombre
else:
print("El nombre debe ser de tipo 'str'")
def getNombre(self):
2 return self._nombre
def resumen(self):
return f"Estudiante(nombre={self.getNombre()})"_nombre.
_nombre.
Si asignamos un nuevo valor a la variable nombre de la instancia, no ocurre ningún efecto indeseado. El objeto crea y almacena esa nueva variable, pero el método getter no la utiliza, ya que sigue accediendo al atributo _nombre.
Sin embargo, Python no evita que reemplacemos el valor de la variable _nombre, nuevamente rompiendo el encapsulamiento del objeto:
Si bien los objetos en Python no cuentan con atributos verdaderamente privados, es posible emular ese comportamiento. Para ello, se emplean nombres de variables que comienzan con dos guiones bajos (__). En nuestro ejemplo, podemos usar __nombre.
class Estudiante:
def __init__(self, nombre):
self.setNombre(nombre)
def setNombre(self, nombre):
if isinstance(nombre, str):
self.__nombre = nombre
else:
print("El nombre debe ser de tipo 'str'")
def getNombre(self):
return self.__nombre
def resumen(self):
return f"Estudiante(nombre={self.getNombre()})"
e = Estudiante("Macarena Gianetti")
e.resumen()'Estudiante(nombre=Macarena Gianetti)'
Si queremos acceder a la variable, obtendremos un error:
Por el contrario, si intentamos asignar un valor a esa variable, Python no mostrará ningún error y el método getter continuará devolviendo el valor esperado:
Nunca debemos olvidar que Python no soporta atributos privados. Por lo tanto, en algún lado tiene que estar disponible el valor de la variable __nombre que se usa dentro de la clase.
En particular, cuando el nombre de una variable comienza con dos guiones bajos, Python utiliza una técnica llamada name mangling o “estropeo de nombre”. Para ello, en realidad opera internamente con otra variable, cuyo nombre es el resultado de concatenar un guión bajo, el nombre de la clase y el nombre del atributo “privado”. Por ejemplo:
@propertyPython ofrece un decorador built-in llamado property que permite definir un método que se comporta como si fuera un atributo, de modo que al accederlo desde fuera parece una variable de instancia, aunque en realidad está ejecutando código dentro de la clase.
Con este decorador se pueden definir dos métodos: un getter y un setter.
@property, y su nombre determina el nombre de la propiedad que se utilizará desde el código externo.@<nombre>.setter y permite asignar valores a esa misma propiedad.Veamos un ejemplo:
class Estudiante:
def __init__(self, nombre):
3 self.nombre = nombre
1 @property
def nombre(self):
return self._nombre
2 @nombre.setter
def nombre(self, valor):
if isinstance(valor, str):
self._nombre = valor
else:
print("El nombre debe ser de tipo 'str'")
def resumen(self):
4 return f"Estudiante(nombre={self.nombre})"@property se indica que los objetos de la clase tendrán un “atributo” llamado nombre. Al acceder a él, Python ejecuta el método decorado y devuelve el valor de la variable protegida _nombre.
@nombre.setter se declara el método setter, que recibe self y el nuevo valor a asignar (valor). Así, cada vez que se asigna un nuevo valor al atributo, Python ejecuta este método y verifica el tipo de dato.
nombre como si fuera un atributo común, sin necesidad de llamar manualmente al método decorado.
Es posible acceder al “atributo” nombre:
También modificarlo:
Y si se intenta asignarle un valor del tipo incorrecto, no se realiza la operación:
Python no ofrece un control absoluto sobre el estado interno de los objetos, pero sí dispone de mecanismos que permiten gestionarlo mejor, como las convenciones de nombres, los getters y setters, o el uso de @property. Estas herramientas ayudan a ocultar detalles internos, validar datos y mantener la coherencia del objeto.
Sin embargo, por la naturaleza dinámica del lenguaje, siempre existe la posibilidad de modificar clases y objetos desde el exterior, por lo que el encapsulamiento funciona más como una convención para un uso correcto que como una barrera estricta.
En Python, no solo los objetos pueden encapsular estado: las clases también.
Un atributo de clase está asociado a la clase en sí y es compartido por todas sus instancias, en lugar de pertenecer a un objeto específico.
Un método de clase, en cambio, recibe la propia clase como primer argumento (cls) en lugar de la instancia (self), lo que permite operar sobre la clase en su conjunto.
Es posible asignar un atributo de clase en el código que implementa a la clase misma. Simplemente hay que asignar una variable en el bloque de definción de la clase.
En el ejemplo a continuación, creamos una clase Gato que representa animales de la especie Felis catus. Como todos los gatos son de la misma especie, tiene sentido utilizar un atributo de clase en vez de un atributo de instancia.
class Gato:
1 especie = "Felis catus"
def __init__(self, nombre, raza=None):
self.nombre = nombre
self.raza = raza
def resumen(self):
return f"Gato(nombre={self.nombre}, raza={self.raza})"especie.
Luego de instanciar dos objetos, podemos ver que ambos tienen asociados el mismo valor de especie.
Podríamos utilizar un atributo de clase análogo para otra especie de animales: Canis lupus familiaris, popularmente conocidos como perro.
Perro(nombre=Bruno, raza=None)
Canis lupus familiaris
Otro escenario donde tiene sentido práctico utilizar un atributo de clase es cuando se necesita mantener un estado global.
La clase Usuario define usuarios de una cierta aplicación. En ella, se tiene la variable total_usuarios que es un contador de los usuarios que se han creado a partir de la clase.
class Usuario:
1 total_usuarios = 0
def __init__(self, nombre):
self.nombre = nombre
2 Usuario.total_usuarios += 1total_usuarios tiene el valor 0.
total_usuarios en 1.
Dado que las instancias también pueden acceder a los atributos de clase, se tiene:
Para definir un método de clase se usa el decorador @classmethod, incluido en Python. Al aplicarlo, el método recibe como primer argumento a la clase en lugar de a una instancia, por lo que la convención es nombrar ese parámetro como cls en vez de self.
class Estudiante:
def __init__(self, nombre, ingreso):
self.nombre = nombre
self.ingreso = ingreso
1 @classmethod
2 def desde_texto(cls, texto):
nombre, ingreso = texto.split(",")
3 return cls(nombre, int(ingreso))
def resumen(self):
return f"Estudiante(nombre={self.nombre}, ingreso={self.ingreso})"@classmethod indica que el método desde_texto se invoca desde la clase y reciba a la clase como primer argumento.
cls.
cls, se crea y devuelve una nueva instancia de la clase (en este caso, Estudiante) utilizando los valores requeridos por su método __init__.
Aunque exista exista un método de clase para crear objetos, se puede seguir creando objetos de la manera usual:
La diferencia es que ahora podemos usar el método desde_texto para crear un objeto Estudiante a partir de una cadena de texto con un formato específico.
'Estudiante(nombre=El Estudiante, ingreso=2024)'
Los atributos del objeto muestran los valores que esperamos en este caso.
Otro escenario en el que resulta útil usar métodos de clase es cuando queremos crear objetos “preconfigurados”.
Por ejemplo, si tenemos una clase que representa sándwiches con una cantidad arbitraria de ingredientes, podemos definir métodos de clase que construyan instancias con combinaciones de ingredientes preestablecidas:
class Sandwich:
def __init__(self, *ingredientes):
self.ingredientes = ingredientes
@classmethod
def jyq(cls):
return cls("jamón", "queso")
@classmethod
def mediterraneo(cls):
return cls("tomate", "mozzarella", "rúcula", "aceitunas")
def resumen(self):
return f"Sandwich de: {', '.join(self.ingredientes)}"Python es tan flexible como lenguaje que incluso podemos asignar atributos y métodos luego de su definición.