Saltar a contenido

3. La clase String en profundidad

Campo Contenido
Unidad UD3, Programación Orientada a Objetos con la API de Java
RA RA2, Escribe y prueba programas sencillos, reconociendo y aplicando los fundamentos de la programación orientada a objetos
CE cubiertos CE 2.2 (se han escrito programas simples), CE 2.4 (se han utilizado métodos y propiedades de los objetos) y CE 2.6 (se han utilizado parámetros en la llamada a métodos) de forma directa; refuerzo de CE 2.3, CE 2.5, CE 2.7 y CE 2.8

En esta sección aprenderás:

  • Por qué String es inmutable, qué consecuencias prácticas tiene esa decisión de diseño en cada línea que escribes y cómo sacarle partido en vez de sufrirla.
  • Qué es el pool de cadenas (string interning): la zona especial del montón donde viven los literales, y por qué esa es la razón profunda de la trampa == frente a .equals() que arrastramos desde la UD1.
  • El repertorio de búsqueda (contains, startsWith, endsWith, indexOf, lastIndexOf), transformación (toUpperCase, toLowerCase, trim, strip, replace, replaceAll), extracción (substring, charAt, split, chars), construcción (String.join, repeat) y comparación y comprobación (equals, equalsIgnoreCase, compareTo, isEmpty, isBlank).
  • A construir cadenas con plantillas: String.format() y formatted(), con sus especificadores %s, %d, %f y %n.

Necesitas tener fresco (de unidades y secciones anteriores):

  • De la UD1: String como tipo de referencia; concatenación con +; String.format() y formatted() como primeras plantillas; la trampa == frente a .equals(); String.valueOf().
  • De la UD2: bucles for y for-each; las sentencias de salto; try-catch; los arrays (String[], .length) y el depurador con Evaluate Expression (Alt+F8).
  • De la sección 1: pila y montón, referencias, identidad frente a igualdad de contenido, y la promesa: «el pool de cadenas lo abrimos en la sección 3».
  • De la sección 2: leer la Javadoc, la regla del receptor (instancia frente a estático), la sobrecarga y el circuito Ctrl+Space → Ctrl+P → Ctrl+Q.

3.1 El protagonista escondido del curso

La sección anterior terminó con una promesa: íbamos a abrir de par en par la clase que más has usado en todo el curso. Ha llegado el momento. String es, con muchísima diferencia, la clase más empleada de todo el API de Java: no existe programa sin cadenas — nombres, mensajes, menús, ficheros, contraseñas, JSON — y cada literal que has escrito desde la UD1 era un objeto de esta clase viviendo tranquilamente en el montón. Si en la sección 2 aprendiste a orientarte en el almacén, hoy tocamos la estantería que más vas a visitar.

Y conviene empezar corrigiendo una impresión. Después de dos secciones de POO, cabe pensar que String es «lo fácil», lo que se ve el primer día y no guarda secretos. Falso. String guarda varios de los detalles más finos del lenguaje — la inmutabilidad, el pool de cadenas, el orden lexicográfico — y es también la clase con la que más errores cometen los programadores noveles: comparar con ==, olvidar que substring no incluye el final, pedirle %d a un double... Esta sección existe para que ninguno de esos errores te pille a ti.

Empecemos por releer lo conocido con las gafas de las dos secciones anteriores:

1
2
3
4
5
6
7
8
9
void main() {
    // Un literal es un objeto: puedo pedirle servicios directamente, sin variable
    IO.println("hola".length());                // 4
    IO.println("hola".getClass().getName());    // java.lang.String: es un objeto de ESA clase

    // Y por supuesto, tambien a traves de una variable-referencia
    String frase = "programar es pensar";
    IO.println(frase.length());                 // 19: longitud de ESTE objeto concreto
}

Todo lo de la sección 2 se cumple aquí a la letra. String vive en java.lang, así que jamás ha necesitado un import (y por eso ninguno de los programas de hoy llevará uno). Casi todo su repertorio son métodos de instancia: la respuesta depende del contenido de esa cadena concreta — frase.length() responde 19 porque esta cadena tiene diecinueve caracteres. Y también tiene su puñado de miembros estáticos — String.format, String.join, la familia valueOf — que pronto volverás a cruzarte. Si abres ahora su Javadoc y bajas al Method Summary, verás más de setenta métodos públicos contando las sobrecargas: hoy recorreremos la treintena que cubre el 95 % del trabajo real, y las herramientas de la sección 2 te permitirán descubrir el resto por tu cuenta cuando lo necesites.

Dos detalles de su declaración que puedes leer directamente en la página de la clase y que ya sabes interpretar. Primero: public final class String. Ese final significa que nadie puede heredar de ella — no habrá subclases de String, jamás — y es una pieza clave de su diseño, como verás en seguida. Segundo: sus índices van de 0 a length() - 1, exactamente como los arrays de la UD2. Acostúmbrate a pensarlo: una cadena es, en el fondo, una fila de caracteres numerada, y casi todos sus métodos hablan de posiciones de esa fila.

Para no perdernos entre treinta métodos, vamos a organizarlos por verbos, que es como tu cabeza los buscará cuando programes: buscar, transformar, extraer, construir, comparar y formatear. Este es el mapa del territorio que recorreremos:

Use mouse to pan and zoom
flowchart TD
    S["El repertorio de String\nque veras hoy"]
    B["BUSCAR\ndonde esta algo\ncontains · startsWith · endsWith\nindexOf · lastIndexOf"]
    T["TRANSFORMAR\ncrear una variante\ntoUpperCase · toLowerCase\nstrip · trim · replace · replaceAll"]
    E["EXTRAER\ntrozos y caracteres\nsubstring · charAt · split · chars"]
    C["CONSTRUIR\ncadenas nuevas\nString.join · repeat · +"]
    P["PREGUNTAR\ncomparar y comprobar\nequals · equalsIgnoreCase\ncompareTo · isEmpty · isBlank"]
    F["FORMATEAR\nplantillas con huecos\nString.format · formatted"]
    S --> B
    S --> T
    S --> E
    S --> C
    S --> P
    S --> F
    style S fill:#2e7d32,color:#ffffff,stroke:#1b5e20
    style B fill:#c8e6c9,color:#000000,stroke:#2e7d32
    style T fill:#c8e6c9,color:#000000,stroke:#2e7d32
    style E fill:#c8e6c9,color:#000000,stroke:#2e7d32
    style C fill:#c8e6c9,color:#000000,stroke:#2e7d32
    style P fill:#c8e6c9,color:#000000,stroke:#2e7d32
    style F fill:#c8e6c9,color:#000000,stroke:#2e7d32
Un apodo que veras en las firmas: CharSequence

Cuando leas la ficha Javadoc de contains o de replace, el parámetro no aparece como String, sino como CharSequence. No te asustes: es un contrato que significa «soy una secuencia de caracteres», y String es su implementación estrella. Mientras llegamos a las interfaces en la UD7, quédate con la regla práctica: si una ficha pide CharSequence, puedes pasarle un String sin pensarlo, porque String es un CharSequence (herencia de tipos, la de la sección 1). El indexOf('a') que verás más abajo declara su parámetro como int: un char, por dentro, es un número de 16 bits, y encaja ahí sin ceremonia.

Pero antes de recorrer los verbos, hay un hecho de String que condiciona a todos los demás. Empecemos por él, porque es la propiedad más importante de toda la sección.

3.2 Inmutabilidad: cadenas de roca

Aquí viene la propiedad que lo cambia todo: un objeto String no puede cambiar de contenido después de haber sido creado. Nunca. No existe ningún método en toda la clase que modifique los caracteres del objeto que lo recibe. Cuando «cambias» una cadena — la pasas a mayúsculas, le quitas espacios, sustituyes letras — lo que ocurre en realidad es que se fabrica un objeto String nuevo con el resultado, y el original permanece exactamente igual que nació. A esa propiedad se la llama inmutabilidad, y String es el ejemplo canónico de todo el API.

La sección 1 te lo adelantó de pasada, cuando hablamos de encapsulación: String es «una caja con cerradura llevada al extremo — ni siquiera el propio objeto se deja modificar». Hoy lo convertimos en mecánica. Observa este experimento, que vale más que mil definiciones:

1
2
3
4
5
6
7
8
9
void main() {
    String frase = "programar";

    frase.toUpperCase();            // la llamada fabrica una cadena nueva... y la TIRA
    IO.println(frase);              // programar: el original no se ha movido un pelo

    frase = frase.toUpperCase();    // ahora si recogemos el resultado en la referencia
    IO.println(frase);              // PROGRAMAR: la variable apunta a la cadena nueva
}

La primera llamada no hace nada visible: crea un String nuevo con el texto "PROGRAMAR", lo devuelve... y como nadie lo recoge, ese objeto recién nacido queda huérfano y el recolector de basura se lo llevará en su próxima ronda. La cadena original, "programar", ni se enteró. Solo en la segunda llamada, al reasignar el resultado con frase = frase.toUpperCase();, la referencia pasa a apuntar al objeto nuevo. La variable cambia de flecha; los objetos, jamás de contenido:

Use mouse to pan and zoom
flowchart LR
    subgraph monton["Montón de memoria"]
        s1["objeto String 'programar'\n(nació con el literal)"]
        s2["objeto String 'PROGRAMAR'\n(nace con la llamada)"]
    end
    ref1["referencia frase\nantes de reasignar"] --> s1
    ref2["referencia frase\ntras frase = frase.toUpperCase()"] --> s2
    s1 -- "toUpperCase() no modifica:\nfabrica una cadena nueva y la devuelve" --> s2
    style s1 fill:#c8e6c9,color:#000000,stroke:#2e7d32
    style s2 fill:#fff3e0,color:#000000,stroke:#e65100
    style ref1 fill:#e3f2fd,stroke:#1565c0
    style ref2 fill:#e3f2fd,stroke:#1565c0

De aquí sale la primera regla práctica de la sección, la que te ahorrará la mitad de los sustos con cadenas:

frase.trim() no limpia frase: limpia una copia. Si quieres conservar el resultado, reasigna: frase = frase.trim();.

Es el patrón «empleado diligente» de la sección 1, llevado al extremo: los métodos de String ni siquiera pueden tocar la obra original; solo te entregan una obra nueva.

Por qué alguien diseñaría algo tan rígido

Que las cadenas sean de roca parece una molestia — ¿no sería cómodo poder cambiar un carácter a lo bestia, como en un array? — y sin embargo es una de las decisiones más acertadas del diseño de Java. Tres motivos que podrás apreciar ya mismo:

  1. Seguridad. Las cadenas viajan por donde más duele: contraseñas, rutas de ficheros, nombres de usuario, consultas. Imagina que validas una contraseña y, justo después de la comprobación, otro trozo del programa modifica ese mismo objeto y coloca dentro otra cosa. Con String, ese ataque es físicamente imposible: la cadena que validaste es la misma que la que se usará después, palabra por palabra, hasta que muera. (Curiosidad de seguridad: por ese mismo motivo, para guardar contraseñas en memoria se prefiere char[], que sí se puede borrar; String no se puede borrar nunca. Lo verás en la práctica profesional, no en este curso.)
  2. Compartir sin miedo. Como nadie puede modificarlas, dos partes del programa pueden compartir el mismo objeto sin riesgo de que una estropee lo de la otra. Esa es justo la puerta que abre el pool de cadenas de la subsección siguiente: prestar es seguro cuando nada se puede romper.
  3. Fiabilidad del cálculo. Un objeto inmutable puede calcular cosas de sí mismo una sola vez y confiar en que no caducan. String guarda su longitud y su código hash precalculados desde que nace, porque sabe que nunca cambiarán. Cuando en la UD5 uses cadenas como claves de mapas, ese detalle se traduce en velocidad pura.

Y una razón transversal: el final de su declaración. Si alguien pudiera heredar de String y añadir métodos que sí modificaran, toda esta fortaleza se vendría abajo; por eso la clase está cerrada con llave. Inmutabilidad y final van de la mano.

Las dos consecuencias que te acompañarán todo el curso

Primera: el encadenado es gratis y natural. Ya lo viste en la sección 1 con frase.strip().toUpperCase(), y ahora sabes por qué funciona tan bien: cada eslabón recibe un objeto nuevo e intacto, le aplica su transformación y entrega otro objeto nuevo. Nadie pisa a nadie; el orden de la cadena es el único que manda.

Segunda: la concatenación en bucles es el talón de Aquiles, y te lo digo hoy para que lo reconozcas cuando lo veas. Este código, que parece inocente, fabrica una cadena nueva en cada vuelta:

1
2
3
4
5
6
7
void main() {
    String informe = "";
    for (int i = 1; i <= 1000; i++) {
        informe = informe + "linea " + i + "\n";   // cada vuelta crea un String NUEVO
    }                                               // y el anterior queda como basura
    IO.println(informe.length());
}

No es un error: funciona y el resultado es correcto. Pero es caro: mil vueltas, mil cadenas intermedias creciendo de tamaño, mil cadenas viejas esperando al recolector. Para mil líneas pasa; para un millón, el programa se arrastra. La solución profesional tiene nombre — StringBuilder — y tiene ya su cita en el calendario: sección 7 de esta unidad, donde desmontaremos este bucle y lo reconstruiremos como es debido. Hoy basta con que sepas que el problema existe y de dónde viene: de la inmutabilidad, precisamente.

Pruébalo en IntelliJ: el laboratorio de Alt+F8

La ventana Evaluate Expression que conociste en la UD2 (con el depurador parado en un breakpoint, Alt+F8) es el banco de pruebas perfecto para los métodos de esta sección, sin escribir ni un programa: evalúa " java 25 ".strip() y mira el resultado; luego "hola".toUpperCase(); luego "hola".repeat(3). Cada expresión te devuelve su valor al instante, y puedes comparar lo que devuelve una llamada con el estado del objeto original en la ventana Variables: el original, intacto siempre. Cadena de roca, comprobado con tus propios ojos.

3.3 El pool de cadenas: la caja prometida se abre

En la UD1 te contamos la trampa de == frente a .equals() y te pedimos que confiaras. En la sección 1 la volviste a ver con el modelo de memoria delante y quedó explicada a medias: == compara flechas (identidad) y .equals() compara contenido. Pero quedó un cabo suelto, y la sección 1 lo dejó citado aquí con nombre y apellido: ¿por qué == con literales a veces funciona y da la falsa sensación de ser válido? Ha llegado el momento de abrir esa caja con calma, porque la respuesta tiene entidad propia.

La caja se llama pool de cadenas (string pool, y el mecanismo, string interning): una zona especial del montón donde la JVM guarda una sola copia de cada cadena literal que aparece en el programa. La regla es sencilla: cuando el compilador se encuentra un literal como "java", no fabrica un objeto nuevo cada vez — consulta el pool, y si esa cadena ya está ahí, reutiliza el mismo objeto. Los literales idénticos de tu programa acaban siendo, literalmente, el mismo objeto.

Compruébalo con el experimento completo, el que cierra el expediente abierto en la UD1:

void main() {
    String a = "java";
    String b = "java";
    IO.println(a == b);        // true: el compilador dirigió ambas referencias AL MISMO
                               // objeto del pool: una sola copia para dos literales

    String c = new String("java");
    IO.println(a == c);        // false: new fabrica un objeto NUEVO, fuera del pool
    IO.println(a.equals(c));   // true:  el contenido, en cambio, es idéntico
}

Tres líneas de salida — true, false, true — y ahora cada una tiene su explicación completa. El diagrama de memoria lo cuenta mejor que yo:

Use mouse to pan and zoom
flowchart LR
    subgraph pila["Pila (stack)"]
        va["a"]
        vb["b"]
        vc["c"]
    end
    subgraph monton["Montón (heap)"]
        subgraph pool["Pool de cadenas (zona especial)"]
            sp["'java' — UN solo objeto\ncompartido por los literales"]
        end
        sc["objeto String nuevo\n'java' — fuera del pool"]
    end
    va --> sp
    vb --> sp
    vc --> sc
    va -. "a == b es true: misma flecha" .- vb
    va -. "a == c es false: flechas a objetos distintos" .- sc
    style sp fill:#c8e6c9,color:#000000,stroke:#2e7d32
    style sc fill:#fff3e0,color:#000000,stroke:#e65100
    style pila fill:#e3f2fd,stroke:#1565c0

Fíjate en lo que acaba de pasar con a == b: comparar contenido con == funcionó. Y ahí está el veneno exacto de esta trampa: no falla siempre — falla a veces, justo cuando algo como new String(...) o una concatenación en tiempo de ejecución mete un objeto nuevo por medio. Un error que aparece a veces es mucho más peligroso que uno que aparece siempre, porque se cuela en los tests y salta en producción. Por eso la regla de oro de la UD1 no se negociaba entonces y no se negocia ahora que ya entiendes su porqué:

Para comparar contenido, .equals(). Siempre. == con cadenas solo responde a «¿son el mismo objeto?», una pregunta de identidad que casi nunca es la que quieres hacer.

¿Y para qué existe el pool, con el lío que trae? Por ahorro de memoria: los programas reales repiten cadenas hasta el hartazgo — etiquetas de interfaz, claves de configuración, nombres de campos, mensajes. Si cada aparición de "nombre" en un programa ocupara su propio objeto, el montón se llenaría de gemelos. El pool lo evita: una copia, muchas referencias. Y fíjate en la simetría del diseño: el pool solo es posible porque las cadenas son inmutables (compartir sería suicida si cualquiera pudiera modificar el objeto compartido). Inmutabilidad y pool son dos piezas del mismo mecanismo, no dos noticias separadas.

Una advertencia de contexto para cerrar: new String("java") es una aberración didáctica — solo se escribe para experimentos como el de arriba. Los constructores de String existen (la Javadoc los lista: a partir de un array de caracteres, y demás), pero en la práctica profesional las cadenas nacen de literales, de fábricas como String.valueOf o de métodos que las construyen. Si en una entrevista te preguntan cómo se crea una cadena, «con un literal» es la respuesta que espera oír.

intern(): pedir la copia del pool a propósito

La clase ofrece el método intern(), que devuelve la representación canónica de una cadena: la copia que vive en el pool (metiéndola antes, si no estaba). Con él puedes comprobar que c, el objeto rebelde de fuera del pool, sí tiene su gemelo dentro:

1
2
3
4
String c = new String("java");
String canonica = c.intern();
IO.println(canonica == "java");   // true: intern() entrega la copia del pool
IO.println(c == "java");          // false: c sigue siendo su objeto aparte

En código de este curso no lo usarás nunca — equals resuelve la comparación de contenido sin ceremonias —, pero conviene haberlo visto: es la llave oficial de la caja que acabamos de abrir.

El último pliegue: concatenar literales en tiempo de compilación

Hay un pliegue final del pool que encanta en exámenes y entrevistas, así que mejor lo ves aquí primero. El compilador es capaz de sumar literales cuando los conoce de antemano, y el resultado entra en el pool:

1
2
3
4
5
6
7
8
9
void main() {
    String porPartes = "ja" + "va";         // resuelto por el COMPILADOR:
    IO.println(porPartes == "java");        // una sola constante → true, está en el pool

    String trozo = "ja";
    String enEjecucion = trozo + "va";      // resuelto en EJECUCIÓN: objeto nuevo
    IO.println(enEjecucion == "java");      // false: no ha pasado por el pool
    IO.println(enEjecucion.equals("java")); // true: el contenido, intacto
}

La diferencia entre las dos sumas es cuándo se hacen: la primera, con todos los datos sobre la mesa antes de ejecutar una sola línea; la segunda, con una variable cuyo valor se conoce en ejecución. Si este pliegue te despista, no te preocupes: no cambia la regla de oro ni una coma. Comparar contenido se hace con equals, y el pool deja de ser una trampa y pasa a ser una curiosidad que ya entiendes.

3.4 Búsqueda: encontrar cosas dentro del texto

Cerrado el expediente de la memoria, toca el repertorio. Empezamos por el verbo más pedido en cualquier programa: buscar. ¿Contiene esta cadena aquello? ¿Empieza o termina así? ¿En qué posición está? La clase String responde con dos familias: los que devuelven boolean (la pregunta es sí o no) y los que devuelven int (la pregunta es dónde).

Método Devuelve Pregunta que responde
contains(trozo) boolean ¿Aparece trozo en algún sitio de la cadena?
startsWith(prefijo) boolean ¿Empieza la cadena por prefijo?
endsWith(sufijo) boolean ¿Termina la cadena por sufijo?
indexOf(x) int ¿En qué posición aparece x por primera vez?
lastIndexOf(x) int ¿En qué posición aparece x por última vez?

Todas comparten dos reglas de casa que conviene fijar antes de usarlas. Primera: son sensibles a mayúsculas y minúsculas — "Java".contains("java") es false, sin apelación. Segunda: indexOf y lastIndexOf aceptan indistintamente un char o un String (sobrecarga, como las de IO en la sección 2: tú eliges la versión con los argumentos), y si lo buscado no aparece, devuelven -1, no cero ni una excepción. Ese -1 es una convención histórica de todo el API y la vas a manejar constantemente.

Vamos a verlas todas en acción sobre un material realista, un nombre de fichero:

void main() {
    String fichero = "informe-final.pdf";

    IO.println(fichero.startsWith("informe"));   // true
    IO.println(fichero.endsWith(".pdf"));        // true: clasificacion por extension
    IO.println(fichero.contains("final"));       // true
    IO.println(fichero.contains("FINAL"));       // false: distingue mayusculas

    IO.println(fichero.indexOf('-'));            // 7: primera aparicion del guion
    IO.println(fichero.lastIndexOf('-'));        // 13: la ultima
    IO.println(fichero.indexOf(".doc"));         // -1: no aparece en ningun sitio
}

Fíjate en el ejemplo del guion: indexOf y lastIndexOf son la pareja con la que se localiza algo que aparece repetido — la primera mención y la última —, y la distancia entre ambas te dice cosas del texto (aquí, que entre el guion de la posición 7 y el de la 13 hay un apellido de cinco letras). Ambos tienen versiones con posición de partida: indexOf(x, desde) empieza a buscar a partir de desde, y es el truco clásico para saltar la primera aparición:

1
2
3
4
5
6
7
void main() {
    String texto = "hola hola";

    IO.println(texto.indexOf("hola"));       // 0: desde el principio
    IO.println(texto.indexOf("hola", 1));   // 5: empieza a buscar en la posicion 1
    IO.println(texto.lastIndexOf("hola"));  // 5: buscando hacia atras, la ultima
}

El patrón del -1: buscar es comprobar antes de usar

La búsqueda con indexOf casi nunca es un fin: quieres la posición para hacer algo con ella — cortar la cadena, saltarte un prefijo. Y ahí vive el patrón más importante de esta subsección, el que escribirás mil veces en tu vida: comprobar el -1 antes de usar la posición. Si te lo saltas, la aritmética posterior produce resultados absurdos (recorta desde -1 y te carga el primer carácter), sin que nada avise:

void main() {
    String correo = "ada@ies.thiar.es";

    int arroba = correo.indexOf('@');   // busco la arroba

    if (arroba == -1) {
        // La busqueda fracaso: no hay arroba en ningun sitio
        IO.println("Correo invalido: no contiene @");
    } else {
        // La busqueda tuvo exito: la posicion es segura de usar
        IO.println("La arroba esta en la posicion " + arroba);   // 3
    }
}

Guárdate ese if (pos == -1) como un reflejo: búsqueda que no comprueba el -1 es búsqueda a medias. Y guarda también su complemento, el antídoto contra la sensibilidad a mayúsculas: como los métodos de transformación devuelven cadenas nuevas (3.2), se encadenan con naturalidad y la búsqueda insensible se escribe bajando todo a un común:

1
2
3
4
5
6
7
8
9
void main() {
    String nombre = "Ada Lovelace";

    // Busqueda insensible a mayusculas: normalizo primero, busco despues
    IO.println(nombre.toLowerCase().contains("love"));   // true

    // El original sigue intacto: nombre no ha cambiado por la cadena de la busqueda
    IO.println(nombre);                                  // Ada Lovelace
}

Ese idiom — normalizar y luego buscar — es exactamente el que usarás en la práctica guiada del final, y también el que hay detrás de equalsIgnoreCase, que llegará con su familia en la subsección de comparación.

3.5 Transformación: cadenas nuevas a partir de la vieja

Segundo verbo del mapa: transformar. Aquí viven los métodos que toman la cadena y devuelven una variante — en mayúsculas, sin espacios de los bordes, con trozos sustituidos. Lo primero, y ya sabes por qué: ninguno modifica el original (inmutabilidad, subsección 3.2), así que todos se usan con el patrón de reasignación o en cadena. Lo segundo: casi todos son de instancia — la transformación opera sobre esta cadena concreta —, con una excepción que ya conoces de la sección 2 y que reaparecerá aquí mismo.

Mayúsculas, minúsculas y limpieza de bordes

void main() {
    String original = "  Java 25  ";

    String limpio = original.strip();      // "Java 25": sin espacios de los bordes
    String grito = limpio.toUpperCase();   // "JAVA 25"
    String susurro = limpio.toLowerCase(); // "java 25"

    IO.println(limpio + " / " + grito + " / " + susurro);
    IO.println(original.length() + " frente a " + limpio.length());   // 11 frente a 7
}

La pareja toUpperCase() / toLowerCase() no guarda secretos: devuelve la cadena con la caja cambiada, y los caracteres que no tienen caja (números, símbolos) pasan tal cual. Lo que sí merece atención es la limpieza de bordes, porque hay dos métodos para lo mismo y la elección cuenta:

trim() strip()
Desde Java 1.0 (los orígenes) Java 11
Qué quita Solo caracteres de control y el espacio «clásico» (códigos hasta U+0020) Todo el espacio en blanco que Unicode considera tal
Con espacios de toda la vida Funciona Funciona
Con espacios «tipográficos» Unicode (p. ej. el espacio ideográfico japonés, o el em space de imprenta) Los deja Los quita
Veredicto del curso Reconocerlo en código antiguo La opción moderna por defecto

La diferencia solo asoma con texto internacional — pero asoma: si limpias un texto japonés con trim(), sus márgenes quedan sucios. Compruébalo con un experimento de laboratorio usando el espacio ideográfico (escribimos su código con \u3000, que el compilador convierte en el carácter real):

1
2
3
4
5
6
void main() {
    String raro = "\u3000Java\u3000";     // espacios ideograficos alrededor (texto japones)

    IO.println(raro.trim().length());     // 6: trim NO los conoce: los deja en los bordes
    IO.println(raro.strip().length());    // 4: strip SI los conoce: los elimina
}

strip() llegó en Java 11 en el mismo paquete de novedades que repeat() e isBlank() — verás ese año repetido en el Since de sus fichas Javadoc — y trae dos hermanos quirúrgicos: stripLeading() (limpia solo por la izquierda) y stripTrailing() (solo por la derecha), útiles cuando el espacio de un lado significa algo (sangrías, alineaciones). Para la limpieza normal de entradas de usuario, strip() solo.

Sustituir: replace y su primo con superpoderes

El repertorio de sustitución tiene tres métodos con nombres sospechosamente parecidos, y la distinción entre ellos es una de esas cosas que separan a quien leyó la ficha de quien copió de Stack Overflow:

1
2
3
4
5
6
7
8
9
void main() {
    String frase = "programar es pensar";

    // replace con DOS char: sustituye TODAS las apariciones del caracter
    IO.println(frase.replace('a', 'e'));              // progremer es penser

    // replace con DOS cadenas: sustituye TODAS las apariciones del trozo LITERAL
    IO.println(frase.replace("programar", "cocinar")); // cocinar es pensar
}

La primera versión, replace('a', 'e'), es la que la sección 1 te adelantó de pasada; ahora ya la lees entera. La segunda, replace("programar", "cocinar"), cambia trozos completos. Ambas comparten dos reglas: sustituyen todas las apariciones (no solo la primera) y trabajan sobre texto literal, sin interpretar nada raro en lo que les pasas. replace es el destornillador de la sustitución: predecible y seguro.

El primo con superpoderes es replaceAll, y aquí va la advertencia más importante de la subsección:

1
2
3
4
5
6
7
void main() {
    String sucio = "java   es\tgenial";                // espacios de sobra y un tabulador
    IO.println(sucio.replaceAll("\\s+", " "));         // java es genial

    String clave = "secreto123";
    IO.println(clave.replaceAll("\\d", "*"));          // secreto***
}

replaceAll interpreta su primer argumento como una expresión regular — un mini-lenguaje de patrones de texto —, y por eso logra en una línea lo que con replace no se puede: «uno o más caracteres de espacio, sean los que sean» (\\s+) o «cualquier dígito» (\\d). El precio es doble. Primero, el mini-lenguaje tiene sintaxis propia: si le pasas replaceAll("a.c", "X") creyendo sustituir el texto literal a.c, te llevarás una sorpresa, porque el punto significa «cualquier carácter» y devaneará tu texto sin avisar. Segundo, esos dobles \\ del código no son un capricho: en Java, \\ dentro de un literal significa «un carácter barra invertida», y es la expresión regular quien recibe entonces \s y \d. Dos niveles de escape, dos motores distintos — el compilador primero, el motor de patrones después —; te lo encontrarás cada vez que unas cadenas con regex.

Las expresiones regulares tienen su propia unidad

replaceAll (y split, que verás enseguida) te acaban de enseñar lo mínimo para no llevarte un susto: saben regex, sustituir todas las coincidencias, y tres recetas (\\s+, \\d, el punto salvaje). El estudio completo del mini-lenguaje — Pattern, Matcher, validaciones de correos y teléfonos — está citado en la UD5, sección 10, cuando ya domines colecciones y streams. Hoy: usa replace para todo lo literal, y replaceAll solo con recetas probadas. Existe además replaceFirst(regex, cambio), que sustituye solo la primera coincidencia del patrón: misma advertencia, misma cita en UD5.

El resumen del verbo, en una tabla

Quiero... Método Devuelve
Todo en mayúsculas / minúsculas toUpperCase() / toLowerCase() cadena nueva
Quitar espacios de los bordes (moderno) strip() (y stripLeading() / stripTrailing()) cadena nueva
Quitar espacios de los bordes (histórico) trim() cadena nueva
Cambiar un carácter por otro (todas las veces) replace('a', 'e') cadena nueva
Cambiar un trozo literal por otro replace("viejo", "nuevo") cadena nueva
Cambiar según un patrón replaceAll("\\d", "*") cadena nueva

Una sola fila de esta tabla no devuelve «cadena nueva»... y es que ninguna lo hace: todas devuelven una cadena nueva. Si escribo esta columna es para que el patrón quede grabado a fuego: el verbo transformar nunca toca el original.

3.6 Extracción: substring, charAt, split y chars

Tercer verbo: extraer. Si buscar pregunta dónde está, extraer responde dame eso. Es el grupo con más trampas de índices de toda la sección, así que vamos con calma y con el modelo de la fila numerada bien presente: los caracteres de una cadena ocupan las posiciones 0 a length() - 1, exactamente como los elementos de un array.

substring: cortar por la línea de puntos

substring recorta un trozo de la cadena, y tiene dos versiones: desde una posición hasta el final, o entre dos posiciones. La segunda es la fuente inagotable de errores de examen, así que mirémosla despacio:

1
2
3
4
5
6
7
void main() {
    String palabra = "programar";

    IO.println(palabra.substring(2));      // rogramar: desde la posicion 2 hasta el final
    IO.println(palabra.substring(1, 4));   // rog: posiciones 1, 2 y 3
    IO.println(palabra.substring(0, 4));   // prog
}

substring(1, 4) devuelve las posiciones 1, 2 y 3... y no la 4. Esa es la regla que hay que tatuar en el antebrazo: el índice inicial va incluido y el final, excluido — el intervalo se escribe en matemáticas [1, 4). Dibuja la fila numerada y lo verás para siempre:

Use mouse to pan and zoom
flowchart LR
    subgraph cadena["'programar' — 9 caracteres, posiciones 0 a 8"]
        c0["p\n0"]
        c1["r\n1"]
        c2["o\n2"]
        c3["g\n3"]
        c4["r\n4"]
        c5["a\n5"]
        c6["m\n6"]
        c7["a\n7"]
        c8["r\n8"]
    end
    c0 --- c1 --- c2 --- c3 --- c4 --- c5 --- c6 --- c7 --- c8
    style c1 fill:#2e7d32,color:#ffffff,stroke:#1b5e20
    style c2 fill:#2e7d32,color:#ffffff,stroke:#1b5e20
    style c3 fill:#2e7d32,color:#ffffff,stroke:#1b5e20
    style c4 fill:#fff3e0,color:#000000,stroke:#e65100

Las casillas verdes son lo que devuelve substring(1, 4): desde la posición 1 (incluida) hasta la 4 (que queda fuera, en naranja, esperando su turno). La recompensa de entenderlo así es que las cuentas salen solas: la longitud del trozo es siempre final - inicial (aquí, 4 - 1 = 3 letras), y una consecuencia elegante: palabra.substring(0, palabra.length()) es la cadena entera. Si te pasas de rango — un final mayor que length(), o un inicial negativo — el programa lanza StringIndexOutOfBoundsException, prima hermana de la ArrayIndexOutOfBoundsException que ya domaste con los arrays de la UD2: mismos límites, mismo nombre de familia.

Con substring e indexOf juntos ya puedes escribir el clásico de los clásicos — cortar un correo en su nombre y su dominio, usando el patrón del -1 de la subsección anterior:

void main() {
    String correo = "ada@ies.thiar.es";

    int corte = correo.indexOf('@');        // busco el punto de corte

    if (corte == -1) {
        IO.println("Correo invalido: no contiene @");
    } else {
        String nombre = correo.substring(0, corte);      // [0, corte): antes de la arroba
        String dominio = correo.substring(corte + 1);    // despues de la arroba: hasta el final
        IO.println(nombre + " @ " + dominio);            // ada @ ies.thiar.es
    }
}

Fíjate en el corte + 1 del segundo recorte: la arroba está en corte, y el dominio empieza en la posición siguiente. Sumar y restar posiciones alrededor de un indexOf es el pan de cada día del trabajo con texto.

charAt: un carácter, por su número

charAt(i) devuelve el carácter que ocupa la posición i, y su tipo de retorno es char — el primitivo de la UD1 que guarda un solo carácter Unicode. Es el puente entre el mundo de las cadenas y el mundo de los números, porque un char por dentro es un número de 16 bits, y con él se puede hacer aritmética:

void main() {
    String palabra = "Java";

    IO.println(palabra.charAt(0));       // J: el caracter en la posicion 0
    IO.println(palabra.charAt(3));       // a: el ultimo (length() - 1)

    char inicial = palabra.charAt(0);    // un char se guarda en una variable char
    IO.println("La inicial es " + inicial);

    // Y como char es, en el fondo, un numero:
    IO.println((int) inicial);           // 74: el codigo Unicode de la J mayuscula
    IO.println('a' - 'A');               // 32: la distancia entre minuscula y mayuscula
}

Ese 32 de la última línea es una cifra que te acompañará toda la vida programando: la distancia entre una letra minúscula y su mayúscula en Unicode. Y la causa profunda de que la búsqueda y la comparación de cadenas distingan caja: 'a' (97) y 'A' (65) son números distintos, y para un ordenador no hay manera «natural» de considerarlos iguales. Comparar caracteres con >= y <=, como se comparan números, es totalmente legal y lo usarás en la práctica guiada para cazar mayúsculas letra a letra.

split: una cadena, muchas piezas

split parte la cadena cada vez que aparece un separador y te entrega un array de String — material de la UD2 que ahora brilla con luz nueva. Recorrerlo con un for-each es el idiom estándar para procesar texto troceado:

void main() {
    String linea = "rojo,verde,azul,amarillo";

    String[] colores = linea.split(",");   // separa por cada coma

    IO.println("Hay " + colores.length + " colores");   // 4: .length del array (campo, sin parentesis)

    for (String color : colores) {         // for-each: conocido de la UD2
        IO.println("- " + color);
    }
}

Su separador es... una expresión regular, como el primer argumento de replaceAll (misma advertencia, misma cita en UD5): con separadores simples — una coma, un espacio, un punto y coma — no hay nada que temer; cuando necesites separar por cosas complicadas («uno o más espacios»), ya sabes qué mini-lenguaje te espera en la unidad 5. Y un detalle fino: split descarta las piezas vacías del final — "a,b,,".split(",") entrega dos piezas, no cuatro —, comportamiento que discutiremos cuando el procesamiento de texto sea serio.

chars: la cadena como chorro de números

Último método del grupo, y es un adelanto con etiqueta de origen: chars() devuelve la cadena como un IntStream, un chorro de los códigos numéricos de sus caracteres. Es tu primer contacto con la Streams API, el tema estrella de la UD5 — el mismo adelanto que te espera con Random.ints() en la sección 5. Hoy basta con dos operaciones de chorro que no necesitan teoría: contar y sumar:

1
2
3
4
5
6
void main() {
    String palabra = "hola";

    IO.println(palabra.chars().count());   // 4: cuantos caracteres tiene el chorro
    IO.println(palabra.chars().sum());     // 420: la suma de sus cuatro codigos Unicode
}

La cuenta da 4 — una por carácter — y la suma da 420, que es lo que suman los códigos de la h, la o, la l y la a (104 + 111 + 108 + 97). No hay que memorizar nada: solo reconocer la forma cadena.chars().operación() y saber que, cuando llegue la UD5, esa flechita desbloqueará un arsenal entero de procesamiento. Hasta entonces, la puerta queda señalizada.

El pariente clásico: toCharArray()

Antes de que existiera chars(), el API ofrecía toCharArray(), que copia la cadena en un char[] de la UD2 y se recorre con un bucle clásico por índices. Hoy es menos elegante pero sigue siendo legal y útil cuando necesitas los caracteres como material manipulable (por ejemplo, para ordenarlos con la clase Arrays —te espera en la sección 9— o para desordenarlos en un juego de anagramas). Coexisten por la misma razón que trim y strip: el API nunca borra, solo añade.

3.7 Construcción: join, repeat y la vieja concatenación

Cuarto verbo: construir — fabricar cadenas nuevas a partir de piezas. La herramienta más vieja ya la dominas desde la UD1: el operador + de concatenación. Hoy le añadimos dos métodos con personalidad propia y una advertencia sobre el operador veterano.

String.join: el ensamblador estático

join recibe un separador y varias piezas, y devuelve la cadena que sale de ensamblarlas con ese separador por medio. La noticia interesante es su receptor, y aquí la sección 2 cobra su inversión: join es un método estático, así que se pide a la clase, no a una cadena:

void main() {
    // ESTATICO: el receptor es la clase String (regla de la seccion 2)
    String fecha = String.join("-", "2026", "10", "31");
    IO.println(fecha);              // 2026-10-31

    String ruta = String.join("/", "usr", "local", "bin");
    IO.println(ruta);               // usr/local/bin

    String lista = String.join(", ", "Ada", "Alan", "Grace");
    IO.println(lista);              // Ada, Alan, Grace
}

Piénsalo con la regla mental de la sección 2 y verás que el diseño es impecable: ¿la respuesta de «ensaémbame estas piezas con guiones» dependería de una cadena concreta que recibiera el mensaje? No — depende solo de sus argumentos —, así que el miembro pertenece al colectivo: Clase.metodo(...). Es exactamente la familia de Integer.parseInt y Math.sqrt. (Y tiene dos versiones sobrecargadas en la Javadoc: una para piezas sueltas y otra para colecciones — esa segunda la apreciarás en la UD5.)

repeat: la cadena que se multiplica

repeat — que tú mismo descubriste en la misión 2 de la sección 2, así que esto es oficialmente un examen de memoria — repite la cadena que lo recibe las veces que le indiques. Es de instancia, obviamente: lo que se repite es esta cadena:

1
2
3
4
5
void main() {
    IO.println("=".repeat(30));    // una linea decorativa de 30 signos igual
    IO.println("ab".repeat(3));    // ababab
    IO.println("ja".repeat(0));    // cadena vacia: repetir cero veces no es error
}

Tres detalles de ficha técnica: llegó en Java 11 (Since: 11, el mismo año que strip e isBlank); repetir cero veces devuelve la cadena vacía, perfectamente legal; y repetir un número negativo lanza IllegalArgumentException — una más para tu colección de excepciones de la UD2.

La advertencia sobre el veterano +

La concatenación con + es cómoda y la seguirás usando toda la vida, pero esconde una trampa de asociatividad que golpea justo cuando mezclas texto y números. Como + se evalúa de izquierda a derecha, todo lo que queda a la derecha del primer texto se convierte en texto y se concatena en lugar de sumarse:

1
2
3
4
5
6
void main() {
    int a = 1, b = 2;

    IO.println("Total: " + a + b);      // Total: 12: concatena de izquierda a derecha
    IO.println("Total: " + (a + b));    // Total: 3: primero suma, luego concatena
}

La primera línea no es ningún error de sintaxis: compila, ejecuta y devuelve Total: 12 — que es exactamente la salida que un bug de estos produce en producción, silencioso y digno. El paréntesis de la segunda fuerza la suma antes de que entre en contacto con el texto. Grábatelo: cuando una expresión mezcla + numérico y + de cadenas, decide tú el orden con paréntesis, y no dejes que sea la asociatividad quien decida por ti.

La familia estatica que ya conoces: String.valueOf

Completa el verbo construir la familia valueOf, que conociste en la UD1 para convertir valores a texto: String.valueOf(42), String.valueOf(3.5), String.valueOf(true). Es una familia sobrecargada estática — una versión por tipo primitivo, más una para Object — así que la regla de la sección 2 la clasifica de un vistazo: la conversión no depende de ninguna cadena concreta, luego se pide a la clase. valueOf era la forma elegante de convertir antes de las plantillas; hoy la seguirás viendo en código ajeno y la tendrás a mano para casos puntuales.

3.8 Comprobación y comparación: preguntar a la cadena

Quinto verbo, y el más delicado de todos: preguntar. ¿Es esta cadena igual a aquella? ¿Va antes o después en el alfabeto? ¿Está vacía? Sobre estas preguntas se construyen las validaciones — de formularios, de menús, de entradas de usuario — que son el pan de cada día de cualquier aplicación.

equals y equalsIgnoreCase: igualdad de contenido

De equals ya sabes lo importante: compara contenido, carácter a carácter, y es el único modo legítimo de comparar cadenas (regla de oro blindada con la explicación del pool en la 3.3). Su hermano equalsIgnoreCase hace la misma pregunta ignorando la caja:

void main() {
    String clave = "Secreto42";

    IO.println(clave.equals("secreto42"));           // false: una letra distinta de caja
    IO.println(clave.equalsIgnoreCase("SECRETO42")); // true: el contenido coincide

    // El idiom de validacion tipico, encadenando con normalizacion:
    String respuesta = IO.readln("Quieres continuar (si/no)? ");
    IO.println(respuesta.strip().equalsIgnoreCase("si"));   // true ante "si", "Si", " SI "...
}

Esa última línea condensa tres herramientas de la sección en una sola: strip limpia los espacios accidentales del usuario, equalsIgnoreCase tolera la caja, y el conjunto convierte «sÍ », « si» y «SI» en un true cada uno. Normalizar y luego comparar, exactamente igual que normalizar y buscar en la subsección 3.4.

Una advertencia de null antes de seguir: equals se invoca sobre una referencia, y si esa referencia está en blanco, la llamada lanza NullPointerException — la mecánica exacta de la sección 1. IO.readln nunca devuelve null (devuelve la cadena vacía si no escribes nada), pero muchos métodos del API sí, y lo verás en la UD6. El patrón defensivo clásico lo resuelve con el cortocircuito de && que conoces de la UD1: comprobar null antes de tocar la cadena.

compareTo: el orden del diccionario

equals responde sí o no; compareTo responde algo más rico: cómo de ordenadas están las dos cadenas. Es el método que usa el orden de los diccionarios — el orden lexicográfico — y su contrato es de una elegancia que conviene leer despacio:

Llamada Devuelve Significado
"casa".compareTo("coche") un número negativo casa va antes que coche
"coche".compareTo("casa") un número positivo coche va después
"casa".compareTo("casa") 0 son idénticas

¿Por qué casa va antes? Se compara letra a letra desde la izquierda: la c inicial empata, y en la segunda letra a (código 97) es menor que o (código 111) — ahí se decide. La comparación es, en el fondo, numérica entre los códigos Unicode de cada posición, y eso tiene una consecuencia que debes conocer de primera mano: las mayúsculas van antes que las minúsculas, porque sus códigos son menores ('Z' es 90 y 'a' es 97):

1
2
3
4
5
void main() {
    // En el orden lexicografico, TODAS las mayusculas van antes que TODAS las minusculas
    IO.println("Zebra".compareTo("apple"));   // negativo: Zebra va antes que apple
    IO.println("Ada".compareTo("ada"));       // negativo: la caja decide
}

Ni Zebra antes que apple ni Ada antes que ada es lo que un humano esperaría del alfabeto: es lo que espera un ordenador del código de caracteres. Cuando necesites el orden «de persona», la receta es comparar las versiones normalizadas: a.toLowerCase().compareTo(b.toLowerCase()).

¿Y para qué sirve un método así? Para ordenar. En la UD2, si ordenaste, ordenaste números a mano, con la burbuja; en la sección 9, la clase java.util.Arrays convertirá esa tarea en una sola llamada — y Arrays.sort sobre un array de cadenas usará exactamente compareTo para decidir quién va antes, sin que tú escribas comparación alguna:

import java.util.Arrays;

void main() {
    String[] nombres = { "Lovelace", "Turing", "Hopper", "Torvalds" };

    Arrays.sort(nombres);   // orden lexicografico: el criterio de compareTo, de serie
    // (la clase Arrays, con toda su caja de herramientas, se abre en la seccion 9)

    for (String n : nombres) {
        IO.println(n);      // Hopper, Lovelace, Torvalds, Turing
    }
}

Cuando en la UD5 ordenes colecciones completas, el motor será el mismo: el orden de las cadenas ya lo defines tú hoy.

isEmpty e isBlank: las tres formas de «no haber nada»

Última pareja del grupo, y la más nueva: isEmpty responde si la cadena tiene cero caracteres; isBlank (Java 11) responde si no tiene nada que no sean espacios — vacía o compuesta solo de espacios en blanco. La diferencia es pequeña y deliciosa:

1
2
3
4
5
6
7
8
9
void main() {
    IO.println("".isEmpty());       // true:  cero caracteres
    IO.println("".isBlank());       // true:  vacia tambien es "todo espacios"

    IO.println("   ".isEmpty());    // false: tiene 3 caracteres (espacios)
    IO.println("   ".isBlank());    // true:  pero no hay nada util

    IO.println("a".isBlank());      // false: hay al menos un caracter de verdad
}

Con las tres piezas juntas puedes por fin escribir el patrón completo de validación de una entrada, que distingue los tres estados de «no haber nada» y es el estándar que usarás en la práctica guiada:

1
2
3
4
5
6
7
8
9
void main() {
    String entrada = IO.readln("Escribe tu nombre: ");

    if (entrada == null || entrada.isBlank()) {
        IO.println("No has escrito nada");
    } else {
        IO.println("Has escrito " + entrada.strip().length() + " caracteres utiles");
    }
}

El || cortocircuita: si la referencia es null, la segunda condición ni se evalúa y el NullPointerException nunca llega a nacer. Y isBlank atrapa de un golpe tanto la cadena vacía como la de puros espacios: es el detector de «el usuario le dio a Enter sin querer». Tabla final de los tres estados, para que el mapa quede completo:

Estado == null isEmpty() isBlank() Ejemplo
Sin objeto true NPE si lo llamas NPE si lo llamas una referencia sin asignar
Cadena vacía false true true ""
Cadena de espacios false false true " "
Cadena con contenido false false false "ada"

3.9 Plantillas de texto: String.format y formatted

Llega el último verbo, y con él la promesa que la sección 2 dejó escrita al hilo de su «error 3»: el método format llegaría a fondo en la sección 3. Hoy cobramos esa deuda entera, porque las plantillas son la forma adulta de construir texto con mezcla de tipos — y porque ya usaste ambas versiones sin criterio en la UD1; ahora les pones fundamento.

El problema que resuelven lo conoces de memoria: componer una línea mezclando texto fijo y valores de tipos distintos con + es verboso y frágil — y con la trampa de asociatividad de la subsección 3.7 acechando. La solución es declarar una plantilla con huecos y rellenarla:

void main() {
    String nombre = "Ada";
    int puntos = 47;

    // Version ESTATICA: se pide a la clase (como Integer.parseInt)
    String linea1 = String.format("%s tiene %d puntos", nombre, puntos);

    // Version DE INSTANCIA: se pide a una cadena-plantilla (Java 15)
    String linea2 = "%s tiene %d puntos".formatted(nombre, puntos);

    IO.println(linea1);   // Ada tiene 47 puntos
    IO.println(linea2);   // Ada tiene 47 puntos: el mismo resultado
}

Las dos llamadas hacen exactamente lo mismo — misma plantilla, mismo motor por dentro — y la pareja es una clase magistral de la sección 2 en miniatura: format es estático (el receptor es la clase String, y ya sabes que escribirlo sobre una instancia es el «error cobarde» que IntelliJ subraya); formatted es de instancia (el receptor es la propia plantilla, que llega con Java 15, la misma versión de los text blocks de la UD1). La regla de estilo para elegir es sencilla: si la plantilla es un literal que tienes delante — el caso más común —, "...".formatted(...) se lee de maravilla; si la plantilla viene de una variable o la construyes a medias, String.format(...) es la forma natural.

Los especificadores: el idioma de los huecos

Los huecos se marcan con % seguido de una letra que anuncia qué tipo de valor espera el hueco. Este es el diccionario esencial:

Especificador Espera Ejemplo de llamada Resultado
%s cualquier cosa: texto, números, objetos "%s vale %s".formatted("x", 3.5) x vale 3.5
%d enteros (int, long...) "%d filas".formatted(42) 42 filas
%f reales (double, float) "%f".formatted(2.5) 2,500000
%.2f real con dos decimales "%.2f".formatted(2.5) 2,50
%n salto de línea del sistema "a%nb".formatted() a y b en dos líneas
%% un % literal "El %d%%".formatted(50) El 50%

Cuatro observaciones de quien ha visto muchos errores. Primera: %s es el comodín universal — convierte a texto lo que le eches —, pero %d y %f son exigentes: %d solo acepta enteros y %f solo reales. Segunda: %.2f es la forma estándar de pintar dinero y notas — recorta y redondea a los decimales pedidos —; sin precisión, %f pinta seis decimales por defecto. Tercera: %% es la única forma de escribir un % dentro de una plantilla, y su ausencia es un error classicísimo cuando la plantilla lleva porcentajes. Cuarta: fíjate en que los ejemplos de %f muestran coma decimal, no punto — sobre eso va justamente el aviso que sigue.

El locale: la coma y el punto de tu ordenador

Las plantillas usan la configuración regional (locale) de la máquina para decidir el separador decimal: en un sistema configurado en español, %.2f pinta 2,50 — con coma, como en la calle —; en uno inglés, 2.50. Es el mismo fenómeno del Scanner y su separador decimal que viste en la UD1, ahora por el lado de la salida. Para tus programas de consola de este curso, la coma es correcta y esperable. Si algún día necesitas forzar el punto (p. ej. para generar datos que leerá otra máquina), existe la versión con locale explícito, String.format(java.util.Locale.ROOT, "%.2f", 2.5): guárdala en la mochila por si acaso, pero no la uses salvo necesidad real.

El error estrella de las plantillas

La exigencia de %d y %f no es caprichosa, y el error que provoca es el más enseñado de todos: mezclar el especificador con el tipo equivocado no falla al compilar — la plantilla es una cadena más, y el compilador no la analiza — sino al ejecutarse, con una excepción de formato que llega sin aviso previo:

1
2
3
4
5
6
7
8
void main() {
    // Compila perfectamente... y revienta AL EJECUTARSE:
    IO.println(String.format("Precio: %d", 9.99));   // IllegalFormatConversionException
    // %d exige un entero; 9.99 es un double

    // La version correcta para un real:
    IO.println(String.format("Precio: %.2f", 9.99)); // Precio: 9,99 (coma con locale espanol)
}

Que el error aparezca en ejecución y no en compilación es la lección de fondo: las plantillas se comprueban con pruebas, no depositando confianza en el compilador. Acostúmbrate a ejecutar cada plantilla nueva con datos reales antes de darla por buena.

Plantillas multilínea: text blocks + formatted

La pareja que abre la UD1 y esta sección se junta por fin: un text block como plantilla y formatted para rellenarla es el idiom moderno de Java para informes y fichas completas. Fíjate en dos detalles de conducción: se usa IO.print (no println) porque el propio bloque ya termina en salto de línea, y el %n interior genera los saltos del sistema:

void main() {
    String producto = "Teclado mecanico";
    int unidades = 2;
    double precio = 79.95;

    String ticket = """
            ==============================
            Producto: %s
            Unidades: %d
            Precio:   %.2f euros
            Total:    %.2f euros
            ==============================
            """.formatted(producto, unidades, precio * unidades);

    IO.print(ticket);
    // (con el locale espanol, los %.2f pintan 79,95 y 159,90: coma decimal)
}

Cuatro huecos, tres especificadores distintos, una expresión aritmética (precio * unidades) calculada antes de rellenar, y el texto alineado de serie. Esta es la apariencia que tendrá tu práctica guiada al terminar la sección.

Que significan los tres puntitos de la firma

Si miras la firma de formatted o format en la Javadoc, verás formatted(Object... args). Ese ... se llama varargs: significa «recibe un número variable de argumentos». Gracias a él puedes rellenar una plantilla con uno, tres o diez valores sin que exista una sobrecarga distinta por cada caso — no es sobrecarga de la sección 2, es otra mecánica —. Y como el hueco genérico es Object, cada valor primitivo que pasas viaja encajonado en su versión objeto por el autoboxing, cuyo capítulo completo vive en la sección 8. Dos detalles que ahora solo necesitas reconocer.

Extras de alineacion que veras en codigo ajeno

Entre el % y la letra pueden intercalarse números que controlan la anchura y la alineación: %10s reserva diez caracteres para el texto (alineado a la derecha), %-10s lo alinea a la izquierda, y %5d hace lo propio con enteros. Se usan para columnas alineadas en informes de consola — "%-10s %5d".formatted("Ada", 47) produce Ada 47 —. Hoy los reconoces; los practicarás cuando la sección de ejercicios pida una tabla bonita.

3.10 Práctica guiada: el protocolo de registro

Toca poner a trabajar toda la sección en un programa de verdad, y el escenario es de los más realistas que existen: el registro de un usuario en un sistema, con sus validaciones de texto. Construiremos el protocolo de registro del aula virtual del centro: un programa que pide nombre de usuario y contraseña, los valida con las reglas de la sección y termina emitiendo un informe con formato. El código base te lo doy entero; las mejoras las construirás tú, con la Javadoc y el autocompletado como brújula — igual que en la sección 2.

Escribe este programa en un fichero nuevo, Registro.java, y ejecútalo probando entradas correctas e incorrectas:

void main() {
    // El usuario teclea su nombre de usuario y su contrasena
    String usuario = IO.readln("Nombre de usuario (5 a 12 letras): ");
    String clave = IO.readln("Contrasena (minimo 8 caracteres): ");

    // Regla 1: la longitud del usuario debe estar entre 5 y 12
    boolean usuarioOk = usuario.length() >= 5 && usuario.length() <= 12;

    // Regla 2: la clave debe tener al menos 8 caracteres
    boolean claveLarga = clave.length() >= 8;

    // Regla 3: la clave NO debe contener el nombre de usuario dentro
    // (la clave "lovelace99" con usuario "love": rechazada por previsible)
    boolean claveSinUsuario = !clave.contains(usuario);

    // Regla 4: la clave no puede ser la palabra de moda "contrasena"
    boolean claveNoTrivial = !clave.equals("contrasena");

    // Informe provisional: la version bonita llega en la mision 4
    IO.println("Usuario valido: " + usuarioOk);
    IO.println("Clave larga: " + claveLarga);
    IO.println("Clave sin el usuario dentro: " + claveSinUsuario);
    IO.println("Clave no trivial: " + claveNoTrivial);
}

Pausa para leerlo con las gafas de la sección: cuatro reglas de validación, cada una ejerciendo un método distinto — length, contains, equals — sobre cadenas concretas, con la lógica booleana de la UD1 soldando todo. Nada nuevo que no hayas visto hoy; el programa base es deliberadamente sencillo para que las misiones tengan espacio para crecer. Ellas van ahora, en orden y sin saltarse la parte incómoda: intenta resolverlas antes de abrir la solución plegable.

Misión 1 — Los espacios traidores. Ejecuta el programa tecleando " ada " (con espacios delante y detrás) como usuario. La regla de longitud cuenta esos espacios como letras y puede validar un usuario que en realidad está sucio. Haz que el programa limpie ambas entradas antes de validar nada.

Solución a la misión 1

La herramienta es strip(), y el detalle fino es la reasignación: por la inmutabilidad (subsección 3.2), usuario.strip() no limpia a usuario — devuelve una cadena limpia nueva que hay que recoger:

1
2
3
// Limpieza de entrada: strip devuelve una cadena NUEVA (reasignar para conservarla)
usuario = usuario.strip();
clave = clave.strip();

Colócalas justo después de los readln y el resto del programa no cambia una coma: las cuatro reglas ahora evalúan las cadenas limpias. Es el patrón «limpiar la entrada en la frontera»: se hace una vez, al entrar, y todo lo demás trabaja con datos decentes. Con " ada " la regla 1 pasa a evaluar "ada" — y a fallar por longitud, que es exactamente lo que debe pasar.

Misión 2 — El detector de dominios. Añade al registro una tercera entrada: el correo. Valida que contiene una arroba y, si la contiene, muestra por separado el dominio (todo lo que va después de la arroba). Para "ada@ies.thiar.es", el programa debe mostrar ies.thiar.es.

Solución a la misión 2

Es el patrón estrella de la sección, indexOf + substring, con su comprobación del -1:

String correo = IO.readln("Tu correo: ");

int arroba = correo.indexOf('@');

if (arroba == -1) {
    IO.println("Correo invalido: no contiene @");
} else {
    String dominio = correo.substring(arroba + 1);   // desde la posicion siguiente
    IO.println("Dominio: " + dominio);
}

El + 1 es la aritmética de índices de la subsección 3.6: la arroba vive en arroba, y el dominio empieza justo después. Existe un camino alternativo con split — correo.split("@")[1] — pero es más frágil: si el correo tiene dos arrobas te da la pieza del medio, y si no tiene ninguna, revienta con ArrayIndexOutOfBoundsException (índice 1 en un array de una pieza). El patrón indexOf + -1 + substring controla todos los casos: es el que debe salirte de forma natural.

Misión 3 — La caza de la mayúscula. Añade una quinta regla: la contraseña debe contener al menos una letra mayúscula. Pista: te hará falta un bucle, charAt y la sorpresa numérica del char de la subsección 3.6.

Solución a la misión 3

Recorremos la cadena posición a posición y comparamos cada carácter con el rango de las mayúsculas — que son, recuerda, números consecutivos del 65 ('A') al 90 ('Z'):

boolean tieneMayuscula = false;

for (int i = 0; i < clave.length(); i++) {
    char c = clave.charAt(i);
    if (c >= 'A' && c <= 'Z') {
        tieneMayuscula = true;
        break;   // con una basta: cortamos el bucle (sentencia de salto, UD2)
    }
}

IO.println("Clave con mayuscula: " + tieneMayuscula);

Dos detalles de factura. El break del bloque if no es obligatorio — el bucle seguiría hasta el final con el mismo resultado — pero es cortesía profesional: no sigas buscando lo que ya has encontrado. Y el rango c >= 'A' && c <= 'Z' funciona porque los códigos de las mayúsculas son consecutivos en Unicode; la misma idea con c >= 'a' && c <= 'z' detecta minúsculas, y con los rangos del '0' al '9', dígitos — tres validaciones clásicas que ya sabes escribir sin regex (que llegan en la UD5 para esto mismo).

Misión 4 — El informe con formato. Sustituye los cuatro IO.println provisionales por un informe digno: un marco de signos =, el nombre del usuario, cuántas de las cinco reglas supera (recuéntalas en un int) y un porcentaje de fortaleza con un decimal. Para "ada" con 4 de 5 reglas superadas, algo así:

1
2
3
4
5
6
7
================================
   PROTOCOLO DE REGISTRO
================================
Usuario: ada
Reglas superadas: 4 de 5
Fortaleza: 80,0%
================================
Solución a la misión 4

Reunión de toda la sección en un solo bloque: repeat para el marco, formatted para las líneas con huecos, %d para las cuentas, %.1f para el porcentaje y %% para el símbolo. Primero cuenta las reglas — un int que suma 1 por cada booleano cierto — y luego formatea:

int superadas = 0;
if (usuarioOk) superadas++;
if (claveLarga) superadas++;
if (claveSinUsuario) superadas++;
if (claveNoTrivial) superadas++;
if (tieneMayuscula) superadas++;

String informe = """
        %s
           PROTOCOLO DE REGISTRO
        %s
        Usuario: %s
        Reglas superadas: %d de 5
        Fortaleza: %.1f%%
        %s
        """.formatted(
                "=".repeat(32),
                "=".repeat(32),
                usuario,
                superadas,
                superadas * 100.0 / 5,
                "=".repeat(32));

IO.print(informe);

Fíjate en las dos artimañas: el mismo "=".repeat(32) se pasa tres veces como argumento a los tres huecos %s del marco (las plantillas no repiten por ti: los argumentos se consumen por orden, uno por hueco); y la operación superadas * 100.0 / 5 usa 100.0 para forzar división real — con 100 entero, 4 * 100 / 5 daría 80, y el %.1f recibiría un int que no le corresponde. El %% final pinta el símbolo de porcentaje. Con locale español, la fortaleza sale con coma decimal (80,0%): el aviso de la subsección 3.9 en acción.

Misión 5 — El arbitraje final. Dos exigencias nuevas del protocolo: primera, si el usuario deja la entrada vacía o de puros espacios, el programa debe rechazarla con un mensaje antes de validar nada; segunda, si el usuario teclea dos alias posibles, el sistema se queda con el que va antes alfabéticamente.

Solución a la misión 5

La primera parte es el patrón de la subsección 3.8 con isBlank; la segunda, compareTo con su signo y el ternario de la UD1:

String alias1 = IO.readln("Primer alias posible: ");
String alias2 = IO.readln("Segundo alias posible: ");

if (alias1.isBlank() || alias2.isBlank()) {
    IO.println("Ningun alias puede quedar vacio");
} else {
    // compareTo devuelve negativo si alias1 va antes: el ternario elige
    String elegido = alias1.compareTo(alias2) <= 0 ? alias1 : alias2;
    IO.println("El sistema se queda con: " + elegido);
}

El <= 0 es deliberado: si los dos alias son idénticos, compareTo devuelve 0 y da igual cuál se elija, así que quedarnos con el primero es una decisión limpia. Y si quieres el arbitraje «de persona» — ignorando la caja —, ya sabes la receta de la subsección 3.8: comparar alias1.toLowerCase().compareTo(alias2.toLowerCase()).

Si has completado las cinco misiones, mira atrás: has validado entradas con isBlank, limpiado con strip, buscado con indexOf y contains, extraído con substring y charAt con su bucle, comparado con equals y compareTo, y emitido un informe con repeat, formatted, %d, %.1f y %%. Eso es, literalmente, el temario de la sección entero funcionando en un programa de veinte líneas. Y una reflexión final de profesor: casi todo lo que has escrito tenía trampa — la reasignación del strip, el -1 del indexOf, el final excluido del substring, el %% del formato — y ninguna te ha pillado, porque ya conoces el terreno. El examen de esta unidad se escribe con estas mismas piezas.

3.11 Resumen, glosario y checklist

Si tuvieras que quedarte con diez ideas de esta sección, que fueran estas:

  1. String es una clase de java.lang, final y con más de setenta métodos públicos; sus literales son objetos y sus índices van de 0 a length() - 1, como los de un array.
  2. Es inmutable: ningún método modifica la cadena que recibe el mensaje; todos devuelven cadenas nuevas. Conservar un resultado exige reasignar: frase = frase.strip();.
  3. La inmutabilidad hace posible el pool de cadenas: los literales idénticos comparten un único objeto en una zona especial del montón; new String(...) fabrica un objeto fuera del pool. Por eso == a veces «funciona»: no te fíes; contenido se compara con equals.
  4. Buscar: contains, startsWith, endsWith (boolean) e indexOf / lastIndexOf (posición, -1 si no aparece), siempre sensibles a mayúsculas; el patrón del -1 es obligatorio antes de usar la posición.
  5. Transformar: toUpperCase, toLowerCase, strip (moderno) frente a trim (histórico), replace literal (dos versiones: char y texto) y replaceAll con regex — recetas básicas hoy, lenguaje completo en la UD5.
  6. Extraer: substring(inicial, final) con el final excluido (la longitud del trozo es final - inicial); charAt(i) devuelve un char, que por dentro es un número; split(separador) devuelve un String[]; chars() abre la puerta de los streams (UD5).
  7. Construir: String.join (estático, al receptor clase), repeat (instancia, Java 11), y el + con su trampa de asociatividad — "Total: " + 1 + 2 da Total: 12.
  8. Preguntar: equals / equalsIgnoreCase para igualdad (con el patrón defensivo del null), compareTo para el orden lexicográfico — las mayúsculas van antes — e isEmpty / isBlank para distinguir "" de " ".
  9. Formatear: String.format (estático) y formatted (instancia) son la misma máquina; los huecos son %s, %d, %f, %.2f, %n y %%; %d con un double revienta en ejecución, no al compilar; y el locale español pinta la coma decimal.
  10. Los patrones de la sección: reasignar para conservar, comprobar el -1, normalizar y luego buscar o comparar, limpiar la entrada en la frontera. Con esos cuatro y el repertorio de métodos, las validaciones de texto de la práctica guiada se escriben solas.

Glosario de la sección

Término Definición en una línea
Inmutabilidad Propiedad de String: su contenido no puede cambiar tras crearse; los métodos devuelven cadenas nuevas
Pool de cadenas Zona del montón con una única copia de cada cadena literal del programa
String interning Mecanismo de reutilización del pool: literales idénticos comparten objeto
Literal Cadena escrita entre comillas en el código fuente
Reasignar Recoger en la variable el objeto nuevo que devuelve un método (frase = frase.trim())
Cadena en blanco Cadena vacía o compuesta solo de espacios en blanco (isBlank la detecta)
Orden lexicográfico Orden de las cadenas comparando códigos Unicode posición a posición (el de compareTo)
Expresión regular Mini-lenguaje de patrones de texto; lo usan replaceAll y split (a fondo en UD5)
char Primitivo de un único carácter; por dentro, un número de 16 bits (su código Unicode)
Índice Posición de un carácter en la cadena: de 0 a length() - 1
StringIndexOutOfBoundsException Excepción al usar un índice fuera de rango en charAt o substring
Plantilla Cadena con huecos (%s, %d...) que se rellenan con format o formatted
Especificador Marcador de hueco: %s texto, %d entero, %f real, %n salto, %% porcentaje
Precisión Número de decimales de un hueco real: %.2f pinta dos
Locale Configuración regional de la máquina: decide el separador decimal de %f
varargs Parámetro de número variable de argumentos, escrito ... en las firmas
CharSequence Contrato «secuencia de caracteres» que String implementa; aparece en firmas
text block Cadena multilínea entre """ (UD1), perfecta como plantilla de formatted
Patrón del -1 Comprobar el fracaso de indexOf antes de usar la posición devuelta
Normalizar Llevar las cadenas a un común (minúsculas, sin bordes) antes de buscar o comparar

Checklist: al terminar, debes ser capaz de...

  • [ ] Explicar por qué frase.toUpperCase() no cambia frase, y corregir un programa que olvide la reasignación (CE 2.4).
  • [ ] Explicar el experimento a == b, a == c, a.equals(c) con literales y new String, usando las palabras «pool» e «identidad» (CE 2.4, base de CE 2.1).
  • [ ] Escribir una búsqueda con indexOf que compruebe el -1 antes de usar la posición (CE 2.2, CE 2.6).
  • [ ] Predecir el resultado de un substring de dos argumentos, con el final excluido, dibujando la fila numerada (CE 2.2).
  • [ ] Elegir entre replace y replaceAll para un caso dado, y decir cuál interpreta regex y dónde se estudian a fondo (CE 2.4, CE 2.7).
  • [ ] Recorrer una cadena carácter a carácter con charAt en un bucle, detectando mayúsculas o dígitos por rangos de char (CE 2.2, CE 2.6).
  • [ ] Elegir entre equals, equalsIgnoreCase y compareTo según la pregunta que se quiera hacer, y usar isBlank para rechazar entradas vacías (CE 2.4).
  • [ ] Emitir un informe con formatted usando %s, %d, %.2f, %% y repeat para el marco (CE 2.2, CE 2.5, CE 2.6).
  • [ ] Explicar por qué "Total: " + 1 + 2 imprime Total: 12 y corregirlo con paréntesis (CE 2.2).
  • [ ] Leer la ficha Javadoc de un método de String que no hayas visto en clase y deducir qué devuelve y qué puede lanzar (CE 2.7, CE 2.9).

Lo que hoy quedó citado, y dónde vive

Tema mencionado hoy Dónde se desarrolla
Concatenar en bucles y StringBuilder / StringJoiner Sección 7 de esta unidad
Expresiones regulares completas (Pattern, Matcher, validaciones) UD5, sección 10
Streams: chars(), Random.ints() UD5
Autoboxing detrás de format(Object...) Sección 8 de esta unidad
equals y hashCode cuando los objetos trabajan como claves UD5, con las colecciones
Fechas formateadas con DateTimeFormatter Sección 6 de esta unidad
Interfaces como CharSequence UD7

3.12 Autoevaluación de la sección

¿Esto no era la sección 11?

Una vez más: el test oficial de repaso de la unidad vive en la sección 11, dividido por secciones para el repaso de conjunto. Este bloque es una autoevaluación rápida para hacer justo después de leer, sin material y sin presión: si fallas algo, vuelve a la subsección correspondiente antes de continuar. El orden de las preguntas sigue el orden de la sección.

#

Pregunta 1

Tras ejecutar String frase = "hola"; frase.toUpperCase();, ¿qué contiene la variable frase?

#

Pregunta 2

Con String a = "java"; String b = "java";, ¿qué devuelve a == b y por qué?

#

Pregunta 3

Con String a = "java"; String c = new String("java");, ¿qué ocurre con a == c y a.equals(c)?

#

Pregunta 4

¿Qué devuelve "banana".indexOf('z')?

#

Pregunta 5

¿Cuál es la diferencia real entre replace("a.c", "X") y replaceAll("a.c", "X")?

#

Pregunta 6

¿Qué devuelve "programar".substring(1, 4)?

#

Pregunta 7

¿Cuál de estas llamadas construye correctamente la cadena "2026-10-31"?

#

Pregunta 8

Con la cadena " " (tres espacios), ¿qué devuelven isEmpty() e isBlank()?

#

Pregunta 9

¿Qué devuelve "casa".compareTo("coche") y qué significa?

#

Pregunta 10

¿Qué ocurre al ejecutar String.format("Precio: %d", 9.99)?

Cuando las diez preguntas te salgan sin dudar, has cerrado el capítulo más transitado de la unidad: manejas la clase más usada del API con sus trampas desactivadas. Estás listo para la sección 4 — La clase Math, donde conocerás la clase de utilidad por excelencia de Java: sin objetos, sin new y con todo su repertorio estático — constantes como Math.PI, funciones como sqrt, pow o el flamante clamp, y la aritmética segura que detecta desbordamientos por ti.