1 - Fundamentos

Introducción

Para hablar de programación orientada a objetos (OOP, por sus siglas en inglés), podemos empezar preguntándonos qué es un objeto. Aunque no solemos pensar en ello de forma consciente, porque todos podemos distinguir un objeto de lo que no lo es, vale la pena definirlo como algo tangible, que se puede percibir, tocar y manipular. ¡Nos pasamos toda la vida interactuando con objetos!

En el mundo real, los objetos tienen atributos o propiedades que los describen. Por ejemplo, un televisor tiene forma, tamaño, color, peso, entre otros. Además, los objetos también pueden realizar acciones. Siguiendo con el ejemplo del televisor, puede encenderse, apagarse, cambiar de canal, modificar la fuente de entrada para ver una serie en Netflix o simplemente reproducir música.

En programación, la definición de objeto no difiere mucho de la anterior. Aunque los objetos no sean elementos físicos, representan entidades que poseen atributos y pueden ejecutar acciones.

La programación orientada a objetos es un paradigma que organiza el código en torno a estos objetos, que combinan datos (atributos) y comportamientos o acciones (métodos) dentro de una misma entidad. Este enfoque permite no solo crear e interactuar con objetos, sino también definir nuestros propios tipos de objetos, adaptados a las necesidades del programa.

Objetos familiares

Así como interactuamos con objetos en la vida real desde nuestros primeros días, también lo hemos estado haciendo en Python desde el comienzo. Por ejemplo, cada vez que creamos una cadena de texto, en realidad estamos creando un objeto de la clase str.

x = "Hola, soy un objeto de tipo 'str'."
y = "¿Y yo? ¡Yo también soy un objeto de tipo 'str'!"
print(f"x = {x}")
print(f"y = {y}")
x = Hola, soy un objeto de tipo 'str'.
y = ¿Y yo? ¡Yo también soy un objeto de tipo 'str'!

Tanto x como y son objetos del mismo tipo: cadenas de caracteres, que en Python corresponden al tipo str. Sin embargo, x e y no son el mismo objeto, sino dos objetos distintos. Esto puede comprobarse no solo por su contenido (uno de sus atributos), sino también comparando sus identificadores únicos (ID), que son diferentes.

print(id(x))
print(id(y))
140223504929120
140223504962192

A su vez, los objetos de tipo str pueden realizar ciertas acciones. Por ejemplo, si queremos convertir todas las letras de una cadena a mayúsculas, podemos hacer lo siguiente:

x.upper()
"HOLA, SOY UN OBJETO DE TIPO 'STR'."

Las cadenas de caracteres son uno de los muchos tipos de objetos con los que ya hemos interactuado. Todas las estructuras de datos que hemos utilizado —desde las más simples, como los enteros, flotantes y booleanos, hasta las más complejas, como listas, tuplas y diccionarios— no son más que distintos tipos de objetos.

En Python, cada tipo de dato está implementado como una clase, y trabajar con estas clases no solo nos permite crear nuestros propios tipos de datos según nuestras necesidades, sino también interactuar con los objetos que generamos a partir de ellas.

Clases y objetos

En el contexto de la programación orientada a objetos, se utilizan clases para definir nuevas clases o tipos de objetos, especificando qué atributos deben tener y qué acciones pueden realizar. De forma informal, una clase puede pensarse como una plantilla que determina cómo se ve y se comporta un objeto.

Por ejemplo, imaginemos un molde para hacer paletas heladas. Este molde permite producir paletas, pero no es una paleta en sí. De hecho, se parece más a una fábrica de paletas.

El molde define aspectos estructurales como la forma y el tamaño de las paletas, pero no determina por completo cómo serán. Podemos llenarlo con diferentes líquidos, sabores, colores o aromas, y así obtener resultados variados a partir del mismo molde.

En programación orientada a objetos, una clase cumple un rol similar: define la estructura y el comportamiento general de los objetos que se crearán a partir de ella. Los objetos, en cambio, son las instancias concretas generadas a partir de esa clase, cada una con sus propias características particulares, como cada paleta helada que sale del molde.

¿Clases o tipos?

En Python, los tipos de datos están implementados como clases. Por eso mismo, en todos los casos debajo, se puede ver que el tipo está precedido por class.

print(type("texto"))
print(type([1, 2, 3]))
print(type({"a": 1, "b": 2}))
<class 'str'>
<class 'list'>
<class 'dict'>

Así, en Python, es lo mismo hablar de clases o tipos de datos.

Ejemplo: la clase dict

Los diccionarios de Python son instancias de la clase dict. Es esta clase la que define, entre otras cosas, que los diccionarios tienen claves y valores.

En el ejemplo debajo, tanto d1 como d2 son instancias u objetos de la clase dict (es decir, son objetos creados con el mismo molde). Sin embargo, d1 y d2 son objetos distintos, que además tienen diferentes valores para sus atributos (claves y valores).

d1 = {"a": 100, "b": 250}
d2 = {"m": 20, "n": False}

print(type(d1))
print(type(d2))
<class 'dict'>
<class 'dict'>
print(d1 is d2)
print(d1 == d2)
False
False

Si consultamos la ayuda de dict mediante help(dict), podemos ver que Python dice que esto se refiere a una clase:

help(dict)
Help on class dict in module builtins:
¿Objetos o instancias?

En el contexto de programación orientada a objetos, los términos “objeto” e “instancia” suelen usarse de manera intercambiable: ambos hacen referencia a una entidad concreta creada a partir de una clase.

En el siguiente ejemplo, decimos que l es una instancia de la clase list:

l = list("abcde")

Creando nuestras propias clases

La definición de una clase, que crea un nuevo tipo de dato, define los atributos de un objeto (datos que representan estado) y las cosas que este objeto puede hacer (funciones que representan comportamiento).

Para definir una nueva clase en Python se usa la sentencia class seguida del nombre de la clase. Veamos el siguiente ejemplo comentado línea a línea.

1class PaletaHelada:
2    def __init__(self):
3        print("Creando nueva paleta helada")
1
La sentencia class indica el inicio de la definición de una clase. A continuación se escribe el nombre de la clase y, al final, los dos puntos (:), que señalan el comienzo del bloque donde se definen sus atributos y funciones (llamados métodos).
2
Por lo general, lo primero que se define dentro de una clase es su método de inicialización, siempre llamado __init__.
3
Esta línea se ejecuta cada vez que se crea un nuevo objeto de la clase. En este caso, imprime un mensaje en pantalla como parte del proceso de inicialización.

Definir una clase no crea ningún objeto por sí misma; simplemente construye el molde o plantilla a partir del cual se podrán crear objetos más adelante.

Para crear un objeto a partir de una clase, es necesario “llamar” a la clase, lo que genera una nueva instancia de esa clase.

paleta1 = PaletaHelada()
Creando nueva paleta helada

La variable paleta1 representa un objeto de la clase PaletaHelada. En otras palabras, hemos creado, al menos en código, una paleta helada.

Como las clases son reutilizables, podemos crear tantas paletas heladas como queramos, simplemente creando nuevas instancias de la clase.

paleta2 = PaletaHelada()
Creando nueva paleta helada

Al imprimir estos objetos, obtenemos una representación generada automáticamente por Python. En ella podemos ver que ambos son instancias de la clase PaletaHelada, aunque también queda claro que ocupan ubicaciones distintas en memoria. Esto confirma que se trata de objetos distintos, aunque provengan de la misma clase.

print(paleta1)
print(paleta2)
<__main__.PaletaHelada object at 0x7f8854339220>
<__main__.PaletaHelada object at 0x7f8854338530>

Parámetros de inicialización

También es posible pasar argumentos al momento de inicializar un objeto. Previamente, tenemos que agregar los parámetros necesarios en el método de inicialización.

class PaletaHelada:
1    def __init__(self, gusto):
2        print(f"Creando nueva paleta helada de gusto {gusto}")
1
El método __init__ ahora recibe dos parámetros:
2
El valor de gusto se utiliza para mostrar un mensaje de inicialización personalizado, indicando el sabor de la paleta.

Gracias a esto, ahora podemos crear paletas heladas con distintos sabores, como frutilla, naranja, entre otros.

paleta1 = PaletaHelada("frutilla")
paleta2 = PaletaHelada("naranja")
Creando nueva paleta helada de gusto frutilla
Creando nueva paleta helada de gusto naranja

Sin embargo, podemos observar que ninguna de estas dos instancias recuerda algo relacionado al gusto con el que fue inicializado.

paleta1.gusto
AttributeError: 'PaletaHelada' object has no attribute 'gusto'
paleta2.gusto
AttributeError: 'PaletaHelada' object has no attribute 'gusto'

Si queremos que nuestros objetos puedan recordar datos, necesitamos comprender cómo se utilizan los atributos.

Instanciación de un nuevo objeto

La instanciación es el proceso mediante el cual se crea un objeto a partir de una clase. La sintaxis general es la siguiente:

<objeto> = <NombreClase>(<argumentos opcionales>)

Aunque al principio pueda parecer poco familiar, en realidad hemos estado instanciando objetos desde los primeros pasos que dimos en Python. Por ejemplo, al escribir list("hola"), estamos creando un objeto de la clase list a partir de la cadena "hola". Del mismo modo, con range(10) craemos un objeto de la clase range.

Atributos

Los atributos son variables asociadas a un objeto que representan su estado.

En Python, los atributos no requieren ninguna sintaxis especial para ser declarados. Simplemente se crean dentro de un método (generalmente en __init__) usando la notación self.<atributo>.

En nuestro ejemplo de la clase PaletaHelada, podemos hacer:

class PaletaHelada:
    def __init__(self, gusto):
1        self.gusto = gusto
1
El valor de gusto que se pasa al momento de crear la instancia se asigna al atributo gusto del objeto.

Aunque la representación automática del objeto no cambia, ahora el objeto tiene estado: puede “recordar” el gusto con el que fue inicializado.

paleta1 = PaletaHelada("frutilla")
paleta1
<__main__.PaletaHelada at 0x7f8854339c70>
paleta1.gusto
'frutilla'

En este caso, la variable gusto es una variable de instancia o atributo de instancia. Estas variables existen solo dentro del objeto que las contiene, y no afectan a otras instancias de la misma clase. Así, distintos objetos de una misma clase pueden tener valores diferentes en sus atributos.

paleta2 = PaletaHelada("naranja")
print(paleta2.gusto)
print(paleta1.gusto)
naranja
frutilla

Los atributos también pueden definirse o modificarse fuera de los métodos de la clase.

Por ejemplo, es posible cambiar el valor de un atributo simplemente asignándole un nuevo valor utilizando la instancia:

paleta1.gusto = "crema americana"
paleta1.gusto
'crema americana'

Y también se puede asignarle un valor a un nuevo atributo:

paleta1.cobertura = "chocolate"

print(paleta1.gusto, paleta1.cobertura, sep=", ")
crema americana, chocolate

Este nuevo atributo existe únicamente en la instancia a la que fue asignado (paleta1). Es decir, paleta2 no tiene un atributo llamado cobertura, ya que no fue definido durante su inicialización ni se le asignó más adelante.

paleta2.cobertura
AttributeError: 'PaletaHelada' object has no attribute 'cobertura'

En Python, los atributos de instancia son independientes entre objetos: si un atributo no se define explícitamente en una instancia, simplemente no existe en ella.

Atributos opcionales

Aunque Python permite crear nuevos atributos fuera del proceso de inicialización de un objeto, eso no significa que sea una práctica recomendable en todos los casos.

Asignar un atributo directamente a una instancia específica puede generar inconsistencias: terminamos con objetos de la misma clase que no comparten la misma estructura de atributos. Esto puede dificultar la lectura del código y producir errores si intentamos acceder a un atributo que no existe en todas las instancias.

Una alternativa más clara y segura es definir atributos opcionales dentro del método __init__, asignándoles un valor por defecto como None. De esta forma, todas las instancias tendrán los mismos atributos, aunque algunos puedan no tener un valor definido inicialmente.

class PaletaHelada:
    def __init__(self, gusto, cobertura=None):
        self.gusto = gusto
        self.cobertura = cobertura

paleta1 = PaletaHelada("crema americana", "chocolate")
paleta2 = PaletaHelada("frutilla")
print(paleta1.gusto, paleta1.cobertura, sep=", ")
print(paleta2.gusto, paleta2.cobertura, sep=", ")
crema americana, chocolate
frutilla, None

Con este enfoque, ambos objetos tienen los mismos atributos (gusto y cobertura), lo que mantiene la consistencia entre instancias de la clase. En el caso de paleta2, como no se especificó ninguna cobertura al crear el objeto, el valor de cobertura es None, lo cual indica que esa paleta no tiene ninguna cobertura.

Métodos

Ahora que comprendemos cómo los datos definen el estado de un objeto, el último concepto que nos falta abordar son las acciones que un objeto puede realizar.

Para llevar a cabo acciones, los objetos utilizan métodos.

Los métodos son funciones asociadas a una clase determinada, y permiten que los objetos de la clase realicen operaciones o modifiquen su propio estado.

Por ejemplo, el método upper es un método propio de los objetos de tipo str (cadenas de caracteres) y podemos invocarlo de la siguiente manera:

"Rosario".upper()
'ROSARIO'

Pero no podemos llamarlo sobre una lista:

["Rosario", "Santa Fe"].upper()
AttributeError: 'list' object has no attribute 'upper'

En el caso de las clases creadas por nosotros, los métodos no son más que funciones definidas dentro de la clase.

A diferencia de las funciones normales, todos los métodos deben tener al menos un parámetro especial, llamado self por convención, que representa a la instancia sobre la que se está llamando el método.

Gracias a self, los métodos pueden acceder y modificar los atributos del objeto:

class PaletaHelada:
    def __init__(self, gusto, cobertura=None):
        self.gusto = gusto
        self.cobertura = cobertura

    def mostrar_info(self):
        print("PaletaHelada")
        print(f" - Gusto: {self.gusto}")
        print(f" - Cobertura: {self.cobertura}")
paleta = PaletaHelada(gusto="Dulce de leche", cobertura="Chocolate amargo")
paleta
<__main__.PaletaHelada at 0x7f885433b440>

Para ejecutar el método, se lo llama de la misma forma que a cualquier otro método.

paleta.mostrar_info()
PaletaHelada
 - Gusto: Dulce de leche
 - Cobertura: Chocolate amargo
paleta2 = PaletaHelada(gusto="Mentra granizada", cobertura="Chocolate blanco")
paleta2.mostrar_info()
PaletaHelada
 - Gusto: Mentra granizada
 - Cobertura: Chocolate blanco

Métodos que modifican estado

El método mostrar_info no altera el estado del objeto: simplemente muestra un resumen de su estado actual.

Sin embargo, como los métodos acceden al objeto mediante self, también pueden modificar su estado.

En el siguiente ejemplo, el método quitar_cobertura elimina cualquier cobertura que pueda tener nuestro helado.

class PaletaHelada:
    def __init__(self, gusto, cobertura=None):
        self.gusto = gusto
        self.cobertura = cobertura

    def mostrar_info(self):
        print("PaletaHelada")
        print(f" - Gusto: {self.gusto}")
        print(f" - Cobertura: {self.cobertura}")

    def quitar_cobertura(self):
        self.cobertura = None


paleta = PaletaHelada("Chocolate", "Chocolate")
paleta.mostrar_info()
print("")
paleta.quitar_cobertura()
paleta.mostrar_info()
PaletaHelada
 - Gusto: Chocolate
 - Cobertura: Chocolate

PaletaHelada
 - Gusto: Chocolate
 - Cobertura: None

Métodos que reciben argumentos

Los métodos mostrar_info y quitar_cobertura no reciben argumentos, a lo sumo usan los datos ya almacenados en el objeto.

También es posible definir métodos que acepten argumentos, lo que permite realizar distintas operaciones y, por ejemplo, modificar el estado interno del objeto.

A continuación, incorporamos a la clase un nuevo método que permite actualizar la cobertura de la paleta helada.

class PaletaHelada:
    def __init__(self, gusto, cobertura=None):
        self.gusto = gusto
        self.cobertura = cobertura

    def mostrar_info(self):
        print("PaletaHelada")
        print(f" - Gusto: {self.gusto}")
        print(f" - Cobertura: {self.cobertura}")

    def cambiar_cobertura(self, cobertura):
        self.cobertura = cobertura


paleta = PaletaHelada("Chocolate", "Chocolate")
paleta.mostrar_info()

print("")
paleta.cambiar_cobertura("Chocolate blanco")
paleta.mostrar_info()
PaletaHelada
 - Gusto: Chocolate
 - Cobertura: Chocolate

PaletaHelada
 - Gusto: Chocolate
 - Cobertura: Chocolate blanco

Métodos que devuelven resultados

Como los métodos son funciones de Python, también pueden devolver un resultado.

Por ejemplo, el método tiene_cobertura retorna True cuando la paleta helada tiene asignada alguna cobertura, sin importar cuál sea.

class PaletaHelada:
    def __init__(self, gusto, cobertura=None):
        self.gusto = gusto
        self.cobertura = cobertura

    def mostrar_info(self):
        print("PaletaHelada")
        print(f" - Gusto: {self.gusto}")
        print(f" - Cobertura: {self.cobertura}")

    def cambiar_cobertura(self, cobertura):
        self.cobertura = cobertura

    def tiene_cobertura(self):
        return self.cobertura is not None


paleta = PaletaHelada("Dulce de leche", "Chocolate blanco")
paleta2 = PaletaHelada("Dulce de leche")

print(paleta.tiene_cobertura())
print(paleta2.tiene_cobertura())
True
False

Métodos que devuelven al objeto

Finalmente, como un método puede devolver cualquier tipo de objeto, también puede retornar la propia instancia sobre la que fue llamado (es decir, la que recibe en self).

Este patrón se utiliza con frecuencia cuando se desea permitir el encadenamiento de métodos, ya que cada método devuelve el mismo objeto y permite seguir llamando otros métodos sobre él en una sola línea.

class PaletaHelada:
    def __init__(self, gusto, cobertura=None):
        self.gusto = gusto
        self.cobertura = cobertura

    def mostrar_info(self):
        print("PaletaHelada")
        print(f" - Gusto: {self.gusto}")
        print(f" - Cobertura: {self.cobertura}")

    def cambiar_cobertura(self, cobertura):
        self.cobertura = cobertura
1        return self

PaletaHelada("Frutilla").cambiar_cobertura("Chocolate").mostrar_info()
1
Luego de actualizar la cobertura, se devuelve a la instancia con la que se llamó al método.
PaletaHelada
 - Gusto: Frutilla
 - Cobertura: Chocolate

Como el método cambiar_cobertura devuelve la propia instancia, es posible encadenar su llamada con otros métodos de la clase, como por ejemplo .mostrar_info.

Ejemplo final: Cuenta bancaria

Para concluir este apunte, vamos a implementar una clase que represente de forma sencilla una cuenta bancaria.

En este modelo simplificado, cada cuenta tendrá tres atributos:

  • titular: el nombre de la persona dueña de la cuenta
  • saldo: el dinero disponible en la cuenta
  • moneda: el tipo de moneda en el que está expresado el saldo (por ejemplo, "ARS" o "USD")

Además, la clase contará con métodos que permitan realizar operaciones básicas:

  • depositar: suma dinero al saldo actual
  • retirar: descuenta dinero del saldo, si hay fondos suficientes
  • transferir: mueve dinero de una cuenta a otra
  • resumen: muestra el estado actual de la cuenta de forma clara y legible
class CuentaBancaria:
    def __init__(self, titular, saldo=0, moneda="ARS"):
        self.titular = titular
        self.saldo = saldo
        self.moneda = moneda

    def depositar(self, monto):
        self.saldo = self.saldo + monto

    def retirar(self, monto):
        if monto <= self.saldo:
            self.saldo = self.saldo - monto
            return True

        print(f"El saldo es insuficiente ({self.saldo} {self.moneda})")
        return False

    def transferir(self, other, monto): # 'other' es tambien una 'CuentaBancaria'
        if self.moneda != other.moneda:
            print(f"Las cuentas usan monedas distintas ({self.moneda} vs {other.moneda})")
            return False

        if self.retirar(monto):
            other.depositar(monto)

        return True

    def resumen(self):
        print("Cuenta bancaria")
        print(f" - Titular: {self.titular}")
        print(f" - Saldo: {self.saldo}")
        print(f" - Moneda: {self.moneda}")
cuenta_A = CuentaBancaria("Guido Van Rossum", saldo=20000, moneda="ARS")
cuenta_B = CuentaBancaria("Ross Ihaka", saldo=8000, moneda="ARS")

cuenta_A.resumen()
print("")
cuenta_B.resumen()
Cuenta bancaria
 - Titular: Guido Van Rossum
 - Saldo: 20000
 - Moneda: ARS

Cuenta bancaria
 - Titular: Ross Ihaka
 - Saldo: 8000
 - Moneda: ARS
cuenta_A.retirar(2500)
True
cuenta_A.resumen()
Cuenta bancaria
 - Titular: Guido Van Rossum
 - Saldo: 17500
 - Moneda: ARS

En el caso de las transferencias, no solo se modifica el estado de la cuenta desde la que se realiza la operación (cuenta_A), sino también el estado de la cuenta que recibe el dinero (cuenta_B).

cuenta_A.transferir(cuenta_B, 10000)
True
cuenta_A.resumen()
Cuenta bancaria
 - Titular: Guido Van Rossum
 - Saldo: 7500
 - Moneda: ARS
cuenta_B.resumen()
Cuenta bancaria
 - Titular: Ross Ihaka
 - Saldo: 18000
 - Moneda: ARS
cuenta_B.retirar(20000)
El saldo es insuficiente (18000 ARS)
False
¿Sabías que …? Sobre __init__
  • __init__ no lleva una sentencia return. Su propósito no es devolver datos, sino inicializar el objeto.
  • __init__ no crea la instancia (eso lo hace Python antes); su función es inicializar el estado del objeto recién creado.
  • __init__ no es obligatorio: una clase puede funcionar perfectamente sin definirlo, aunque en ese caso no se podrán establecer valores iniciales personalizados al instanciar.
Sobre self

Nunca se debe pasar explícitamente un valor para self, Python lo hace automáticamente.