Strings en Java — práctica con un importador de alumnos, un generador de usuarios y un buscador de contactos (y por qué hay que normalizar antes de comparar)
Hice un comprobador de palíndromos y, al probarlo con "ana", funcionó a la primera. Ya me veía entregándolo. Después probé con "Ana" y falló; y con "Anita lava la tina", también. Lo que me pasaba era que había olvidado pasar el texto a minúsculas y quitar los espacios antes de comparar. Desde entonces, cuando trabajo con textos, hago siempre lo mismo: primero normalizo, después comparo. Esa idea aparece en los tres programas de esta práctica, y verás que es más importante de lo que parece.
Si ya has leído la teoría de Strings, este artículo es para aplicarla con tres programas completos: un importador de alumnos desde líneas CSV, un generador de usuarios y correos, y un buscador de contactos. Todos son de dificultad media y trabajan con datos que se parecen a los de verdad: con espacios de más, tildes, campos vacíos y líneas mal formadas. Cada uno trae el código comentado, la salida y las dudas que suelen surgir.
Tabla de Contenidos
Antes de empezar
Conviene tener claro:
- Que los
Stringson inmutables:trim,toLowerCasey compañía devuelven uno nuevo y hay que guardarlo. - Que se comparan con
equals, no con==. - Cómo funcionan
substring(el final no se incluye),indexOf(devuelve-1si no encuentra) ysplit(devuelve un array).
Si alguno te suena dudoso, repasa la teoría antes de seguir.
Los programas llevan tildes y eñes en el código. Si ves caracteres raros al copiarlos, comprueba que tu editor guarda los archivos en UTF-8 (es lo normal en IntelliJ y VS Code).
Programa 1: importador de alumnos desde CSV
Vamos a procesar líneas de texto con el formato nombre,correo,asignatura,nota, como las que podría exportar una hoja de cálculo. Los datos vienen sucios: hay espacios sobrantes, líneas a las que les falta un campo, correos que no son de la universidad y notas inválidas. El programa tiene que separar lo válido de lo erróneo, explicar el motivo de cada error y calcular la media de las notas válidas.
La idea
Cada línea se parte con split(",") y se limpia con trim. Después se hacen las comprobaciones en un orden concreto: primero el número de campos (si faltan, no podemos mirar los demás), luego el correo y por último la nota. Como no sabemos de antemano cuántas líneas serán válidas, usamos el patrón de la práctica de arrays: arrays «de sobra» y un contador (validos) que dice cuántas posiciones están en uso.
Antes de programar, piensa en qué hay que comprobar para que una nota sea válida: que solo tenga dígitos y como mucho un punto, que no esté vacía y que no pase de 10. Eso se resuelve con un for sobre charAt, como en la teoría.
El código
public class ImportadorAlumnos {
// Parte una línea CSV y limpia los espacios de cada campo
static String[] partir(String linea) {
String[] campos = linea.split(",", -1); // -1: conserva los campos vacíos del final
for (int i = 0; i < campos.length; i++) {
campos[i] = campos[i].trim(); // trim devuelve una String NUEVA: la guardamos
}
return campos;
}
// Una nota válida: solo dígitos y como mucho un punto, y entre 0 y 10
static boolean esNota(String s) {
int digitos = 0;
int puntos = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '.') {
puntos++;
} else if (c >= '0' && c <= '9') {
digitos++;
} else {
return false; // cualquier otro carácter la invalida
}
}
if (digitos == 0 || puntos > 1) {
return false;
}
return Double.parseDouble(s) <= 10; // ya sabemos que se puede convertir
}
static boolean esCorreoUniversitario(String correo) {
int arroba = correo.indexOf('@'); // -1 si no hay arroba
return arroba > 0 && correo.endsWith("@alu.ulpgc.es");
}
public static void main(String[] args) {
String[] lineas = {
"Ana Pérez Suárez, ana.perez@alu.ulpgc.es, fp2, 8.5",
" Luis Gómez ,luis.gomez@alu.ulpgc.es,FP2,6",
"Marta Ruiz,marta.ruiz@alu.ulpgc.es,fp2",
"Pablo Sosa,pablo.sosa@gmail.com,fp2,7.25",
"Elena Díaz,elena.diaz@alu.ulpgc.es,fp2,9x",
"Sara Vega,sara.vega@alu.ulpgc.es,fp2,",
"Iván Mora,ivan.mora@alu.ulpgc.es,fp2,10"
};
// Arrays paralelos con espacio para todas las líneas; 'validos' dice cuántas posiciones están en uso
String[] nombres = new String[lineas.length];
double[] notas = new double[lineas.length];
int validos = 0;
System.out.println("Líneas con error:");
for (int i = 0; i < lineas.length; i++) {
String[] campos = partir(lineas[i]);
String motivo = null; // null = todavía no hemos encontrado ningún problema
if (campos.length != 4) {
motivo = "se esperaban 4 campos y hay " + campos.length;
} else if (!esCorreoUniversitario(campos[1])) {
motivo = "correo no universitario (" + campos[1] + ")";
} else if (!esNota(campos[3])) {
motivo = "nota no válida (" + campos[3] + ")";
}
if (motivo != null) {
System.out.println(" Línea " + (i + 1) + ": " + motivo);
} else {
nombres[validos] = campos[0];
notas[validos] = Double.parseDouble(campos[3]);
validos++;
}
}
System.out.println("\nAlumnos válidos:");
double suma = 0;
for (int i = 0; i < validos; i++) {
System.out.printf(" %-18s %5.2f%n", nombres[i], notas[i]);
suma += notas[i];
}
if (validos > 0) {
System.out.printf("Media: %.2f%n", suma / validos);
}
}
}
Salida
Líneas con error: Línea 3: se esperaban 4 campos y hay 3 Línea 4: correo no universitario (pablo.sosa@gmail.com) Línea 5: nota no válida (9x) Línea 6: nota no válida () Alumnos válidos: Ana Pérez Suárez 8,50 Luis Gómez 6,00 Iván Mora 10,00 Media: 8,17
Compruébalo a mano: (8,5 + 6 + 10) / 3 = 8,1666… ✔. Y fíjate en que la línea 2 (con espacios por todas partes y la asignatura en mayúsculas) es válida: el trim la ha limpiado antes de comprobar nada.
Los decimales salen con coma porque printf usa el formato de tu idioma. En el CSV, en cambio, los decimales van con punto (8.5), porque es lo que entiende Double.parseDouble.
Preguntas que te puedes hacer
¿Por qué split(",", -1) y no split(",")? Por la línea de Sara Vega, que acaba en coma (...,fp2,). Con split(","), el campo vacío del final desaparece y la línea parecería tener solo 3 campos, con un mensaje de error engañoso. Con -1 se conserva, hay 4 campos y el error correcto es «nota no válida ()». Es el detalle 3 de split de la teoría.
¿Por qué se comprueba primero campos.length? Porque las comprobaciones siguientes acceden a campos[1] y campos[3]. Si la línea solo tuviera 3 campos, campos[3] lanzaría una ArrayIndexOutOfBoundsException. El orden de las comprobaciones no es casual: cada una protege a la siguiente.
¿Por qué esNota recorre el texto a mano en vez de llamar directamente a Double.parseDouble? Porque Double.parseDouble("9x") lanza una NumberFormatException y todavía no hemos visto cómo tratar excepciones. Validamos antes de convertir y así nunca lo llamamos con un texto que no se pueda convertir.
¿Por qué se puede escribir c >= '0' && c <= '9'? Porque cada char tiene un código numérico y los dígitos van seguidos ('0' hasta '9'). Un carácter está entre esos dos si es un dígito. Es la forma clásica de comprobarlo sin librerías.
¿Por qué motivo = null al principio? Es una forma de usar la variable como «bandera»: mientras siga siendo null, no hemos encontrado ningún problema; en cuanto una comprobación falla, se rellena con la explicación. Así, después, una sola pregunta (motivo != null) decide si la línea es válida.
Escribir campos[i].trim(); sin guardar el resultado. El programa compila y no da error, pero los campos siguen con sus espacios y comparaciones como endsWith empiezan a fallar misteriosamente. Siempre: campos[i] = campos[i].trim();.
Pruébalo tú
- Comprueba también que la asignatura sea
FP1,FP2oIC2, sin distinguir mayúsculas, y añade el error correspondiente. - Muestra también cuántas líneas eran válidas y cuántas erróneas.
Programa 2: generador de usuarios y correos
Ahora, el camino inverso: a partir del nombre completo de un alumno, generar su usuario y su correo. La regla es la inicial del nombre más el primer apellido, en minúsculas y sin tildes: "Ana Pérez Suárez" → aperez@alu.ulpgc.es. Si el usuario ya existe, se le añade un número (aperez2, aperez3…). Y los datos vienen con espacios de sobra, mayúsculas y tildes en cualquier parte.
La idea
Aquí es donde aparece mi anécdota con toda su fuerza. Antes de hacer nada con un nombre, hay que normalizarlo: quitar espacios de los extremos, pasar a minúsculas y quitar tildes y eñes. Sin eso, "Álvaro" y "alvaro" serían cosas distintas, y "PÉREZ" y "Pérez" también. Con el texto normalizado, el resto es fácil: dividir por espacios (saltando los vacíos), coger la primera letra del nombre y pegarle el apellido.
Para evitar repetidos, guardamos los usuarios ya generados en un array (otra vez con contador) y usamos un bucle que prueba sufijos hasta encontrar uno libre.
Hay un orden importante dentro de normalizar: primero minúsculas, después quitar tildes. Así solo hace falta tratar á, é, í, ó, ú, ü, ñ en minúscula y no hay que repetir el trabajo con las mayúsculas.
El código
public class GeneradorUsuarios {
// Pasa a minúsculas y quita tildes y eñes (primero minúsculas: así solo hay que tratar las minúsculas)
static String normalizar(String s) {
String r = s.trim().toLowerCase();
r = r.replace('á', 'a');
r = r.replace('é', 'e');
r = r.replace('í', 'i');
r = r.replace('ó', 'o');
r = r.replace('ú', 'u');
r = r.replace('ü', 'u');
r = r.replace('ñ', 'n');
return r;
}
// Inicial del nombre + primer apellido: "Ana Pérez Suárez" -> "aperez". Devuelve null si faltan datos.
static String usuarioBase(String nombreCompleto) {
String[] palabras = normalizar(nombreCompleto).split(" ");
String nombre = null;
String apellido = null;
for (String palabra : palabras) {
if (palabra.isEmpty()) {
continue; // espacios dobles: saltamos las Strings vacías
}
if (nombre == null) {
nombre = palabra;
} else if (apellido == null) {
apellido = palabra;
}
}
if (nombre == null || apellido == null) {
return null;
}
return nombre.substring(0, 1) + apellido;
}
static boolean existe(String[] usados, int cuantos, String usuario) {
for (int i = 0; i < cuantos; i++) {
if (usados[i].equals(usuario)) { // equals, no ==
return true;
}
}
return false;
}
// Si "aperez" ya existe, prueba "aperez2", "aperez3"...
static String usuarioUnico(String base, String[] usados, int cuantos) {
String candidato = base;
int numero = 2;
while (existe(usados, cuantos, candidato)) {
candidato = base + numero;
numero++;
}
return candidato;
}
public static void main(String[] args) {
String[] nombres = {
"Ana Pérez Suárez",
"Álvaro Pérez Gil",
" luis gómez vega ",
"María Núñez Ortega",
"Ángela PÉREZ Díaz",
"Pedro"
};
String[] usados = new String[nombres.length];
int cuantos = 0;
for (String nombre : nombres) {
String base = usuarioBase(nombre);
if (base == null) {
System.out.println("[" + nombre.trim() + "] -> nombre incompleto, no se genera usuario");
continue;
}
String usuario = usuarioUnico(base, usados, cuantos);
usados[cuantos] = usuario;
cuantos++;
System.out.println("[" + nombre.trim() + "] -> " + usuario + "@alu.ulpgc.es");
}
}
}
Salida
[Ana Pérez Suárez] -> aperez@alu.ulpgc.es [Álvaro Pérez Gil] -> aperez2@alu.ulpgc.es [luis gómez vega] -> lgomez@alu.ulpgc.es [María Núñez Ortega] -> mnunez@alu.ulpgc.es [Ángela PÉREZ Díaz] -> aperez3@alu.ulpgc.es [Pedro] -> nombre incompleto, no se genera usuario
Mira los casos incómodos: " luis gómez vega " (espacios por todas partes) da lgomez; "Ángela PÉREZ Díaz" (mayúsculas y tilde) termina igualmente en aperez y, como ya existía, se convierte en aperez3; y "Pedro" no tiene apellido, así que el programa lo detecta en lugar de fallar.
Preguntas que te puedes hacer
¿Por qué usuarioBase devuelve null en vez de un texto cualquiera? Porque null es la señal clara de «no he podido generar nada». Quien llama puede comprobarlo (base == null) y decidir qué hacer. Si devolviera, por ejemplo, un texto vacío, sería más fácil confundirlo con un resultado válido. (Fíjate en que aquí sí comparamos con ==: con null siempre se usa ==, no equals.)
¿Qué hace continue? Salta directamente a la siguiente vuelta del bucle, sin ejecutar lo que queda debajo. Aquí lo usamos para ignorar las Strings vacías que deja split(" ") cuando hay espacios dobles.
¿Por qué existe recibe cuantos? Por la misma razón que el hasta de la práctica de arrays: usados tiene espacio para todos los nombres, pero solo las primeras cuantos posiciones están rellenas. Las demás valen null, y llamar a .equals sobre un null lanzaría un NullPointerException. Por eso solo recorremos lo ya guardado.
¿Cómo funciona el bucle de usuarioUnico? Empieza con el candidato aperez. Mientras exista, prueba aperez2, luego aperez3, y así hasta encontrar uno libre. Como existe se vuelve a llamar con el nuevo candidato en cada vuelta, siempre acaba en un nombre que no está en uso.
¿No hay una forma más corta de quitar las tildes? Sí: la clase java.text.Normalizer lo hace en pocas líneas, pero usa conceptos que aún no hemos visto. La cadena de replace es más larga, pero se entiende de un vistazo.
Pasar a minúsculas después de quitar tildes. Con "Á" en mayúscula, replace('á', 'a') no la encuentra (son caracteres distintos) y la tilde se queda. Por eso toLowerCase() va primero.
El patrón «array grande + contador» (usados y cuantos) es el mismo que usamos en el importador. Es la forma de construir colecciones de tamaño desconocido hasta que lleguen los ArrayList.
Pruébalo tú
- Escribe
esPalindromo(String texto): usanormalizary además quita los espacios (replace(" ", "")) antes de comparar el texto con su reverso. Pruébalo con"ana","Ana"y"Anita lava la tina"(ya sabes por qué). - Haz que, además del usuario, el programa genere una contraseña inicial con las tres primeras letras del apellido en mayúsculas y los dos últimos caracteres del nombre.
Programa 3: buscador de contactos
El último programa es una agenda: cada contacto es una línea con el formato nombre;teléfono;correo. Queremos buscar por nombre sin distinguir mayúsculas ni tildes (así, buscar "nunez" encuentra a "María Núñez"), mostrar el teléfono con formato legible (612 34 56 78) y ocultar parcialmente el correo (a***@alu.ulpgc.es).
La idea
Otra vez la normalización, pero ahora con una vuelta de tuerca: hay que normalizar los dos lados de la comparación, la consulta del usuario y el nombre del contacto. Si solo normalizaras uno, buscar "nunez" no encontraría "Núñez". Después, contains hace el trabajo.
El formato del teléfono es un ejercicio de substring con índices exactos, y el correo oculto combina indexOf (para saber dónde está la arroba) con substring (para quedarnos con la inicial y con todo lo que va desde la arroba).
El código
public class Contactos {
static String normalizar(String s) {
String r = s.trim().toLowerCase();
r = r.replace('á', 'a');
r = r.replace('é', 'e');
r = r.replace('í', 'i');
r = r.replace('ó', 'o');
r = r.replace('ú', 'u');
r = r.replace('ü', 'u');
r = r.replace('ñ', 'n');
return r;
}
// "612345678" -> "612 34 56 78"
static String formatearTelefono(String t) {
return t.substring(0, 3) + " " + t.substring(3, 5) + " " + t.substring(5, 7) + " " + t.substring(7);
}
// "ana.perez@alu.ulpgc.es" -> "a***@alu.ulpgc.es"
static String ocultarCorreo(String correo) {
int arroba = correo.indexOf('@');
if (arroba < 1) {
return correo; // sin arroba (o arroba al principio): lo dejamos tal cual
}
return correo.substring(0, 1) + "***" + correo.substring(arroba);
}
// Busca contactos cuyo nombre contenga la consulta (sin distinguir mayúsculas ni tildes)
static void buscar(String[] contactos, String consulta) {
String q = normalizar(consulta);
System.out.println("Búsqueda: \"" + consulta + "\"");
int encontrados = 0;
for (String contacto : contactos) {
String[] campos = contacto.split(";"); // nombre;teléfono;correo
if (normalizar(campos[0]).contains(q)) {
System.out.println(" " + campos[0] + " | " + formatearTelefono(campos[1]) + " | " + ocultarCorreo(campos[2]));
encontrados++;
}
}
if (encontrados == 0) {
System.out.println(" Sin resultados");
}
}
public static void main(String[] args) {
String[] contactos = {
"Ana Pérez Suárez;612345678;ana.perez@alu.ulpgc.es",
"Álvaro Pérez Gil;623456789;alvaro.perez@alu.ulpgc.es",
"María Núñez Ortega;634567890;maria.nunez@gmail.com",
"Luis Gómez Vega;645678901;luis.gomez@alu.ulpgc.es"
};
buscar(contactos, "perez");
buscar(contactos, " PÉREZ ");
buscar(contactos, "nunez");
buscar(contactos, "gómez");
buscar(contactos, "zzz");
}
}
Salida
Búsqueda: "perez" Ana Pérez Suárez | 612 34 56 78 | a***@alu.ulpgc.es Álvaro Pérez Gil | 623 45 67 89 | a***@alu.ulpgc.es Búsqueda: " PÉREZ " Ana Pérez Suárez | 612 34 56 78 | a***@alu.ulpgc.es Álvaro Pérez Gil | 623 45 67 89 | a***@alu.ulpgc.es Búsqueda: "nunez" María Núñez Ortega | 634 56 78 90 | m***@gmail.com Búsqueda: "gómez" Luis Gómez Vega | 645 67 89 01 | l***@alu.ulpgc.es Búsqueda: "zzz" Sin resultados
Fíjate en que las dos primeras búsquedas ("perez" y " PÉREZ ", con espacios, mayúsculas y tilde) dan exactamente el mismo resultado. Eso es lo que consigue normalizar, y es la lección que me enseñó el comprobador de palíndromos.
Preguntas que te puedes hacer
¿Por qué se normaliza campos[0] y también consulta? Porque la comparación necesita que ambos lados estén en la misma «forma». Si normalizas solo la consulta ("nunez") pero comparas con "María Núñez Ortega" sin normalizar, contains no encuentra nada, porque en el nombre hay una ñ y en la consulta una n.
¿Por qué split(";") funciona sin escapar nada? Porque el punto y coma no tiene significado especial en una expresión regular. Con | o . sí habría que escaparlo ("\\|", "\\."), como viste en la teoría.
¿Por qué los índices de formatearTelefono son 0-3, 3-5, 5-7 y 7? El teléfono tiene 9 dígitos y lo cortamos en 3 + 2 + 2 + 2: de la posición 0 a la 3 (sin incluir la 3), de la 3 a la 5, de la 5 a la 7 y de la 7 hasta el final. Recuerda que el final de substring no se incluye.
¿Qué pasa si un teléfono tiene menos de 9 dígitos? Que substring se saldría del texto y lanzaría una StringIndexOutOfBoundsException. El método asume datos correctos; en un programa real validarías antes la longitud con t.length() == 9.
¿Por qué ocultarCorreo comprueba arroba < 1? Porque indexOf devuelve -1 si no hay arroba, y substring(arroba) con -1 fallaría. Con < 1 cubrimos también el caso de arroba en la posición 0 (un correo sin nada delante, que no tendría «inicial» que mostrar).
Normalizar solo uno de los dos lados de la comparación. Es un fallo muy sutil porque algunas búsquedas funcionan (las que no tienen tildes) y otras no, y a mí me costó verlo con el comprobador de palíndromos. Si comparas textos, normaliza ambos.
Cuando un método de texto devuelve otro texto (como normalizar, formatearTelefono o ocultarCorreo), conviene que no imprima nada: solo devuelve. Así puedes combinarlos y probarlos por separado, como hace buscar.
Pruébalo tú
- Haz que
buscartambién encuentre por teléfono: si la consulta son solo dígitos, que busque en el teléfono en vez de en el nombre. - Muestra el nombre en formato «Apellidos, Nombre» (pista:
indexOf(' ')ysubstring; para este ejercicio supón que el nombre tiene una sola palabra y el resto son apellidos).
Errores típicos en esta práctica
- No guardar el resultado de
trim,toLowerCaseoreplace. LosStringno cambian. - Comparar sin normalizar, o normalizar solo uno de los dos lados.
- Olvidar que
splitdescarta los vacíos del final (usa-1si importan) y deja vacíos en medio con espacios dobles. - Acceder a
campos[i]sin comprobar cuántos hay:ArrayIndexOutOfBoundsException. - Usar
substringocharAtcon unindexOfque dio-1. - Usar
==en vez deequalspara comparar textos (connullsí se usa==).
Resumen
- Antes de comparar textos, normaliza:
trim,toLowerCasey, si hace falta, quitar tildes. Y hazlo en los dos lados de la comparación. - Los
Stringno cambian: los métodos devuelven uno nuevo y hay que guardarlo. - Con datos reales, valida en orden: cada comprobación protege a la siguiente (primero cuántos campos hay, después su contenido).
split(",", -1)conserva los campos vacíos del final; los espacios dobles dejan Strings vacías en medio.indexOfdevuelve-1si no encuentra: comprueba antes de usar su resultado ensubstring.- El patrón «array grande + contador» sirve para guardar resultados cuyo número no conoces de antemano.
Checklist: ¿lo has practicado?
- ¿Has ejecutado los tres programas y comprobado las salidas?
- ¿Sabes explicar por qué
esNotavalida antes de llamar aDouble.parseDouble? - ¿Sabes por qué en
normalizarva primerotoLowerCase? - ¿Entiendes por qué hay que normalizar la consulta y el nombre?
- ¿Sabes cuándo se usa
split(",", -1)en vez desplit(",")? - ¿Has intentado los «Pruébalo tú» sin mirar la teoría?
Si has respondido que sí a casi todo, estás listo para los ejercicios de Strings, donde ya no te doy el código hecho. ¡Nos vemos allí!
Siguiente paso: ejerciciosStrings en Java — ejercicios con palíndromos, palabras y cifrado César (y una trampa con == que no vi a tiempo)¿Te ha servido este artículo? Así puedes apoyar SergioLearns →
