Las cadenas te acompañan desde la primera línea del curso (print("Bienvenido a Papyrus")), y métodos como upper(), lower() o isdigit() han ido apareciendo de pasada. Ha llegado el momento de tratarlas como merecen: como una secuencia inmutable de caracteres con la caja de herramientas más rica de Python. En esta lección sistematizamos sus métodos, exprimimos las f-strings más allá del :.2f del módulo 1, y resolvemos un problema real de Papyrus: cuando Luis teclea " el quijote " en el menú, el programa debe entender "El Quijote" — la normalización de entradas que buscar_libro() lleva haciendo a medias.

Contenido

  1. Las cadenas como secuencias: indexación, slicing, in
  2. Limpiar y trocear: strip(), split(), join()
  3. Buscar y sustituir: replace(), find() vs index(), startswith()/endswith()
  4. Mayúsculas y minúsculas: lower(), casefold(), title(), upper()
  5. Clasificar y rellenar: isdigit(), isalpha(), isalnum(), zfill()
  6. Inmutabilidad y sus consecuencias prácticas
  7. F-strings avanzadas: miles, porcentajes, fechas y =
  8. Normalización de entradas en Papyrus
  9. Construir cadenas eficientemente: join() vs concatenación en bucle

Las cadenas como secuencias

Todo lo aprendido en 04-01 sobre secuencias aplica a las cadenas — cada carácter es un elemento:

tienda = "Papyrus"

print(tienda[0])       # P        → indexación desde 0
print(tienda[-1])      # s        → negativa desde el final
print(tienda[:4])      # Papy     → slicing: [inicio:fin:paso]
print(tienda[::-1])    # surypaP  → invertir, el idiom de siempre
print(len(tienda))     # 7
print("pyr" in tienda) # True     → in busca SUBCADENAS, no solo caracteres
for letra in tienda[:3]:
    print(letra)       # P, a, p  → iterable como cualquier secuencia

Diferencia clave con las listas: in sobre cadenas encuentra subcadenas completas ("Qui" in "El Quijote" es True), no solo elementos sueltos.

Limpiar y trocear: strip(), split(), join()

El trío imprescindible para procesar texto de usuarios y de ficheros (les sacaremos partido de verdad en el módulo 6):

entrada = "   El Quijote   "
print(entrada.strip())      # 'El Quijote'  → sin espacios en los extremos
print(entrada.lstrip())     # 'El Quijote   ' (solo izquierda) / rstrip(): solo derecha
print("--Fausto--".strip("-"))   # 'Fausto' → strip acepta qué caracteres pelar

linea = "Fausto;21.00;0"
campos = linea.split(";")
print(campos)               # ['Fausto', '21.00', '0'] → de cadena a LISTA

frase = "la odisea de homero"
print(frase.split())        # ['la', 'odisea', 'de', 'homero'] → sin argumento: por espacios

titulos = ["La Odisea", "Hamlet", "Fausto"]
print(", ".join(titulos))   # 'La Odisea, Hamlet, Fausto' → de LISTA a cadena
print(" | ".join(titulos))  # 'La Odisea | Hamlet | Fausto'

split() y join() son inversos: uno trocea, el otro cose. Fíjate en la sintaxis de join(), que despista al principio: se llama sobre el separador, y recibe la lista. Todos los elementos deben ser str (convierte antes con una comprensión: ", ".join(str(p) for p in precios)).

Buscar y sustituir

titulo = "El Quijote"

print(titulo.replace("El", "Don"))    # 'Don Quijote' → devuelve una cadena NUEVA
print(titulo.find("Qui"))             # 3  → índice de la primera aparición
print(titulo.find("xyz"))             # -1 → NO encontrado: -1, sin error
print(titulo.index("Qui"))            # 3  → igual que find...
# titulo.index("xyz")                 # ...pero lanza ValueError si no está

print(titulo.startswith("El "))       # True → ¿empieza por...?
print(titulo.endswith("ote"))         # True → ¿acaba en...?
print("banana".count("na"))           # 2   → apariciones de una subcadena
Método Si no encuentra... Úsalo cuando...
find(sub) Devuelve -1 La ausencia es un caso normal
index(sub) Lanza ValueError La ausencia es un bug que debe fallar pronto

¿Te suena el patrón? Es el mismo criterio que get() vs corchetes en dicts y discard() vs remove() en sets. Python es consistente en su filosofía. Cuidado con el clásico if titulo.find("x"): — si la subcadena está en la posición 0, find devuelve 0, que es falso. Compara siempre explícitamente: if titulo.find("x") != -1: (o mejor, usa in).

Mayúsculas y minúsculas

titulo = "el quijote"

print(titulo.upper())     # 'EL QUIJOTE'
print(titulo.lower())     # 'el quijote'
print(titulo.title())     # 'El Quijote'  → Primera Letra De Cada Palabra
print(titulo.capitalize())# 'El quijote'  → solo la primera de todo

print("STRASSE".lower())     # 'strasse'
print("straße".casefold())   # 'strasse' → casefold es lower() "agresivo"

casefold() vs lower(): para español apenas se nota, pero casefold() está diseñado específicamente para comparar texto ignorando mayúsculas en cualquier idioma (la ß alemana se pliega a "ss", por ejemplo). Regla práctica: para mostrar, lower()/title(); para comparar, casefold(). Ninguno modifica la cadena original — enseguida veremos por qué.

Clasificar y rellenar

print("42".isdigit())      # True  → solo dígitos (lo usaste para validar input en M2)
print("4.2".isdigit())     # False → el punto no es un dígito
print("Fausto".isalpha())  # True  → solo letras
print("Fausto3".isalnum()) # True  → letras y/o dígitos
print("   ".isspace())     # True  → solo espacios en blanco

print("7".zfill(3))        # '007' → rellena con ceros a la izquierda
print("LUIS-1".split("-")[1].zfill(3))   # '001' → normalizar códigos de socio

zfill() es oro para códigos e identificadores de longitud fija: los socios de Papyrus son LUIS-001, no LUIS-1. Para rellenar con otros caracteres existen ljust(), rjust() y center(), aunque para presentar en pantalla las f-strings (siguiente sección) suelen ser más claras.

Inmutabilidad y sus consecuencias prácticas

Como las tuplas, las cadenas son inmutables. Ningún método las modifica: todos devuelven una cadena nueva.

titulo = "el quijote"
titulo.upper()               # correcto... pero no se guarda en ningún sitio
print(titulo)                # 'el quijote' → sin cambios: ¡el resultado se perdió!

titulo = titulo.upper()      # el idiom correcto: reasignar
print(titulo)                # 'EL QUIJOTE'

titulo[0] = "x"              # TypeError: no hay asignación por índice

Consecuencias prácticas:

  • Reasigna siempre: s = s.strip().lower(). Llamar a un método "al aire" no hace nada visible.
  • Encadenar métodos es seguro y idiomático: cada uno devuelve la cadena nueva sobre la que se llama el siguiente: " EL QUIJOTE ".strip().lower().title()'El Quijote'.
  • Las cadenas pueden ser claves de dict y elementos de set (son hashables) — todo el catálogo de 04-03 depende de ello.
  • Concatenar en bucle es caro: cada += crea una cadena nueva copiando todo lo anterior. La solución, al final de la lección.

F-strings avanzadas

Del módulo 1 conoces {x:.2f} y las alineaciones {x:<12} / {x:>6}. La minisintaxis de formato da mucho más de sí:

from datetime import date

recaudacion_anual = 48231.5
cuota_socios = 0.62
hoy = date(2026, 7, 13)
precio = 15.90

print(f"{recaudacion_anual:,.2f}")     # '48,231.50'  → separador de miles
print(f"{recaudacion_anual:,.2f} EUR".replace(",", "."))  # estilo europeo: '48.231.50 EUR'
print(f"{cuota_socios:.1%}")           # '62.0%'      → multiplica por 100 y añade %
print(f"{hoy:%d/%m/%Y}")               # '13/07/2026' → formato de fecha (datetime, 03-05)
print(f"{precio:>8.2f}")               # '   15.90'   → ancho 8, derecha, 2 decimales
print(f"{'TOTAL':^20}")                # '       TOTAL        ' → centrado en 20
print(f"{precio=}")                    # 'precio=15.9' → el = imprime nombre Y valor: depuración
Especificador Efecto Ejemplo
:, Separador de miles f"{48231.5:,.2f}"48,231.50
:.1% Porcentaje con 1 decimal f"{0.62:.1%}"62.0%
:%d/%m/%Y Formato de fecha/hora f"{hoy:%d/%m/%Y}"
:>8.2f / :<8 / :^8 Alinear en ancho 8 tickets alineados
{expr=} Nombre y valor a la vez f"{stock=}"stock=4

