Mutabilidad, copias y matrices en Python — práctica con un ticket de compra, un carrito que cambia y una tabla de precios (y la lista ordenada que era None)

Quería saber cuál era el producto más caro y el más barato de mi carrito, así que ordené los precios. Escribí ordenados = precios.sort(), imprimí ordenados para ver el resultado y la pantalla me contestó con una sola palabra: None. Ni la lista, ni un error, ni un aviso. Una lista que había estado ahí hacía un segundo se había convertido en «nada».

La lista no se había perdido: sort() la había ordenado por dentro, y lo único que devolvía era None, que yo estaba guardando muy contento en ordenados. Es el tipo de fallo que se arregla quitando media línea y que cuesta un rato largo encontrar si no sabes que los métodos que modifican una lista no devuelven nada.

Hoy practicamos mutabilidad, copias, zip, enumerate, list comprehension y matrices con tres programas completos: un ticket de la compra, un carrito que se modifica en el sitio y una tabla de precios por tienda. Esta práctica se apoya en la teoría de mutabilidad, copias y matrices. Si todavía dudas con las listas básicas, repasa antes listas y tuplas en Python.

Cómo usar este artículo

Cada programa va completo en un solo archivo. Cópialo, guárdalo con el nombre que se indica y lánzalo desde la terminal con python3 nombre.py. Antes de mirar la salida, intenta predecirla: es la mejor forma de saber si has entendido el código.

Ningún programa usa input(). Los datos van fijos en el código para que tu salida sea idéntica a la que ves aquí.

Programa 1 · El ticket de la compra

Tenemos la compra en tres listas paralelas: los productos, sus precios y las cantidades, siempre en el mismo orden. Queremos un ticket numerado con el importe de cada línea, el subtotal, el IVA y el total, y una lista con las líneas que superan los 5 €. Es el programa perfecto para zip (recorrer listas en paralelo), enumerate (numerar) y una comprehension.

ticket.py

PRODUCTOS = ["Pan", "Leche", "Huevos", "Arroz", "Aceite"]
PRECIOS = [1.20, 0.95, 2.40, 1.75, 6.90]
CANTIDADES = [2, 3, 1, 2, 1]
IVA = 0.10


def importes(precios, cantidades):
    """Importe de cada línea: precio por cantidad."""
    return [precio * cantidad for precio, cantidad in zip(precios, cantidades)]


lineas = importes(PRECIOS, CANTIDADES)

for num, (producto, cantidad, importe) in enumerate(zip(PRODUCTOS, CANTIDADES, lineas), 1):
    print(f"{num}. {producto} x{cantidad} -> {importe:.2f} €")

subtotal = sum(lineas)
print(f"Subtotal: {subtotal:.2f} €")
print(f"IVA ({IVA:.0%}): {subtotal * IVA:.2f} €")
print(f"Total: {subtotal * (1 + IVA):.2f} €")

caras = [producto for producto, importe in zip(PRODUCTOS, lineas) if importe > 5]
print("Líneas de más de 5 €:", caras)

Lánzalo con python3 ticket.py:

1. Pan x2 -> 2.40 €
2. Leche x3 -> 2.85 €
3. Huevos x1 -> 2.40 €
4. Arroz x2 -> 3.50 €
5. Aceite x1 -> 6.90 €
Subtotal: 18.05 €
IVA (10%): 1.81 €
Total: 19.86 €
Líneas de más de 5 €: ['Aceite']

Entre líneas:

  • importes construye una lista nueva con una comprehension: para cada pareja (precio, cantidad) que le da zip, calcula precio * cantidad. No modifica nada y devuelve la lista de importes.
  • El for principal es el más denso del programa: zip(PRODUCTOS, CANTIDADES, lineas) empareja las tres listas a la vez (zip admite todas las que quieras) y enumerate(…, 1) numera cada tripleta empezando en 1. Cada vuelta recibe algo como (1, ("Pan", 2, 2.4)), y el patrón num, (producto, cantidad, importe) lo desempaqueta de golpe, incluida la tupla de dentro.
  • {importe:.2f} fuerza dos decimales, y {IVA:.0%} convierte el 0.10 en 10%.
  • Las cuentas cuadran: 2.40 + 2.85 + 2.40 + 3.50 + 6.90 = 18.05. El IVA es 1.805, que se muestra como 1.81, y 18.05 + 1.81 = 19.86, que es el total.
  • caras es una comprehension con filtro: solo se queda con los productos cuyo importe pasa de 5 €. Solo el aceite lo cumple (6.90).
  • Este programa no modifica ninguna lista: crea listas nuevas a partir de las anteriores. Es la manera segura de trabajar cuando no necesitas cambiar los datos de origen.

Pruébalo tú

  • Quita el último precio de PRECIOS (deja cuatro) y ejecuta. ¿Qué pasa con el ticket? ¿Dónde está el aceite? ¿Por qué no hay ningún error?
  • Escribe una comprehension que genere una lista con los importes de lineas con un 15 % de descuento, y otra que se quede solo con los productos cuya cantidad sea mayor que 1.

Programa 2 · Un carrito que cambia

Ahora el carrito es una lista de tuplas (producto, precio) que vamos a modificar con funciones. Aquí se ve la diferencia entre las funciones que cambian el carrito en el sitio y las que devuelven uno nuevo, justo lo que la teoría llama mutabilidad.

carrito.py

def anadir(carrito, producto, precio):
    """Añade un producto al final del carrito (modifica el carrito)."""
    carrito.append((producto, precio))


def quitar(carrito, producto):
    """Quita el primer producto con ese nombre. Devuelve True si lo quitó."""
    for pos, (nombre, precio) in enumerate(carrito):
        if nombre == producto:
            carrito.pop(pos)
            return True
    return False


def con_descuento(carrito, porcentaje):
    """Devuelve un carrito NUEVO con los precios rebajados (no toca el original)."""
    return [(nombre, round(precio * (1 - porcentaje), 2)) for nombre, precio in carrito]


def total(carrito):
    """Suma de los precios del carrito."""
    return sum([precio for nombre, precio in carrito])


carrito = [("Pan", 1.20), ("Leche", 0.95)]
anadir(carrito, "Arroz", 1.75)
anadir(carrito, "Aceite", 6.90)
print(carrito)
print("Quitar Leche:", quitar(carrito, "Leche"))
print("Quitar Café:", quitar(carrito, "Café"))
print(carrito)

rebajado = con_descuento(carrito, 0.20)
print(rebajado)
print(carrito)
print(f"Total: {total(carrito):.2f} € -> {total(rebajado):.2f} €")

precios = [precio for nombre, precio in carrito]
ordenados = sorted(precios, reverse=True)
print("Precios de mayor a menor:", ordenados)
print("Lista original:", precios)

Lánzalo con python3 carrito.py:

[('Pan', 1.2), ('Leche', 0.95), ('Arroz', 1.75), ('Aceite', 6.9)]
Quitar Leche: True
Quitar Café: False
[('Pan', 1.2), ('Arroz', 1.75), ('Aceite', 6.9)]
[('Pan', 0.96), ('Arroz', 1.4), ('Aceite', 5.52)]
[('Pan', 1.2), ('Arroz', 1.75), ('Aceite', 6.9)]
Total: 9.85 € -> 7.88 €
Precios de mayor a menor: [6.9, 1.75, 1.2]
Lista original: [1.2, 1.75, 6.9]

Entre líneas:

  • anadir y quitar modifican el carrito que reciben (append y pop). Por eso anadir no devuelve nada y se llama sin guardar el resultado: el cambio ya se ve fuera. La primera línea de salida lo demuestra: tras dos llamadas, el carrito tiene cuatro productos.
  • quitar recorre con enumerate para conocer la posición, y en cuanto encuentra el producto hace pop(pos) y return True inmediatamente. Parar justo después de quitar no es un capricho: seguir recorriendo una lista a la que acabas de quitar un elemento haría que las posiciones se corrieran. Si no lo encuentra, llega al final del bucle y devuelve False.
  • Por eso quitar(carrito, "Leche") da True y la leche desaparece, y quitar(carrito, "Café") da False y el carrito se queda igual.
  • con_descuento hace lo contrario: no toca el carrito original y devuelve uno nuevo, construido con una comprehension. Cada elemento es una tupla nueva (nombre, precio rebajado), porque las tuplas no se pueden modificar. La prueba está en la salida: rebajado tiene los precios con el 20 % de descuento y el print(carrito) siguiente sigue mostrando los precios de antes.
  • total suma los precios con una comprehension que desempaqueta cada tupla. Los dos totales (9.85 y 7.88) cuadran con las listas de arriba.
  • Las dos últimas líneas son la historia del principio: sorted(precios, reverse=True) devuelve una lista nueva ordenada de mayor a menor, y precios sigue en su orden original. Si hubiéramos escrito precios.sort(reverse=True), la lista original habría cambiado y no habría nada que guardar.

Pruébalo tú

  • Escribe ordenados = precios.sort(reverse=True) e imprime ordenados y precios. ¿Qué sale en cada uno y por qué?
  • Cambia con_descuento para que modifique el carrito en el sitio, recorriendo por índice y asignando carrito[pos] = (nombre, nuevo_precio). ¿Qué cambia en la salida? ¿Qué función dejaría de tener sentido devolver nada?

Programa 3 · Una tabla de precios por tienda

Para terminar, una matriz: tres tiendas (filas) y tres productos (columnas). Queremos el coste de una unidad de cada producto en cada tienda, la tienda más barata para cada producto, una tabla nueva con descuento y una copia de la matriz que podamos modificar sin romper la original.

tiendas.py

TIENDAS = ["Mercado", "Súper", "Online"]
PRODUCTOS = ["Pan", "Leche", "Aceite"]
PRECIOS = [
    [1.20, 0.85, 6.90],     # Mercado
    [1.10, 0.95, 7.20],     # Súper
    [1.35, 0.80, 6.50],     # Online
]

