UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase 2 — Variables y tipos básicos

Asignatura  Introducción a Python Profesor  Daniel Miles Touya Universidad  UdelaR-Uvigo
Parte I

Todo en Python es un objeto

Identidad, tipo y valor: las tres propiedades de todo objeto teoría

La documentación oficial de Python lo dice sin rodeos: "Objects are Python's abstraction for data. All data in a Python program is represented by objects or by relations between objects. Even code is represented by objects."

Un número, un texto, una función, un módulo importado — todo es un objeto. Y todo objeto tiene exactamente tres propiedades, definidas en el momento de su creación:

— 1. Identidad —

La identidad es la dirección en memoria donde vive el objeto. Nunca cambia una vez creado el objeto. La función id() la devuelve como número entero. El operador is compara identidades — no valores.

Documentación oficial: "An object's identity never changes once it has been created; you may think of it as the object's address in memory." → fuente

# Identidad: id() devuelve la dirección en memoria
x = 42
print(id(x))

y = 42
print(id(y))

print(x is y)   # ¿mismo objeto o distinto?

— 2. Tipo —

El tipo determina qué operaciones puede hacer el objeto y qué valores puede tener. Tampoco cambia nunca. La función type() lo devuelve — y es en sí mismo un objeto.

Documentación oficial: "An object's type determines the operations that the object supports and also defines the possible values for objects of that type. Like its identity, an object's type is also unchangeable." → fuente

# Tipo: type() devuelve el tipo del objeto
print(type(42))        # int
print(type(3.14))      # float
print(type("hola"))    # str
print(type(True))      # bool
print(type(type(42)))  # ¿qué devuelve esto?

— 3. Valor y mutabilidad —

El valor es el contenido del objeto. A diferencia de la identidad y el tipo, el valor puede cambiar en algunos objetos. Los que pueden cambiar se llaman mutables; los que no, inmutables.

Números, strings y booleanos son inmutables. Listas y diccionarios son mutables. Esto no es una convención — es una propiedad del tipo, definida por el lenguaje.

Documentación oficial: "Objects whose value can change are said to be mutable; objects whose value is unchangeable once they are created are called immutable. An object's mutability is determined by its type; for instance, numbers, strings and tuples are immutable, while dictionaries and lists are mutable." → fuente

# Inmutable: no puedes cambiar el objeto, solo redirigir la etiqueta
x = "hola"
print(id(x))
x = "hola mundo"   # ¿mismo id o distinto?
print(id(x))

# Mutable: el objeto sí cambia
lista = [1, 2, 3]
print(id(lista))
lista.append(4)    # modificamos el objeto
print(id(lista))   # ¿mismo id?
print(lista)
  1. Abre JupyterLab con uv run jupyter lab en tu terminal y ejecuta cada bloque en una celda nueva (Shift + Enter). ¿x e y tienen el mismo id? ¿Qué dice x is y? ¿Por qué Python puede reutilizar el mismo objeto para dos variables distintas con el mismo valor?
  2. Ejecuta type(type(42)). ¿Qué devuelve? ¿Qué te dice eso sobre cómo trata Python al propio concepto de "tipo"?
  3. En el tercer bloque, ¿cambia el id de x cuando reasignas el string? ¿Y el id de lista cuando haces append? ¿Qué diferencia fundamental muestra esto entre objetos mutables e inmutables?
  4. La documentación dice que el tipo de un objeto nunca cambia. Pero en Python puedes hacer x = 42 y luego x = "hola" sin error. ¿Hay contradicción? ¿Qué ha cambiado exactamente?
Respuesta orientativa
a

En la mayoría de implementaciones de Python, x e y tienen el mismo id y x is y devuelve True. Esto ocurre porque Python optimiza memoria reutilizando objetos inmutables pequeños muy frecuentes — los enteros entre -5 y 256 están precreados y no se duplican. Dos variables con valor 42 apuntan al mismo objeto en memoria.

Atención: esto es un detalle de implementación de CPython, no una garantía del lenguaje. Para valores grandes (x = 1000; y = 1000) el comportamiento puede ser distinto. Por eso nunca debes usar is para comparar valores — usa siempre ==. is compara identidades, no valores.
b

type(type(42)) devuelve <class 'type'>. El tipo de 42 es int, y el tipo de int es type. En Python, incluso los tipos son objetos — instancias de la clase type. No hay ningún elemento del lenguaje que quede fuera del modelo de objetos.

Todo es un objeto: números, strings, funciones, módulos, y también los propios tipos. Esta coherencia es una de las razones por las que Python es un lenguaje especialmente legible — hay muy pocas excepciones al modelo general.
c

El id de x cambia al reasignar el string: no has modificado el objeto "hola", has creado un objeto nuevo "hola mundo" y has redirigido la etiqueta. El objeto original sigue existiendo en memoria sin cambios.

El id de lista no cambia al hacer append: es el mismo objeto en la misma dirección de memoria, pero su valor ha cambiado — ahora contiene cuatro elementos. Eso es precisamente lo que significa ser mutable.

La diferencia clave: con objetos inmutables, cualquier "cambio" crea un objeto nuevo. Con objetos mutables, el objeto se modifica en su lugar. Esto tiene consecuencias importantes cuando varias variables apuntan al mismo objeto mutable — lo veremos en la Parte II.
d

No hay contradicción. El tipo del objeto 42 nunca cambia: sigue siendo int para siempre. Lo que ha cambiado es que la etiqueta x ya no apunta a ese objeto — ahora apunta al objeto "hola", que es un str. Son dos objetos distintos con tipos distintos.

La variable x no tiene tipo propio. El tipo lo tiene el objeto, no el nombre. x es simplemente una etiqueta que puede redirigirse a cualquier objeto en cualquier momento. Esto es el tipado dinámico de Python.

Un anticipo: funciones, módulos y datos también son objetos anticipo

Lo que sigue no es necesario entenderlo todavía. La idea es que veas, antes de seguir, hasta dónde llega la afirmación de que "todo dato en Python está representado por objetos". No son solo los números y los strings — también las funciones, los módulos importados y las estructuras de datos. Todo sigue el mismo modelo: identidad, tipo, valor.

— Las funciones son objetos —

Una función no es solo una instrucción que se ejecuta. Es un objeto con identidad y tipo, igual que un número o un string. Puedes asignarla a una variable, pasarla como argumento, o guardarla en una lista. Lo veremos en detalle más adelante.

— Los módulos son objetos —

Cuando escribes import math, Python crea un objeto de tipo module y le pone la etiqueta math. Ese objeto contiene funciones, constantes y otros objetos dentro. Los módulos y los paquetes los veremos en detalle más adelante.

import math

print(type(math))       # module
print(id(math))         # tiene identidad
print(type(math.sqrt))  # la función sqrt también es un objeto
print(math.pi)          # incluso las constantes son objetos
El modelo completo: en Python no hay distinción entre "datos" y "código". Un número, un string, una lista, una función, un módulo importado — todo es un objeto con identidad, tipo y valor. Esta coherencia es lo que hace que Python sea tan legible: una vez que entiendes el modelo de objetos, el comportamiento del lenguaje deja de sorprenderte.
Parte II

Variables: etiquetas, no cajas

La variable como etiqueta, no como caja teoría

En muchos lenguajes de programación, una variable es una caja: reservas un espacio en memoria, le pones un nombre, y metes un valor dentro. El valor vive dentro de la caja.

En Python es diferente. Una variable es una etiqueta — o más precisamente, un name binding: un nombre que apunta a un objeto que ya existe en memoria. El objeto vive en memoria independientemente de cuántas etiquetas apunten a él.

La documentación oficial sostiene: las variables no son contenedores — son referencias a objetos. El operador = no significa "mete este valor en esta caja". Significa "haz que este nombre apunte a este objeto".

— Lo que ocurre en memoria —

Cuando escribes x = 42 ocurren dos cosas: Python crea el objeto 42 en memoria y luego crea la etiqueta x que apunta a él. El objeto existe primero — la etiqueta viene después.

Cuando escribes x = 43, Python no modifica el objeto 42. Crea un objeto nuevo 43 y mueve la etiqueta x para que apunte al nuevo. El objeto 42 sigue en memoria — sin etiquetas que lo referencien, el recolector de basura lo eliminará en algún momento.

# Paso 1: Python crea el objeto 42 y la etiqueta x apunta a él
x = 42
print(id(x))    # dirección del objeto 42 en memoria

# Paso 2: Python crea el objeto 43 y mueve la etiqueta
x = 43
print(id(x))    # dirección diferente: es un objeto nuevo

# El objeto 42 sigue existiendo — compruébalo
print(id(42))   # el objeto 42 tiene su propio id
print(id(43))   # el objeto 43 también

Observa también lo que ocurre cuando dos etiquetas apuntan al mismo objeto: es como que dos flechas apuntan al mismo bloque en memoria:

# Dos etiquetas, un solo objeto
x = 42
y = x          # y no copia el valor — apunta al mismo objeto

print(id(x))
print(id(y))
print(x is y)  # True: mismo objeto, no solo mismo valor

— Ejercicio —

  1. Ejecuta el primer bloque en JupyterLab. ¿Cambia el id de x al pasar de 42 a 43? ¿Qué nos dice eso sobre qué ha cambiado y qué no?
  2. Ejecuta el segundo bloque. ¿x e y tienen el mismo id? Ahora escribe x = 99 y vuelve a imprimir id(x) e id(y). ¿Qué le ha pasado a y? ¿Por qué?
  3. En otros lenguajes se dice que una variable "contiene" un valor. En Python se dice que una variable "referencia" o "apunta a" un objeto. ¿Qué diferencia práctica tiene esta distinción cuando lees código ajeno?
Respuesta orientativa
a

Sí, el id cambia. Al escribir x = 43 no has modificado el objeto 42 — ese objeto sigue en memoria con su propia identidad. Lo que ha cambiado es que la etiqueta x ahora apunta a un objeto diferente. La etiqueta se ha movido, el objeto no.

La imagen del vídeo: piensa en una etiqueta adhesiva que puedes despegar de un objeto y pegar en otro. El objeto original no desaparece inmediatamente — sigue en memoria hasta que el recolector de basura lo elimina al detectar que nadie lo referencia.
b

Sí, x e y tienen el mismo id inicialmente: apuntan al mismo objeto 42. Cuando escribes x = 99, la etiqueta x se mueve al objeto 99, pero y no se mueve — sigue apuntando al objeto 42.

Cada etiqueta es independiente. Reasignar una etiqueta nunca afecta a las demás, porque lo que has cambiado es a qué objeto apunta esa etiqueta, no el objeto en sí.

Atención: esto es cierto para objetos inmutables como los números. Con objetos mutables como las listas, la situación es diferente — y más peligrosa. Lo veremos en el Bloque 4.
c

La diferencia práctica es significativa cuando lees código con funciones o con estructuras de datos. Si una función recibe una variable como argumento, no recibe una copia del valor — recibe una referencia al mismo objeto. Si ese objeto es mutable, la función puede modificarlo y el cambio se verá fuera de la función.

Entender que las variables son referencias es lo que te permite predecir este tipo de comportamiento sin ejecutar el código. Es una de las habilidades de lectura más importantes en Python.

El operador =: evaluar, crear, apuntar teoría

En matemáticas, x = x + 1 es una contradicción: ningún número es igual a sí mismo más uno. En Python, x = x + 1 es una instrucción perfectamente válida — pero no significa lo mismo.

El símbolo = en Python no es igualdad. Es el operador de asignación, y funciona siempre en tres pasos, en este orden:

Paso 1

Evalúa el lado derecho. Python lee todo lo que hay a la derecha del = y calcula el resultado. Todavía no ha tocado nada a la izquierda.

Paso 2

Crea un objeto nuevo en memoria con el resultado de ese cálculo. Ese objeto tiene su propia identidad — su propia dirección en memoria.

Paso 3

Mueve la etiqueta del lado izquierdo para que apunte al objeto nuevo. El objeto anterior sigue en memoria — simplemente ya no tiene esta etiqueta apuntándole.

— ¿x = x + 1 crea un objeto nuevo o modifica el anterior? —

Esta es la pregunta clave. Cuando Python ejecuta x = x + 1, ¿mueve la etiqueta x a un nuevo objeto, o modifica el objeto al que ya apuntaba?

La respuesta la da hex(id(x)) — la dirección de memoria en hexadecimal, que es como normalmente se representan las direcciones y es más fácil de comparar visualmente que un número entero de 15 dígitos. Si el id cambia después de la operación, x apunta a un objeto nuevo. Si no cambia, el objeto fue modificado en su lugar.

x = 10
print(f"Antes:  x = {x},  id = {hex(id(x))}")

x = x + 1
print(f"Después: x = {x},  id = {hex(id(x))}")

# ¿El id cambió? ¿Qué nos dice eso?

El id cambia. Esto confirma que x + 1 ha creado un objeto nuevo — el 11 — y la etiqueta x se ha movido a ese objeto nuevo. El objeto 10 sigue en memoria, sin etiquetas que lo referencien, esperando a ser recogido por el recolector de basura.

Esto ocurre porque los números en Python son inmutables: no se pueden modificar. Cualquier operación aritmética produce siempre un objeto nuevo.

— La forma abreviada: += —

x += 1 es exactamente lo mismo que x = x + 1 — es solo una forma más corta de escribirlo. Python la llama operador de asignación aumentada. Mismos tres pasos, mismo resultado, mismo cambio de id.

x = 10
print(f"Antes:   x = {x},  id = {hex(id(x))}")

x += 1    # equivale a x = x + 1
print(f"Después: x = {x},  id = {hex(id(x))}")

x *= 2    # equivale a x = x * 2
print(f"Después: x = {x},  id = {hex(id(x))}")

— El lado derecho se evalúa completo antes de asignar —

Python siempre evalúa el lado derecho entero antes de tocar el lado izquierdo. Esto hace posible el intercambio elegante de dos variables en una sola línea — algo que en otros lenguajes requiere una variable temporal.

a = 10
b = 20
print(f"Antes:   a = {a},  b = {b}")

# Python evalúa (b, a) completo primero → (20, 10)
# luego asigna: a = 20, b = 10
a, b = b, a
print(f"Después: a = {a},  b = {b}")

— Asignación múltiple: ¿una etiqueta o dos? —

Python permite encadenar asignaciones en una sola línea. x = y = 0 no crea dos objetos 0 — crea uno solo y pone dos etiquetas apuntando a él. Verifica con hex(id()).

x = y = 0
print(f"x = {x},  id = {hex(id(x))}")
print(f"y = {y},  id = {hex(id(y))}")
print(f"¿Mismo objeto? {x is y}")

# ¿Qué pasa si ahora cambias x?
x = 1
print(f"\nDespués de x = 1:")
print(f"x = {x},  id = {hex(id(x))}")
print(f"y = {y},  id = {hex(id(y))}")  # ¿y ha cambiado?
  1. Ejecuta el primer bloque. ¿Cambia el id después de x = x + 1? Explica con tus propias palabras qué ha pasado en memoria en los tres pasos.
  2. Ejecuta el bloque de += y *=. ¿Cambia el id en cada operación? ¿Qué confirma eso sobre la inmutabilidad de los números?
  3. En el intercambio a, b = b, a, ¿qué pasaría si Python evaluara el lado izquierdo primero? ¿Por qué el resultado sería incorrecto?
  4. En la asignación múltiple x = y = 0, después de hacer x = 1, ¿ha cambiado el valor de y? ¿Por qué? ¿Qué pasaría si en lugar de 0 hubieras asignado una lista mutable?
Respuesta orientativa
a

Sí, el id cambia. Los tres pasos en memoria son: primero Python evalúa x + 1 tomando el valor del objeto al que apunta x (el 10) y sumándole 1. Segundo, crea un objeto nuevo 11 en una dirección de memoria distinta. Tercero, mueve la etiqueta x para que apunte a ese objeto nuevo. El objeto 10 no ha sido modificado — sigue existiendo en memoria hasta que el recolector de basura lo elimine.

b

Sí, el id cambia en cada operación. += y *= son azúcar sintáctico — formas abreviadas que hacen exactamente lo mismo que x = x + 1 y x = x * 2. En cada caso se crea un objeto nuevo y la etiqueta se mueve. Esto confirma que los números son inmutables: no existe ninguna operación que modifique un objeto numérico en su lugar.

c

Si Python evaluara el lado izquierdo primero, en a, b = b, a primero asignaría a = b (con lo que a valdría 20), y luego intentaría b = a — pero ahora a ya vale 20, así que b también quedaría en 20. El intercambio fallaría. Que Python evalúe el lado derecho completo primero — (b, a) = (20, 10) — es lo que hace posible el intercambio elegante.

d

No, y no cambia. Al hacer x = 1, la etiqueta x se mueve al objeto 1, pero la etiqueta y sigue apuntando al objeto 0 — cada etiqueta es independiente.

Con una lista mutable sería diferente y peligroso. Si haces x = y = [] y luego x.append(1), tanto x como y verían el cambio — porque ambas etiquetas apuntan al mismo objeto y ese objeto ha sido modificado en su lugar. Lo veremos en detalle en el Bloque 4.

Regla práctica: con objetos inmutables como números y strings, reasignar una etiqueta nunca afecta a las demás. Con objetos mutables como listas, modificar el objeto sí afecta a todas las etiquetas que apuntan a él.
Los tipos de objetos: ¿qué puedes hacer con cada uno? teoría

En el Bloque 1 vimos que todo objeto tiene tres propiedades: identidad, tipo y valor. El tipo es la más importante para el trabajo diario porque determina qué operaciones puede hacer el objeto.

La documentación oficial lo dice explícitamente: "An object's type determines the operations that the object supports and also defines the possible values for objects of that type." → fuente

Esto tiene una consecuencia directa para las variables: cuando asignas un objeto a una variable, la etiqueta apunta a ese objeto y hereda implícitamente todas sus capacidades y limitaciones. Saber de qué tipo es el objeto al que apunta una variable es lo que te permite predecir qué puedes hacer con ella y qué no.

— Descubre el tipo antes de que te lo digamos —

Para cada bloque de abajo: antes de ejecutar, intenta predecir qué tipo devolverá type(). Luego ejecuta en JupyterLab y comprueba. La función type() es tu herramienta de inspección — úsala siempre que leas código ajeno y no estés seguro de qué tipo de objeto estás manejando.

— Números —

# Asignamos objetos numéricos a variables
# ¿De qué tipo es cada uno?
ventas_enero = 42000
tasa_iva = 0.21
activo = True
fecha = "2024-01-15"

print(type(ventas_enero))
print(type(tasa_iva))
print(type(activo))
print(type(fecha))     # ¿sorprende este resultado?

La última línea suele sorprender: "2024-01-15" es una fecha para nosotros, pero para Python es un str — texto. Python no sabe que esos números separados por guiones representan una fecha. Para trabajar con fechas como fechas hay que convertirlas explícitamente. Si no lo haces, Python las tratará como texto y no podrás calcular diferencias de días ni ordenarlas cronológicamente.

— Estructuras de datos —

# Distintas formas de agrupar datos
lista_ventas = [100, 200, 150, 300]
coordenadas = (40.4168, -3.7038)
alumno = {"nombre": "Ana", "nota": 8.5}

print(type(lista_ventas))
print(type(coordenadas))
print(type(alumno))

— Objetos de librerías de análisis de datos —

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# Un array de numpy
array_ventas = np.array([100, 200, 150, 300])
print(type(array_ventas))   # ¿mismo tipo que lista_ventas?

# Un DataFrame de pandas
df = pd.DataFrame({
    "mes": ["ene", "feb", "mar"],
    "ventas": [100, 200, 150]
})
print(type(df))             # ¿qué tipo es un DataFrame?
print(type(df["ventas"]))   # ¿y una columna del DataFrame?

# Un gráfico
fig = plt.figure()
print(type(fig))            # incluso un gráfico es un objeto
plt.close()
Lo que acabas de ver: una lista y un array de numpy parecen lo mismo — ambos agrupan números — pero son tipos distintos con comportamientos distintos. Un DataFrame y una Series de pandas son también tipos distintos. Un gráfico de matplotlib es un objeto con su propio tipo. Cada uno de estos objetos está asignado a una variable, y el tipo del objeto determina qué puedes hacer con esa variable.

— Cuando los tipos no son compatibles —

El tipo de un objeto no es solo información descriptiva — es lo que Python usa para decidir si una operación tiene sentido o no. Cuando intentas operar sobre objetos de tipos incompatibles, Python lanza un error. Estos errores son visibles y fáciles de corregir.

Pero hay un caso más peligroso: cuando la operación no lanza error pero produce un resultado incorrecto porque el tipo no era el que esperabas. Lo veremos en los ejemplos de abajo.

— Error visible: tipos incompatibles —

# Una fecha como string + un número: ¿qué pasa?
fecha = "2024-01-15"
dias = 30

# Intenta sumar: ¿qué error produce?
print(fecha + dias)

Python lanza un TypeError: no puede sumar un str y un int. El error es claro y fácil de detectar. La solución sería convertir la fecha a un objeto de tipo datetime antes de operar.

— Error silencioso: el tipo no es el que esperabas —

import pandas as pd

# Simulamos datos de ventas donde el campo numérico
# llegó como texto — algo muy común con datos reales
df = pd.DataFrame({
    "mes": ["ene", "feb", "mar"],
    "ventas": ["100", "200", "150"]   # strings, no números
})

# ¿Qué tipo tiene la columna ventas?
print(type(df["ventas"]))
print(df["ventas"].dtype)

# Calculamos la media — ¿funciona?
print(df["ventas"].mean())    # ¿qué resultado obtienes?

# Comparamos con la versión correcta
df["ventas_num"] = df["ventas"].astype(int)
print(df["ventas_num"].mean()) # ahora sí

Este es el error más peligroso: mean() no lanza ningún error cuando la columna es de tipo object (string en pandas) — simplemente devuelve NaN o un resultado incorrecto sin avisar. Si no verificas el tipo de la columna antes de operar, puedes publicar resultados incorrectos sin saberlo.

Este tipo de error — datos numéricos que llegan como strings — es uno de los más frecuentes en análisis de datos reales. La primera línea de defensa es siempre verificar el tipo con type() o df.dtypes.

— Lista vs array: mismo aspecto, comportamiento distinto —

import numpy as np

lista = [1, 2, 3]
array = np.array([1, 2, 3])

# Multiplicar por 2: ¿mismo resultado?
print(lista * 2)    # ¿qué hace esto con una lista?
print(array * 2)   # ¿y con un array?

# Sumar dos colecciones: ¿mismo resultado?
print(lista + [4, 5])          # concatena
print(array + np.array([4, 5, 6]))  # suma elemento a elemento

El operador * sobre una lista repite sus elementos — [1, 2, 3] * 2 produce [1, 2, 3, 1, 2, 3]. Sobre un array de numpy multiplica cada elemento por 2 — [2, 4, 6]. El mismo operador, el mismo símbolo, comportamientos completamente distintos según el tipo.

Esto es exactamente lo que decía la documentación oficial: el tipo determina las operaciones que soporta el objeto. Cuando lees código ajeno y ves un *, necesitas saber el tipo del objeto para saber qué está haciendo realmente.

  1. Antes de ejecutar el primer bloque, predice el tipo de cada variable. Luego ejecuta y comprueba. ¿Hubo alguna sorpresa? ¿Cuál y por qué?
  2. En el bloque de pandas con ventas como strings: ¿qué devuelve df["ventas"].mean()? ¿Produce un error o un resultado silenciosamente incorrecto? ¿Qué tiene que ver esto con el error de nivel 2 que vimos en la Clase 1?
  3. Ejecuta lista * 2 y array * 2. ¿Producen el mismo resultado? ¿Qué nos dice esto sobre la importancia de saber el tipo de un objeto antes de operar con él?
  4. Dado este fragmento de código, sin ejecutarlo: ¿qué tipo tiene cada variable y qué operación fallará?
    precio = "9.99"
    cantidad = 3
    total = precio * cantidad
    print(f"Total: {total} euros")
Respuesta orientativa
a

La sorpresa habitual es fecha: intuitivamente parece una fecha, pero Python la ve como str — texto. También puede sorprender activo = True siendo de tipo bool, que es un subtipo de int en Python: True == 1 y False == 0.

b

En versiones recientes de pandas, df["ventas"].mean() devuelve NaN sin lanzar ningún error. Es un error silencioso — exactamente el error de nivel 2 de la Clase 1: el código ejecuta, produce un resultado, y ese resultado es incorrecto sin ningún mensaje de aviso. La única defensa es verificar el tipo antes de operar: df.dtypes o df["ventas"].dtype.

c

No producen el mismo resultado. lista * 2 repite los elementos: [1, 2, 3, 1, 2, 3]. array * 2 multiplica cada elemento: [2, 4, 6]. El mismo operador hace cosas radicalmente distintas según el tipo. Cuando lees código ajeno que usa operadores aritméticos sobre colecciones, necesitas saber el tipo antes de interpretar qué está haciendo el código.

d

precio es str, cantidad es int. La operación precio * cantidad no falla — produce "9.993": Python repite el string tres veces. El print mostrará "Total: 9.993 euros" sin ningún error, pero el resultado es completamente incorrecto.

Error silencioso típico: datos numéricos que vienen como strings de un CSV o una API. La solución es convertir explícitamente antes de operar: precio = float("9.99"). Verificar siempre el tipo con type() cuando los datos vienen de fuentes externas.
Tipado dinámico y anotaciones de tipo teoría

En el Bloque 3 vimos que el tipo va con el objeto, no con la variable. Esto tiene un nombre: tipado dinámico. En Python no declaras el tipo de una variable antes de usarla — el tipo se determina en el momento de la asignación, cuando la etiqueta apunta a un objeto concreto.

Contrasta con lenguajes de tipado estático como Java o C, donde tienes que declarar el tipo antes de usarlo y no puedes cambiarlo:

Java — tipado estático

int x = 42;        // tipo declarado
x = "hola";        // ERROR en compilación

Python — tipado dinámico

x = 42        # int
x = "hola"    # str — sin error

El tipado dinámico tiene una ventaja clara: flexibilidad — puedes escribir código más rápido sin preocuparte por declaraciones. Pero tiene un riesgo igual de claro: puedes perder el control de qué tipo de objeto apunta tu etiqueta en cada momento, especialmente en programas largos o en código generado por IA que no has escrito tú.

— El tipo de una variable puede cambiar a lo largo del programa —

# Una variable que cambia de tipo varias veces
resultado = 0
print(f"1. resultado = {resultado}, tipo = {type(resultado)}")

resultado = resultado + 3.14
print(f"2. resultado = {resultado}, tipo = {type(resultado)}")

resultado = f"El valor es {resultado}"
print(f"3. resultado = {resultado}, tipo = {type(resultado)}")

resultado = [resultado, resultado]
print(f"4. resultado = {resultado}, tipo = {type(resultado)}")

La misma etiqueta resultado apunta sucesivamente a un int, un float, un str y una list. Python no se queja en ningún momento. Pero si intentaras hacer resultado / 2 después del paso 3, obtendrías un TypeError porque en ese momento resultado apunta a un string, no a un número.

Este patrón — una variable que cambia de tipo sin que el código lo señalice claramente — es muy común en código generado por IA y es una fuente habitual de errores difíciles de detectar.

— Un caso real: función que devuelve tipos distintos —

import pandas as pd

def buscar_venta(df, mes):
    resultado = df[df["mes"] == mes]["ventas"]
    if len(resultado) == 0:
        return None         # NoneType
    elif len(resultado) == 1:
        return resultado.iloc[0]  # int o float
    else:
        return resultado    # Series de pandas

df = pd.DataFrame({
    "mes": ["ene", "feb", "mar"],
    "ventas": [100, 200, 150]
})

v1 = buscar_venta(df, "ene")
v2 = buscar_venta(df, "abr")   # no existe

print(type(v1), v1)
print(type(v2), v2)

Esta función devuelve tipos distintos según el caso: None, un número, o una Serie de pandas. La variable v1 apunta a objetos de tipo distinto dependiendo de qué mes se busca. Si después intentas hacer v2 + 10 sin verificar el tipo primero, obtienes un error porque v2 es None.

Este patrón es extremadamente común en código real y en código generado por IA. La única defensa es verificar el tipo antes de operar: if v2 is not None:.

— Anotaciones de tipo: documentar sin restringir —

Python es dinámicamente tipado, pero desde Python 3.5 puedes añadir anotaciones de tipo opcionales. Son una forma de documentar qué tipo esperas que tenga una variable o qué tipo devuelve una función.

Importante: Python no verifica las anotaciones en tiempo de ejecución. No son restricciones — son documentación para humanos e IDEs. Si pones x: int = 42 y luego haces x = "hola", Python no lanza ningún error.

Las verás constantemente en código generado por IA y en librerías modernas. Saber leerlas es imprescindible.

— Sintaxis básica en variables —

# Sin anotación — Python clásico
precio = 9.99
cantidad = 3

# Con anotación — documenta la intención
precio: float = 9.99
cantidad: int = 3
nombre: str = "Ana"
activo: bool = True

# Python no verifica — esto no da error
precio: float = "nueve con noventa y nueve"
print(type(precio))   # str, no float

— Sintaxis en funciones: lo que verás en código real —

# Sin anotaciones
def calcular_total(precio, cantidad):
    return precio * cantidad

# Con anotaciones — mucho más legible
# precio: float → espera un float
# cantidad: int  → espera un int
# -> float       → devuelve un float
def calcular_total(precio: float, cantidad: int) -> float:
    return precio * cantidad

# Funciona igual — las anotaciones no cambian el comportamiento
print(calcular_total(9.99, 3))
print(calcular_total("9.99", 3))  # ¿error o resultado inesperado?

La última línea no produce error — produce "9.999.999.99": Python repite el string tres veces porque str * int es concatenación. La anotación precio: float no impidió que pasáramos un string. Eso es el tipado dinámico: flexible pero silenciosamente peligroso.

— Código generado por IA: leer anotaciones —

# Típico código generado por IA — ¿puedes leer qué hace?
def procesar_ventas(
    datos: pd.DataFrame,
    columna: str,
    umbral: float = 0.0
) -> pd.Series:
    """Filtra y devuelve ventas por encima del umbral."""
    return datos[datos[columna] > umbral][columna]

# ¿Qué tipo espera cada parámetro?
# ¿Qué tipo devuelve?
# ¿Qué pasa si pasas una lista en lugar de un DataFrame?
  1. Ejecuta el primer bloque donde resultado cambia de tipo cuatro veces. Después de cada asignación, ¿qué operación fallaría? Por ejemplo: después del paso 3, ¿qué pasa si intentas resultado / 2?
  2. En el ejemplo de buscar_venta, ¿qué tipo devuelve la función cuando el mes no existe? ¿Qué error producirá v2 + 10 y por qué? ¿Cómo lo evitarías antes de operar?
  3. Ejecuta el bloque de anotaciones en variables. Después de precio: float = "nueve con noventa", ¿qué devuelve type(precio)? ¿Qué nos dice eso sobre la naturaleza de las anotaciones en Python?
  4. Mira la función procesar_ventas generada por IA. Sin ejecutarla: ¿qué tipo espera el parámetro datos? ¿Qué devuelve? ¿Qué valor tiene umbral si no lo especificas al llamar a la función?
Respuesta orientativa
a

Después del paso 1 (int): resultado.upper() fallaría — los enteros no tienen métodos de string. Después del paso 2 (float): resultado.split() fallaría — los floats tampoco. Después del paso 3 (str): resultado / 2 fallaría con TypeError — no puedes dividir un string. Después del paso 4 (list): resultado + 1 fallaría — no puedes sumar un número a una lista.

Hábito de lectura: cuando veas una variable en código ajeno, busca su última asignación para saber qué tipo tiene en ese momento. El tipo puede haber cambiado varias veces antes de llegar a la línea que estás leyendo.
b

Cuando el mes no existe, la función devuelve None — de tipo NoneType. v2 + 10 produce un TypeError: "unsupported operand type(s) for +: 'NoneType' and 'int'". La forma de evitarlo es verificar antes de operar:

if v2 is not None:
    print(v2 + 10)
else:
    print("Mes no encontrado")
c

type(precio) devuelve <class 'str'>, no float. La anotación : float no hizo nada — Python la ignoró completamente en tiempo de ejecución. Las anotaciones son documentación, no restricciones. Si quieres verificación real necesitarías herramientas externas como mypy o activar la verificación en tu IDE.

d

datos espera un pd.DataFrame. columna espera un str — el nombre de la columna a filtrar. umbral espera un float y tiene valor por defecto 0.0, así que si no lo especificas la función devuelve todas las ventas mayores que cero. La función devuelve un pd.Series.

Las anotaciones como documentación: aunque Python no las verifica, te dicen exactamente qué espera la función y qué devuelve. En código generado por IA son especialmente útiles — son la primera pista para entender qué tipo de objeto necesitas pasar y qué tipo recibirás de vuelta.
Mutabilidad e inmutabilidad: cuando dos etiquetas comparten un objeto teoría

En el Bloque 1 vimos que todo objeto tiene una propiedad llamada mutabilidad: algunos objetos pueden cambiar su valor después de ser creados (mutables) y otros no (inmutables). Ahora que entendemos las variables como etiquetas, podemos ver por qué esto importa tanto en la práctica.

El modelo de etiquetas crea una situación que sorprende a casi todo el mundo la primera vez: dos etiquetas pueden apuntar al mismo objeto. Si ese objeto es inmutable, no hay problema — cualquier operación crea un objeto nuevo. Si ese objeto es mutable, modificarlo desde una etiqueta afecta a todas las demás que apuntan a él.

— Con objetos inmutables: sin sorpresas —

Los números, strings y tuplas son inmutables. Cualquier operación sobre ellos crea un objeto nuevo. Por eso, reasignar una etiqueta nunca afecta a las demás: cada etiqueta se mueve de forma independiente.

# Dos etiquetas apuntando al mismo número
a = 42
b = a
print(f"Antes — a: {a}, b: {b}")
print(f"Mismo objeto: {a is b}, id(a): {hex(id(a))}, id(b): {hex(id(b))}")

# Reasignamos a — ¿cambia b?
a = 99
print(f"\nDespués — a: {a}, b: {b}")
print(f"Mismo objeto: {a is b}, id(a): {hex(id(a))}, id(b): {hex(id(b))}")

b no cambia. Al hacer a = 99, la etiqueta a se mueve al objeto 99, pero b sigue apuntando al objeto 42. El id de b no ha variado. Con objetos inmutables, las etiquetas son completamente independientes.

Mutabilidad e inmutabilidad: cuando dos etiquetas comparten un objeto teoría

En el Bloque 1 vimos que todo objeto tiene una propiedad llamada mutabilidad: algunos objetos pueden cambiar su valor después de ser creados (mutables) y otros no (inmutables). Ahora que entendemos las variables como etiquetas, podemos ver por qué esto importa tanto en la práctica.

El modelo de etiquetas crea una situación que sorprende a casi todo el mundo la primera vez: dos etiquetas pueden apuntar al mismo objeto. Si ese objeto es inmutable, no hay problema — cualquier operación crea un objeto nuevo. Si ese objeto es mutable, modificarlo desde una etiqueta afecta a todas las demás que apuntan a él.

— Con objetos inmutables: sin sorpresas —

Los números, strings y tuplas son inmutables. Cualquier operación sobre ellos crea un objeto nuevo. Por eso, reasignar una etiqueta nunca afecta a las demás: cada etiqueta se mueve de forma independiente.

# Dos etiquetas apuntando al mismo número
a = 42
b = a
print(f"Antes — a: {a}, b: {b}")
print(f"Mismo objeto: {a is b}, id(a): {hex(id(a))}, id(b): {hex(id(b))}")

# Reasignamos a — ¿cambia b?
a = 99
print(f"\nDespués — a: {a}, b: {b}")
print(f"Mismo objeto: {a is b}, id(a): {hex(id(a))}, id(b): {hex(id(b))}")

b no cambia. Al hacer a = 99, la etiqueta a se mueve al objeto 99, pero b sigue apuntando al objeto 42. El id de b no ha variado. Con objetos inmutables, las etiquetas son completamente independientes.

— Con objetos mutables: la sorpresa —

Las listas, diccionarios y conjuntos son mutables. Cuando dos etiquetas apuntan al mismo objeto mutable, modificarlo desde una etiqueta afecta a la otra. No porque estén "conectadas" — sino porque ambas apuntan al mismo objeto en memoria, y ese objeto ha cambiado en su lugar.

# Predice antes de ejecutar: ¿qué vale b después?
a = [1, 2, 3]
b = a
print(f"Antes — a: {a}, b: {b}")
print(f"Mismo objeto: {a is b}")
print(f"id(a): {hex(id(a))}, id(b): {hex(id(b))}")

# Modificamos a — ¿cambia b?
a.append(4)
print(f"\nDespués de a.append(4):")
print(f"a: {a}")
print(f"b: {b}")   # ¿qué esperas?
print(f"id(a): {hex(id(a))}, id(b): {hex(id(b))}")

b también cambia — aunque no hayas tocado b en ningún momento. Los id de a y b siguen siendo idénticos: es el mismo objeto. append no crea un objeto nuevo — modifica el objeto existente en su lugar. Ambas etiquetas lo ven porque ambas apuntan al mismo sitio.

Antes:   a ──┐
            ├──→ [1, 2, 3]
          b ──┘

Después: a ──┐
             ├──→ [1, 2, 3, 4]
           b ──┘

— Reasignar vs modificar: la diferencia clave —

# Predice: ¿qué vale b en cada caso?

# Caso 1: reasignación
a = [1, 2, 3]
b = a
a = [10, 20, 30]   # reasignamos a — nueva lista
print(f"Caso 1 — a: {a}, b: {b}")

# Caso 2: modificación
a = [1, 2, 3]
b = a
a[0] = 99           # modificamos el objeto — mismo id
print(f"Caso 2 — a: {a}, b: {b}")

En el Caso 1, a = [10, 20, 30] crea una lista nueva y mueve la etiqueta a a ese nuevo objeto. b sigue apuntando a la lista original [1, 2, 3] — no cambia.

En el Caso 2, a[0] = 99 modifica el objeto existente en su lugar — no crea uno nuevo. b apunta al mismo objeto, así que también ve el cambio: b es ahora [99, 2, 3].

La distinción clave: a = algo_nuevo mueve la etiqueta — no afecta a otras etiquetas. a[0] = 99 o a.append(4) modifica el objeto — afecta a todas las etiquetas que apuntan a él.

— La solución: copy() crea un objeto nuevo —

Si necesitas una copia independiente de una lista, usa .copy(). Esto crea un objeto nuevo con su propio id — modificar uno no afecta al otro.

# Sin copy: mismo objeto
a = [1, 2, 3]
b = a
print(f"Sin copy — mismo objeto: {a is b}")

# Con copy: objeto nuevo
a = [1, 2, 3]
b = a.copy()
print(f"Con copy — mismo objeto: {a is b}")
print(f"id(a): {hex(id(a))}, id(b): {hex(id(b))}")

# Ahora modificar a no afecta a b
a.append(4)
print(f"\nDespués de a.append(4):")
print(f"a: {a}")
print(f"b: {b}")   # b no cambia
Anticipo — copy superficial vs deepcopy: .copy() crea una copia superficial: los elementos de la lista se copian por referencia. Si la lista contiene otras listas dentro, esas listas internas siguen siendo el mismo objeto. Para copias completamente independientes existe copy.deepcopy() — lo veremos cuando trabajemos con estructuras de datos anidadas.
  1. Antes de ejecutar el bloque de mutables: predice qué vale b después de a.append(4). Luego ejecuta y verifica con hex(id()). ¿El id de a cambió? ¿Por qué?
  2. En el bloque de reasignar vs modificar: predice el resultado de cada caso antes de ejecutar. ¿En cuál cambia b y en cuál no? ¿Qué diferencia hay entre a = [10, 20, 30] y a[0] = 99 en términos de lo que ocurre en memoria?
  3. Ejecuta el bloque de copy(). Compara los id de a y b con y sin copy(). ¿Qué confirma eso sobre lo que hace copy() en memoria?
  4. Dado este fragmento de código generado por IA, sin ejecutarlo: ¿qué vale original al final?
    original = [10, 20, 30]
    procesado = original
    procesado.append(40)
    procesado[0] = 99
    print(original)
Respuesta orientativa
a

b vale [1, 2, 3, 4] — aunque no hayas tocado b. El id de a no cambia porque append modifica el objeto en su lugar — no crea uno nuevo. Ambas etiquetas siguen apuntando al mismo objeto en memoria, y ese objeto ahora contiene cuatro elementos.

b

En el Caso 1, b no cambia: sigue siendo [1, 2, 3]. a = [10, 20, 30] crea un objeto nuevo y mueve la etiqueta a — b no se mueve.

En el Caso 2, b sí cambia: pasa a ser [99, 2, 3]. a[0] = 99 modifica el objeto existente — el id no cambia y b ve la modificación porque apunta al mismo objeto.

La regla: si el id cambia, se ha creado un objeto nuevo y las demás etiquetas no se ven afectadas. Si el id no cambia, el objeto ha sido modificado en su lugar y todas las etiquetas lo ven.
c

Con copy(), los id de a y b son distintos: son dos objetos diferentes en memoria con el mismo contenido inicial. Modificar uno no afecta al otro porque ya no comparten identidad.

d

original vale [99, 20, 30, 40]. procesado = original no crea una copia — hace que procesado apunte al mismo objeto que original. Todas las modificaciones sobre procesado — el append y el cambio del primer elemento — se aplican al objeto compartido, que también es original.

Error típico en análisis de datos: hacer df_procesado = df_original y luego modificar df_procesado pensando que df_original está protegido. No lo está — es el mismo objeto. La solución es df_procesado = df_original.copy().

Anticipo — ¿qué es un view?

En NumPy y pandas existe un concepto relacionado con todo lo que acabamos de ver: el view. Un view no es una copia — es una ventana al mismo objeto en memoria. Cuando creas un view de un array o un DataFrame, no estás creando un objeto nuevo: estás creando una etiqueta que apunta a una porción del objeto original.

Esto tiene exactamente las mismas consecuencias que hemos visto con las listas: modificar el view modifica el original, porque ambos comparten el mismo espacio en memoria.

¿Para qué sirve entonces? Para eficiencia: si tienes un array de un millón de filas y solo necesitas trabajar con las primeras mil, un view no duplica los datos en memoria — simplemente apunta a esa porción. Es más rápido y consume menos memoria. Pero hay que saber cuándo tienes un view y cuándo tienes una copia, o los resultados pueden sorprenderte.

Lo veremos en detalle cuando trabajemos con NumPy y pandas en clases posteriores. Por ahora, quédate con la idea: un view es una etiqueta que apunta a una parte de otro objeto — el mismo modelo que hemos visto hoy, aplicado a estructuras de datos grandes.

Parte III

Los cuatro tipos básicos y sus operadores

int y float: números enteros y decimales teoría

Python distingue entre dos tipos numéricos principales. Los enteros (int) representan números sin parte decimal — pueden ser arbitrariamente grandes, limitados solo por la memoria disponible. Los decimales (float) representan números con parte decimal — internamente se almacenan en formato de punto flotante de doble precisión, lo que introduce limitaciones de precisión que conviene conocer.

La documentación oficial los define como objetos inmutables: una vez creados, su valor no cambia. Cualquier operación aritmética produce un objeto nuevo. → fuente

— Crear y verificar tipos numéricos —

# int: enteros sin límite de tamaño
alumnos = 27
poblacion_españa = 47_400_000   # _ como separador visual
numero_grande = 10 ** 100        # un googol — sin problema

print(type(alumnos))
print(type(poblacion_españa))
print(numero_grande)

# float: decimales en punto flotante
tasa_iva = 0.21
pi = 3.14159
notacion_cientifica = 1.5e10   # 1.5 × 10^10

print(type(tasa_iva))
print(notacion_cientifica)

— Operadores aritméticos —

Python tiene siete operadores aritméticos. Lo más importante para la lectura de código: el resultado de combinar un int con un float siempre es un float. Y la división / siempre devuelve un float, incluso si el resultado es exacto.

a = 17
b = 5

print(a + b,   type(a + b))    # suma
print(a - b,   type(a - b))    # resta
print(a * b,   type(a * b))    # multiplicación
print(a / b,   type(a / b))    # división real — siempre float
print(a // b,  type(a // b))   # división entera — descarta decimal
print(a % b,   type(a % b))    # módulo — resto de la división
print(a ** b,  type(a ** b))   # potencia

— La trampa de los floats: precisión limitada —

Los float se almacenan en binario, lo que hace que algunos decimales no puedan representarse exactamente. Esto no es un bug de Python — es una limitación del estándar IEEE 754 que afecta a todos los lenguajes de programación. Es especialmente importante en análisis financiero y en comparaciones de igualdad.

# Predice antes de ejecutar
print(0.1 + 0.2)           # ¿0.3?
print(0.1 + 0.2 == 0.3)    # ¿True o False?

# La solución: round() para comparaciones
print(round(0.1 + 0.2, 10) == round(0.3, 10))

# O usar el módulo decimal para cálculos financieros
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))

— Operadores de comparación: producen bool —

Los operadores de comparación toman dos números y devuelven un bool — True o False. Son el puente entre los tipos numéricos y el tipo booleano que veremos en el Bloque 3. Aparecen constantemente en código real: en condiciones if, en filtros de pandas, en bucles.

ventas = 150
umbral = 100

print(ventas > umbral,   type(ventas > umbral))   # mayor que
print(ventas < umbral)                              # menor que
print(ventas >= umbral)                             # mayor o igual
print(ventas <= umbral)                             # menor o igual
print(ventas == umbral)                             # igual
print(ventas != umbral)                             # distinto