El = merece amor especial: print(f"{catalogo['Fausto']['stock']=}") te dice qué estás mirando y cuánto vale, sin escribir la etiqueta dos veces. Es la herramienta de depuración rápida hasta que llegue el módulo 9.

Normalización de entradas en Papyrus

Juntemos las piezas. El problema: Luis teclea títulos como " el quijote ", "HAMLET" o "fausto", y el catálogo canónico usa claves exactas. La solución: una función de normalización única y oficial en papyrus_utils.py:

def normalizar_titulo(texto):
    """Prepara un título tecleado por el usuario para compararlo con el catálogo."""
    return texto.strip().casefold()

def buscar_libro(catalogo, buscado):
    """Devuelve la clave canónica del catálogo o None. Versión definitiva."""
    objetivo = normalizar_titulo(buscado)
    for titulo in catalogo:
        if normalizar_titulo(titulo) == objetivo:
            return titulo
    return None

catalogo = {
    "La Odisea": {"precio": 12.50, "stock": 4},
    "Hamlet": {"precio": 9.95, "stock": 6},
    "El Quijote": {"precio": 15.90, "stock": 8},
    "Fausto": {"precio": 21.00, "stock": 0},
}
print(buscar_libro(catalogo, "  el quijote "))   # 'El Quijote'
print(buscar_libro(catalogo, "HAMLET"))          # 'Hamlet'
print(buscar_libro(catalogo, "dracula"))         # None

Dos decisiones de diseño que conviene subrayar:

  • La normalización vive en una función: si mañana hay que quitar también las tildes, se toca un solo sitio.
  • Se normalizan las dos partes de la comparación (entrada y clave), pero el catálogo conserva sus claves bonitas para mostrar. Normalizar solo un lado es el bug más habitual.

Construir cadenas eficientemente: join() vs concatenación en bucle

Para montar el ticket de Papyrus podríamos concatenar línea a línea... pero cada += copia toda la cadena acumulada (inmutabilidad obliga):

# Versión ingenua: funciona, pero copia y copia y copia
ticket = ""
for titulo, ficha in catalogo.items():
    ticket += f"{titulo:<12} {ficha['precio']:>6.2f} EUR\n"   # cadena nueva en cada vuelta

# Versión idiomática: acumular en LISTA y coser una sola vez al final
lineas = [f"— Ticket de {'Papyrus'} —"]
for titulo, ficha in catalogo.items():
    lineas.append(f"{titulo:<12} {ficha['precio']:>6.2f} EUR")
ticket = "\n".join(lineas)
print(ticket)

Con 4 libros la diferencia es invisible; con 100 000 líneas de un fichero de ventas (módulo 6), la versión con join() es órdenes de magnitud más rápida. El patrón "acumula en lista, une con join" es de los que distinguen código principiante de código profesional.

Errores Comunes y Consejos

  • Olvidar reasignar: s.strip() sin s = no hace nada visible. Las cadenas son inmutables; todos los métodos devuelven una nueva.
  • if s.find(x): — devuelve 0 (falso) si está al principio y -1 (¡verdadero!) si no está. Usa in para pertenencia y != -1 si necesitas la posición.
  • join() con no-cadenas: ", ".join([12.5, 9.95]) lanza TypeError. Convierte antes: ", ".join(str(x) for x in ...).
  • Normalizar solo un lado de la comparación: entrada.lower() == "El Quijote" jamás será cierto. Normaliza ambos, con la misma función.
  • isdigit() no valida floats: "4.2".isdigit() es False. Para precios tecleados necesitarás otra estrategia (el módulo 7 la resolverá con try/except).
  • title() y los conectores: "la odisea de homero".title()'La Odisea De Homero' — capitaliza también "De". Para títulos editoriales perfectos hará falta lógica propia; no lo des por resuelto.
  • Consejo: cuando proceses texto de usuario, normaliza en la frontera (nada más recibirlo) y trabaja ya siempre con la forma limpia. Evita ir repartiendo strip() por todo el código.

Ejercicios

  1. Parser de altas. Ana recibe altas de libros como " fausto ; 21.00 ; 0 ". Escribe parsear_alta(linea) que devuelva la tupla (titulo, precio, stock) con el título en formato title(), el precio como float y el stock como int, tolerando espacios alrededor de cada campo. Pruébala e incorpora el resultado al catálogo canónico con una sola asignación.
  2. Ticket profesional. Genera con el patrón lista + join() un ticket para la compra de Luis (1 "El Quijote" y 1 "Hamlet", precios de socio 15.71 y 9.83): cabecera centrada en 24 caracteres con el nombre de la tienda, una línea por libro (título a la izquierda en 14, importe a la derecha en 8 con 2 decimales), una línea de separación de guiones y el total. Extra: muestra el ahorro del socio como porcentaje con :.1% sobre la suma de precios sin descuento (16.54 + 10.35).
  3. Validador de códigos de socio. Los códigos válidos tienen el formato NOMBRE-NNN (letras, guion, exactamente 3 dígitos). Escribe es_socio_valido(codigo) que normalice (strip().upper()), haga split("-"), y valide con isalpha(), isdigit() y len(). Debe aceptar " luis-001 " y rechazar "LUIS001", "LUIS-1" y "L2IS-001".

Soluciones

# Ejercicio 1
def parsear_alta(linea):
    """De 'titulo ; precio ; stock' (con espacios) a (titulo, float, int)."""
    titulo, precio, stock = linea.split(";")        # desempaquetado de la lista (¡3 campos!)
    return titulo.strip().title(), float(precio.strip()), int(stock.strip())

titulo, precio, stock = parsear_alta("  fausto ; 21.00 ; 0  ")
catalogo[titulo] = {"precio": precio, "stock": stock}
print(catalogo["Fausto"])    # {'precio': 21.0, 'stock': 0}

Fíjate: float() e int() ya toleran espacios exteriores, pero el strip() explícito documenta la intención y protege el title() del título.

# Ejercicio 2
NOMBRE_TIENDA = "Papyrus"
compra = [("El Quijote", 15.71), ("Hamlet", 9.83)]
sin_descuento = 16.54 + 10.35

lineas = [f"{NOMBRE_TIENDA:^24}"]
total = 0.0
for titulo, importe in compra:
    lineas.append(f"{titulo:<14}{importe:>8.2f} EUR")
    total += importe
lineas.append("-" * 24)
lineas.append(f"{'TOTAL':<14}{total:>8.2f} EUR")
ahorro = (sin_descuento - total) / sin_descuento
lineas.append(f"Ahorro de socio: {ahorro:.1%}")
print("\n".join(lineas))
# Ejercicio 3
def es_socio_valido(codigo):
    """Formato NOMBRE-NNN: letras, guion, exactamente 3 dígitos."""
    partes = codigo.strip().upper().split("-")
    if len(partes) != 2:
        return False
    nombre, numero = partes
    return nombre.isalpha() and numero.isdigit() and len(numero) == 3

print(es_socio_valido(" luis-001 "))   # True
print(es_socio_valido("LUIS001"))      # False (sin guion)
print(es_socio_valido("LUIS-1"))       # False (no son 3 dígitos)
print(es_socio_valido("L2IS-001"))     # False (el nombre no es alfabético)

Error común: validar con len(partes) != 2 después de desempaquetar — el desempaquetado ya habría lanzado ValueError con "LUIS001". Comprueba la longitud primero, como aquí.

Conclusión

Las cadenas han pasado de compañeras silenciosas a herramienta dominada: son secuencias inmutables (todo método devuelve una cadena nueva — reasigna siempre), se limpian con strip(), se trocean y cosen con split()/join(), se buscan con el criterio find() vs index() que ya conocías de dicts y sets, y se comparan con casefold(). Las f-strings avanzadas (:,, :.1%, fechas, =) elevan tickets e informes de Papyrus a nivel profesional, normalizar_titulo() ha dejado buscar_libro() en su versión definitiva, y el patrón lista + join() te ahorra el coste oculto de concatenar en bucle. El módulo ya tiene todas las estructuras fundamentales; la última lección sube un peldaño: el módulo collectionsnamedtuple prometida en 04-02, Counter, defaultdict, deque — y la tabla de criterio definitiva para elegir estructura sin dudar.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados