Arrays de Objetos: Guardar y Recorrer Muchas Instancias
En la lección Arrays y Manejo de Strings en Java aprendiste a guardar muchos valores del mismo tipo en un array. En las lecciones Fundamentos de POO: Clases, Objetos y Atributos y Constructores, Modificadores de Acceso y Getters/Setters aprendiste a diseñar una clase que garantiza que cada objeto nazca válido y se mantenga consistente.
Esta lección junta las dos cosas, y ese cruce es donde Java empieza a servir para algo real:
Persona p1 = new Persona("Laura", 28);
Persona p2 = new Persona("Carlos", 35);
Persona p3 = new Persona("Ana", 41);
// ¿y si son 500?
Exactamente el mismo problema que te llevó al array en la lección Arrays y Manejo de Strings en Java, pero ahora con objetos. La solución es la misma —un array— salvo por un detalle que cambia todo: un array de objetos no guarda objetos, guarda referencias a objetos.
Ese detalle es responsable del NullPointerException más común de todo Java, de que dos casillas puedan modificar el mismo objeto sin que lo notes, y de que Arrays.sort no funcione hasta que le expliques cómo comparar. Vamos por partes.
1. Un array de objetos tiene dos niveles de memoria
Un int[] guarda los números adentro. Un Persona[] guarda direcciones que apuntan a objetos que viven en otro lado del Heap.
De esa indirección salen las tres reglas que gobiernan el resto de la lección:
- Crear el array no crea los objetos. Son dos pasos, no uno.
- Copiar una casilla copia la flecha, no el objeto. Dos casillas pueden apuntar al mismo lugar.
- Una casilla puede no apuntar a nada. Ese “nada” se llama
null, y es la fuente de errores número uno.
2. Los dos pasos de la creación
Este es el error inaugural de todo el que empieza con arrays de objetos:
Persona[] personas = new Persona[3];
System.out.println(personas.length); // 3 — el array existe
System.out.println(personas[0]); // null — pero está vacío
System.out.println(personas[0].getNombre()); // NullPointerException
new Persona[3] reserva tres casillas capaces de apuntar a una Persona. No construye ninguna Persona. Es la diferencia entre comprar tres sobres y escribir tres cartas.
new int[3] ya te deja tres ceros usables. Con objetos, el array nace vacío y llenarlo es trabajo tuyo.Las tres formas de llenarlo:
// A) Literal: creás array y objetos en una sola expresión
Persona[] personas = {
new Persona("Laura", 28),
new Persona("Carlos", 35),
new Persona("Ana", 41)
};
// B) Casilla por casilla
Persona[] equipo = new Persona[3];
equipo[0] = new Persona("Laura", 28);
equipo[1] = new Persona("Carlos", 35);
equipo[2] = new Persona("Ana", 41);
// C) En un bucle, que es el caso real
String[] nombres = {"Laura", "Carlos", "Ana"};
int[] edades = {28, 35, 41};
Persona[] plantel = new Persona[nombres.length];
for (int i = 0; i < plantel.length; i++) {
plantel[i] = new Persona(nombres[i], edades[i]);
}
Fijate lo que hace posible la forma C: el constructor validador de la lección Constructores, Modificadores de Acceso y Getters/Setters corre una vez por cada objeto. Si alguno de los datos de entrada es inválido, el constructor lo reemplaza por un valor por defecto seguro y avisa por consola: el objeto igual nace, pero nunca con datos rotos. Sin constructores tendrías que crear los tres objetos vacíos y llenarlos después a mano, que es exactamente la ventana de tiempo con objetos rotos que esa lección se ocupó de cerrar.
3. El null en las casillas: el error más común de Java
Un array parcialmente lleno es una bomba de tiempo:
Persona[] personas = new Persona[5];
personas[0] = new Persona("Laura", 28);
personas[1] = new Persona("Carlos", 35);
// las casillas 2, 3 y 4 quedaron en null
for (Persona p : personas) {
System.out.println(p.getNombre()); // NullPointerException en la tercera vuelta
}
Tenés tres formas de defenderte, en orden de preferencia:
// 1. La mejor: que el array no tenga casillas de más
Persona[] personas = new Persona[2]; // creá exactamente las que vas a llenar
// 2. Si no podés, verificá antes de usar
for (Persona p : personas) {
if (p != null) {
System.out.println(p.getNombre());
}
}
// 3. Si el null nunca debería pasar, fallá fuerte y temprano
import java.util.Objects;
public void registrar(Persona p) {
this.persona = Objects.requireNonNull(p, "La persona no puede ser null");
}
La opción 2 es un parche, no una solución. Un array con
nulladentro casi siempre significa que en realidad necesitabas capacidad y cantidad por separado — es el tema de la sección 8, y es la puerta de entrada al TAD Lista.
4. Recorrer y mostrar
Recorrer es igual que con primitivos, con la diferencia de que ahora cada elemento tiene métodos.
for (Persona p : personas) {
System.out.println(p.getNombre() + " tiene " + p.getEdad() + " años");
}
// Con índice, cuando necesitás reemplazar la referencia de una casilla
for (int i = 0; i < personas.length; i++) {
if (personas[i].getEdad() < 18) {
personas[i] = new Persona(personas[i].getNombre(), 18);
}
}
toString(): sin él, imprimir un array de objetos no sirve
System.out.println(Arrays.toString(personas));
// [Persona@1b6d3586, Persona@4554617c, Persona@74a14482]
Eso que ves es el toString() que hereda de Object: nombre de la clase, arroba y el hash en hexadecimal. Inútil. La solución es sobrescribirlo en tu clase:
public class Persona {
private final String nombre;
private final int edad;
public Persona(String nombre, int edad) {
if (nombre == null || nombre.isBlank()) {
System.out.println("Nombre inválido, se usó \"Sin nombre\" por defecto.");
nombre = "Sin nombre";
}
if (edad < 0 || edad > 130) {
System.out.println("Edad fuera de rango, se usó 0 por defecto.");
edad = 0;
}
this.nombre = nombre;
this.edad = edad;
}
public String getNombre() { return nombre; }
public int getEdad() { return edad; }
@Override
public String toString() {
return nombre + " (" + edad + ")";
}
}
System.out.println(Arrays.toString(personas));
// [Laura (28), Carlos (35), Ana (41)]
Escribí toString() en toda clase que vayas a meter en un array. El costo son tres líneas y te ahorra horas de depuración a ciegas.
5. Buscar dentro de un array de objetos
Con primitivos buscabas un valor. Con objetos buscás el objeto cuyo atributo cumple algo, y eso siempre es un recorrido lineal escrito a mano:
public static Persona buscarPorNombre(Persona[] personas, String nombre) {
for (Persona p : personas) {
if (p != null && p.getNombre().equalsIgnoreCase(nombre)) {
return p; // encontrada: cortamos acá
}
}
return null; // recorrimos todo y no está
}
Tres decisiones de diseño que valen más que el código:
equalsIgnoreCase, nunca==. Comparás el contenido de dosString, y ya viste en la lección Arrays y Manejo de Strings en Java por qué==te traiciona apenas el texto viene de afuera.p != nullprimero. El orden importa: si evaluásp.getNombre()antes de la comprobación, explota.- Devolver
nullcuando no está. Es la opción de esta lección: quien llama recibe la responsabilidad de comprobar el resultado antes de usarlo.
Persona encontrada = buscarPorNombre(personas, "Ana");
if (encontrada != null) {
System.out.println("Encontrada: " + encontrada);
} else {
System.out.println("No está en el plantel");
}
Olvidarte de ese
if (encontrada != null)es la forma más común de convertir un “no encontrado” en unNullPointerExceptiontres líneas más abajo.
6. Ordenar: escribir el criterio a mano
Con int[] alcanzaba con Arrays.sort(numeros), porque los números tienen un orden obvio. Con objetos no lo hay: ¿dos personas se ordenan por nombre, por edad, por fecha de ingreso? Alguien tiene que decidirlo, y por ahora ese alguien sos vos: Arrays.sort(personas) compila, pero revienta en tiempo de ejecución porque Persona no sabe compararse a sí misma.
Java tiene un mecanismo para declarar ese criterio una sola vez y reutilizarlo en cualquier Arrays.sort —lo vas a ver en la lección Iteradores, Ordenamiento y Contrato equals/hashCode—. Hasta entonces, ordenás a mano: recorrés el array y comparás el campo elegido con < y >, igual que harías con un int[] si no existiera Arrays.sort.
Selección es el algoritmo más simple para escribirlo a mano: en cada vuelta buscás el elemento con el valor más chico del resto del array y lo llevás al frente.
public static void ordenarPorEdad(Persona[] personas) {
for (int i = 0; i < personas.length - 1; i++) {
int indiceMenor = i;
for (int j = i + 1; j < personas.length; j++) {
if (personas[j].getEdad() < personas[indiceMenor].getEdad()) {
indiceMenor = j;
}
}
if (indiceMenor != i) {
Persona temporal = personas[i];
personas[i] = personas[indiceMenor];
personas[indiceMenor] = temporal;
}
}
}
ordenarPorEdad(personas);
System.out.println(Arrays.toString(personas));
// [Laura (28), Carlos (35), Ana (41)]
Fijate que el intercambio mueve referencias, no objetos: temporal guarda una flecha, no una copia de Persona. Ordenar un array de objetos nunca duplica lo que apuntan las casillas.
Para ordenar por otro campo —el nombre, por ejemplo— repetirías el mismo bucle cambiando solo la condición del if. Repetir ese bucle una vez por criterio, y no tener ninguna garantía sobre qué pasa con los empates si además querés desempatar por un segundo campo, es exactamente el problema que la lección Iteradores, Ordenamiento y Contrato equals/hashCode resuelve: vas a declarar el criterio de orden una sola vez y pasárselo a Arrays.sort, sin repetir el bucle.
Un array de objetos con null adentro rompe cualquier ordenamiento a mano con NullPointerException, porque personas[j].getEdad() explota apenas j cae en una casilla vacía. Otro motivo para no dejar huecos.
7. Aliasing: la casilla apunta, no contiene
Esta es la consecuencia menos intuitiva de los dos niveles de memoria.
Persona[] equipo = {
new Persona("Laura", 28),
new Persona("Carlos", 35),
new Persona("Ana", 41)
};
equipo[2] = equipo[0]; // ahora [0] y [2] apuntan al MISMO objeto
System.out.println(equipo[0] == equipo[2]); // true
Lo mismo pasa al copiar el array entero:
Persona[] copia = Arrays.copyOf(equipo, equipo.length);
System.out.println(copia == equipo); // false — son dos arrays distintos
System.out.println(copia[0] == equipo[0]); // true — comparten los objetos
Una copia profunda hay que escribirla:
Persona[] profunda = new Persona[equipo.length];
for (int i = 0; i < equipo.length; i++) {
profunda[i] = new Persona(equipo[i].getNombre(), equipo[i].getEdad());
}
Este problema desaparece si tu clase es inmutable —atributos
finaly sin setters, como laPersonade arriba—. Si nadie puede modificar el objeto, compartir la referencia no puede hacer daño. Es la misma razón por la que nunca sufrís aliasing conString.
8. Capacidad contra cantidad: el array parcialmente lleno
Casi siempre no sabés de antemano cuántos objetos vas a guardar. La solución clásica es reservar de más y llevar la cuenta de cuántas casillas están realmente usadas.
public class Registro {
private Persona[] datos = new Persona[6];
private int cantidad = 0; // cuántas casillas están usadas
public void agregar(Persona p) {
if (cantidad == datos.length) {
// se llenó: duplicamos la capacidad (ver Arrays y Manejo de Strings en Java, sección 4)
datos = Arrays.copyOf(datos, datos.length * 2);
}
datos[cantidad] = p;
cantidad++;
}
public boolean eliminar(int indice) {
if (indice < 0 || indice >= cantidad) {
System.out.println("Índice inválido: " + indice);
return false;
}
// corremos todo lo que está a la derecha una posición a la izquierda
for (int i = indice; i < cantidad - 1; i++) {
datos[i] = datos[i + 1];
}
datos[cantidad - 1] = null; // liberamos la referencia sobrante
cantidad--;
return true;
}
public int getCantidad() {
return cantidad;
}
public void listar() {
for (int i = 0; i < cantidad; i++) { // hasta cantidad, no hasta length
System.out.println(datos[i]);
}
}
}
Prestá atención a datos[cantidad - 1] = null; en eliminar. Sin esa línea el programa funciona igual, pero la última casilla sigue apuntando a un objeto que ya nadie usa y el recolector de basura no puede liberarlo. Es una fuga de memoria pequeña, silenciosa y perfectamente evitable.
9. Un array de objetos como atributo: composición y copia defensiva
El caso más frecuente no es un array suelto en el main, es un array adentro de otra clase:
public class Curso {
private final String nombre;
private final Persona[] alumnos;
public Curso(String nombre, Persona[] alumnos) {
this.nombre = nombre;
this.alumnos = alumnos; // MAL
}
public Persona[] getAlumnos() {
return alumnos; // MAL
}
}
Las dos líneas marcadas anulan el encapsulamiento de la lección Constructores, Modificadores de Acceso y Getters/Setters, por el mismo motivo de la sección 7: se está compartiendo la flecha.
Persona[] lista = { new Persona("Laura", 28) };
Curso curso = new Curso("Java", lista);
lista[0] = new Persona("Intruso", 99); // modificamos el array desde afuera
System.out.println(curso.getAlumnos()[0]); // Intruso (99)
curso.getAlumnos()[0] = null; // y también desde el getter
La corrección es copiar en la entrada y en la salida:
public Curso(String nombre, Persona[] alumnos) {
this.nombre = nombre;
this.alumnos = Arrays.copyOf(alumnos, alumnos.length); // copia al entrar
}
public Persona[] getAlumnos() {
return Arrays.copyOf(alumnos, alumnos.length); // copia al salir
}
Con una Persona inmutable, la copia superficial alcanza: nadie puede modificar los objetos apuntados. Si Persona tuviera setters, harían falta copias profundas para blindar el Curso de verdad.
10. Errores frecuentes
- Creer que
new Persona[3]crea tres personas. Crea tresnull. - Recorrer hasta
lengthteniendo un contador. Recorré hastacantidad. - No sobrescribir
toString(). Imprimís hashes en hexadecimal y depurás a ciegas. - Tratar un array de objetos como si
Arrays.sortsupiera ordenarlo solo. Con objetos necesitás decidir el campo de comparación y escribir el bucle vos mismo. - Ordenar un array con
nulladentro.NullPointerExceptionapenas la comparación toca una casilla vacía. - Comparar objetos con
==. Compara identidad. Para contenido hace faltaequals()— su contrato completo llega en la lección Iteradores, Ordenamiento y Contrato equals/hashCode. - Asignar
a[i] = a[j]creyendo que copia. Copia la referencia; quedan dos flechas al mismo objeto. - Exponer el array interno de una clase. Un
privateno sirve de nada si el getter devuelve la referencia. - Olvidar
datos[cantidad - 1] = nullal eliminar. Deja viva una referencia que el GC no puede liberar.
11. Ejercicios prácticos guiados
Ejercicio 1 — Estadísticas del plantel
Dado un Persona[] sin null, mostrá la persona más joven, la más grande y el promedio de edad en un solo recorrido.
Ver solución sugerida
public class Estadisticas {
public static void informe(Persona[] personas) {
if (personas == null || personas.length == 0) {
System.out.println("No hay personas para analizar");
return;
}
// Arrancamos con el primer elemento, no con valores inventados
Persona masJoven = personas[0];
Persona masGrande = personas[0];
long sumaEdades = 0;
for (Persona p : personas) {
if (p.getEdad() < masJoven.getEdad()) masJoven = p;
if (p.getEdad() > masGrande.getEdad()) masGrande = p;
sumaEdades += p.getEdad();
}
double promedio = (double) sumaEdades / personas.length;
System.out.println("Más joven: " + masJoven);
System.out.println("Más grande: " + masGrande);
System.out.println("Promedio: %.1f años".formatted(promedio));
}
public static void main(String[] args) {
Persona[] plantel = {
new Persona("Laura", 28),
new Persona("Carlos", 35),
new Persona("Ana", 41)
};
informe(plantel);
}
}
Fijate que masJoven guarda la referencia al objeto, no la edad. Así, cuando terminás el recorrido, tenés la persona entera y no solo su número: podés imprimir su nombre sin volver a buscarla.
Ejercicio 2 — Filtrar en un array nuevo
Escribí un método que reciba un Persona[] y devuelva otro array solo con las personas mayores de una edad dada. El array resultante no debe tener casillas sobrantes.
Ver solución sugerida
import java.util.Arrays;
public static Persona[] mayoresDe(Persona[] personas, int edadMinima) {
// 1. Reservamos con la capacidad máxima posible
Persona[] resultado = new Persona[personas.length];
int cantidad = 0;
// 2. Llenamos solo con los que cumplen
for (Persona p : personas) {
if (p != null && p.getEdad() >= edadMinima) {
resultado[cantidad] = p;
cantidad++;
}
}
// 3. Recortamos a la cantidad real: sin este paso quedan null al final
return Arrays.copyOf(resultado, cantidad);
}
Los tres pasos son el patrón estándar de filtrado con arrays: reservar de más, contar, recortar. El paso 3 es el que suele faltar, y es el que evita que el array devuelto tenga null que exploten después.
Notá también que el array resultante comparte los objetos con el original (aliasing, sección 7). Para este caso está bien: filtrar no debería duplicar personas.
Ejercicio 3 — Ordenar por edad descendente
Ordená un Persona[] de mayor a menor edad, sin usar Arrays.sort.
Ver solución sugerida
import java.util.Arrays;
public class OrdenarPersonas {
public static void main(String[] args) {
Persona[] personas = {
new Persona("Laura", 35),
new Persona("Ana", 41),
new Persona("Bruno", 35),
new Persona("Carlos", 28)
};
for (int i = 0; i < personas.length - 1; i++) {
int indiceMayor = i;
for (int j = i + 1; j < personas.length; j++) {
if (personas[j].getEdad() > personas[indiceMayor].getEdad()) {
indiceMayor = j;
}
}
if (indiceMayor != i) {
Persona temporal = personas[i];
personas[i] = personas[indiceMayor];
personas[indiceMayor] = temporal;
}
}
System.out.println(Arrays.toString(personas));
// [Ana (41), Laura (35), Bruno (35), Carlos (28)]
}
}
Laura y Bruno empatan en edad (35). Este bucle, tal como está escrito, no da ninguna garantía sobre el orden relativo de los empates —en esta corrida particular Laura queda antes que Bruno, pero eso es una consecuencia de los intercambios, no una regla del algoritmo—. Desempatar de forma predecible por un segundo criterio, como el nombre, sin reescribir el bucle entero cada vez, es exactamente lo que vas a aprender a hacer en la lección Iteradores, Ordenamiento y Contrato equals/hashCode.
Ejercicio 4 — Agenda con capacidad dinámica
Implementá una clase Agenda que guarde Contacto en un array interno, crezca sola cuando se llena, y permita agregar, buscar por nombre y eliminar por índice.
Ver solución sugerida
import java.util.Arrays;
public class Agenda {
private Contacto[] contactos = new Contacto[4];
private int cantidad = 0;
public boolean agregar(Contacto c) {
if (c == null) {
System.out.println("El contacto no puede ser null, no se agregó.");
return false;
}
if (cantidad == contactos.length) {
contactos = Arrays.copyOf(contactos, contactos.length * 2);
}
contactos[cantidad++] = c;
return true;
}
public Contacto buscar(String nombre) {
for (int i = 0; i < cantidad; i++) {
if (contactos[i].getNombre().equalsIgnoreCase(nombre)) {
return contactos[i];
}
}
return null;
}
public boolean eliminar(int indice) {
if (indice < 0 || indice >= cantidad) {
System.out.println("Índice " + indice + " fuera de rango [0, " + (cantidad - 1) + "].");
return false;
}
for (int i = indice; i < cantidad - 1; i++) {
contactos[i] = contactos[i + 1];
}
contactos[--cantidad] = null;
return true;
}
public int getCantidad() {
return cantidad;
}
public Contacto[] listar() {
// copia defensiva y recortada: ni referencias internas ni null sobrantes
return Arrays.copyOf(contactos, cantidad);
}
}
Esta clase ya es, conceptualmente, una lista dinámica en miniatura: array interno, capacidad que se duplica, tamaño lógico separado del físico y desplazamiento al eliminar. En la lección TAD Lista: Estáticas, Dinámicas y Enlazadas vas a formalizarla como TAD Lista y a compararla con la versión enlazada.
Para llevarte
- Un array de objetos guarda referencias, no objetos. Todo lo raro que hace sale de ahí.
new Persona[3]crea tresnull. Crear el array y crear los objetos son dos pasos.- Un array con casillas vacías es la causa del
NullPointerExceptionmás frecuente de Java. - Sobrescribí
toString()en toda clase que vayas a guardar en un array: sin él, imprimir no informa nada. - Buscar es siempre un recorrido lineal escrito a mano; si devolvés
nullcuando no hay resultado, quien llama debe comprobarlo con!= nullantes de usarlo. - Ordenar objetos exige elegir un criterio y compararlo campo a campo con tu propio bucle; la lección Iteradores, Ordenamiento y Contrato equals/hashCode muestra cómo declarar ese criterio una sola vez y reutilizarlo.
- Asignar una casilla a otra no copia el objeto: quedan dos flechas al mismo lugar.
- Un array interno de una clase se copia al entrar y al salir, o el
privateno protege nada. - Separar capacidad de cantidad es lo que convierte un array en una estructura de datos. Ese es el punto de partida del TAD Lista.