Programación en Java
Fundamentos de orientación a objetos en Java: clases, objetos, campos y métodos
Principiante11 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Esta guía explica el modelo de objetos sobre el que se construye todo lo demás en Java: qué describe una clase, qué es realmente un objeto en memoria, cómo funcionan los constructores y los métodos, por qué importa la encapsulación y cuándo un record sustituye a una clase escrita a mano. Al terminar sabrás diseñar una clase pequeña y correcta, y explicar qué ocurre en cada línea de un programa que la usa.
Lo que aprenderás
- Distinguir una clase de un objeto, y una referencia del objeto al que apunta
- Escribir constructores, getters y métodos que mantengan válido el estado de un objeto
- Elegir de forma consciente entre miembros de instancia y miembros static
- Implementar correctamente equals, hashCode y toString, o dejar que lo haga un record
- Explicar cómo se pasan los argumentos a los métodos en Java y por qué no es paso por referencia
Java es un lenguaje orientado a objetos en un sentido muy concreto: casi cada línea de código que escribes vive dentro de una clase, y casi cada valor que manejas, aparte de los ocho tipos primitivos, es un objeto al que se accede mediante una referencia. Entender bien este modelo desde el principio compensa durante años: la mayoría de los bugs desconcertantes con los que se topan los principiantes —una NullPointerException, un método que «no cambia» la variable que se le pasa, dos objetos «iguales» que un HashSet trata como distintos— nacen de una idea borrosa de lo que son una clase, un objeto y una referencia. Esta guía construye esa idea con código que compila en Java 21.
Las clases describen, los objetos existen
Una clase es un plano: declara qué estado (campos) y qué comportamiento (métodos) tiene un tipo de cosa. Un objeto es una instancia concreta creada a partir de ese plano en tiempo de ejecución, que vive en el heap con su propia copia de cada campo de instancia. Los objetos se crean con new, y el resultado de new es una referencia: un valor que apunta al objeto, no el objeto en sí.
public class BankAccount {
private final String iban; // set once, never changes
private long balanceCents; // mutable state, kept private
public BankAccount(String iban, long openingBalanceCents) {
if (openingBalanceCents < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.iban = iban;
this.balanceCents = openingBalanceCents;
}
public void deposit(long cents) {
if (cents <= 0) throw new IllegalArgumentException("Deposit must be positive");
balanceCents += cents;
}
public long balanceCents() {
return balanceCents;
}
}
BankAccount a = new BankAccount("FR7630006000011234567890189", 10_000);
BankAccount b = a; // b now refers to the SAME object as a
b.deposit(500);
System.out.println(a.balanceCents()); // 10500 — there is only one account
Es en el segundo fragmento donde el modelo de referencias cobra importancia. b = a no copia la cuenta; copia la flecha. Ambas variables apuntan a un único objeto, así que un cambio hecho a través de b se ve a través de a. Si quieres una segunda cuenta independiente, tienes que crearla con new.
Dentro de un método, la palabra this significa «el objeto sobre el que se ha llamado a este método». Sobre todo la necesitarás para distinguir un campo de un parámetro con el mismo nombre, como en el constructor anterior.
Tipos primitivos frente a referencias
Java tiene ocho tipos primitivos (int, long, double, boolean, char, byte, short, float) cuyas variables guardan el valor directamente. Todos los demás tipos —String, los arrays, tus propias clases— son tipos referencia. Una variable de tipo referencia también puede contener null, que significa «no apunta a nada», y desreferenciar null (llamar a un método o leer un campo a través de ella) lanza NullPointerException. Desde Java 15, el mensaje indica por defecto qué expresión era nula (because "order.customer" is null), lo que hace que estos errores se diagnostiquen mucho más rápido que antes.
Las clases envoltorio (wrappers) Integer, Long, Double y compañía son tipos referencia que envuelven un primitivo para que pueda vivir en una colección. El auto-boxing oculta la conversión, pero no sus consecuencias: los valores Integer pueden ser null, y comparar dos objetos Integer con == compara referencias, no números. Usa equals o haz unboxing primero.
Constructores e inicialización de objetos
Un constructor se ejecuta exactamente una vez por objeto, cuando se evalúa new, y su trabajo es dejar el objeto en un estado válido. Tres reglas rigen el comportamiento de los constructores:
- Si no declaras ningún constructor, el compilador proporciona uno público y sin argumentos. En cuanto declaras cualquier constructor, ese constructor por defecto desaparece.
- Un constructor puede llamar a otro constructor de la misma clase con
this(...)como primera sentencia, lo que evita duplicar la lógica de validación. - Los campos que no asignas reciben valores por defecto:
0,falseonull. En un campofinal, el compilador te obliga a asignarlo en todos los constructores.
public class Temperature {
private final double celsius;
public Temperature(double celsius) {
this.celsius = celsius;
}
public Temperature() {
this(20.0); // delegate, do not repeat
}
public static Temperature ofFahrenheit(double f) { // static factory
return new Temperature((f - 32) * 5 / 9);
}
public double celsius() { return celsius; }
}
El método ofFahrenheit muestra el patrón de la factoría estática (static factory): un método static que devuelve una instancia. Las factorías pueden tener nombres descriptivos, devolver una instancia en caché en lugar de una nueva y devolver un subtipo; un constructor no puede hacer nada de eso. Muchos tipos del JDK las usan: List.of, Integer.valueOf, LocalDate.of.
Es preferible validar en el constructor y lanzar IllegalArgumentException que aceptarlo todo y comprobarlo más tarde. Un objeto que solo puede existir en un estado válido es mucho más fácil de entender que uno que hay que revisar antes de cada uso.
Encapsulación: campos privados, intención pública
La encapsulación significa que un objeto controla su propio estado. En concreto: declara los campos private, expón el comportamiento mediante métodos y haz que la superficie pública sea lo más pequeña posible. No se trata de ceremonia, sino de que cada regla sobre el estado viva en un solo sitio. Si balanceCents fuera público, la regla de «nunca negativo» tendría que volver a comprobarse en cada fragmento de código que lo toque.
Los getters y setters son la forma mecánica de exponer el estado, y los IDE los generan con un atajo de teclado, y precisamente por eso se abusa de ellos. Una clase cuya API es getX/setX para cada campo no es más que un struct con más ceremonia. Pregúntate, en cambio, qué operaciones admite el objeto —deposit, withdraw, close— y expón esas. Cuando un accesor simple es lo adecuado, el estilo moderno de Java suele prescindir del prefijo get, igual que los accesores que generan los records.
Los modificadores de acceso, de más a menos restrictivo:
| Modificador | Visible desde |
|---|---|
private | Solo la clase que lo declara (incluidas sus clases anidadas) |
| (ninguno, «package-private») | Cualquier clase del mismo paquete |
protected | El mismo paquete más las subclases de cualquier paquete |
public | Desde cualquier sitio |
Package-private se usa menos de lo que debería. Una clase auxiliar que solo necesita el paquete no debería llevar ningún modificador; así la API pública de una librería expone solo lo que de verdad debe exponer.
Inmutabilidad
Un objeto inmutable nunca cambia después de construirse: todos los campos son final, no hay setters y cualquier componente mutable (una List, un Date, un array) se copia de forma defensiva al entrar y al salir. Los objetos inmutables se pueden compartir sin riesgo entre threads, usar como claves de un map y guardar en caché. String, LocalDate, BigDecimal y todas las clases envoltorio son inmutables, y las colecciones que devuelven List.of y Map.of no son modificables. Recurre a la inmutabilidad por defecto y añade mutabilidad solo donde el dominio lo requiera.
Métodos, sobrecarga y cómo viajan los argumentos
Un método tiene un nombre, un tipo de retorno (o void), parámetros y un cuerpo. Dos métodos de una misma clase pueden compartir nombre si sus listas de parámetros difieren; esto es la sobrecarga (overloading), y el compilador elige la versión según los tipos estáticos de los argumentos en el punto de llamada. Sobrecargar con tipos que se convierten entre sí por auto-boxing o ampliación (int frente a long frente a Integer) lleva a elecciones sorprendentes, así que mantén las sobrecargas claramente diferenciadas.
Java pasa todos los argumentos por valor. Para un primitivo, el valor es el número. Para un objeto, el valor es la referencia. El método recibe su propia copia de la referencia, que apunta al mismo objeto:
static void rename(Person p) {
p.setName("Grace"); // mutates the shared object — caller sees this
p = new Person("Ada"); // rebinds the local copy — caller does NOT see this
}
Después de ejecutar rename(person), person.getName() es "Grace". La reasignación de la segunda línea solo cambió la variable propia del método. Este único hecho explica la mayoría de las discusiones del tipo «Java pasa por referencia; no, no es así».
Los parámetros de aridad variable (String... parts) permiten que un método acepte cero o más argumentos de un mismo tipo; dentro del método son un array. Pon el parámetro varargs al final y evita sobrecargar un método varargs, porque la elección del compilador se vuelve difícil de predecir.
Miembros static: el espacio de nombres de la clase
Un campo o método static pertenece a la clase y no a ninguna instancia. Existe una sola copia, inicializada cuando la clase se carga por primera vez, y se invoca como Math.max(a, b) o Temperature.ofFahrenheit(98.6) sin necesidad de un objeto. Usa static para:
- Constantes:
public static final int MAX_RETRIES = 3; - Funciones de utilidad puras que solo operan sobre sus argumentos (
Math,Collections,Objects). - Métodos factoría, como vimos antes.
No uses static para estado que lógicamente pertenece a un objeto, y desconfía en general del estado static mutable: es global, lo comparten todos los threads y es invisible para los tests. Un método static no puede usar this ni puede sobrescribirse, así que abusar de static también bloquea el polimorfismo, que trata la siguiente guía.
La palabra clave static en una clase anidada significa que esta no captura una referencia a una instancia contenedora. Casi todas las clases anidadas deberían ser static; una clase interna (no static) mantiene viva en silencio a su instancia externa, lo que es una fuente clásica de fugas de memoria.
equals, hashCode y toString
Toda clase hereda de java.lang.Object tres métodos que sobrescribirás a menudo.
toString() devuelve una descripción legible. La de por defecto (BankAccount@1b6d3586) no sirve de nada en los logs; sobrescríbela pronto.
equals(Object) define cuándo dos objetos representan el mismo valor. La implementación por defecto compara referencias, así que dos objetos Money distintos de 10 € no son iguales hasta que digas lo contrario. Una sobrescritura correcta es reflexiva, simétrica, transitiva y consistente, y devuelve false para null:
public final class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = Objects.requireNonNull(currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money other)) return false; // pattern matching, Java 16+
return cents == other.cents && currency.equals(other.currency);
}
@Override
public int hashCode() {
return Objects.hash(cents, currency);
}
@Override
public String toString() {
return cents / 100 + "." + String.format("%02d", cents % 100) + " " + currency;
}
}
hashCode() debe ser coherente con equals: los objetos iguales deben producir el mismo hash. HashMap y HashSet usan el hash para elegir un bucket y solo después llaman a equals, así que una pareja incoherente hace que las búsquedas fallen de maneras que parecen aleatorias. Nunca sobrescribas uno sin el otro, y nunca bases ninguno de los dos en un campo mutable de un objeto que vayas a meter en una colección basada en hash: cambiar el campo tras la inserción deja el elemento inalcanzable.
Records: el compilador lo escribe por ti
Desde Java 16, un record declara en una línea un contenedor de datos inmutable y genera el constructor, los accesores, equals, hashCode y toString:
public record Money(long cents, String currency) {
public Money { // compact constructor: validation only
Objects.requireNonNull(currency);
if (cents < 0) throw new IllegalArgumentException("negative amount");
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(cents + other.cents, currency);
}
}
El constructor compacto no tiene lista de parámetros y se ejecuta antes de asignar los campos, por lo que es el lugar para la validación y la normalización. Los records pueden tener factorías estáticas y métodos de instancia, e implementar interfaces, pero no pueden declarar campos de instancia adicionales ni extender una clase. Cuando tu clase es «solo datos», un record es más corto, correcto por construcción y deja clara la intención a quien lo lea.
Un ejemplo completo
Juntando todas las piezas: un pequeño modelo de pedidos con una clase de entidad y dos records.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
record Product(String sku, String name, long priceCents) {}
record OrderLine(Product product, int quantity) {
OrderLine {
if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive");
}
long totalCents() { return product.priceCents() * quantity; }
}
public class Order {
private static long nextId = 1; // shared counter — fine for a single thread
private final long id;
private final List<OrderLine> lines = new ArrayList<>();
public Order() { this.id = nextId++; }
public void add(Product product, int quantity) {
lines.add(new OrderLine(product, quantity));
}
public long totalCents() {
long sum = 0;
for (OrderLine line : lines) sum += line.totalCents();
return sum;
}
public List<OrderLine> lines() {
return Collections.unmodifiableList(lines); // callers can read, not mutate
}
public long id() { return id; }
}
Order tiene identidad (su id) y cambia con el tiempo, así que es una clase. Product y OrderLine son valores, así que son records. El accesor lines() devuelve una vista no modificable en lugar de la lista interna; sin ella, quien llama podría hacer order.lines().clear() y saltarse todas las reglas de la clase. El contador static funciona aquí, pero no es thread-safe; la guía de concurrencia muestra AtomicLong para hacerlo como es debido.
Errores comunes
Comparar objetos con ==. Con referencias, == pregunta «¿el mismo objeto?», no «¿el mismo valor?». Usa equals y, con los strings en particular, no confíes nunca en ==, aunque los literales internados a veces hagan que parezca funcionar.
Exponer las colecciones internas. Devolver el propio campo desde un getter es entregar un acceso directo a tu estado privado. Devuelve una vista no modificable o una copia.
Sobrescribir equals sin hashCode. Ya explicado; el síntoma es un HashSet que contiene «duplicados» o un HashMap que no encuentra una clave.
Estado static mutable. Un static List<Order> allOrders es una variable global con acento de Java. Provoca fugas de memoria, rompe los tests que se ejecutan en una misma JVM y no es seguro con concurrencia.
Constructores que hacen trabajo de verdad. Abrir ficheros, conectarse a bases de datos o arrancar threads en un constructor hace que la clase sea imposible de probar de forma aislada y deja escapar un objeto a medio construir si ese trabajo lanza una excepción. Construye de forma barata; haz el trabajo en un método explícito.
Clases anidadas no static por descuido. Una clase interna guarda una referencia oculta a su instancia contenedora. Declara static las clases auxiliares anidadas, salvo que de verdad necesites esa referencia.
Olvidar que los campos tienen valores por defecto. Un int sin asignar vale 0 y una referencia sin asignar vale null, sin avisar. Asígnalos en el constructor y declara los campos final siempre que puedas, para que el compilador detecte los olvidos.
Buenas prácticas
- Haz
privatetodos los campos yfinaltodas las clases, salvo las diseñadas para extenderse. - Prefiere tipos valor inmutables; desde Java 16, los records son la opción por defecto para ellos.
- Valida en los constructores y en los constructores compactos para que no puedan existir objetos inválidos.
- Implementa
toStringen todo lo que vaya a aparecer en una línea de log. - Basa
equals/hashCodeen campos inmutables, y genéralos (record, IDE uObjects.hash) en lugar de escribirlos a mano. - Usa static solo para constantes, utilidades puras y factorías.
- Nombra las clases con sustantivos y los métodos con verbos, y deja que la guía de convenciones de código resuelva el resto.
Puntos clave
- Una clase es un plano; un objeto es una instancia en el heap; una variable guarda una referencia a él. La asignación copia la referencia, no el objeto.
- Los constructores establecen un estado válido; las factorías estáticas son una alternativa más flexible y con nombres significativos.
- La encapsulación reúne en un solo lugar todas las reglas sobre una parte del estado: campos privados, métodos públicos elegidos con intención y vistas no modificables de las colecciones internas.
- Java siempre pasa por valor; el valor de un argumento de tipo objeto es una referencia, así que los métodos pueden modificar el objeto de quien llama, pero no reasignar su variable.
equalsyhashCodese sobrescriben juntos y se basan en campos estables; los records generan ambos correctamente.- Los miembros static son de nivel de clase; úsalos para constantes y utilidades, no para el estado de los objetos.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Java pasa los argumentos por referencia o por valor?
- Estrictamente por valor. En un argumento de tipo objeto, lo que se copia es la referencia, así que el método puede modificar los campos del objeto, pero no hacer que la variable de quien llama apunte a otro objeto. Reasignar el parámetro dentro del método no tiene ningún efecto fuera de él.
- ¿Cuándo debo usar un record en lugar de una clase?
- Usa un record cuando el tipo sea un contenedor de datos transparente e inmutable: un punto, un importe, un DTO, una clave de un map. Usa una clase cuando el objeto tenga estado mutable, necesite ocultar su representación o tenga un ciclo de vida con un comportamiento que vaya más allá de un conjunto de accesores.
- ¿Por qué tengo que sobrescribir hashCode si sobrescribo equals?
- HashMap y HashSet localizan un elemento primero por su hashCode y solo después llaman a equals. Si dos objetos iguales devuelven hash codes distintos, un set aceptará tranquilamente ambos y un map no encontrará una clave que acabas de insertar. El contrato de java.lang.Object exige que los objetos iguales tengan el mismo hash code.
- ¿Qué diferencia hay entre un campo static y un campo de instancia?
- Un campo de instancia tiene un valor por objeto; un campo static tiene un valor por clase, compartido por todas las instancias y accesible sin ninguna instancia. Static sirve para constantes, contadores compartidos por todos los objetos y métodos de utilidad, no para estado que pertenece a un objeto concreto.