# Comparaciones encadenadas — exclusivo de Python
nota = 7.5
print(5 <= nota < 9)    # ¿aprobado pero no sobresaliente?
  1. Ejecuta el bloque de operadores aritméticos. ¿Qué tipo devuelve 17 / 5? ¿Y 17 // 5? ¿Qué diferencia práctica tiene esto cuando necesitas un índice entero para acceder a una lista?
  2. Antes de ejecutar el bloque de floats: predice qué devuelve 0.1 + 0.2 y si 0.1 + 0.2 == 0.3 es True o False. ¿Te sorprende el resultado? ¿Qué implicación tiene esto si estás calculando precios o importes financieros?
  3. ¿Qué devuelve 17 % 5? ¿Para qué sirve el operador módulo en análisis de datos? Pista: piensa en cómo detectar si un número es par o impar.
  4. Dado este fragmento, predice el resultado de cada línea antes de ejecutar:
    x = 10
    y = 3.0
    print(type(x + y))
    print(type(x // y))
    print(x > y)
    print(type(x > y))
Respuesta orientativa
a

17 / 5 devuelve 3.4 de tipo float — siempre, incluso cuando el resultado es exacto: 10 / 2 devuelve 5.0, no 5. 17 // 5 devuelve 3 de tipo int — descarta el decimal. Cuando necesitas un índice entero para acceder a una lista — lista[i] — el índice debe ser int, no float. Si usas / para calcularlo, obtendrás un TypeError. Usa // en ese caso.

b

0.1 + 0.2 devuelve 0.30000000000000004, no 0.3. Y 0.1 + 0.2 == 0.3 es False. La implicación financiera es directa: nunca uses float para acumular importes monetarios en un bucle — el error de precisión se acumula. Usa Decimal o trabaja con céntimos como enteros.

c

17 % 5 devuelve 2: el resto de dividir 17 entre 5. En análisis de datos el módulo tiene varios usos prácticos: detectar pares (n % 2 == 0), agrupar datos en bloques de tamaño fijo, o calcular posiciones cíclicas en una secuencia.

d

type(x + y) es float: mezclar int y float siempre produce float. type(x // y) también es float: aunque sea división entera, si uno de los operandos es float el resultado es float — en este caso 3.0, no 3. x > y es True y su tipo es bool — los operadores de comparación siempre devuelven un booleano.

Regla de promoción de tipos: cuando operas con int y float juntos, Python siempre promociona el resultado a float — el tipo más general. Es una de las reglas más importantes para leer código numérico sin ejecutarlo.
str: cadenas de texto teoría

Un str es una secuencia inmutable de caracteres Unicode. Inmutable significa que no puedes cambiar un carácter dentro de un string existente — cualquier operación que "modifica" un string crea un objeto nuevo. Unicode significa que puede contener cualquier carácter de cualquier idioma — letras, números, emojis, caracteres especiales.

En análisis de datos los strings aparecen en todas partes: nombres de columnas, categorías, fechas mal formateadas, códigos de producto, mensajes de error. Saber leer operaciones sobre strings es imprescindible.

— Crear strings: tres formas —

# Comillas simples, dobles o triples — el resultado es el mismo
s1 = 'hola'
s2 = "hola"
s3 = """hola
mundo"""                        # string multilínea

print(type(s1), s1 == s2)        # mismo tipo, mismo valor
print(s3)                         # conserva los saltos de línea

# Las comillas triples son útiles para docstrings
def calcular(x):
    """Esta función calcula algo."""   # docstring
    return x * 2

print(type(calcular.__doc__))     # el docstring es un str

— Strings como secuencias: indexación y longitud —

Un string es una secuencia — puedes acceder a cada carácter por su posición usando corchetes. La primera posición es 0, no 1. Las posiciones negativas cuentan desde el final. Esto se llama indexación y funciona igual en listas, tuplas y cualquier otra secuencia.

ciudad = "Madrid"

print(len(ciudad))        # longitud: 6
print(ciudad[0])          # primer carácter: 'M'
print(ciudad[-1])         # último carácter: 'd'
print(ciudad[1:4])        # slice: 'adr'
print(ciudad[:3])         # primeros 3: 'Mad'
print(ciudad[3:])         # desde pos 3: 'rid'

# Inmutabilidad: no puedes cambiar un carácter
try:
    ciudad[0] = "m"        # ¿qué error produce?
except TypeError as e:
    print(f"Error: {e}")

— Operadores sobre strings —

# Concatenación con +
nombre = "Ana"
apellido = "García"
nombre_completo = nombre + " " + apellido
print(nombre_completo)
print(type(nombre_completo))

# Repetición con *
separador = "-" * 30
print(separador)

# Pertenencia con in
print("Ana" in nombre_completo)    # True
print("Pedro" in nombre_completo)  # False

# Comparación — compara lexicográficamente
print("ana" == "Ana")   # ¿True o False?
print("a" < "b")        # orden alfabético

— f-strings: la forma moderna de formatear —

Los f-strings (desde Python 3.6) son la forma más limpia de construir strings con variables. Permiten insertar expresiones Python directamente dentro del texto usando {}. Los verás constantemente en código moderno y en código generado por IA.

producto = "manzanas"
cantidad = 5
precio = 2.5

# Forma antigua — concatenación
msg1 = "He comprado " + str(cantidad) + " " + producto
print(msg1)

# Forma moderna — f-string
msg2 = f"He comprado {cantidad} {producto} por {precio:.2f} euros"
print(msg2)

# Expresiones dentro del f-string
msg3 = f"Total: {cantidad * precio:.2f} euros"
print(msg3)

# Alineación y formato
print(f"{'Producto':15} {'Precio':>8}")
print(f"{'Manzanas':15} {2.50:>8.2f}")
print(f"{'Naranjas':15} {1.80:>8.2f}")

— Métodos de strings: operaciones frecuentes —

Los strings tienen métodos propios — funciones que pertenecen al objeto y se llaman con la notación objeto.metodo(). Todos devuelven un string nuevo — recuerda que los strings son inmutables. Estos son los que más verás en análisis de datos:

texto = "  Ventas del Mes de Enero  "

# Limpieza — muy común con datos de CSVs
print(texto.strip())          # elimina espacios al inicio y final
print(texto.strip().lower())  # todo en minúsculas
print(texto.strip().upper())  # todo en mayúsculas

# Búsqueda y reemplazo
print(texto.strip().replace("Enero", "Febrero"))

# División — muy útil para parsear datos
fecha = "2024-01-15"
partes = fecha.split("-")
print(partes)                  # ['2024', '01', '15']
print(type(partes))            # ¿qué tipo devuelve split?

# Verificación
codigo = "ABC123"
print(codigo.isalpha())        # ¿solo letras?
print(codigo.isdigit())        # ¿solo dígitos?
print(codigo.startswith("ABC")) # ¿empieza por ABC?
Los strings no son números aunque lo parezcan: "2024" es un string, no un entero. "2024" + 1 produce un TypeError. "2024" + "01" produce "202401", no 2025. Esto es uno de los errores más frecuentes cuando los datos vienen de un CSV o una API — los números llegan como strings y hay que convertirlos explícitamente con int() o float().
  1. Ejecuta el bloque de indexación. ¿Qué error produce ciudad[0] = "m"? ¿Cómo crearías un string nuevo con la primera letra en minúscula sin modificar el original?
  2. Antes de ejecutar: predice qué devuelve "ana" == "Ana". ¿Por qué? ¿Cómo lo resolverías si necesitas comparar nombres independientemente de mayúsculas y minúsculas?
  3. Ejecuta el bloque de métodos. ¿Qué tipo devuelve fecha.split("-")? ¿Cómo accederías solo al año? ¿Y cómo lo convertirías a entero?
  4. Dado este fragmento típico de limpieza de datos, predice el resultado de cada línea:
    valor = "  42.5  "
    print(type(valor))
    print(valor.strip())
    print(type(valor.strip()))
    print(float(valor.strip()))
    print(type(float(valor.strip())))
Respuesta orientativa
a

Produce un TypeError: "'str' object does not support item assignment". Los strings son inmutables — no puedes cambiar un carácter en su lugar. Para crear un string con la primera letra en minúscula sin modificar el original:

nuevo = ciudad[0].lower() + ciudad[1:]
print(nuevo)   # "madrid"
print(ciudad)  # "Madrid" — sin cambios

O más elegante: ciudad.lower() convierte todo a minúsculas, ciudad[0].lower() + ciudad[1:] solo la primera letra.

b

"ana" == "Ana" es False. Python distingue mayúsculas de minúsculas en la comparación de strings — "a" y "A" son caracteres distintos. Para comparar independientemente de mayúsculas:

print("ana".lower() == "Ana".lower())  # True

Es un patrón muy frecuente en limpieza de datos: normalizar todo a minúsculas antes de comparar o agrupar categorías.

c

split() devuelve una list — una lista de strings. Para acceder al año: partes[0], que devuelve el string "2024". Para convertirlo a entero: int(partes[0]), que devuelve el entero 2024.

Patrón frecuente en datos: año = int(fecha.split("-")[0]) — partir una fecha como string y extraer el componente que necesitas como entero.
d

type(valor) → str. valor.strip() → "42.5" — sigue siendo str, solo sin espacios. type(valor.strip()) → str. float(valor.strip()) → 42.5 — ahora sí es un número. type(float(valor.strip())) → float.

El patrón completo de limpieza: float(valor.strip()) es la forma estándar de convertir un número que llegó como string con espacios a un float operable. Es una de las líneas más frecuentes en pipelines de limpieza de datos reales.
bool: el tipo que controla toda la lógica de un programa teoría

El tipo bool tiene solo dos valores posibles: True y False. Es el tipo más pequeño de Python — pero es el mecanismo que controla toda la lógica de cualquier programa.

Cada vez que Python decide si ejecutar un bloque de código o no — cada if, cada while, cada filtro de pandas, cada condición en un bucle — está evaluando algo a True o False. Sin entender booleanos no puedes leer ningún programa real, por sencillo que sea.

La documentación oficial añade algo que sorprende: "The Boolean type is a subtype of the integer type, and Boolean values behave like the values 0 and 1, respectively, in almost all contexts." → fuente

— bool como subtipo de int: True == 1, False == 0 —

bool es un subtipo de int. Esto no es una curiosidad — tiene consecuencias prácticas muy concretas. True se comporta como 1 y False como 0 en operaciones aritméticas. Esto permite contar cuántos elementos cumplen una condición con sum() — uno de los patrones más frecuentes en análisis de datos.

# bool es subtipo de int
print(type(True))
print(type(False))
print(isinstance(True, int))   # ¿True es un int?

# True == 1, False == 0
print(True == 1)
print(False == 0)
print(True + True)      # predice el resultado
print(True + 5)          # ¿y este?
print(False * 100)       # ¿y este?

# Contar cuántos elementos cumplen una condición
notas = [4.5, 7.0, 3.0, 8.5, 5.0, 9.0, 4.0]
aprobados = [nota >= 5 for nota in notas]
print(aprobados)           # lista de booleanos
print(sum(aprobados))      # cuenta los True — ¿cuántos aprobados?

— Operadores lógicos: and, or, not —

Los tres operadores lógicos combinan condiciones booleanas. Son los que permiten expresar lógica compleja en una sola línea: "si llueve Y no tengo paraguas", "si es fin de semana O es festivo". Aparecen en cada if con condiciones múltiples y en cada filtro de pandas con varias condiciones.

# Tabla de verdad — ejecuta y observa
print("=== AND ===")
print(True  and True)    # ambos verdaderos → True
print(True  and False)   # uno falso → False
print(False and True)    # False primero → False
print(False and False)   # ambos falsos → False

print("=== OR ===")
print(True  or True)     # alguno verdadero → True
print(True  or False)    # alguno verdadero → True
print(False or True)     # alguno verdadero → True
print(False or False)    # ambos falsos → False

print("=== NOT ===")
print(not True)           # invierte → False
print(not False)          # invierte → True

— Cortocircuito: Python no evalúa más de lo necesario —

Python evalúa las condiciones de izquierda a derecha y se detiene en cuanto conoce el resultado final. Con and: si el primer operando es False, el resultado ya es False — no evalúa el segundo. Con or: si el primer operando es True, el resultado ya es True — no evalúa el segundo. Esto se llama evaluación de cortocircuito y tiene consecuencias prácticas importantes.

# Cortocircuito con and: si el primero es False, el segundo no se evalúa
def condicion_costosa():
    print("  → evaluando condición costosa...")
    return True

print("Con False and ...:")
resultado = False and condicion_costosa()   # ¿se llama la función?
print(f"Resultado: {resultado}")

print("Con True and ...:")
resultado = True and condicion_costosa()    # ¿se llama la función?
print(f"Resultado: {resultado}")

# Uso práctico: evitar errores con cortocircuito
lista = []
# Sin cortocircuito: lista[0] daría IndexError si lista está vacía
# Con cortocircuito: si lista es vacía (falsy), no evalúa lista[0]
if lista and lista[0] > 5:
    print("El primer elemento es mayor que 5")
else:
    print("Lista vacía o primer elemento <= 5")

— Valores truthy y falsy: todo objeto tiene un valor de verdad —

En Python no necesitas comparar explícitamente con True o False — cualquier objeto puede usarse directamente en una condición. Python lo convierte implícitamente a booleano. Los valores que se comportan como False se llaman falsy; el resto son truthy.

Son falsy: 0, 0.0, "", [], {}, (), None. Todo lo demás es truthy. Esta es una de las características más importantes para leer código Python real — aparece en todas partes.

# Predice antes de ejecutar: ¿truthy o falsy?
valores = [0, 1, "", "hola", [], [1, 2], None, 0.0, 3.14]

for v in valores:
    print(f"{repr(v):12} → {bool(v)}")

# Uso en código real — estas dos formas son equivalentes
nombre = ""

# Forma explícita
if nombre != "":
    print(f"Hola, {nombre}")

# Forma pythónica — más común en código real
if nombre:
    print(f"Hola, {nombre}")
else:
    print("Nombre vacío")

— Booleanos en análisis de datos —

En pandas, los filtros producen Series de booleanos — una máscara que indica qué filas cumplen la condición. Entender que un filtro de pandas es simplemente una lista de True y False es lo que te permite combinarlos, invertirlos y entender qué está haciendo el código.

import pandas as pd

df = pd.DataFrame({
    "producto": ["A", "B", "C", "D", "E"],
    "ventas":   [120, 45, 200, 80, 310],
    "activo":   [True, False, True, True, False]
})

# Un filtro produce una Serie de booleanos
mascara = df["ventas"] > 100
print(type(mascara))
print(mascara)

# Aplicar la máscara para filtrar filas
print(df[mascara])

# Combinar condiciones con & (and) y | (or)
# Nota: en pandas se usa & y | en lugar de and y or
filtro = (df["ventas"] > 100) & (df["activo"] == True)
print(df[filtro])

# Contar cuántas filas cumplen la condición
print(f"Productos con ventas > 100: {mascara.sum()}")
print(f"Porcentaje: {mascara.mean() * 100:.1f}%")
El patrón completo: en pandas, un filtro como df["ventas"] > 100 devuelve una Serie de booleanos — una por cada fila. Esa Serie se puede usar directamente para filtrar el DataFrame, combinarla con otras condiciones con & y |, invertirla con ~, o usar sum() y mean() para contar y calcular porcentajes. Todo esto funciona porque los booleanos son subtipos de int.
  1. Ejecuta el bloque de bool como subtipo de int. Predice antes: ¿qué devuelve True + True? ¿Y True + 5? ¿Y False * 100? ¿Cuántos aprobados hay en la lista de notas?
  2. Ejecuta el bloque de cortocircuito. ¿Se llama condicion_costosa() cuando el primer operando es False? ¿Y cuando es True? ¿Qué ventaja práctica tiene esto en un programa real?
  3. Antes de ejecutar el bloque de truthy y falsy: predice qué devuelve bool() para cada valor de la lista. ¿Cuáles te sorprenden? ¿Por qué bool([]) es False pero bool([0]) es True?
  4. En el bloque de pandas: ¿qué tipo tiene mascara? ¿Cuántos productos tienen ventas mayores de 100? ¿Qué devuelve mascara.mean() y qué representa ese número?
  5. Dado este fragmento, predice el resultado antes de ejecutar:
    x = 5
    y = 10
    z = 3
    
    print(x > z and y > x)
    print(x > y or z < x)
    print(not (x == z))
    print(x > z > y)        # ¿qué hace esto?
    print(bool(x - 5))      # ¿True o False?
Respuesta orientativa
a

True + True → 2 porque True == 1. True + 5 → 6. False * 100 → 0 porque False == 0. En la lista de notas hay 4 aprobados (notas ≥ 5): 7.0, 8.5, 5.0 y 9.0. sum(aprobados) cuenta los True sumándolos como enteros.

b

Cuando el primer operando es False, condicion_costosa() no se llama — no aparece el mensaje en pantalla. Python sabe que False and X siempre es False sin necesidad de evaluar X. Cuando es True, sí se llama.

Ventaja práctica: el cortocircuito permite escribir condiciones seguras sin verificaciones adicionales. if lista and lista[0] > 5 nunca lanza IndexError porque si lista está vacía (falsy), Python no evalúa lista[0].
c

Falsy: 0, "", [], None, 0.0. Truthy: 1, "hola", [1, 2], 3.14. bool([]) es False porque la lista está vacía — no tiene elementos. bool([0]) es True porque la lista tiene un elemento — aunque ese elemento sea 0, la lista en sí no está vacía. Python evalúa la verdad del contenedor, no de su contenido.

d

mascara es una pd.Series de booleanos. Hay 3 productos con ventas mayores de 100: A (120), C (200) y E (310). mascara.mean() devuelve 0.6 — el 60% de los productos tienen ventas mayores de 100. Funciona porque mean() trata los booleanos como 0 y 1, y la media de una lista de ceros y unos es la proporción de unos.

e

x > z and y > x → 5 > 3 and 10 > 5 → True and True → True. x > y or z < x → False or True → True. not (x == z) → not False → True. x > z > y → 5 > 3 > 10 → False: Python evalúa comparaciones encadenadas como 5 > 3 and 3 > 10. bool(x - 5) → bool(0) → False: 0 es falsy.

La trampa de las comparaciones encadenadas: x > z > y no significa (x > z) > y — significa x > z and z > y. En este caso 5 > 3 es True pero 3 > 10 es False, así que el resultado es False.
Conversión entre tipos: cuándo y cómo teoría

Python no convierte tipos automáticamente — salvo en casos muy concretos como mezclar int y float en una operación aritmética. En el resto de casos, si necesitas un tipo distinto al que tienes, debes convertirlo tú explícitamente. Esto se llama casting.

En análisis de datos el casting es una operación cotidiana: los datos casi siempre llegan con el tipo incorrecto. Números que vienen como strings de un CSV, fechas como texto, booleanos como 0 y 1. Saber cuándo convertir y qué puede fallar es una de las habilidades más prácticas de este curso.

— Las cuatro funciones de conversión básicas —

# int() — convierte a entero
print(int("42"))        # str → int
print(int(3.9))         # float → int: ¿redondea o trunca?
print(int(True))        # bool → int
print(int(False))       # bool → int

# float() — convierte a decimal
print(float("3.14"))    # str → float
print(float(42))        # int → float
print(float(True))      # bool → float

# str() — convierte a texto
print(str(42))           # int → str
print(str(3.14))         # float → str
print(str(True))         # bool → str
print(str(None))         # None → str: ¿qué devuelve?

# bool() — convierte a booleano
print(bool(0))           # 0 → False
print(bool(42))          # cualquier otro número → True
print(bool(""))          # string vacío → False
print(bool("False"))     # ¿True o False? Piensa bien

Dos resultados que suelen sorprender: int(3.9) devuelve 3 — trunca, no redondea. Si necesitas redondear, usa round(). Y bool("False") devuelve True — porque "False" es un string no vacío, y cualquier string no vacío es truthy. Para convertir el string "True"/"False" a booleano correctamente necesitas lógica explícita, no bool().

— Conversiones que fallan: qué error producen —

No todas las conversiones son posibles. Antes de ejecutar cada línea, predice si fallará y qué tipo de error producirá.

# Predice: ¿cuáles fallan y cuáles no?
conversiones = [
    ("int('3.14')",   lambda: int("3.14")),    # ¿funciona?
    ("int('42')",    lambda: int("42")),      # ¿funciona?
    ("int('hola')",  lambda: int("hola")),    # ¿funciona?
    ("float('abc')", lambda: float("abc")),   # ¿funciona?
    ("int(None)",    lambda: int(None)),      # ¿funciona?
    ("float('1e3')", lambda: float("1e3")),   # ¿funciona?
]

for nombre, fn in conversiones:
    try:
        resultado = fn()
        print(f"✓ {nombre:20} → {resultado} ({type(resultado).__name__})")
    except Exception as e:
        print(f"✗ {nombre:20} → {type(e).__name__}: {e}")

— Conversión implícita: los pocos casos donde Python convierte solo —

Python convierte tipos automáticamente solo en casos muy específicos y predecibles. Conocerlos evita sorpresas al leer código ajeno.

# int + float → float siempre
resultado = 3 + 1.0
print(type(resultado), resultado)

# bool en operaciones aritméticas → int
print(True + 1)      # 2
print(False + 1.5)   # 1.5

# Python NO convierte automáticamente en estos casos
try:
    print("42" + 1)   # str + int → TypeError
except TypeError as e:
    print(f"TypeError: {e}")

try:
    print("3.14" * 2)  # str * int → repetición, no multiplicación
except Exception as e:
    print(f"Error: {e}")
else:
    print("No hubo error — ¿el resultado es el esperado?")

— Caso real: pipeline de limpieza de datos —

Este es el patrón más frecuente en análisis de datos reales: los valores llegan como strings desde un CSV o una API y hay que convertirlos antes de operar. Algunos valores pueden ser incorrectos — hay que manejarlos sin que el programa se detenga.

# Datos típicos de un CSV mal formateado
datos_crudos = [
    {"producto": "A", "ventas": "1200",  "activo": "1"},
    {"producto": "B", "ventas": "  850", "activo": "0"},
    {"producto": "C", "ventas": "N/A",   "activo": "1"},
    {"producto": "D", "ventas": "2100",  "activo": "1"},
]

datos_limpios = []
for fila in datos_crudos:
    try:
        ventas = int(fila["ventas"].strip())   # strip + conversión
        activo = bool(int(fila["activo"]))     # "1"→1→True, "0"→0→False
        datos_limpios.append({
            "producto": fila["producto"],
            "ventas": ventas,
            "activo": activo
        })
    except ValueError as e:
        print(f"Fila ignorada — {fila['producto']}: {e}")

print("\nDatos limpios:")
for fila in datos_limpios:
    print(fila)

# Ahora podemos operar correctamente
total = sum(f["ventas"] for f in datos_limpios)
print(f"\nTotal ventas válidas: {total}")
print(f"Productos activos: {sum(f['activo'] for f in datos_limpios)}")
  1. Ejecuta el bloque de las cuatro funciones. ¿Qué devuelve int(3.9)? ¿Redondea o trunca? ¿Y bool("False")? ¿Por qué ese resultado?
  2. Antes de ejecutar el bloque de conversiones que fallan: predice cuáles funcionarán y cuáles lanzarán error. ¿Qué tipo de error produce cada caso fallido?
  3. En el bloque de conversión implícita, "3.14" * 2 no lanza error. ¿Qué devuelve? ¿Es el resultado que esperabas? ¿Cómo lo convertirías para obtener la multiplicación numérica correcta?
  4. En el pipeline de limpieza: ¿qué fila se ignora y por qué? ¿Qué error produce exactamente? ¿Cómo modificarías el código para guardar las filas con error en lugar de ignorarlas?
Respuesta orientativa
a

int(3.9) devuelve 3 — trunca hacia cero, no redondea. int(-3.9) devuelve -3, no -4. Si necesitas redondear al entero más cercano usa round(3.9) que devuelve 4.

bool("False") devuelve True porque "False" es un string no vacío — y cualquier string no vacío es truthy. bool() no interpreta el contenido del string, solo si está vacío o no. Para convertir correctamente:

valor = "False"
booleano = valor.strip().lower() == "true"
print(booleano)   # False
b

int("3.14") → falla con ValueError: no puede convertir un string con decimal a entero directamente. Solución: int(float("3.14")). int("42") → funciona: 42. int("hola") → falla con ValueError. float("abc") → falla con ValueError. int(None) → falla con TypeError: tipo incompatible. float("1e3") → funciona: 1000.0 — Python entiende notación científica en strings.

c

"3.14" * 2 devuelve "3.143.14" — Python repite el string dos veces porque str * int es repetición. No hay error pero el resultado es completamente incorrecto para una multiplicación numérica. Para obtener 6.28:

print(float("3.14") * 2)   # 6.28
Error silencioso: "3.14" * 2 no lanza ningún error — produce un resultado que parece válido pero es incorrecto. Es exactamente el tipo de error que no detectas sin verificar el tipo antes de operar.
d

Se ignora la fila del producto "C" porque int("N/A".strip()) lanza un ValueError: "N/A" no es convertible a entero. Para guardar las filas con error en lugar de ignorarlas:

datos_limpios = []
datos_error = []
for fila in datos_crudos:
    try:
        ventas = int(fila["ventas"].strip())
        activo = bool(int(fila["activo"]))
        datos_limpios.append({
            "producto": fila["producto"],
            "ventas": ventas,
            "activo": activo
        })
    except ValueError as e:
        datos_error.append({**fila, "error": str(e)})
Patrón profesional: separar datos válidos de datos con error es una práctica estándar en pipelines de datos reales. Los datos con error se guardan para revisión manual en lugar de descartarse silenciosamente.
Parte IV

Tareas para casa

Tareas para antes de la Clase 3 tareas

Tres tipos de tareas: dos vídeos, ejercicios de práctica y ejercicios de profundización. Los ejercicios se hacen en JupyterLab, cada uno en su propia celda. Los de profundización son opcionales — para quien quiera ir más allá.

— Tarea 1: vídeo —

Vídeo How variables work in Python — Explained with Animations

Este vídeo explica con animaciones exactamente el modelo de etiquetas que hemos trabajado hoy — incluyendo la representación interna de los objetos en memoria (PyObject). Es el complemento visual perfecto para consolidar la Parte II.

▶ Ver vídeo — How variables work in Python

Mientras lo ves, anota: ¿qué tres cosas del vídeo conectan con lo que hemos visto hoy en clase? ¿Hay algo que te sorprenda o que contradiga lo que creías entender? Lo discutiremos al inicio de la Clase 3.


El vídeo a continuación está mas relacionado al pensamiento crítico: analiza cómo está cambiando la programación con la llegada de los asistentes de IA. Presenta distintos niveles de trabajo con LLMs: desde usar IA como autocompletado hasta delegar tareas completas mediante especificaciones y revisión de resultados.

Aunque algunas afirmaciones son deliberadamente provocadoras, el vídeo plantea una cuestión muy relevante para este curso: ¿qué habilidades siguen siendo humanas cuando la IA también escribe código?

▶ Ver vídeo — The 5 Levels of AI Coding

Mientras lo ves, piensa en estas preguntas: ¿en qué nivel dirías que trabajas tú actualmente? ¿Qué cosas haces “de verdad” tú y cuáles delegas ya en la IA? ¿Qué significa realmente comprender un programa cuando parte del código ha sido generado automáticamente?


— Tarea 2: ejercicios de práctica —

Cálculo del IMC práctica

Crea variables peso = 70 (kg) y altura = 1.75 (m). Calcula el IMC con la fórmula peso / altura² y muestra el resultado redondeado a 2 decimales con un f-string descriptivo.

Solución
→
peso = 70
altura = 1.75

imc = peso / (altura ** 2)
print(f"Peso: {peso} kg | Altura: {altura} m | IMC: {imc:.2f}")

** es el operador de potencia. Los paréntesis son necesarios — sin ellos peso / altura ** 2 calcularía peso / (altura ** 2) por la precedencia de operadores, que en este caso coincide, pero es mejor ser explícito.

Cálculo de descuentos práctica

Crea variables precio_original = 49.99 y descuento_porcentaje = 15. Calcula cuánto dinero ahorras y el precio final. Muestra todo con f-strings y 2 decimales.

Solución
→
precio_original = 49.99
descuento_porcentaje = 15

descuento_euros = precio_original * (descuento_porcentaje / 100)
precio_final = precio_original - descuento_euros

print(f"Precio original: {precio_original:.2f}€")
print(f"Descuento ({descuento_porcentaje}%): -{descuento_euros:.2f}€")
print(f"Precio final: {precio_final:.2f}€")
Operadores lógicos: ¿aplicar descuento? práctica

Crea variables booleanas: es_estudiante = True, tiene_descuento = False, es_fin_semana = True. Usa operadores lógicos para decidir si aplicar descuento — solo si es estudiante Y además es fin de semana O tiene descuento. Antes de ejecutar, predice el resultado.

Solución
→
es_estudiante = True
tiene_descuento = False
es_fin_semana = True

aplicar = es_estudiante and (es_fin_semana or tiene_descuento)
print(f"¿Aplicar descuento? {aplicar}")

Los paréntesis son imprescindibles aquí. Sin ellos, and tiene mayor precedencia que or y el resultado sería distinto.

Métodos de string: limpieza de texto práctica

Crea texto = " Hola Mundo Python ". Usando métodos de string: elimina los espacios, convierte a mayúsculas, cuenta las palabras y verifica si contiene "Python". Muestra cada resultado con su tipo.

Solución
→
texto = "  Hola Mundo Python  "

limpio = texto.strip()
mayusculas = limpio.upper()
palabras = limpio.split()
contiene = "Python" in limpio

print(f"Limpio:    '{limpio}'")
print(f"Mayúscula: '{mayusculas}'")
print(f"Palabras:  {len(palabras)} → {palabras}")
print(f"Python:    {contiene} ({type(contiene).__name__})")

— Tarea 3: profundización (opcional) —

Referencias e identidad: is vs == profundización

Crea a = [1, 2, 3], b = a y c = [1, 2, 3]. Usa id(), is y == para explorar la diferencia entre identidad y igualdad. Luego haz a.append(4) y observa qué pasa con b y c. Predice antes de ejecutar.

Solución
→
a = [1, 2, 3]
b = a
c = [1, 2, 3]

print(f"a == b: {a == b}, a is b: {a is b}")  # True, True
print(f"a == c: {a == c}, a is c: {a is c}")  # True, False

a.append(4)
print(f"\nb: {b}")  # cambia — mismo objeto
print(f"c: {c}")  # no cambia — objeto distinto
Clave: == compara valores — is compara identidades. Dos objetos pueden ser iguales (==) sin ser el mismo objeto (is).
+= con listas vs números: comportamiento distinto profundización

Crea a = [1, 2] y b = a. Ejecuta a += [3] y observa qué pasa con el id de a y con b. Luego repite con a = a + [4]. Compara el comportamiento con números: x = 5, y = x, x += 3. Predice antes de ejecutar cada paso.

Solución
→
# Con listas: += modifica en lugar — mismo id
a = [1, 2]
b = a
print(f"Antes: id(a)={hex(id(a))}, b={b}")
a += [3]
print(f"Después +=: id(a)={hex(id(a))}, b={b}")  # b cambia

a = a + [4]
print(f"Después = +: id(a)={hex(id(a))}, b={b}")   # b no cambia

# Con números: += siempre crea objeto nuevo
x = 5
y = x
x += 3
print(f"\nx={x}, y={y}")   # y no cambia
Sorpresa: con listas, a += [3] y a = a + [3] no son equivalentes — el primero modifica el objeto en su lugar, el segundo crea uno nuevo. Con números siempre crean objeto nuevo.
Precedencia de operadores: evaluación paso a paso profundización

Con x = 5 y y = 2, evalúa paso a paso esta expresión antes de ejecutarla:

resultado = x + y * 3 ** 2 - x // y + x % y

Descompón en sub-expresiones en el orden en que Python las evalúa y verifica el resultado final.

Solución
→
x, y = 5, 2

# Precedencia: ** → *, //, % → +, -
print(f"3 ** 2     = {3 ** 2}")       # 9
print(f"y * 9     = {y * 9}")         # 18
print(f"x // y    = {x // y}")        # 2
print(f"x % y     = {x % y}")         # 1
print(f"x + 18 - 2 + 1 = {x + 18 - 2 + 1}")  # 22

resultado = x + y * 3 ** 2 - x // y + x % y
print(f"\nResultado: {resultado}")  # 22
Orden de precedencia: ** primero, luego *, //, % de izquierda a derecha, finalmente + y -. Cuando tengas dudas, usa paréntesis.
Garbage collection y conteo de referencias avanzado

Usa sys.getrefcount() para explorar cuántas referencias apuntan a un objeto en cada momento. Crea una lista grande, añade referencias con nuevas etiquetas y elimínalas con del. Observa cómo varía el contador de referencias.

Solución
→
import sys

datos = list(range(1000))
print(f"Referencias: {sys.getrefcount(datos) - 1}")  # 1

copia1 = datos
copia2 = datos
print(f"Referencias: {sys.getrefcount(datos) - 1}")  # 3

del copia1
print(f"Referencias: {sys.getrefcount(datos) - 1}")  # 2

del copia2
print(f"Referencias: {sys.getrefcount(datos) - 1}")  # 1

getrefcount() devuelve siempre uno más de lo esperado porque la propia llamada a la función crea una referencia temporal. Por eso restamos 1. Cuando el contador llega a 0, el objeto es elegible para el recolector de basura.

— Lectura opcional —

Sección 3.1 de la documentación oficial de Python: Objects, values and types. Es la fuente de todo lo que hemos visto hoy — tres párrafos densos pero con toda la precisión técnica. Léela después de haber hecho los ejercicios, no antes.

→ Documentación oficial — Objects, values and types