Una clase de Java describe qué guarda un objeto y qué puede hacer, y un constructor decide cómo empieza cada objeto. Aprende campos, this, constructores, private, static y final ejecutando programas pequeños.
Una clase describe un tipo de cosa: qué datos guarda y qué puede hacer. Un objeto es una de esas cosas, creada a partir de la clase con new. Casi todo el Java que vas a leer son clases hablando con objetos, así que las reglas para crear y proteger objetos importan en todas partes.
Este post cubre campos y métodos, this, toString, los constructores y cómo se encadenan, el cambio de Java 25 que permite ejecutar código antes de super(...), private y los demás niveles de acceso, static y final. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.
Una clase es un plano, y new crea un objeto
Una clase declara campos, que guardan los datos de cada objeto, y métodos, que actúan sobre esos datos. Aquí hay un Player con un nombre y un puntaje:
class Player {
String name;
int score;
void addPoints(int points) {
score = score + points;
}
}
void main() {
var ana = new Player();
ana.name = "Ana";
ana.addPoints(10);
ana.addPoints(5);
var ben = new Player();
ben.name = "Ben";
ben.addPoints(7);
IO.println(ana.name + " has " + ana.score);
IO.println(ben.name + " has " + ben.score);
}
Imprime:
Ana has 15
Ben has 7
new Player() crea un objeto nuevo, y cada objeto tiene su propia copia de name y score. Sumarle puntos a ana no toca a ben. Los campos de un objeto nuevo empiezan con un valor por defecto: 0 para los números, false para boolean y null para referencias como String.
Dentro de addPoints, score es el puntaje del objeto sobre el que se llamó el método. Para ana.addPoints(10), es el puntaje de Ana.
this es el objeto sobre el que se llamó el método
La palabra clave this nombra al objeto actual, y la necesitas cuando un parámetro tiene el mismo nombre que un campo. Si la olvidas, el código igual compila:
class Player {
String name;
void rename(String name) {
name = name;
}
void renameProperly(String name) {
this.name = name;
}
}
void main() {
var p = new Player();
p.rename("Ana");
IO.println("after rename: " + p.name);
p.renameProperly("Ana");
IO.println("after renameProperly: " + p.name);
}
Imprime:
after rename: null
after renameProperly: Ana
En rename, los dos lados de name = name son el parámetro. El parámetro oculta el campo, así que el método copia el parámetro sobre sí mismo y el campo se queda en null. Ni siquiera javac -Xlint:all avisa. Escribir this.name dice “el campo de este objeto”, y esa es la versión que funciona.
Imprimir un objeto: sobrescribe toString
Cuando imprimes un objeto, Java llama a su método toString(), y el que viene por defecto no está pensado para personas. Imprime un Player sin toString y obtienes algo como Main$Player@ seguido de ocho dígitos hexadecimales.
La parte antes de @ es el nombre de la clase. Dice Main$Player porque, en un archivo con un void main() suelto, tus clases quedan dentro de una clase oculta llamada Main. Ese detalle vuelve en la sección sobre private. La parte después de @ es el hash code de identidad del objeto, en hexadecimal. No es una dirección de memoria y no depende de los campos. La JVM lo inventa la primera vez que algo lo pide.
Como la JVM lo inventa, no puedes confiar en él. Cuando ejecutamos el mismo archivo cinco veces con java Main.java, imprimió los mismos dígitos cada vez. Cuando compilamos el mismo archivo con javac y ejecutamos la clase, los dígitos fueron otros. Otra JVM, otro modo de ejecución o un objeto más al que se le calculó el hash antes pueden cambiarlo. Por eso ninguna salida de esta serie muestra uno.
Escribe tu propio toString e imprimir se vuelve útil y predecible:
class Player {
String name;
int score;
Player(String name, int score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "Player[" + name + ", " + score + "]";
}
}
void main() {
var ana = new Player("Ana", 15);
IO.println(ana);
IO.println("winner: " + ana);
}
Imprime:
Player[Ana, 15]
winner: Player[Ana, 15]
toString tiene que ser public, porque sobrescribe un método público de Object, la clase que extienden todas las clases. @Override le pide al compilador que verifique que de verdad estás sobrescribiendo algo. Si lo escribes mal, como toSting, la compilación falla en vez de imprimir el hash sin decir nada. La concatenación de strings también llama a toString, y por eso funciona "winner: " + ana.
Ese programa también usó un constructor, Player(String name, int score). Los constructores vienen ahora.
Los constructores preparan un objeto nuevo
Un constructor es el código que se ejecuta cuando escribes new. Tiene el nombre de la clase y no tiene tipo de retorno, y su trabajo es dejar el objeto en un estado inicial válido.
Sobrecarga y encadenamiento con this(...)
Una clase puede tener varios constructores, siempre que sus listas de parámetros sean distintas. Eso se llama sobrecarga. Un constructor puede llamar a otro con this(...), así la preparación real vive en un solo lugar:
class Pizza {
String size;
int slices;
Pizza(String size, int slices) {
this.size = size;
this.slices = slices;
}
Pizza(String size) {
this(size, size.equals("large") ? 12 : 8);
}
Pizza() {
this("medium");
}
@Override
public String toString() {
return size + " pizza, " + slices + " slices";
}
}
void main() {
IO.println(new Pizza("small", 6));
IO.println(new Pizza("large"));
IO.println(new Pizza());
}
Imprime:
small pizza, 6 slices
large pizza, 12 slices
medium pizza, 8 slices
new Pizza() llama a this("medium"), que llama a this("medium", 8), que asigna los campos. El compilador elige un constructor según los argumentos que pasas, igual que elige entre métodos sobrecargados.
El constructor gratis desaparece en cuanto escribes uno
Si una clase no tiene ningún constructor, Java le da uno vacío que no recibe argumentos. Por eso new Player() funcionó en el primer programa. Escribe cualquier constructor tú mismo y el gratis desaparece:
class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
void main() {
var origin = new Point();
IO.println(origin.x);
}
La compilación falla con:
Main.java:12: error: constructor Point in class Main.Point cannot be applied to given types;
var origin = new Point();
^
javac agrega que esperaba int,int y no encontró argumentos. Esto es a propósito. Una vez que dijiste que un Point necesita dos números, Java no va a crear uno sin ellos. Si quieres los dos, escribe tú mismo el constructor sin argumentos, idealmente como this(0, 0).
Valida en el constructor
Un constructor es el único lugar por el que pasa cada objeto, así que es el lugar correcto para rechazar datos inválidos. Lanza IllegalArgumentException y el objeto nunca se crea:
class Temperature {
double celsius;
Temperature(double celsius) {
if (celsius < -273.15) {
throw new IllegalArgumentException("below absolute zero: " + celsius);
}
this.celsius = celsius;
}
}
void main() {
var room = new Temperature(21.5);
IO.println("room is " + room.celsius);
var impossible = new Temperature(-300);
IO.println("never printed " + impossible.celsius);
}
Imprime y se detiene:
room is 21.5
Exception in thread "main" java.lang.IllegalArgumentException: below absolute zero: -300.0
-300 se volvió -300.0 en el mensaje porque el parámetro es un double. Nada llega a tener una Temperature por debajo del cero absoluto, así que ningún otro método tiene que revisarlo.
Código antes de super(...): nuevo en Java 25
Un constructor de una subclase tiene que llamar a un constructor de su clase padre, con super(...). Durante casi toda la historia de Java, esa llamada tenía que ser la primera sentencia. Java 25 hizo definitivos los flexible constructor bodies (cuerpos de constructor flexibles), y ahora puedes ejecutar código antes.
Eso resuelve una molestia antigua. Muchas veces quieres revisar un argumento antes de pasárselo al padre, y antes tenías que meter esa revisión en un método auxiliar estático llamado dentro de los argumentos de super(...). Ahora lo escribes de forma directa:
class Shape {
String name;
Shape(String name) {
this.name = name;
IO.println("Shape constructor for " + name);
}
}
class Square extends Shape {
int side;
Square(int side) {
if (side <= 0) {
throw new IllegalArgumentException("side must be positive: " + side);
}
String label = "square " + side;
super(label);
this.side = side;
IO.println("Square constructor, area " + side * side);
}
}
void main() {
var sq = new Square(3);
IO.println(sq.name + " is ready");
}
Imprime:
Shape constructor for square 3
Square constructor, area 9
square 3 is ready
Primero se ejecutan la revisión y la variable local label, luego super(label) ejecuta el constructor de Shape, y luego el resto del constructor de Square. Un Square(0) lanza la excepción antes de que Shape intervenga. La misma regla aplica a this(...).
Verificamos que de verdad es nuevo. La misma clase, compilada con javac --release 24, falla con flexible constructors is not supported in -source 24. Esa prueba usó un public class Main clásico, porque --release 24 también rechaza la forma con void main() suelto.
Todavía no puedes tocar this antes de super(...)
El código antes de super(...) puede usar parámetros y variables locales, pero no el objeto que se está construyendo. Su parte de la clase padre todavía no existe:
class Shape {
Shape(String name) {
IO.println("Shape " + name);
}
}
class Square extends Shape {
int side = 1;
Square(int side) {
IO.println("old side was " + this.side);
super("square");
this.side = side;
}
}
void main() {
new Square(3);
}
La compilación falla con:
Main.java:11: error: cannot reference this before supertype constructor has been called
IO.println("old side was " + this.side);
^
Leer un campo, llamar a un método de instancia o pasar this a algún lado: todo eso se rechaza antes de super(...). Hay una excepción: puedes asignar un campo ahí, siempre que no tenga inicializador. Eso permite que una subclase asigne sus propios campos antes de que el constructor padre pueda verlos.
private mantiene las reglas en un solo lugar
Java tiene cuatro niveles de acceso, y private es el que más vas a usar para los campos. Un miembro privado solo se puede usar dentro de su propia clase. Veamos por qué importa. El saldo de una cuenta bancaria nunca debería quedar negativo, y si cualquiera puede escribir en balance, cualquiera puede romper esa regla:
class BankAccount {
private int balance;
void deposit(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("deposit must be positive");
}
balance += amount;
}
boolean withdraw(int amount) {
if (amount <= 0 || amount > balance) {
return false;
}
balance -= amount;
return true;
}
int balance() {
return balance;
}
}
void main() {
var account = new BankAccount();
account.deposit(100);
IO.println("withdraw 30: " + account.withdraw(30));
IO.println("withdraw 500: " + account.withdraw(500));
IO.println("balance: " + account.balance());
}
Imprime:
withdraw 30: true
withdraw 500: false
balance: 70
Cada cambio al saldo pasa por deposit o withdraw, y los dos revisan la regla. El método balance() deja que cualquiera lo lea sin dejar que lo escriba. Eso es encapsulamiento: la clase es dueña de sus datos, y la única forma de entrar es a través de métodos que los mantienen válidos. Si la regla cambia, por ejemplo para permitir un sobregiro, cambias una sola clase.
En un archivo Java normal, el compilador lo hace cumplir. Este programa usa el public class Main clásico, así que BankAccount es una clase de nivel superior real, junto a él:
class BankAccount {
private int balance = 70;
}
public class Main {
public static void main(String[] args) {
var account = new BankAccount();
account.balance = -1000;
IO.println(account.balance);
}
}
La compilación falla con:
Main.java:8: error: balance has private access in BankAccount
account.balance = -1000;
^
Una sorpresa en los archivos fuente compactos
Quita public class Main, usa un void main() suelto, y el mismo acceso compila. Esto nos sorprendió, así que lo ejecutamos:
class BankAccount {
private int balance = 70;
}
void main() {
var account = new BankAccount();
account.balance = -1000;
IO.println(account.balance);
}
Imprime:
-1000
La razón es la clase oculta de la sección sobre toString. En un archivo fuente compacto, Java envuelve todo, incluido BankAccount, en una clase implícita. Así que BankAccount no es de nivel superior. Es una clase anidada dentro de esa clase implícita, y por eso la salida por defecto de toString empieza con Main$. Java permite que el código de cualquier parte de una clase de nivel superior use los miembros privados de todas las clases anidadas en ella.
Entonces, en los programas de un solo archivo de esta serie, private no impide que main entre. Igual documenta tu intención, y se vuelve una pared real en cuanto la clase pasa a su propio archivo. Sigue escribiéndolo.
Package-private y public
Los otros niveles importan cuando un programa tiene varios archivos. Sin modificador, un miembro es package-private: cualquier clase del mismo paquete puede usarlo. public significa que cualquier código puede usarlo. protected queda entre los dos y se ve junto con la herencia. Un buen punto de partida: campos privados, y métodos solo tan visibles como necesiten ser.
static pertenece a la clase, no a un objeto
Un campo static tiene una sola copia compartida por toda la clase, en vez de una por objeto. El caso clásico es un contador de cuántos objetos se crearon:
class Ticket {
static int created = 0;
int number;
Ticket() {
created++;
number = created;
}
static String summary() {
return created + " tickets so far";
}
}
void main() {
IO.println(Ticket.summary());
var a = new Ticket();
var b = new Ticket();
var c = new Ticket();
IO.println("a=" + a.number + " b=" + b.number + " c=" + c.number);
IO.println(Ticket.summary());
}
Imprime:
0 tickets so far
a=1 b=2 c=3
3 tickets so far
Cada ticket tiene su propio number, pero los tres comparten un solo created. summary() es un método estático, así que lo llamas sobre la clase, Ticket.summary(), y puedes llamarlo antes de que exista cualquier ticket. IO.println y Math.max también son métodos estáticos.
Un método estático no tiene this
Un método estático se ejecuta sin un objeto, así que no hay this que pueda usar ni campos de instancia que leer:
class Ticket {
int number;
static void show() {
IO.println(this.number);
}
}
void main() {
Ticket.show();
}
La compilación falla con:
Main.java:5: error: non-static variable this cannot be referenced from a static context
IO.println(this.number);
^
Escribir solo number en vez de this.number falla igual, con non-static variable number. La pregunta es “¿el número de qué ticket?”, y un método estático no tiene respuesta.
Métodos factory estáticos
Un método estático que devuelve un objeto nuevo se llama static factory (método factory estático), y el Java moderno los usa mucho: List.of(...), Path.of(...), Duration.ofSeconds(...). A diferencia de un constructor, un factory tiene un nombre que dice lo que hace.
En un archivo fuente compacto, el primer intento obvio no compila, y este nos sorprendió:
class Delay {
static Delay ofSeconds(int seconds) {
return new Delay();
}
}
void main() {
IO.println(Delay.ofSeconds(90) != null);
}
La compilación falla con:
Main.java:3: error: non-static variable this cannot be referenced from a static context
return new Delay();
^
No hay ningún this en ese código, así que el mensaje parece equivocado. No lo está. En un archivo fuente compacto, Delay está anidada dentro de la clase oculta Main, y una clase anidada simple es una clase interna (inner class): cada objeto Delay está atado a un objeto Main. Tu void main() se ejecuta sobre un objeto así, y por eso new Delay() funciona ahí. Un método estático no tiene un objeto Main al cual atar el nuevo Delay, y ese objeto que falta es el this al que se refiere javac.
La misma clase en su propio archivo, o junto a un public class Main clásico, compila sin problema. En un archivo compacto, marca la clase como static y ya no necesita un objeto externo:
static class Delay {
private final int seconds;
private Delay(int seconds) {
this.seconds = seconds;
}
static Delay ofSeconds(int seconds) {
return new Delay(seconds);
}
static Delay ofMinutes(int minutes) {
return new Delay(minutes * 60);
}
@Override
public String toString() {
return seconds + "s";
}
}
void main() {
IO.println(Delay.ofSeconds(90));
IO.println(Delay.ofMinutes(2));
}
Imprime:
90s
120s
ofSeconds(90) y ofMinutes(2) reciben los dos un int, así que no podrían ser dos constructores: las listas de parámetros serían idénticas. El constructor es privado, así que en un proyecto normal los factories son la única forma de crear uno. La clase Ticket de antes no necesitó static porque su método estático nunca creaba un Ticket.
Explicado como si tuvieras diez años
Una clase es un cortador de galletas. Cada objeto es una galleta que sacas con él. El cortador decide la forma, pero cada galleta es una galleta distinta. Puedes ponerle chispas de colores a una, y las demás se quedan sin nada. Esas chispas son los campos.
El constructor es el momento de apretar. Es cuando se hace la galleta, y es tu oportunidad de negarte a hacer una rota.
Algo static está impreso en el cortador, no en ninguna galleta. Imagina que llevas la cuenta en el mango del cortador: una marca cada vez que aprietas. Hay una sola cuenta, sin importar cuántas galletas hagas, y puedes leerla antes de hacer la primera.
La versión precisa
Una clase es un tipo. new reserva un objeto de ese tipo en el heap, pone cada campo en su valor por defecto, ejecuta los inicializadores de campos y el constructor, y devuelve una referencia al objeto. Los campos de instancia viven en cada objeto. Los métodos de instancia reciben el objeto como un parámetro oculto, que es this.
Un campo estático existe una vez por clase, no por objeto, y un método estático no recibe ningún objeto oculto. A los miembros estáticos se llega a través del nombre de la clase. La clase misma se carga e inicializa una sola vez, la primera vez que se usa.
Dónde falla la analogía: un cortador de galletas no puede impedir que hagas una galleta a la que le falta media forma, pero un constructor sí puede, lanzando una excepción. Además, las galletas son pedazos de masa separados, pero una variable de Java no guarda la galleta. Guarda una referencia a ella, así que dos variables pueden apuntar al mismo objeto y ver las chispas de la otra.
Los campos final se asignan una vez
A un campo final hay que darle un valor exactamente una vez, donde se declara o en cada constructor, y después no puede cambiar. Intenta cambiarlo más tarde y la compilación falla:
class Money {
final long cents;
Money(long cents) {
this.cents = cents;
}
void addTip(long tip) {
cents = cents + tip;
}
}
void main() {
var bill = new Money(1250);
bill.addTip(200);
}
La compilación falla con:
Main.java:9: error: cannot assign a value to final variable cents
cents = cents + tip;
^
Objetos inmutables
Si todos los campos son final y ninguno apunta a algo mutable, el objeto nunca puede cambiar después de construirse. Eso es un objeto inmutable. En vez de cambiarlo, los métodos devuelven un objeto nuevo:
class Money {
private final long cents;
private final String currency;
Money(long cents, String currency) {
if (cents < 0) {
throw new IllegalArgumentException("negative amount: " + cents);
}
this.cents = cents;
this.currency = currency;
}
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(cents + other.cents, currency);
}
@Override
public String toString() {
return String.format("%d.%02d %s", cents / 100, cents % 100, currency);
}
}
void main() {
var price = new Money(1250, "EUR");
var tip = new Money(200, "EUR");
var total = price.plus(tip);
IO.println("price: " + price);
IO.println("tip: " + tip);
IO.println("total: " + total);
}
Imprime:
price: 12.50 EUR
tip: 2.00 EUR
total: 14.50 EUR
price.plus(tip) no tocó price y devolvió un tercer objeto. Es una decisión de diseño con beneficios reales. Puedes pasar un Money a cualquier método, guardarlo en cualquier lado o compartirlo entre hilos, y nadie puede cambiarlo a tus espaldas. El constructor revisa las reglas una vez, y siguen siendo ciertas para siempre.
Fíjate en lo que final no hace: en un campo que guarda una lista, te impide reemplazar la lista, no agregarle elementos. final fija la referencia, no el objeto al que apunta.
Escribir Money tomó muchas líneas para dos valores. La parte sobre records muestra cómo Java escribe esa clase por ti en una sola.
El orden en que se ejecutan las cosas
Una clase también puede tener bloques estáticos, que se ejecutan una vez cuando la clase se usa por primera vez, y bloques de inicialización de instancia, que se ejecutan con cada objeto nuevo. El orden es fácil de comprobar:
class Robot {
static int built = 0;
static {
IO.println("1. static block: the class is initialized");
}
String name = "unnamed";
{
IO.println("2. instance block: name is " + name);
}
Robot(String name) {
IO.println("3. constructor: renaming to " + name);
this.name = name;
built++;
}
}
void main() {
IO.println("main starts");
new Robot("R1");
new Robot("R2");
IO.println("built: " + Robot.built);
}
Imprime:
main starts
1. static block: the class is initialized
2. instance block: name is unnamed
3. constructor: renaming to R1
2. instance block: name is unnamed
3. constructor: renaming to R2
built: 2
El bloque estático se ejecutó una vez, y solo cuando Robot se usó por primera vez, después de que main ya había empezado. Para cada objeto, los inicializadores de campos y los bloques de instancia se ejecutaron de arriba hacia abajo, y luego el cuerpo del constructor. Los bloques de instancia son raros en código real. Un constructor casi siempre es más claro.
Qué recordar
- Una clase declara campos y métodos.
newcrea un objeto con su propia copia de cada campo de instancia. Usathis.fieldcuando un parámetro tiene el mismo nombre. - Sobrescribe
toString. La salida por defectoMain$Player@…es un hash de identidad que puede cambiar entre ejecuciones, modos de ejecución y JVMs. - En cuanto escribes cualquier constructor, el gratis sin argumentos desaparece. Encadena constructores con
this(...)y valida los argumentos en el constructor. - Desde Java 25, un constructor puede ejecutar sentencias antes de
super(...)othis(...), pero no puede usarthisahí. - Haz los campos
privatey cámbialos solo a través de métodos que respeten las reglas. En un archivo fuente compacto, las clases están anidadas en una clase oculta, así quemainigual puede llegar a los miembros privados. - Los miembros
staticpertenecen a la clase, y un método estático no tienethis. Los factories estáticos comoof(...)les dan un nombre a los constructores. - Un campo
finalse asigna una vez. Los objetos cuyos campos son todos final e inmutables no pueden cambiar, así que los métodos devuelven objetos nuevos.
Un constructor decide cómo empieza un objeto, y
privateyfinaldeciden qué puede cambiar después.