# Una unidad de cada producto, por tienda (la suma de cada fila)
for tienda, fila in zip(TIENDAS, PRECIOS):
    print(tienda, round(sum(fila), 2))

# Producto a producto: la tienda más barata (recorrido por columnas)
for j, producto in enumerate(PRODUCTOS):
    columna = [fila[j] for fila in PRECIOS]
    barata = columna.index(min(columna))
    print(producto, "->", TIENDAS[barata], min(columna))

# Una tabla nueva con un 10 % de descuento (comprehension dentro de otra)
rebajados = [[round(precio * 0.90, 2) for precio in fila] for fila in PRECIOS]
print(rebajados[0])
print(PRECIOS[0])

# Copiar una matriz: hay que copiar cada fila
copia = [fila[:] for fila in PRECIOS]
copia[1][0] = 0.99
print(PRECIOS[1], copia[1])

Lánzalo con python3 tiendas.py:

Mercado 8.95
Súper 9.25
Online 8.65
Pan -> Súper 1.1
Leche -> Online 0.8
Aceite -> Online 6.5
[1.08, 0.77, 6.21]
[1.2, 0.85, 6.9]
[1.1, 0.95, 7.2] [0.99, 0.95, 7.2]

Entre líneas:

  • El primer bucle recorre por filas: zip(TIENDAS, PRECIOS) empareja cada tienda con su lista de precios, y sum(fila) suma una fila entera. round(…, 2) evita que aparezcan decimales raros de la aritmética de coma flotante. Cuadra: 1.20 + 0.85 + 6.90 = 8.95 en el mercado.
  • El segundo bucle recorre por columnas. Con enumerate(PRODUCTOS) tenemos la posición j de cada producto, y [fila[j] for fila in PRECIOS] saca esa columna. Después, columna.index(min(columna)) da la posición del mínimo, que es también la posición de la tienda: el pan es más barato en el súper (1.10) y la leche y el aceite, en la tienda online.
  • rebajados es una comprehension dentro de otra: la de fuera crea una fila por cada fila, y la de dentro rebaja cada precio. El resultado es una matriz nueva; la primera fila de los precios originales se imprime después sin cambios ([1.2, 0.85, 6.9]).
  • Al final, la copia: [fila[:] for fila in PRECIOS] copia cada fila. Si hubiéramos escrito copia = PRECIOS[:] (o .copy()), tendríamos una lista exterior nueva pero con las mismas filas, es decir, una copia superficial: modificar copia[1][0] habría cambiado también PRECIOS. Así, copia[1] tiene el 0.99 y PRECIOS[1] conserva su 1.1.

Pruébalo tú

  • Cambia [fila[:] for fila in PRECIOS] por PRECIOS[:] y vuelve a ejecutar. ¿Qué sale ahora en la última línea? Compruébalo con PRECIOS[1] is copia[1].
  • Añade una función tienda_mas_barata(precios) que devuelva el nombre de la tienda cuya fila suma menos. Pista: una comprehension con sum(fila) te da los totales y min + index te dan la posición.

Míralo paso a paso en Python Tutor

Python Tutor ejecuta tu código línea a línea y dibuja variables y objetos en memoria. Elige Python 3 y pega este programa, que compara las dos formas de crear una matriz de 2 por 2 que vimos en la tabla de precios:

mal = [[0] * 2] * 2
bien = [[0] * 2 for _ in range(2)]

mal[0][0] = 5
bien[0][0] = 5

print(mal)
print(bien)
print(mal[0] is mal[1], bien[0] is bien[1])

Salida esperada:

[[5, 0], [5, 0]]
[[5, 0], [0, 0]]
True False

Qué observar, paso a paso:

  1. Tras las dos primeras líneas, mal es una lista exterior con dos flechas que apuntan a la misma lista interior, mientras que bien tiene dos flechas hacia dos listas interiores distintas. La diferencia de dibujo es toda la diferencia del error.
  2. Al ejecutar mal[0][0] = 5, sigue la flecha de la fila 0 hasta su lista y cambia la posición 0. Como la fila 1 apunta a esa misma lista, el 5 aparece en las dos filas.
  3. Con bien[0][0] = 5 pasa lo esperado: solo cambia la primera fila, porque la segunda es una lista independiente.
  4. Los dos primeros print lo confirman: [[5, 0], [5, 0]] frente a [[5, 0], [0, 0]].
  5. La última línea saca True False: en mal, las dos filas son el mismo objeto; en bien, no. Python Tutor te lo enseña con las flechas, pero is te lo dice por escrito.

Para terminar

Hoy has visto en acción las herramientas de este tema: zip y enumerate para recorrer sin índices a mano, comprehension para construir listas nuevas, funciones que modifican y funciones que devuelven, y matrices recorridas por filas y por columnas, con su copia correcta. Si algo no te ha cuadrado, vuelve a la teoría; y cuando te sientas cómodo, pasa a los ejercicios, donde los fallos vienen incluidos.

Siguiente paso: ejerciciosMutabilidad, copias y matrices en Python — ejercicios con una lista de la compra, un ticket y un stock de almacenes (y el producto que zip se comió)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Un comentario

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *