Saltar al contenido
clases.java · devschool

Clases y objetos en Java

Lección 13 de 26 · 13 min de lectura · Actualizado el

En esta lección
  1. Clases y objetos: el molde y la pieza
  2. Crear una clase: atributos y métodos
  3. Crear y usar objetos
  4. Constructores
  5. La palabra this
  6. toString: cómo se imprime un objeto
  7. Variables que guardan referencias
  8. null y NullPointerException
  9. Comparar objetos: == frente a equals
  10. Proteger los datos: una primera idea de encapsulación
  11. Dibujar una clase: UML básico
  12. Errores frecuentes
  13. Resumen

Java es un lenguaje orientado a objetos: los programas se organizan en torno a las “cosas” que manejan, como alumnos, productos, pedidos o cuentas bancarias. Cada una de esas cosas agrupa sus datos y las acciones que se pueden hacer con ellos. En esta lección aprenderás a definir tus propias clases, a crear objetos, a escribir constructores y métodos, y a entender qué guarda realmente una variable de tipo objeto, que es la clave para evitar errores como el temido NullPointerException.

Clases y objetos: el molde y la pieza

  • Una clase es el molde: define qué datos tiene algo y qué puede hacer.
  • Un objeto (o instancia) es una cosa concreta hecha con ese molde, con sus propios valores.

La clase Alumno describe a todos los alumnos: tienen nombre, edad y nota, y pueden decir si han aprobado. Ana, con 20 años y un 7,5, es un objeto de esa clase; Luis, con un 4, es otro.

¿Por qué no bastan las variables y los arrays?

Con lo que ya sabes, podrías guardar los datos de una clase de 30 alumnos en tres arrays paralelos: nombres, edades y notas. Funciona, pero es frágil: si ordenas uno y te olvidas de los otros, el nombre de Ana acaba junto a la nota de Luis. Y cualquier método que trabaje con un alumno necesita recibir tres parámetros.

Una clase junta los datos que van juntos. Un alumno es una sola variable que lo lleva todo dentro, y las operaciones sobre alumnos viven en la misma clase.

Crear una clase: atributos y métodos

Archivo Alumno.java:

public class Alumno {
    // Atributos: los datos de cada alumno
    String nombre;
    int edad;
    double nota;

    // Métodos: lo que puede hacer
    boolean estaAprobado() {
        return nota >= 5;
    }

    void subirNota(double puntos) {
        nota = Math.min(nota + puntos, 10);
    }

    void presentarse() {
        System.out.println("Hola, soy " + nombre + " y tengo " + edad + " años.");
    }
}
  • Los atributos (también llamados campos o variables de instancia) se declaran dentro de la clase y fuera de cualquier método. Cada objeto tendrá su propia copia.
  • Los métodos de instancia no llevan static: trabajan con los datos de un objeto concreto. Por eso estaAprobado puede usar nota directamente, sin recibirla como parámetro.

Una clase public va en un archivo con su mismo nombre. En un proyecto real cada clase va en su archivo; para pruebas rápidas puedes poner varias clases en el mismo archivo si solo una es public.

Crear y usar objetos

Los objetos se crean con new, y a sus atributos y métodos se accede con el punto:

public class Main {
    public static void main(String[] args) {
        Alumno ana = new Alumno();
        ana.nombre = "Ana";
        ana.edad = 20;
        ana.nota = 7.5;

        Alumno luis = new Alumno();
        luis.nombre = "Luis";
        luis.nota = 4;

        System.out.println(ana.estaAprobado());  // true
        System.out.println(luis.estaAprobado()); // false

        luis.subirNota(1.5);
        System.out.println(luis.nota);           // 5.5
        ana.presentarse();                       // Hola, soy Ana y tengo 20 años.
    }
}

Cada objeto tiene sus propios valores: subir la nota de Luis no afecta a Ana. Y cuando llamas a luis.subirNota(1.5), el método trabaja con la nota de Luis porque lo has llamado sobre él.

Valores por defecto de los atributos

Los atributos que no asignas toman un valor por defecto, igual que las posiciones de un array: 0 para números, false para boolean y null para objetos (incluidos los String).

Alumno nuevo = new Alumno();
System.out.println(nuevo.nombre + " " + nuevo.edad + " " + nuevo.nota); // null 0 0.0

Un alumno sin nombre rara vez tiene sentido. Para obligar a dar los datos al crear el objeto están los constructores.

Constructores

Un constructor es un bloque especial que se ejecuta al hacer new. Se llama igual que la clase y no tiene tipo de retorno (ni siquiera void). Su trabajo es dejar el objeto listo para usar.

public class Producto {
    String nombre;
    double precio;
    int stock;

    public Producto(String nombre, double precio, int stock) {
        this.nombre = nombre;
        this.precio = precio;
        this.stock = stock;
    }
}
Producto camiseta = new Producto("Camiseta", 19.99, 10);

Ahora es imposible crear un producto sin nombre por despiste: el compilador exige los tres argumentos.

El constructor por defecto

Si no escribes ningún constructor, Java añade uno invisible sin parámetros. Por eso funcionaba new Alumno(). Pero en cuanto escribes uno cualquiera, ese constructor invisible desaparece:

Producto p = new Producto(); // no compila: no existe un constructor sin parámetros

Si también lo quieres, tienes que escribirlo tú.

Varios constructores y this(...)

Una clase puede tener varios constructores con distintos parámetros, igual que la sobrecarga de métodos. Así ofreces varias formas de crear objetos:

public Producto(String nombre, double precio, int stock) {
    this.nombre = nombre;
    this.precio = precio;
    this.stock = stock;
}

public Producto(String nombre, double precio) {
    this(nombre, precio, 0);          // sin stock inicial
}

public Producto(String nombre) {
    this(nombre, 9.99);               // precio estándar
}

this(...) llama a otro constructor de la misma clase. Así la lógica de inicialización está en un único sitio (el constructor completo) y los demás solo aportan valores por defecto. La regla: this(...) tiene que ser la primera instrucción del constructor.

Producto p1 = new Producto("Camiseta", 19.99, 10);
Producto p2 = new Producto("Gorra", 12.5);
Producto p3 = new Producto("Sudadera");

La palabra this

this significa “este objeto”, el objeto sobre el que se está ejecutando el método o constructor. Su uso más habitual es distinguir un atributo de un parámetro que se llama igual:

public Producto(String nombre, double precio, int stock) {
    this.nombre = nombre;   // atributo = parámetro
    ...
}

Dentro del constructor, nombre a secas es el parámetro (la variable más cercana tapa al atributo). this.nombre es el atributo del objeto. Si escribieras nombre = nombre;, asignarías el parámetro a sí mismo y el atributo seguiría a null. Es un error muy común y el compilador no avisa.

Cuando no hay conflicto de nombres, this es opcional: en estaAprobado() escribimos nota y Java entiende this.nota.

toString: cómo se imprime un objeto

Si imprimes un objeto sin más, Java muestra el nombre de la clase y un código:

System.out.println(ana); // Alumno@489115ef

Para mostrar algo útil, añade a la clase un método toString que devuelva un texto:

@Override
public String toString() {
    return nombre + " - " + precio + " € (" + stock + " uds.)";
}
System.out.println(p1);             // Camiseta - 19.99 € (10 uds.)
System.out.println(p3);             // Sudadera - 9.99 € (0 uds.)
System.out.println("Ha llegado: " + p2); // Ha llegado: Gorra - 12.5 € (0 uds.)

println y la concatenación con + llaman a toString() automáticamente. @Override indica que estás redefiniendo un método que todas las clases heredan de Object; lo entenderás del todo en herencia. Mientras tanto, escríbelo siempre: si te equivocas en el nombre (tostring), el compilador te avisará.

Variables que guardan referencias

Esta es la idea más importante de la lección. Una variable de tipo objeto no contiene el objeto, sino una referencia: la dirección de memoria donde está. new crea el objeto y devuelve esa dirección.

Por eso, al asignar una variable a otra, no se copia el objeto; las dos apuntan al mismo:

Alumno ana = new Alumno();
ana.nota = 7.5;

Alumno otra = ana;        // otra referencia al MISMO alumno
otra.nota = 9;

System.out.println(ana.nota);     // 9.0
System.out.println(otra == ana);  // true: son el mismo objeto
ana  ──┐
       ├──> [ Alumno: nombre=null, nota=9.0 ]
otra ──┘

Pasa lo mismo al pasar un objeto a un método: el método recibe una copia de la referencia y puede modificar el objeto original.

static void aprobar(Alumno a) {
    a.nota = 5;
}
aprobar(luis);
System.out.println(luis.nota); // 5.0

Es el mismo comportamiento que viste con arrays, porque los arrays también son objetos.

null y NullPointerException

null es el valor de una referencia que no apunta a ningún objeto. Es el valor por defecto de los atributos de tipo objeto y de las posiciones de un array de objetos.

Alumno nadie = null;
nadie.presentarse();
Exception in thread "main" java.lang.NullPointerException:
    Cannot invoke "Alumno.presentarse()" because "nadie" is null

No puedes llamar a un método ni leer un atributo de algo que no existe. Desde Java 14 el mensaje dice exactamente qué era null, así que léelo con atención. Las causas típicas:

  • Un atributo de tipo objeto que nunca se inicializó (el nombre de un Alumno creado sin constructor).
  • Una posición de un array de objetos que no se rellenó.
  • Un método que devuelve null cuando no encuentra algo, y usar el resultado sin comprobarlo.

La defensa más sencilla es comprobar antes de usar:

if (alumno != null) {
    alumno.presentarse();
}

Y la mejor es no dejar que aparezcan: un buen constructor rellena todos los atributos. Verás cómo tratar este y otros errores en excepciones.

Comparar objetos: == frente a equals

Como las variables guardan referencias, == compara si son el mismo objeto, no si tienen los mismos datos:

Punto a = new Punto(1, 2);
Punto b = new Punto(1, 2);

System.out.println(a == b);      // false: dos objetos distintos
System.out.println(a.equals(b)); // false: el equals heredado hace lo mismo que ==

Para que dos objetos con los mismos datos se consideren iguales, la clase debe redefinir equals. Un ejemplo para Producto, donde dos productos son iguales si coinciden nombre y precio:

@Override
public boolean equals(Object o) {
    if (this == o) return true;                          // el mismo objeto
    if (!(o instanceof Producto otro)) return false;     // no es un Producto
    return nombre.equals(otro.nombre) && precio == otro.precio;
}

@Override
public int hashCode() {
    return Objects.hash(nombre, precio);                 // import java.util.Objects;
}
Producto c1 = new Producto("Camiseta", 19.99, 10);
Producto c2 = new Producto("Camiseta", 19.99, 10);
System.out.println(c1 == c2);      // false
System.out.println(c1.equals(c2)); // true

La regla de oro: si redefines equals, redefine también hashCode usando los mismos atributos. Dos objetos iguales deben dar el mismo hashCode; si no, estructuras como HashSet y HashMap no los reconocerán como iguales (mapas y conjuntos). La explicación completa de equals, hashCode e instanceof está en herencia y polimorfismo. Por ahora quédate con la idea: == compara identidad; equals, contenido (si la clase lo ha definido).

Proteger los datos: una primera idea de encapsulación

Con los atributos accesibles desde fuera, cualquiera puede escribir producto.precio = -5; o alumno.nota = 57;. La solución es declararlos private y permitir cambiarlos solo mediante métodos que validan:

public class CuentaBancaria {
    private String titular;
    private double saldo;

    public CuentaBancaria(String titular, double saldoInicial) {
        this.titular = titular;
        this.saldo = saldoInicial;
    }

    public void ingresar(double cantidad) {
        if (cantidad > 0) {
            saldo += cantidad;
        }
    }

    public boolean retirar(double cantidad) {
        if (cantidad <= 0 || cantidad > saldo) {
            System.out.println("Operación rechazada");
            return false;
        }
        saldo -= cantidad;
        return true;
    }

    public double getSaldo() {        // getter: solo lectura
        return saldo;
    }
}
CuentaBancaria cuenta = new CuentaBancaria("Marta", 100);
cuenta.ingresar(50);
cuenta.retirar(500);                   // Operación rechazada
cuenta.retirar(30);
System.out.println(cuenta.getSaldo()); // 120.0
// cuenta.saldo = 1000000;             // no compila: saldo es private

El saldo solo cambia a través de ingresar y retirar, que nunca lo dejan en un estado imposible. Esto es la encapsulación, y tiene mucho más detrás (getters y setters, static, final, clases inmutables y records): lo tienes a fondo en encapsulación, static y final.

Dibujar una clase: UML básico

En los ciclos y en las empresas las clases se representan con diagramas UML de clases: una caja con tres partes (nombre, atributos y métodos). El símbolo delante indica la visibilidad: - privado, + público.

+------------------------------------------+
|              CuentaBancaria              |
+------------------------------------------+
| - titular: String                        |
| - saldo: double                          |
+------------------------------------------+
| + CuentaBancaria(titular, saldoInicial)  |
| + ingresar(cantidad: double): void       |
| + retirar(cantidad: double): boolean     |
| + getSaldo(): double                     |
+------------------------------------------+

Fíjate en que en UML el tipo va después del nombre, separado por dos puntos. Diseñar primero la caja (qué datos y qué operaciones necesita la clase) antes de escribir código te ahorrará mucho trabajo.

Errores frecuentes

  • Poner tipo de retorno a un constructor: public void Producto(...) ya no es un constructor, sino un método normal, y new Producto("Gorra", 12.5) no compilará.
  • Olvidar this: nombre = nombre; no asigna el atributo.
  • Esperar el constructor vacío después de haber escrito otro.
  • Usar static en los atributos de cada objeto: todos los objetos compartirían el mismo valor (se explica en encapsulación).
  • Creer que b = a copia el objeto: solo copia la referencia.
  • Comparar objetos con ==: usa equals, y defínelo en tus clases.
  • Llamar a métodos sobre null: NullPointerException.
  • Imprimir un objeto sin toString: sale Clase@1a2b3c.

Resumen

ConceptoQué es
ClaseMolde que define atributos y métodos
ObjetoInstancia concreta creada con new
AtributoDato que tiene cada objeto
Método de instanciaAcción que usa los datos del objeto (sin static)
ConstructorSe ejecuta con new; mismo nombre que la clase y sin tipo de retorno
thisEl objeto actual; this.x es el atributo, this(...) llama a otro constructor
toString()Texto que se muestra al imprimir el objeto
ReferenciaLo que guarda una variable de objeto: la dirección, no el objeto
nullReferencia que no apunta a nada; usarla lanza NullPointerException
== / equalsMismo objeto / mismo contenido
privateOculta un atributo; se accede mediante métodos

Con clases, objetos y constructores ya tienes la base de la programación orientada a objetos. El siguiente paso es aprender a proteger bien los datos con la encapsulación, y después vendrán la herencia y las interfaces.

Pon a prueba lo que has aprendido

[Java] ¿Cómo se crea un objeto de la clase Perro?

[Java] ¿Qué imprime System.out.println(new Gato("Misi").nombre)?
class Gato {
    String nombre;

    Gato(String nombre) {
        nombre = nombre;
    }
}

[Java] ¿Qué ocurre con la línea Libro l = new Libro(); si la clase es esta?
class Libro {
    String titulo;

    Libro(String titulo) {
        this.titulo = titulo;
    }
}

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.