Clases y objetos en Java — teoría con alumnos y asignaturas (y el array lleno de null que me hizo creer que Java me odiaba)
La primera vez que hice una lista de alumnos en Java escribí esto, muy convencido:
Alumno[] clase = new Alumno[30]; System.out.println(clase[0].getNombre());
Y Java me respondió con un NullPointerException. Yo pensaba que new Alumno[30] creaba treinta alumnos. Lo que crea son treinta huecos vacíos donde podrías guardar alumnos, y cada hueco vale null hasta que pones uno dentro. Esa diferencia entre «el sitio para un objeto» y «el objeto de verdad» es justo lo que vamos a desmontar en este artículo.
Si vienes de Python, buena parte te sonará: class, __init__, self… En Java todo eso existe, pero con nombres distintos y con un par de reglas más estrictas. Usaremos un ejemplo que se entiende solo: alumnos y asignaturas.
Tabla de Contenidos
Clase y objeto: el molde y las galletas
Una clase es un molde: describe qué datos tendrá cada cosa y qué sabrá hacer. Un objeto es una galleta hecha con ese molde: una cosa concreta, con sus propios datos.
- La clase
Alumnodice: «todo alumno tiene DNI, nombre y nota, y sabe decir si está aprobado». anayluisson dos objetos de esa clase: cada uno con su DNI, su nombre y su nota.
Los objetos se crean con new:
Alumno ana = new Alumno("11111111H", "Ana", 7.5);
Alumno luis = new Alumno("22222222J", "Luis");
new hace tres cosas: reserva memoria para un objeto nuevo, ejecuta el constructor para darle sus valores iniciales y devuelve una referencia a ese objeto. Esa referencia es lo que se guarda en la variable ana.
Si vienes de Python, Alumno("11111111H", "Ana", 7.5) sería la llamada equivalente. En Java el new es obligatorio: sin él, no hay objeto.
La clase Alumno completa
Antes de explicar cada pieza, aquí tienes la clase entera para tener el mapa delante:
public class Alumno {
// ----- Atributos de clase (static): uno para TODA la clase -----
public static final double NOTA_APROBADO = 5.0; // constante compartida
private static int totalAlumnos = 0; // cuántos objetos se han creado
// ----- Atributos de instancia: cada objeto tiene los suyos -----
private final String dni; // no cambia nunca: final
private String nombre;
private double nota;
// Constructor completo
public Alumno(String dni, String nombre, double nota) {
this.dni = dni; // this.dni = el atributo; dni = el parámetro
this.nombre = nombre;
setNota(nota); // reutilizamos la validación del setter
totalAlumnos++;
}
// Constructor sobrecargado: sin nota, empieza en 0
public Alumno(String dni, String nombre) {
this(dni, nombre, 0.0); // llama al constructor de arriba
}
// ----- Getters y setters -----
public String getDni() { return dni; }
public String getNombre() { return nombre; }
public double getNota() { return nota; }
public void setNota(double nota) {
if (nota < 0 || nota > 10) {
System.out.println("Nota no válida para " + nombre + ": " + nota);
return; // no cambiamos nada
}
this.nota = nota;
}
// ----- Métodos de instancia -----
public boolean estaAprobado() {
return nota >= NOTA_APROBADO;
}
// ----- Método de clase -----
public static int getTotalAlumnos() {
return totalAlumnos;
}
@Override
public String toString() {
return nombre + " (" + dni + "): " + nota;
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // misma referencia
if (!(o instanceof Alumno)) return false; // otro tipo o null
Alumno otro = (Alumno) o;
return dni.equals(otro.dni); // mismo DNI = mismo alumno
}
@Override
public int hashCode() {
return dni.hashCode();
}
}
Parece mucho, pero son solo cinco ideas: atributos, constructores, encapsulación, static y los métodos toString/equals. Vamos una por una.
Atributos: los datos de cada objeto
Los atributos son variables que viven dentro del objeto. dni, nombre y nota son atributos de instancia: cada alumno tiene los suyos, y cambiar la nota de Ana no toca la de Luis.
Si un atributo no se inicializa, Java le da un valor por defecto: 0 para números, false para boolean y null para objetos (incluidos los String). Por eso un Alumno recién creado con el segundo constructor tiene nota 0.0.
final en un atributo significa «se asigna una vez y ya no cambia». Es perfecto para datos que identifican al objeto, como el DNI. Si intentas cambiarlo después, el código ni compila.
Constructores y this
El constructor es el método especial que se ejecuta con new. Se llama igual que la clase y no tiene tipo de retorno (ni siquiera void).
¿Para qué sirve this?
Mira esta línea del constructor:
this.dni = dni;
Hay dos variables llamadas dni: el parámetro que recibe el constructor y el atributo del objeto. Dentro del constructor, dni a secas es el parámetro (el más cercano). this.dni es el atributo del objeto que se está construyendo. this significa «este objeto»: es el equivalente al self de Python, con una diferencia, que en Java no hace falta escribirlo como primer parámetro.
Escribir nombre = nombre; sin this. Compila sin quejarse, pero lo que hace es asignar el parámetro a sí mismo: el atributo se queda en null. Es uno de los errores más silenciosos de Java. (Con dni, que es final, al menos el compilador te avisa de que nunca se inicializa.)
Sobrecarga de constructores
Una clase puede tener varios constructores, siempre que reciban parámetros distintos. Eso se llama sobrecarga. El segundo constructor de Alumno no repite código: llama al primero con this(dni, nombre, 0.0).
public Alumno(String dni, String nombre) {
this(dni, nombre, 0.0); // tiene que ser la PRIMERA línea
}
Así toda la lógica (validar la nota, contar alumnos) vive en un solo sitio. Si mañana cambia, la cambias una vez.
Si no escribes ningún constructor, Java te regala uno vacío sin parámetros. En cuanto escribes uno, ese regalo desaparece: new Alumno() dejaría de compilar.
Encapsulación: private, getters y setters
¿Por qué los atributos son private? Porque así nadie de fuera puede tocarlos directamente. Si nota fuera public, cualquiera podría escribir luis.nota = 12; y tendrías un alumno con un 12 sobre 10.
Con private, la única forma de cambiar la nota es pasar por setNota, y ese método comprueba el valor antes de aceptarlo:
luis.setNota(12); // Nota no válida para Luis: 12.0 luis.setNota(4.5); // aceptada System.out.println(luis.getNombre() + " aprobado? " + luis.estaAprobado());
Salida:
Nota no válida para Luis: 12.0 Luis aprobado? false
Esto es la encapsulación: el objeto protege sus datos y decide qué se puede hacer con ellos. Fíjate también en que el constructor llama a setNota en vez de hacer this.nota = nota: así la validación se aplica también al crear el alumno.
| Modificador | ¿Quién puede usarlo? |
|---|---|
public | Cualquier clase |
private | Solo la propia clase |
| (sin nada) | Las clases del mismo paquete |
protected | El paquete y las subclases (lo verás en Herencia) |
Regla práctica de FP2: atributos private, métodos public. Solo crea un setter si de verdad hace falta cambiar ese dato; dni no tiene setter porque no debe cambiar nunca.
static: lo que es de la clase, no del objeto
Hasta ahora, cada atributo pertenecía a un objeto. Pero hay datos que son de toda la clase: por ejemplo, cuántos alumnos se han creado. Eso no es de Ana ni de Luis; es de «los alumnos» en general. Para eso está static.
private static int totalAlumnos = 0; // existe UNA sola vez public static final double NOTA_APROBADO = 5.0;
totalAlumnosse comparte: cadanew Alumno(...)lo incrementa, y todos ven el mismo valor.NOTA_APROBADOes una constante de clase:static(una para todos) yfinal(no cambia). Por convenio, en MAYÚSCULAS.
Los métodos static se llaman con el nombre de la clase, sin necesidad de ningún objeto:
System.out.println("Total: " + Alumno.getTotalAlumnos()); // Total: 2
System.out.println(Alumno.NOTA_APROBADO); // 5.0
Desde un método static no puedes usar atributos de instancia (nombre, nota) ni this. Tiene sentido: un método de clase no sabe de qué alumno le hablas. Si lo intentas, el error es non-static variable cannot be referenced from a static context. Te sonará: main también es static.
| De instancia | static (de clase) | |
|---|---|---|
| ¿Cuántos hay? | Uno por objeto | Uno para toda la clase |
| Se usa con… | ana.getNota() | Alumno.getTotalAlumnos() |
¿Puede usar this? | Sí | No |
| Ejemplo | nota, estaAprobado() | totalAlumnos, NOTA_APROBADO |
Referencias: la variable no es el objeto
Esta es la idea que más cuesta y la que más errores evita. Una variable de tipo Alumno no contiene al alumno: contiene una referencia, una especie de dirección que apunta a dónde está el objeto.
Alumno otraVez = ana; // NO copia a Ana: apunta al mismo objeto otraVez.setNota(9); System.out.println(ana.getNota()); // 9.0
Al cambiar la nota a través de otraVez, cambia la de ana, porque son el mismo objeto con dos nombres. Si has leído el artículo de arrays, es exactamente lo mismo que pasaba con int[] b = a;.
== frente a equals
Alumno copia = new Alumno("11111111H", "Ana", 9);
System.out.println(ana == copia); // false: dos objetos distintos
System.out.println(ana.equals(copia)); // true: mismo DNI
System.out.println(ana == otraVez); // true: misma referencia
==pregunta si dos variables apuntan al mismo objeto.equalspregunta si dos objetos son iguales según tú. Por eso lo hemos sobrescrito: para nosotros, dos alumnos son el mismo si tienen el mismo DNI.
Es la misma historia que con los String, solo que ahora el equals lo escribes tú.
Sobrescribir equals y olvidar hashCode. Mientras uses arrays no notarás nada, pero cuando llegues a colecciones como HashSet o HashMap, dos alumnos «iguales» se tratarían como distintos. La regla: si dos objetos son equals, deben tener el mismo hashCode.
null: la referencia que no apunta a nada
null es una referencia vacía. Llamar a un método sobre ella lanza NullPointerException:
Alumno nadie = null; nadie.getNombre(); // NullPointerException Alumno[] clase = new Alumno[3]; System.out.println(clase[0]); // null: el hueco existe, el alumno no
Es mi historia del principio: un array de objetos se crea lleno de null, y cada alumno hay que crearlo con su propio new.
toString: cómo se imprime un objeto
Si imprimes un objeto sin toString, Java muestra algo como Alumno@1b6d3586: el nombre de la clase y un código interno. Al sobrescribir toString, decides tú el texto:
System.out.println(ana); // Ana (11111111H): 7.5
println llama a toString() automáticamente. La anotación @Override le dice al compilador «estoy sustituyendo un método que ya existe»; si te equivocas en el nombre (tostring, por ejemplo), te avisa en lugar de crear un método nuevo que nadie llamará.
Objetos dentro de objetos: la clase Asignatura
Una asignatura tiene alumnos. En Java eso se expresa con un atributo que es un array de objetos:
public class Asignatura {
private final String nombre;
private final Alumno[] alumnos;
private int numAlumnos = 0;
public Asignatura(String nombre, int capacidad) {
this.nombre = nombre;
this.alumnos = new Alumno[capacidad]; // huecos vacíos: todos null
}
public boolean matricular(Alumno a) {
if (numAlumnos == alumnos.length) return false; // llena
alumnos[numAlumnos++] = a;
return true;
}
public double notaMedia() {
if (numAlumnos == 0) return 0;
double suma = 0;
for (int i = 0; i < numAlumnos; i++) suma += alumnos[i].getNota();
return suma / numAlumnos;
}
public int aprobados() {
int n = 0;
for (int i = 0; i < numAlumnos; i++) if (alumnos[i].estaAprobado()) n++;
return n;
}
public String getNombre() { return nombre; }
}
Fíjate en que los bucles van hasta numAlumnos, no hasta alumnos.length: los huecos sin usar son null, y llamar a getNota() sobre ellos sería otro NullPointerException. Es el patrón de array + contador que ya usaste en los ejercicios de arrays.
Asignatura fp2 = new Asignatura("FP2", 3);
fp2.matricular(ana);
fp2.matricular(luis);
fp2.matricular(new Alumno("33333333P", "Marta", 5));
System.out.println(fp2.matricular(new Alumno("44444444A", "Pablo", 6))); // false: llena
System.out.printf("%s: media %.2f, aprobados %d%n", fp2.getNombre(), fp2.notaMedia(), fp2.aprobados());
Salida:
false FP2: media 6,17, aprobados 2
Ana tiene un 9 (se lo pusimos a través de otraVez), Luis un 4,5 y Marta un 5: media 6,17 y dos aprobados, porque 5 >= NOTA_APROBADO.
Sale 6,17 con coma porque el ordenador está en español. printf usa el formato numérico del sistema.
Míralo paso a paso en Python Tutor
Python Tutor ejecuta el código línea a línea y dibuja las variables y los objetos. Elige Java en el desplegable, pega este programa y avanza con Next:
public class Tutor {
static class Alumno {
String nombre;
double nota;
Alumno(String nombre, double nota) {
this.nombre = nombre;
this.nota = nota;
}
}
public static void main(String[] args) {
Alumno a = new Alumno("Ana", 7.5);
Alumno b = a; // b apunta al MISMO objeto
Alumno c = new Alumno("Ana", 7.5); // objeto nuevo con los mismos datos
b.nota = 9;
System.out.println(a.nota + " " + c.nota);
System.out.println((a == b) + " " + (a == c));
}
}
Salida:
9.0 7.5 true false
Qué observar:
- Al ejecutar el primer
new, aparece un objeto a la derecha (el montón, heap) y una flecha desdea. La variable no contiene el objeto: apunta a él. - Dentro del constructor verás un marco con
this: es la flecha al objeto que se está construyendo. - Tras
Alumno b = a;, no aparece un objeto nuevo: sale otra flecha al mismo. Tras el segundonew, sí hay dos objetos. - Al hacer
b.nota = 9, cambia el único objeto al que apuntanayb. El decsigue con 7,5. Por esoa == bestrueya == cesfalse.
Resumen y tabla rápida
| Concepto | En Java | Si vienes de Python |
|---|---|---|
| Molde | class Alumno { … } | class Alumno: |
| Crear objeto | new Alumno(…) | Alumno(…) |
| Constructor | public Alumno(…) | def __init__(self, …) |
| El propio objeto | this (implícito) | self (explícito) |
| Ocultar datos | private | convención _nombre |
| Dato de la clase | static | atributo de clase |
| Texto del objeto | toString() | __str__ |
| Igualdad | equals() + hashCode() | __eq__ + __hash__ |
Tres ideas para quedarte:
- La variable no es el objeto: es una referencia. Asignar copia la flecha, no los datos, y
==compara flechas. - **
this.x = x** distingue el atributo del parámetro; sinthis, el atributo se queda vacío. - **
private+ métodos protegen el objeto;static** es para lo que pertenece a la clase entera.
En la práctica construirás tres programas completos con clases: una cuenta bancaria, una biblioteca y el inventario de un laboratorio. Y si vienes directo del tema anterior y quieres repasarlo, tienes expresiones regulares en Java.
Siguiente paso: prácticaClases y objetos en Java — práctica con un banco, una biblioteca y el inventario de un laboratorio (y la cuenta que era de nadie)¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

4 comentarios