Serie Fundamentos de Python — Parte 7. Esta publicación continúa la Parte 1: Lo que Todo Principiante Debería Saber, la Parte 2: Variables y Tipos de Datos, la Parte 3: Listas y Diccionarios, la Parte 4: La Sentencia If, la Parte 5: Entrada del Usuario y Ciclos While y la Parte 6: Funciones. Cada publicación de la serie toma un tema y lo explica a fondo. Esta presenta las clases.

En la Parte 3 guardaste un proyecto de un cliente en un diccionario. En la Parte 6 escribiste funciones como calculate_quote() que trabajan con esos datos. Eso funciona bien, pero a medida que un programa crece, los datos y las funciones que trabajan con ellos terminan dispersos. ¿Qué funciones esperan un diccionario de proyecto? ¿Qué claves necesita? ¿Qué pasa si alguien escribe "buget" en lugar de "budget"?

Las clases resuelven esto agrupando los datos y las funciones que trabajan con ellos en una sola unidad. Una clase describe lo que tiene un tipo de cosa (sus datos) y lo que puede hacer (sus funciones). A partir de esa clase puedes crear tantas cosas individuales, llamadas objetos, como necesites.

En realidad has usado objetos desde la Parte 2. Cada cadena es un objeto de la clase str, por eso puedes llamar "hello".upper(). Cada lista es un objeto de la clase list, y de ahí viene .append(). En esta publicación aprenderás a escribir tus propias clases.

Como siempre, abre el intérprete de Python (python3 en tu terminal), o guarda los ejemplos en un archivo .py, y prueba cada uno por tu cuenta.

Clases y Objetos

Una clase es un plano o plantilla. Un objeto (también llamado instancia) es una cosa específica construida a partir de esa plantilla.

Piensa en una clase como un formulario para recibir proyectos. El formulario define los campos que tiene todo proyecto: cliente, número de páginas, presupuesto. Cada formulario lleno es un objeto: el sitio web de 5 páginas de Bright Bakery es un objeto, y la tienda en línea de 8 páginas de Calexico Coffee es otro. Misma estructura, datos distintos.

Puedes verlo con los tipos integrados de Python:

print(type("Bright Bakery"))   # <class 'str'>
print(type([1, 2, 3]))         # <class 'list'>

"Bright Bakery" es un objeto, y str es su clase.

Definir una Clase

Esta es una clase sencilla para un proyecto web:

class Project:
    """A website project for a client."""

    def __init__(self, client, pages):
        self.client = client
        self.pages = pages

Veámosla paso a paso:

  • class Project: inicia la clase. Los nombres de clases usan PascalCase (cada palabra con mayúscula inicial, sin guiones bajos), lo que facilita distinguirlas de las variables y las funciones.
  • El docstring describe lo que representa la clase.
  • __init__ (con dos guiones bajos de cada lado) es un método especial que Python ejecuta automáticamente cada vez que creas un objeto nuevo. Su trabajo es preparar los datos iniciales del objeto. A menudo se le llama constructor.
  • self es el objeto que se está creando. Todos los métodos de una clase lo reciben como primer parámetro. Nunca lo pasas tú: Python lo hace por ti.
  • self.client = client crea un atributo: una variable que pertenece al objeto. El client de la derecha es el parámetro; self.client es el atributo guardado en el objeto.

Una función que pertenece a una clase se llama método. __init__ es el primer método que escribirás en casi todas las clases.

Crear Objetos

Para crear un objeto, llama a la clase como si fuera una función, pasando los argumentos que necesita __init__ (todos excepto self):

bakery = Project("Bright Bakery", 5)
coffee = Project("Calexico Coffee", 8)

print(bakery.client)   # Bright Bakery
print(coffee.pages)    # 8

bakery y coffee son dos objetos distintos creados con la misma clase. Cada uno tiene su propio client y sus propias pages. Un atributo se lee con un punto: objeto.atributo.

Puedes cambiar un atributo igual que cambias una variable:

bakery.pages = 6
print(bakery.pages)   # 6
print(coffee.pages)   # 8  (no se ve afectado)

Agregar Métodos

Los atributos guardan los datos de un objeto. Los métodos son lo que un objeto puede hacer. Se definen dentro de la clase, igual que las funciones, con self como primer parámetro:

class Project:
    """A website project for a client."""

    HOURLY_RATE = 85

    def __init__(self, client, pages, rush=False):
        self.client = client
        self.pages = pages
        self.rush = rush
        self.paid = False

    def hours(self):
        """Return the estimated hours for the project."""
        return self.pages * 4

    def total(self):
        """Return the total price, including any rush fee."""
        price = self.hours() * self.HOURLY_RATE
        if self.rush:
            price = price * 1.25
        return price

    def mark_paid(self):
        """Record that the client has paid."""
        self.paid = True

Un método se llama con un punto, igual que .upper() o .append():

bakery = Project("Bright Bakery", 5)
rush_job = Project("Valley Dental", 2, rush=True)

print(bakery.hours())      # 20
print(bakery.total())      # 1700
print(rush_job.total())    # 850.0

print(bakery.paid)         # False
bakery.mark_paid()
print(bakery.paid)         # True

Algunos detalles importantes:

  • Dentro de un método, self te da acceso a los datos del propio objeto. total() usa self.pages y self.rush sin necesitarlos como parámetros, porque pertenecen al objeto.
  • Los métodos pueden llamar a otros métodos del mismo objeto: total() llama a self.hours().
  • __init__ puede crear atributos que no son parámetros. Todo proyecto nuevo empieza con self.paid = False.
  • rush=False es un valor predeterminado, igual que los que usaste con funciones en la Parte 6.

Atributos de Clase vs. Atributos de Instancia

HOURLY_RATE = 85 está directamente dentro de la clase, no dentro de un método. Eso lo convierte en un atributo de clase: un solo valor compartido por todos los proyectos. Los atributos creados con self. dentro de __init__ son atributos de instancia, y cada objeto tiene su propia copia.

print(Project.HOURLY_RATE)   # 85  (leído desde la clase)
print(bakery.HOURLY_RATE)    # 85  (todos los objetos también pueden leerlo)

Usa atributos de clase para los valores que son iguales para todos los objetos, como constantes y configuraciones, y atributos de instancia para los datos que cambian de un objeto a otro.

Métodos que Protegen los Datos

Una de las mayores ventajas de los métodos es que pueden revisar los cambios antes de hacerlos. En lugar de permitir que cualquier parte de tu programa ponga cualquier cosa en pages, dale a la clase un método que valide el cambio:

class Project:
    """A website project for a client."""

    def __init__(self, client):
        self.client = client
        self.pages = []

    def add_page(self, name):
        """Add a page, ignoring blanks and duplicates."""
        name = name.strip().title()
        if not name:
            print("Page name can't be empty.")
        elif name in self.pages:
            print(f"{name} is already in the project.")
        else:
            self.pages.append(name)

    def page_count(self):
        return len(self.pages)


bakery = Project("Bright Bakery")
bakery.add_page("home")
bakery.add_page("menu")
bakery.add_page("Home")
bakery.add_page("  ")

print(bakery.pages)          # ['Home', 'Menu']
print(bakery.page_count())   # 2
Home is already in the project.
Page name can't be empty.
['Home', 'Menu']
2

Las reglas sobre los nombres de las páginas ahora viven en un solo lugar, el método add_page(), en lugar de repetirse en cada parte donde se agrega una página.

Imprimir Objetos con str

Si imprimes un objeto, Python muestra algo como <__main__.Project object at 0x104c2f0d0>, que no ayuda mucho. Agrega un método __str__ para controlar lo que muestran print() y str(). Debe devolver una cadena:

class Project:
    def __init__(self, client, pages):
        self.client = client
        self.pages = pages

    def __str__(self):
        return f"{self.client} ({self.pages} pages)"


bakery = Project("Bright Bakery", 5)
print(bakery)                      # Bright Bakery (5 pages)
print(f"Next up: {bakery}")        # Next up: Bright Bakery (5 pages)

__init__ y __str__ son dos de los métodos especiales de Python (a veces llamados métodos “dunder”, por los dobles guiones bajos, double underscores en inglés). Python los llama automáticamente en el momento adecuado. Hay muchos más, pero estos dos son los que más usarás.

Trabajar con Muchos Objetos

Los objetos encajan de forma natural en las listas de la Parte 3. Una lista de objetos funciona igual que la lista de diccionarios que usaste ahí, pero con puntos en lugar de corchetes y con métodos incluidos:

class Project:
    HOURLY_RATE = 85

    def __init__(self, client, pages, paid=False):
        self.client = client
        self.pages = pages
        self.paid = paid

    def total(self):
        return self.pages * 4 * self.HOURLY_RATE


projects = [
    Project("Bright Bakery", 5, paid=True),
    Project("Lopez Landscaping", 3),
    Project("Calexico Coffee", 8, paid=True),
    Project("Valley Dental", 1),
]

revenue = sum(p.total() for p in projects)
unpaid = [p.client for p in projects if not p.paid]
biggest = max(projects, key=lambda p: p.total())

print(revenue)          # 5780
print(unpaid)           # ['Lopez Landscaping', 'Valley Dental']
print(biggest.client)   # Calexico Coffee

Compara p.total() con p["budget"]: el objeto sabe calcular su propio total, así que el código que lo usa no tiene que hacerlo.

Herencia: Construir Sobre una Clase Existente

A veces necesitas una clase que es casi igual a una que ya tienes, con algunas adiciones. En lugar de copiar toda la clase, puedes heredar de ella. La clase nueva (la hija) recibe automáticamente todos los atributos y métodos de la original (la padre), y solo escribes lo que es diferente.

Supongamos que las tiendas en línea se cotizan como un sitio web normal, más una tarifa de instalación y un cargo por cada producto que se carga:

class Project:
    """A website project for a client."""

    HOURLY_RATE = 85

    def __init__(self, client, pages):
        self.client = client
        self.pages = pages

    def total(self):
        return self.pages * 4 * self.HOURLY_RATE

    def __str__(self):
        return f"{self.client}: ${self.total():,.2f}"


class StoreProject(Project):
    """An online store: a website plus products and a setup fee."""

    SETUP_FEE = 500
    PRICE_PER_PRODUCT = 10

    def __init__(self, client, pages, products):
        super().__init__(client, pages)
        self.products = products

    def total(self):
        website = super().total()
        return website + self.SETUP_FEE + self.products * self.PRICE_PER_PRODUCT


site = Project("Bright Bakery", 5)
store = StoreProject("Calexico Coffee", 8, products=40)

print(site)    # Bright Bakery: $1,700.00
print(store)   # Calexico Coffee: $3,620.00

Esto es lo que pasa:

  • class StoreProject(Project):: la clase padre va entre paréntesis.
  • super().__init__(client, pages) llama al __init__ de la clase padre, así que la hija no tiene que repetir el código que asigna client y pages. Luego agrega su propio atributo, products.
  • StoreProject define su propio total(), que sobrescribe (reemplaza) la versión de la clase padre. Dentro de él, super().total() sigue llamando a la versión de la padre para obtener el precio del sitio web, y luego suma los costos propios de la tienda.
  • StoreProject nunca define __str__, pero print(store) funciona de todos modos: lo hereda de Project. Y como __str__ llama a self.total(), usa automáticamente la versión de la tienda.

Puedes revisar si un objeto pertenece a una clase con isinstance(). Un objeto hijo también cuenta como instancia de su clase padre:

print(isinstance(store, StoreProject))   # True
print(isinstance(store, Project))        # True
print(isinstance(site, StoreProject))    # False

La herencia es poderosa, pero úsala solo cuando la hija realmente es un tipo de la padre: un proyecto de tienda en línea es un proyecto. Si una cosa simplemente tiene otra, usa composición.

Composición: Objetos que Contienen Objetos

La composición consiste en usar objetos como atributos de otros objetos. Un cliente no es un tipo de proyecto, pero un cliente tiene proyectos:

class Client:
    """A client and their projects."""

    def __init__(self, name):
        self.name = name
        self.projects = []

    def add_project(self, project):
        self.projects.append(project)

    def lifetime_value(self):
        return sum(p.total() for p in self.projects)


maria = Client("Bright Bakery")
maria.add_project(Project("Bright Bakery", 5))
maria.add_project(StoreProject("Bright Bakery", 3, products=20))

print(len(maria.projects))       # 2
print(maria.lifetime_value())    # 3420

lifetime_value() no necesita saber qué tipo de proyecto es cada uno. Solo llama a total(), y cada objeto usa su propia versión. Que clases distintas respondan al mismo nombre de método cada una a su manera se llama polimorfismo, y es una de las principales razones por las que el código orientado a objetos se mantiene flexible a medida que crece.

Un Atajo para Clases de Datos: @dataclass

Muchas clases sirven principalmente para guardar datos, y escribir __init__ con una línea por cada atributo se vuelve repetitivo. El módulo dataclasses de Python puede escribir esa parte por ti. Pon @dataclass encima de la clase y enumera los atributos con sus tipos:

from dataclasses import dataclass

@dataclass
class Invoice:
    client: str
    amount: float
    paid: bool = False

    def mark_paid(self):
        self.paid = True


invoice = Invoice("Valley Dental", 340.0)
print(invoice)   # Invoice(client='Valley Dental', amount=340.0, paid=False)

invoice.mark_paid()
print(invoice.paid)   # True

@dataclass crea el método __init__ automáticamente, además de una representación legible al imprimir y la posibilidad de comparar dos facturas con ==. Las partes : str, : float y : bool son anotaciones de tipo (type hints): documentan lo que debe guardar cada atributo. Aun así puedes agregar tus propios métodos, como mark_paid().

Clases en Módulos

Igual que las funciones de la Parte 6, las clases pueden vivir en su propio archivo e importarse. Si guardas Project y StoreProject en un archivo llamado projects.py, cualquier otro archivo de la misma carpeta puede usarlas:

from projects import Project, StoreProject

site = Project("Bright Bakery", 5)

Los programas más grandes suelen organizarse así: un módulo por cada grupo de clases relacionadas.

¿Cuándo Conviene Usar una Clase?

Las clases son una herramienta, no una obligación. Muchos programas útiles de Python nunca definen una. Algunas pautas:

  • Usa un diccionario para datos sencillos que solo necesitas guardar y consultar, sobre todo si vienen de JSON o de una hoja de cálculo.
  • Usa funciones para acciones que reciben una entrada y devuelven un resultado.
  • Usa una clase cuando tienes datos y comportamiento que van juntos, sobre todo si vas a crear muchos objetos parecidos, necesitas proteger los datos con reglas o tienes varios tipos de una cosa que comparten la mayor parte de su comportamiento.

Si notas que le pasas el mismo diccionario a cinco funciones distintas, suele ser señal de que debería ser una clase.

Todo Junto: Un Registro de Proyectos de Clientes

Este script combina todo lo de esta publicación:

class Project:
    """A website project with pages, pricing, and payment status."""

    HOURLY_RATE = 85
    HOURS_PER_PAGE = 4

    def __init__(self, name, pages):
        self.name = name
        self.pages = pages
        self.paid = False

    def total(self):
        return self.pages * self.HOURS_PER_PAGE * self.HOURLY_RATE

    def mark_paid(self):
        self.paid = True

    def __str__(self):
        status = "paid" if self.paid else "unpaid"
        return f"{self.name}: ${self.total():,.2f} ({status})"


class StoreProject(Project):
    """An online store: a website plus a setup fee and product loading."""

    SETUP_FEE = 500
    PRICE_PER_PRODUCT = 10

    def __init__(self, name, pages, products):
        super().__init__(name, pages)
        self.products = products

    def total(self):
        return super().total() + self.SETUP_FEE + self.products * self.PRICE_PER_PRODUCT


class Client:
    """A client and all of their projects."""

    def __init__(self, business):
        self.business = business
        self.projects = []

    def add_project(self, project):
        self.projects.append(project)

    def amount_owed(self):
        return sum(p.total() for p in self.projects if not p.paid)

    def report(self):
        print(f"\n{self.business}")
        for project in self.projects:
            print(f"  - {project}")
        print(f"  Amount owed: ${self.amount_owed():,.2f}")


bakery = Client("Bright Bakery")
bakery.add_project(Project("Website redesign", 5))
bakery.add_project(StoreProject("Online ordering", 3, products=25))
bakery.projects[0].mark_paid()

coffee = Client("Calexico Coffee")
coffee.add_project(Project("Landing page", 1))

for client in [bakery, coffee]:
    client.report()

Al ejecutarlo, imprime:


Bright Bakery
  - Website redesign: $1,700.00 (paid)
  - Online ordering: $1,770.00 (unpaid)
  Amount owed: $1,770.00

Calexico Coffee
  - Landing page: $340.00 (unpaid)
  Amount owed: $340.00

Fíjate en lo poco que necesitan saber las últimas líneas. Crean clientes y proyectos y piden reportes; todos los detalles sobre precios, estado de pago y formato viven dentro de las clases. Para cambiar cómo se cotizan los proyectos de tienda, editarías un solo método, y todos los reportes se actualizarían automáticamente.

Errores Comunes de Principiantes

Olvidar self al definir un método. Todo método necesita self como primer parámetro. Sin él, llamar al método produce un TypeError como Project.total() takes 0 positional arguments but 1 was given: Python pasó el objeto, pero no había ningún parámetro para recibirlo.

Olvidar self. al usar un atributo. Dentro de un método, pages por sí solo es una variable local que no existe; self.pages es el atributo del objeto. Escribir pages = pages en __init__ (en lugar de self.pages = pages) no guarda nada en el objeto, y Python no te avisa.

Escribir mal __init__. Necesita dos guiones bajos de cada lado. Con _init_ o __int__, Python nunca lo llama, así que tus atributos nunca se asignan y crear el objeto falla con un error confuso.

Olvidar los paréntesis al crear un objeto. bakery = Project no crea un proyecto: solo le da otro nombre a la clase. Necesitas Project("Bright Bakery", 5).

Devolver un valor desde __init__. __init__ prepara el objeto; no debe devolver nada. Si necesitas un valor calculado, agrega un método aparte.

Poner una lista en un atributo de clase. Un atributo de clase lo comparten todos los objetos, así que uno mutable, como una lista, también se comparte:

class Project:
    pages = []          # ¡compartida por TODOS los proyectos!

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

a = Project("Bright Bakery")
b = Project("Calexico Coffee")
a.pages.append("Home")
print(b.pages)   # ['Home']   ← b recibió la página de a

Crea las listas y los diccionarios en __init__ con self.pages = [], para que cada objeto tenga los suyos.

Usar una clase para todo. Una clase que solo tiene __init__ y ningún método suele ser un diccionario disfrazado, y una clase con un solo método suele ser simplemente una función. Empieza con lo sencillo y recurre a una clase cuando los datos y el comportamiento claramente vayan juntos.

Ejercicios de Práctica

  1. Escribe una clase Client con los atributos name y email, y un método __str__ que devuelva "Nombre <correo>". Crea dos clientes e imprímelos.
  2. Escribe una clase BankAccount con un balance que empiece en 0 y los métodos deposit(amount) y withdraw(amount). withdraw no debe permitir que el saldo quede por debajo de cero.
  3. Agrega a una clase Project un método discount(percent) que reduzca su total, y asegúrate de que el descuento no pueda ser mayor al 50%.
  4. Crea una clase Task con un title y un estado done, y una clase TodoList que guarde tareas y tenga los métodos add(), complete(title) y remaining().
  5. Escribe una clase Product y luego una subclase DigitalProduct que agregue un download_url y sobrescriba el método shipping_cost() para que devuelva 0.
  6. Reescribe uno de tus programas anteriores de esta serie que use una lista de diccionarios para que use una lista de objetos.

Conclusión

Las clases te permiten modelar las cosas con las que trabaja tu programa (clientes, proyectos, facturas, productos) como objetos que agrupan sus propios datos y comportamiento. __init__ prepara cada objeto nuevo, self da a los métodos acceso a los datos de ese objeto, y los métodos permiten que los objetos hagan cosas y protejan sus datos con reglas. __str__ hace que se impriman de forma legible, la herencia permite que una clase se construya sobre otra, la composición permite que los objetos contengan otros objetos y @dataclass te ahorra escribir en las clases que principalmente guardan datos.

Ya viste cómo Python organiza el código en todos los niveles: valores en variables, grupos de valores en listas y diccionarios, decisiones con if, repetición con ciclos, pasos reutilizables en funciones y, ahora, datos y comportamiento juntos en clases. Son los mismos bloques que se usan en frameworks como Flask y bibliotecas como SQLAlchemy, donde verás clases por todas partes.

Siguiente en la serie Fundamentos de Python: Archivos y Excepciones, cómo guardar tus datos en archivos y manejar errores sin que el programa se detenga.

Serie Fundamentos de Python: Parte 1: Lo que Todo Principiante Debería Saber · Parte 2: Variables y Tipos de Datos · Parte 3: Listas y Diccionarios · Parte 4: La Sentencia If · Parte 5: Entrada del Usuario y Ciclos While · Parte 6: Funciones · Parte 7: Clases · Parte 8: Archivos y Excepciones · Parte 9: Cómo Probar tu Código