Interfaces en Java — teoría con pagos, tarjetas y Bizum (y el método que olvidé poner public)
Mi primera interfaz tenía una sola línea útil: boolean pagar(double importe);. Escribí la clase Tarjeta, copié la firma tal cual y pulsé compilar. Esto fue lo que me devolvió Java:
A1.java:6: error: pagar(double) in Tarjeta cannot implement pagar(double) in Pagable
boolean pagar(double importe) { // ¡falta public!
^
attempting to assign weaker access privileges; was public
1 error
«¿Más débiles? ¡Si yo no he puesto nada en la interfaz!» Y ahí estaba el problema: precisamente porque no había puesto nada, Java ya había puesto public por mí. Lo que no escribes en la interfaz, Java lo pone por ti; lo que no escribes en la clase, no se pone solo. Esa pequeña asimetría resume casi toda la teoría de hoy: una interfaz es un contrato, y firmarlo tiene sus letras pequeñas.
Esta es la teoría de interfaces con un ejemplo que usaremos de principio a fin: un sistema de pagos en el que da igual si pagas con tarjeta, con Bizum o en efectivo. Si ya has leído la teoría de herencia, vas a ver que las interfaces resuelven justo lo que a la herencia se le queda corto. Y si vienes de la teoría de excepciones, te sonará la idea: allí un tipo general (Exception) podía guardar uno más concreto, y aquí pasa lo mismo con las interfaces. Al final, te llevo a la práctica de interfaces para aplicarlo con tres programas completos.
Tabla de Contenidos
Qué es una interfaz
Una interfaz es un contrato: una lista de cosas que un objeto sabe hacer, sin decir cómo las hace. Quien la implementa se compromete a cumplir todos los puntos del contrato; quien la usa solo necesita conocer el contrato, no al que lo cumple.
Piensa en un enchufe. Al enchufe le da igual si detrás hay una lámpara, un cargador o una tostadora: solo exige que el aparato tenga la clavija adecuada. En Java, ese enchufe es la interfaz, y la clavija es cada método que declara.
Empezamos con la nuestra:
public interface Pagable {
double MAXIMO = 1000.0; // constante: public static final por defecto
boolean pagar(double importe); // public abstract por defecto
String nombre();
default String resumen(double importe) { // con cuerpo: quien implementa puede usarlo tal cual
return nombre() + " (" + String.format("%.2f", importe) + " €)";
}
static boolean importeValido(double importe) { // pertenece a la interfaz, no a los objetos
return importe > 0 && importe <= MAXIMO;
}
}
Lo que se ve aquí y lo que no:
pagarynombreno tienen cuerpo: solo la firma. Son los puntos del contrato.- No pone
publicniabstracten los métodos porque Java los pone solo. Todos los métodos de una interfaz sonpublic abstractsalvo que tengandefaultostatic. MAXIMOes una constante: en una interfaz, todo campo espublic static finalaunque no lo escribas.resumenes un métododefault(con cuerpo) yimporteValidoesstatic. Los veremos con calma más abajo.
Por convención, los nombres de interfaces suelen terminar en -able / -ble cuando describen una capacidad (Pagable, Comparable, Reproducible) o son sustantivos cuando describen un rol (List, Map). Las dos cosas son válidas.
Interfaz vs clase abstracta
Esta es probablemente la duda más común: «si una clase abstracta también tiene métodos sin cuerpo, ¿para qué quiero interfaces?». La diferencia no está en los métodos, sino en lo que representan.
| Clase abstracta | Interfaz | |
|---|---|---|
| Qué expresa | «es un tipo de…» (una familia) | «sabe hacer…» (una capacidad) |
| Palabra clave | extends | implements |
| ¿Cuántas puedo usar? | Una sola | Todas las que quiera |
| Campos de instancia (estado) | Sí | No (solo constantes) |
| Constructores | Sí | No |
| Métodos con cuerpo | Sí, de cualquier tipo | Solo default y static |
| Modificadores de los miembros | Los que quieras | Todo es public |
¿Se puede hacer new? | No | No |
Tres preguntas rápidas para decidir:
- ¿Necesito compartir estado (campos) o código de constructor? → clase abstracta.
- ¿Las clases no tienen nada que ver entre sí pero comparten una capacidad? (una
Tarjetay unBizumno son «familia», pero los dos se pueden pagar) → interfaz. - ¿Una misma clase tiene que cumplir varios papeles a la vez? → interfaces, porque la herencia solo permite un padre.
Esa última es la clave. Con herencia, una clase solo puede tener un padre. Si intentas dos, Java ni lo intenta:
class Aparato { }
class Sonido { }
class Altavoz extends Aparato, Sonido {
}
A8.java:4: error: '{' expected
class Altavoz extends Aparato, Sonido {
^
1 error
En cambio, una clase puede implementar tantas interfaces como necesite: class Altavoz implements Encendible, Reproducible es perfectamente válido. Es la forma que tiene Java de conseguir «casi» herencia múltiple sin los problemas que daría con el estado.
Una regla práctica. Si dudas, empieza con una interfaz. Es más fácil convertir una interfaz en clase abstracta que al revés, y no te cierra la puerta de la herencia a quien la use.
Implementar una interfaz: implements y public
Para cumplir el contrato, la clase escribe implements y sobrescribe todos los métodos abstractos:
public class Tarjeta implements Pagable {
protected double limite; // protected: las hijas lo ven
public Tarjeta(double limite) {
this.limite = limite;
}
@Override
public boolean pagar(double importe) { // public: obligatorio, la interfaz lo declara public
if (importe > limite) {
return false;
}
limite -= importe;
return true;
}
@Override
public String nombre() {
return "Tarjeta";
}
public double limiteRestante() { // solo existe en Tarjeta, no en Pagable
return limite;
}
}
Tres detalles que conviene mirar:
@Overrideno es obligatorio, pero sí muy recomendable: si te equivocas en la firma, Java te avisa en vez de crear un método distinto sin que te enteres.publices obligatorio enpagarynombre. Los métodos de la interfaz son públicos y una clase no puede reducir la visibilidad de lo que hereda (la regla que me pilló al principio del artículo).limiteRestante()es un método extra: la interfaz no lo conoce. La clase puede tener todos los métodos que quiera además de los del contrato.
Otras dos clases que cumplen el mismo contrato, cada una a su manera:
public class Bizum implements Pagable {
private double saldo;
public Bizum(double saldo) {
this.saldo = saldo;
}
@Override
public boolean pagar(double importe) {
if (importe > saldo) {
return false;
}
saldo -= importe;
return true;
}
@Override
public String nombre() {
return "Bizum";
}
}
public class Efectivo implements Pagable {
private double cartera;
public Efectivo(double cartera) {
this.cartera = cartera;
}
@Override
public boolean pagar(double importe) {
if (importe > cartera) {
return false;
}
cartera -= importe;
return true;
}
@Override
public String nombre() {
return "Efectivo";
}
@Override
public String resumen(double importe) { // sobrescribimos el default
return "Efectivo en mano (" + String.format("%.2f", importe) + " €)";
}
}
Efectivo hace algo más: **sobrescribe resumen**, que en la interfaz ya venía con cuerpo. Volveremos sobre ello cuando lleguemos a default.
Los errores típicos al implementar
Si te olvidas de uno de los métodos del contrato, Java te lo dice así:
interface Pagable {
boolean pagar(double importe);
String nombre();
}
class Cheque implements Pagable {
public boolean pagar(double importe) {
return true;
}
// falta nombre()
}
A2.java:6: error: Cheque is not abstract and does not override abstract method nombre() in Pagable
class Cheque implements Pagable {
^
1 error
«is not abstract and does not override abstract method». Significa que la clase dice implements Pagable, pero no ha escrito alguno de los métodos del contrato (aquí, nombre()). Mira el final del mensaje: te dice qué método falta y en qué interfaz estaba declarado. Escríbelo con public y listo, o declara la clase abstract si de verdad no quieres implementarlo ahí.
Y la otra cara de la moneda: no se puede instanciar una interfaz, igual que una clase abstracta.
A3.java:7: error: Pagable is abstract; cannot be instantiated
Pagable p = new Pagable();
^
1 error
new Pagable() no compila: una interfaz no tiene objetos propios. Lo que sí puedes hacer es new Tarjeta() y guardarlo en una variable de tipo Pagable, que es justo el siguiente apartado.
Polimorfismo con el tipo interfaz
Aquí es donde las interfaces demuestran su valor. Una interfaz es un tipo: puedes declarar variables, parámetros, arrays y retornos de tipo Pagable, y guardar dentro cualquier objeto cuya clase la implemente.
public class Cobro {
public static void main(String[] args) {
Pagable[] medios = { new Tarjeta(100), new Bizum(30), new Efectivo(20) };
double importe = 25;
if (!Pagable.importeValido(importe)) {
System.out.println("Importe no válido");
return;
}
for (Pagable p : medios) {
boolean ok = p.pagar(importe);
System.out.println(p.resumen(importe) + " -> " + (ok ? "pagado" : "rechazado"));
if (p instanceof Tarjeta t) {
System.out.printf(" Límite restante: %.2f €%n", t.limiteRestante());
}
}
System.out.println("Máximo permitido: " + Pagable.MAXIMO);
}
}
Salida:
Tarjeta (25,00 €) -> pagado Límite restante: 75,00 € Bizum (25,00 €) -> pagado Efectivo en mano (25,00 €) -> rechazado Máximo permitido: 1000.0
El bucle for (Pagable p : medios) no sabe ni le importa qué hay dentro de cada posición. Llama a p.pagar(importe) y Java ejecuta **el pagar del objeto real** (el de Tarjeta, el de Bizum o el de Efectivo). Es el mismo polimorfismo que viste en herencia, pero ahora sin que las tres clases tengan un padre común.
Fíjate también en tres cosas de la salida:
- El
Efectivosalió comoEfectivo en mano (25,00 €): usó su propioresumen, mientras queTarjetayBizumusaron el de la interfaz. - A
Efectivose le rechazó el pago porque su cartera tiene 20 € y pedíamos 25 €. No es un error: es el contrato funcionando. Máximo permitido: 1000.0salió de la constante, llamada con el nombre de la interfaz.
Lo que «ve» la variable: el tipo estático
Una variable de tipo Pagable **solo puede usar lo que Pagable declara**, aunque el objeto de dentro sea una Tarjeta con más métodos:
interface Pagable {
boolean pagar(double importe);
}
class Tarjeta implements Pagable {
public boolean pagar(double importe) { return true; }
public double limiteRestante() { return 75; }
}
public class A6 {
public static void main(String[] args) {
Pagable p = new Tarjeta();
System.out.println(p.limiteRestante());
}
}
A6.java:13: error: cannot find symbol
System.out.println(p.limiteRestante());
^
symbol: method limiteRestante()
location: variable p of type Pagable
1 error
«cannot find symbol … location: variable p of type Pagable». El método limiteRestante() existe en Tarjeta, pero la variable p es de tipo Pagable y el compilador solo mira el tipo de la variable, no el del objeto. Fíjate en la última línea del mensaje: te dice con qué tipo está declarada.
Para usar los métodos propios de Tarjeta hay que preguntar primero si el objeto es de esa clase y, solo entonces, convertirlo. Es lo que hace Cobro:
if (p instanceof Tarjeta t) { // ¿es una Tarjeta? Si sí, la llamo t
System.out.printf(" Límite restante: %.2f €%n", t.limiteRestante());
}
Esa forma (instanceof Tarjeta t) se llama pattern matching y existe desde Java 16: comprueba y declara la variable convertida en un solo paso. La versión clásica, que seguirás viendo en muchos apuntes y en Python Tutor, es:
if (p instanceof Tarjeta) {
Tarjeta t = (Tarjeta) p; // cast explícito
System.out.println(t.limiteRestante());
}
Casting: de interfaz a clase y vuelta
- De clase a interfaz (hacia arriba): es automático, sin cast.
Pagable p = new Tarjeta(100);funciona sin más porque todaTarjetaes unPagable. - De interfaz a clase (hacia abajo): hay que escribir el cast a mano, porque el compilador no puede saber qué hay dentro.
A7.java:12: error: incompatible types: Pagable cannot be converted to Tarjeta
Tarjeta t = p;
^
1 error
«incompatible types: Pagable cannot be converted to Tarjeta». Sin cast, Java no se fía. Con cast ((Tarjeta) p) compila, pero si el objeto de dentro no es una Tarjeta, falla en tiempo de ejecución con ClassCastException. Por eso conviene protegerlo siempre con instanceof.
instanceof también sirve con interfaces. p instanceof Reembolsable pregunta si el objeto cumple esa capacidad, algo muy útil cuando una colección mezcla objetos con capacidades distintas.
default, static y constantes
Desde Java 8, las interfaces pueden llevar código. Hay tres piezas, y cada una responde a una necesidad distinta.
Métodos default
Un método default es un método con cuerpo dentro de la interfaz. Sirve para dar una versión «por defecto» que las clases pueden usar tal cual o sobrescribir.
default String resumen(double importe) {
return nombre() + " (" + String.format("%.2f", importe) + " €)";
}
- Fíjate en que
resumen**llama anombre()**, que es abstracto. Eso es lo bonito: el default usa el contrato para construir algo nuevo sin saber quién lo implementa. TarjetayBizumlo reciben gratis.Efectivolo sobrescribe y devuelve otro texto. Es lo que ya viste en la salida.- Gracias a
defaultse pueden añadir métodos a una interfaz antigua sin romper las clases que ya existían, que es para lo que se inventó.
Métodos static
Un método static en una interfaz pertenece a la interfaz, no a los objetos. Se llama con el nombre de la interfaz:
static boolean importeValido(double importe) {
return importe > 0 && importe <= MAXIMO;
}
...
if (!Pagable.importeValido(importe)) { ... }
Sirve para agrupar utilidades relacionadas con el contrato (validaciones, creadores…). Importante: no se hereda. Tarjeta.importeValido(5) no compila; hay que escribir Pagable.importeValido(5).
Constantes
Todo campo de una interfaz es public static final, aunque no lo escribas. Por eso MAXIMO = 1000.0 es una constante global que se lee con Pagable.MAXIMO. Y por eso no puedes cambiarla:
interface Pagable {
double MAXIMO = 1000.0;
}
public class A4 {
public static void main(String[] args) {
Pagable.MAXIMO = 2000;
}
}
A4.java:7: error: cannot assign a value to static final variable MAXIMO
Pagable.MAXIMO = 2000;
^
1 error
«cannot assign a value to static final variable». Las constantes de una interfaz son final: se escriben en mayúsculas y no se reasignan. Si necesitas un valor que cambie, no es una constante: ponlo como campo en la clase.
Interfaces que heredan de interfaces
Una interfaz puede extender otra (o varias). Hereda todos sus métodos y puede añadir más:
public interface Reembolsable extends Pagable { // una interfaz puede extender otra
boolean reembolsar(double importe);
default String politica() {
return "devolución en 30 días";
}
}
Ahora, quien implemente Reembolsable tiene que cumplir los dos contratos: pagar y nombre (de Pagable) más reembolsar. Además, Reembolsable trae su propio default, politica().
Creamos otra capacidad independiente:
public interface Fraccionable {
default String politica() {
return "pago en 3 plazos";
}
}
Y una clase que las junta todas: una tarjeta premium que hereda de Tarjeta y además implementa dos interfaces.
public class TarjetaPremium extends Tarjeta implements Reembolsable, Fraccionable {
public TarjetaPremium(double limite) {
super(limite);
}
@Override
public boolean reembolsar(double importe) {
limite += importe;
return true;
}
// Las dos interfaces traen un default llamado politica(): Java no elige por nosotros
@Override
public String politica() {
return Reembolsable.super.politica() + " y " + Fraccionable.super.politica();
}
}
Fíjate en el orden de la cabecera: primero extends (un solo padre) y luego implements (los que quieras, separados por comas).
El diamante: dos defaults con el mismo nombre
Reembolsable y Fraccionable tienen un default llamado politica(). Si una clase implementa las dos y no decide cuál manda, Java se niega a elegir por ti:
interface Reembolsable {
default String politica() { return "devolución en 30 días"; }
}
interface Fraccionable {
default String politica() { return "pago en 3 plazos"; }
}
class TarjetaOro implements Reembolsable, Fraccionable {
// dos defaults con el mismo nombre y ninguna decisión
}
A5.java:9: error: types Reembolsable and Fraccionable are incompatible;
class TarjetaOro implements Reembolsable, Fraccionable {
^
class TarjetaOro inherits unrelated defaults for politica() from types Reembolsable and Fraccionable
1 error
«inherits unrelated defaults for politica() from types…». Hay dos candidatos para el mismo método y ninguno gana. La solución es sobrescribirlo en la clase y decidir: elegir uno, combinarlos o escribir otro nuevo. Para llamar a la versión de una interfaz concreta se usa la sintaxis NombreInterfaz.super.metodo(), como en TarjetaPremium.
Lo probamos:
public class Premium {
public static void main(String[] args) {
TarjetaPremium tp = new TarjetaPremium(200);
tp.pagar(50);
tp.reembolsar(20);
System.out.println("Política: " + tp.politica());
System.out.printf("Límite: %.2f €%n", tp.limiteRestante());
Pagable p = tp; // una TarjetaPremium ES un Pagable...
System.out.println(p instanceof Reembolsable); // ...y también Reembolsable
System.out.println(p instanceof Fraccionable); // ...y Fraccionable
}
}
Política: devolución en 30 días y pago en 3 plazos Límite: 170,00 € true true
Mira el último bloque: una misma TarjetaPremium es un Pagable, un Reembolsable y un Fraccionable a la vez. Según con qué «gafas» la mires (el tipo de la variable), usarás unas capacidades u otras.
Si las dos interfaces declaran el método como abstracto (sin cuerpo), no hay conflicto: lo implementas una vez y cumples los dos contratos. El problema aparece cuando al menos una trae un default. Y si el choque es entre una clase y una interfaz, gana la clase: un método heredado de una clase tiene prioridad sobre un default.
Míralo paso a paso en Python Tutor
Python Tutor ejecuta el programa línea a línea y dibuja las variables, los objetos y las referencias. Elige Java en el desplegable. Para que no se líe, dejamos el ejemplo mínimo: dos clases que cumplen el mismo contrato y un bucle que las trata por igual. Lo dejamos en lo esencial, sin default ni pattern matching, para que sea fácil de seguir.
public class Tutor {
interface Pagable {
boolean pagar(double importe);
}
static class Tarjeta implements Pagable {
double limite = 100;
public boolean pagar(double importe) {
if (importe > limite) return false;
limite -= importe;
return true;
}
}
static class Efectivo implements Pagable {
double cartera = 20;
public boolean pagar(double importe) {
if (importe > cartera) return false;
cartera -= importe;
return true;
}
}
public static void main(String[] args) {
Pagable[] medios = { new Tarjeta(), new Efectivo() };
for (int i = 0; i < medios.length; i++) {
Pagable p = medios[i];
boolean ok = p.pagar(25);
System.out.println("Medio " + i + ": " + ok);
}
}
}
Salida esperada:
Medio 0: true Medio 1: false
Qué observar paso a paso:
- Cuando se ejecuta
Pagable[] medios = ..., fíjate en que el array tiene dos flechas apuntando a dos objetos distintos: unoTarjetay otroEfectivo. La interfaz no aparece como objeto: solo existen objetos de las clases concretas. - En la primera vuelta,
Pagable p = medios[i]hace quepapunte al objetoTarjeta. Al entrar enp.pagar(25), mira el marco del método: estás dentro delpagarde **Tarjeta** ylimitebaja de 100 a 75. - En la segunda vuelta, la misma línea
p.pagar(25)entra ahora en elpagarde **Efectivo**. Como25 > 20, devuelvefalseycarterase queda como estaba. - Compara las dos vueltas: el código no cambia, cambia el objeto al que apunta
p. Eso es el polimorfismo.
Resumen y tabla rápida
| Quiero… | Escribo | Detalle |
|---|---|---|
| Declarar un contrato | interface Pagable { … } | Los métodos son public abstract solos |
| Cumplir un contrato | class Tarjeta implements Pagable | Sobrescribir todo y con public |
| Cumplir varios contratos | implements A, B, C | Separados por comas |
| Heredar y además implementar | class X extends Y implements A, B | extends va primero |
| Extender un contrato | interface B extends A | Puede extender varias |
| Guardar cualquier objeto que cumpla | Pagable p = new Tarjeta(100); | El tipo de la variable limita lo que ves |
| Preguntar la capacidad | p instanceof Reembolsable | También con clases |
| Acceder a lo propio de la clase | if (p instanceof Tarjeta t) { t.… } | O cast (Tarjeta) p tras comprobar |
| Dar un método por defecto | default String resumen() { … } | Se puede sobrescribir |
| Resolver un diamante | A.super.metodo() | O escribir uno nuevo |
| Utilidad de la interfaz | static … importeValido(…) | Se llama como Pagable.importeValido(…) |
| Constante | double MAXIMO = 1000.0; | Siempre public static final |
Tres ideas para quedarte:
- Una interfaz es un contrato: dice qué se puede hacer, no cómo. Implementarla obliga a sobrescribir todo y con
public. - El tipo de la variable manda en tiempo de compilación; el objeto real manda en ejecución. Por eso necesitas
instanceofpara ver lo propio de una clase. - Una clase, un padre; pero tantas interfaces como quieras. Si dos
defaultchocan, decides tú conInterfaz.super.metodo().
Y ahora, a practicar
Con esto tienes la teoría completa. En la práctica de interfaces lo aplicamos con una pasarela de pagos, un carrito que acepta cualquier cosa pagable y un mando que controla dispositivos con capacidades distintas. Y si quieres repasar antes lo que hay debajo (clases, objetos y polimorfismo), vuelve a la teoría de clases y objetos.
Siguiente paso: prácticaInterfaces en Java — práctica con una pasarela de pagos, un carrito y un mando de dispositivos (y la clase abstracta que no pudo con dos padres)¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

3 comentarios