Al terminar la lección anterior tus objetos eran cajas de datos: Libro guarda un título y un ISBN, pero no sabe hacer absolutamente nada con ellos. Toda la inteligencia de BiblioTech sigue encerrada en las doscientas líneas de main, que calcula días de retraso, aplica el tope de la multa y clasifica la gravedad como si los objetos no existieran. Esta lección aporta la mitad que falta: el comportamiento. Un método es una operación con nombre que agrupa un conjunto de instrucciones, recibe datos, hace un trabajo y normalmente devuelve un resultado. Es la unidad de descomposición del código —lo que impide que main crezca sin fin— y, en la POO, es la forma en que un objeto expone lo que sabe hacer. Al acabar, calcularMulta() no será un bloque suelto en medio de un switch: será algo que un Prestamo sabe hacer consigo mismo.

Contenido

  1. Anatomía de un método
  2. El valor de retorno y return
  3. Métodos void
  4. Paso de parámetros: Java siempre pasa por valor
  5. Qué implica eso con primitivos
  6. Qué implica eso con referencias (y por qué confunde a todo el mundo)
  7. Sobrecarga de métodos
  8. Métodos static frente a métodos de instancia
  9. La referencia this
  10. Varargs: número variable de argumentos
  11. Ámbito de las variables y shadowing
  12. Buenas prácticas de diseño de métodos
  13. Recursión: un método que se llama a sí mismo
  14. Refactorización guiada de BiblioTech
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. Anatomía de un método

Esta es la estructura completa de un método Java:

public double calcularMulta(int diasRetraso) {
    return diasRetraso * TARIFA_DIARIA;
}
Parte En el ejemplo Qué significa
Modificador de acceso public Quién puede llamarlo (03-07)
Otros modificadores (ninguno) static, final, abstract...
Tipo de retorno double Qué tipo de valor devuelve; void si no devuelve nada
Nombre calcularMulta Verbo en lowerCamelCase
Lista de parámetros (int diasRetraso) Datos de entrada, con tipo y nombre
Cuerpo { return ...; } Las instrucciones que se ejecutan

Al conjunto nombre + tipos de parámetros en orden se le llama firma del método. La firma de este es calcularMulta(int). El tipo de retorno no forma parte de la firma, y eso tendrá consecuencias en el apartado 7.

Vocabulario que conviene fijar para no confundirse nunca más:

  • Parámetro: la variable declarada en la definición del método (int diasRetraso).
  • Argumento: el valor concreto que se pasa al llamarlo (calcularMulta(5) pasa el argumento 5).

Llamar a un método de instancia se hace siempre a través de un objeto:

double multa = prestamo.calcularMulta(5);

  1. El valor de retorno y return

return hace dos cosas a la vez: entrega un valor al llamador y termina inmediatamente la ejecución del método. Lo segundo lo viste en la lección 02-04, aplicado al control de flujo.

public String clasificarGravedad(int diasRetraso) {
    if (diasRetraso == 0) {
        return "SIN RETRASO";      // sale aqui mismo
    }
    if (diasRetraso <= UMBRAL_LEVE) {
        return "LEVE";
    }
    return "GRAVE";
}

Reglas del compilador que debes conocer:

  • El valor devuelto debe ser compatible con el tipo declarado. Un método double puede devolver un int (promoción automática), pero un método int no puede devolver un double sin casting explícito.
  • Todos los caminos deben devolver un valor. Si el compilador encuentra una ruta sin return, falla con "missing return statement".
  • El código posterior a un return alcanzado siempre es código inalcanzable y no compila.

Este método, por ejemplo, no compila:

public String clasificar(int dias) {
    if (dias > 0) {
        return "CON RETRASO";
    }
    // ERROR: si dias <= 0 no hay return
}

Basta añadir un else o un return final para arreglarlo. La segunda forma —usar cláusulas de guarda y dejar el caso general al final, sin else— es la que usaste en 02-01 y la que se prefiere en código profesional.

  1. Métodos void

Cuando un método hace algo en lugar de calcular algo, su tipo de retorno es void:

public void prestar() {
    disponible = false;
}

En un método void puedes usar return; a secas para salir antes de tiempo, pero no puedes devolver un valor:

public void devolver() {
    if (disponible) {
        System.out.println("El libro ya estaba disponible.");
        return;                    // salida anticipada, sin valor
    }
    disponible = true;
}

Un criterio útil de diseño, conocido como separación entre consultas y órdenes: un método o cambia el estado (y devuelve void) o responde una pregunta (y devuelve un valor), pero preferiblemente no ambas cosas. Un método llamado getMulta() que además marque el libro como devuelto sorprendería a cualquiera que lo lea.

  1. Paso de parámetros: Java siempre pasa por valor

Aquí está uno de los puntos donde más gente se equivoca, incluso con años de experiencia. La regla es esta, y no tiene excepciones:

En Java, los argumentos se pasan siempre por valor. Es decir: al llamar a un método, se copia el valor del argumento en el parámetro.

Lo que despista es qué se está copiando en cada caso:

Tipo de argumento Qué se copia Consecuencia
Primitivo (int, double, boolean...) El valor mismo El método trabaja con una copia independiente
Referencia (String, Libro, Prestamo...) La dirección, no el objeto El método llega al mismo objeto, pero con su propia flecha

De ahí las dos consecuencias que hay que memorizar:

  1. Reasignar el parámetro nunca afecta al llamador (ni con primitivos ni con objetos).
  2. Mutar el objeto al que apunta el parámetro sí afecta al llamador (porque es el mismo objeto).

Los dos apartados siguientes lo demuestran con código.

  1. Qué implica eso con primitivos

public static void intentarModificar(int dias) {
    dias = 999;                       // solo cambia la copia local
    System.out.println("  Dentro del metodo: " + dias);
}

public static void main(String[] args) {
    int diasTranscurridos = 20;
    System.out.println("Antes:   " + diasTranscurridos);
    intentarModificar(diasTranscurridos);
    System.out.println("Despues: " + diasTranscurridos);
}

Salida:

Antes:   20
  Dentro del metodo: 999
Despues: 20

El parámetro dias es una variable nueva en la pila del método, inicializada con una copia de 20. Cambiarla no toca la variable original. Si un método debe "modificar" un primitivo, la única vía es devolver el nuevo valor:

diasTranscurridos = incrementar(diasTranscurridos);

  1. Qué implica eso con referencias (y por qué confunde a todo el mundo)

Ahora el caso interesante. Dos métodos aparentemente similares, con resultados opuestos:

public static void mutarLibro(Libro libro) {
    libro.titulo = "TITULO CAMBIADO";   // muta el objeto compartido
}

public static void reasignarLibro(Libro libro) {
    libro = new Libro();                // reapunta SOLO la flecha local
    libro.titulo = "OTRO LIBRO";
}

public static void main(String[] args) {
    Libro javaEfectivo = new Libro();
    javaEfectivo.titulo = "Java Efectivo";

    mutarLibro(javaEfectivo);
    System.out.println("Tras mutar:     " + javaEfectivo.titulo);

    reasignarLibro(javaEfectivo);
    System.out.println("Tras reasignar: " + javaEfectivo.titulo);
}

Salida:

Tras mutar:     TITULO CAMBIADO
Tras reasignar: TITULO CAMBIADO

La segunda llamada no ha hecho nada visible. Veamos por qué, en memoria:

flowchart TB
    subgraph P1["Pila de main"]
        M["javaEfectivo = 0x1A"]
    end
    subgraph P2["Pila de reasignarLibro"]
        L1["libro = 0x1A (al entrar)"]
        L2["libro = 0x9F (tras new)"]
    end
    subgraph H["Heap"]
        O1["0x1A : Libro
        titulo = TITULO CAMBIADO"]
        O2["0x9F : Libro
        titulo = OTRO LIBRO"]
    end
    M --> O1
    L1 -.-> O1
    L2 --> O2

Al entrar en reasignarLibro, el parámetro libro recibe una copia de la dirección 0x1A: hay dos flechas apuntando al mismo objeto. Cuando el método hace libro = new Libro(), mueve su propia flecha al nuevo objeto 0x9F. La flecha de main no se ha tocado. Al salir del método, la pila local desaparece y el objeto 0x9F queda inalcanzable.

En mutarLibro, en cambio, no se toca ninguna flecha: se usa el punto para modificar el objeto al que ambas apuntan, y por eso el cambio se ve desde main.

La frase que resume todo: puedes cambiar el contenido del objeto que te pasan, pero no puedes cambiar a qué objeto apunta la variable del llamador.

Un corolario práctico: String es inmutable (módulo 1), así que ningún método puede alterar la cadena que le pasas. Esto no funciona nunca:

public static void ensuciar(String texto) {
    texto = texto.toUpperCase();   // crea otra cadena; el llamador no se entera
}

  1. Sobrecarga de métodos

Sobrecargar es declarar varios métodos con el mismo nombre y distinta lista de parámetros en la misma clase. Java elige cuál llamar según los argumentos.

public class Prestamo {

    /** Multa de este prestamo, usando sus propios dias transcurridos. */
    public double calcularMulta() {
        return calcularMulta(diasTranscurridos);
    }

    /** Multa que corresponderia a un numero concreto de dias transcurridos. */
    public double calcularMulta(int diasTranscurridos) {
        int retraso = Math.max(0, diasTranscurridos - DIAS_PRESTAMO);
        return Math.min(retraso * TARIFA_DIARIA, MULTA_MAXIMA);
    }

    /** Multa con una tarifa distinta (por ejemplo, en una campana especial). */
    public double calcularMulta(int diasTranscurridos, double tarifa) {
        int retraso = Math.max(0, diasTranscurridos - DIAS_PRESTAMO);
        return Math.min(retraso * tarifa, MULTA_MAXIMA);
    }
}

Qué distingue una sobrecarga válida:

Cambio ¿Sobrecarga válida?
Distinto número de parámetros
Distintos tipos de parámetros
Distinto orden de tipos distintos Sí ((int, String) frente a (String, int))
Solo distinto nombre de los parámetros No: error de compilación
Solo distinto tipo de retorno No: el retorno no forma parte de la firma

Reglas de resolución, que Java aplica en este orden hasta encontrar una coincidencia:

  1. Coincidencia exacta de tipos.
  2. Promoción de tipos primitivos (intlongfloatdouble).
  3. Autoboxing/unboxing (intInteger, módulo 1).
  4. Varargs (apartado 10), que siempre es la última opción considerada.
public static void registrar(int dias)    { System.out.println("version int"); }
public static void registrar(double dias) { System.out.println("version double"); }
public static void registrar(Object dias) { System.out.println("version Object"); }

registrar(5);      // "version int"     -> coincidencia exacta
registrar(5.0);    // "version double"  -> coincidencia exacta
registrar(5L);     // "version double"  -> long promociona a double antes que a Object

Consejo: la sobrecarga es útil cuando las variantes hacen conceptualmente lo mismo con distintos datos. Si dos métodos con el mismo nombre hacen cosas distintas, ponles nombres distintos.

  1. Métodos static frente a métodos de instancia

La diferencia es la misma que viste con los campos en 03-02:

Aspecto Método de instancia Método static
Pertenece a Un objeto concreto La clase
Cómo se llama prestamo.calcularMulta() Prestamo.esRetrasoLeve(5)
Puede usar campos de instancia No (no hay objeto)
Puede usar this No
Puede usar campos static
Cuándo usarlo La operación depende del estado del objeto La operación solo depende de sus parámetros

Ejemplos del mundo real que ya conoces: Math.max(a, b) y Integer.parseInt(texto) son static porque no necesitan ningún estado; scanner.nextLine() es de instancia porque depende del estado de ese Scanner. Y main es static porque la JVM debe poder llamarlo antes de que exista ningún objeto.

Aplicado a BiblioTech:

public class Prestamo {

    /** De instancia: usa los datos de ESTE prestamo. */
    public int calcularDiasRetraso() {
        return Math.max(0, diasTranscurridos - DIAS_PRESTAMO);
    }

    /** Estatico: pura funcion de sus parametros, util sin tener un prestamo. */
    public static boolean esRetrasoLeve(int diasRetraso) {
        return diasRetraso > 0 && diasRetraso <= UMBRAL_LEVE;
    }
}

Error clásico de principiante, y probablemente el mensaje del compilador que más veces verás:

public class Prestamo {
    public int diasTranscurridos;

    public static int mal() {
        return diasTranscurridos;   // ERROR
    }
}
error: non-static variable diasTranscurridos cannot be referenced from a static context

La traducción es literal: un método static no está asociado a ningún objeto, así que la pregunta "¿los días transcurridos de quién?" no tiene respuesta.

  1. La referencia this

Dentro de un método de instancia, this es una referencia al objeto sobre el que se llamó el método. Tiene tres usos:

a) Desambiguar cuando un parámetro se llama igual que un campo (el uso más frecuente, y el que verás constantemente en constructores):

public void establecerDiasTranscurridos(int diasTranscurridos) {
    this.diasTranscurridos = diasTranscurridos;   // campo = parametro
}

Sin this, diasTranscurridos = diasTranscurridos; asigna el parámetro a sí mismo y el campo no cambia. El compilador no se queja; el error es silencioso.

b) Llamar a otro método del mismo objeto (opcional, pero a veces más legible):

public String generarResumen() {
    return this.clasificarGravedad() + " - " + this.calcularMulta() + " EUR";
}

c) Pasar el objeto actual a otro método:

public void notificar(RegistroAuditoria registro) {
    registro.anotar(this);   // "aqui estoy yo"
}

Fuera de esos casos, escribir this. delante de todo es ruido. En Java el estilo dominante es omitirlo salvo cuando sea necesario. En 03-04 aparecerá una cuarta forma, this(...), para encadenar constructores.

  1. Varargs: número variable de argumentos

Los varargs permiten declarar un parámetro que acepta cero, uno o muchos argumentos del mismo tipo. Se escriben con tres puntos:

public static void registrarEventos(String... eventos) {
    System.out.println("Eventos recibidos: " + eventos.length);
    for (String evento : eventos) {
        System.out.println("  - " + evento);
    }
}

Llamadas válidas:

registrarEventos();                                   // 0 eventos
registrarEventos("Prestamo registrado");              // 1
registrarEventos("Devolucion", "Multa aplicada");     // 2

Dentro del método, eventos es un array (String[]), con su length y su bucle for-each. Los arrays se estudian a fondo en la lección 05-01; aquí basta con saber que se recorren así.

Dos reglas obligatorias:

  • Solo puede haber un parámetro varargs por método.
  • Debe ser el último de la lista: registrar(String empleado, String... eventos) es válido; al revés, no compila.

Ya has usado varargs sin saberlo: System.out.printf(String formato, Object... args) es exactamente esto, y por eso admite cualquier número de valores tras la cadena de formato.

  1. Ámbito de las variables y shadowing

El ámbito (scope) de una variable es la región de código donde es visible. Resumen:

Dónde se declara Desde dónde se ve Cuánto vive
Campo de instancia Todos los métodos de la clase Lo que viva el objeto
Campo static Todos los métodos, incluidos los static Todo el programa
Parámetro Solo dentro de su método La ejecución del método
Variable local Desde su declaración hasta el } de su bloque La ejecución del bloque

El shadowing ocurre cuando una variable local o un parámetro tiene el mismo nombre que un campo: dentro de ese ámbito, el nombre se refiere a la variable local, y el campo queda "en la sombra".

public class Prestamo {
    public int diasTranscurridos = 20;   // campo

    public void demostrarShadowing(int diasTranscurridos) {   // parametro que ensombrece
        System.out.println(diasTranscurridos);        // el PARAMETRO
        System.out.println(this.diasTranscurridos);   // el CAMPO
    }
}
new Prestamo().demostrarShadowing(5);
// Salida:
// 5
// 20

El shadowing no es un error —de hecho es la práctica habitual en constructores y setters, donde el parámetro se llama igual que el campo a propósito—, pero exige recordar this. al asignar.

  1. Buenas prácticas de diseño de métodos

Estos criterios distinguen un método profesional de uno improvisado:

  • Un método, un propósito. Si al describir lo que hace necesitas la palabra "y", probablemente sean dos métodos.
  • Cortos. No hay una cifra mágica, pero si un método no cabe en la pantalla, cuesta razonar sobre él. Los doscientos de main son el contraejemplo perfecto.
  • Nombres verbales y precisos. calcularMulta, registrarDevolucion, estaDisponible. Evita procesar, gestionar, hacer o datos.
  • Convención de nombres booleanos: esX, estaX, tieneX. if (libro.estaDisponible()) se lee como una frase.
  • Pocos parámetros. Con más de tres o cuatro, plantéate agrupar datos relacionados en un objeto. Es, otra vez, el problema de las variables sueltas.
  • Evita los parámetros booleanos. generarInforme(true) no dice nada al leerlo. ¿Qué es true?
// Mal: ¿que significa ese false?
imprimirRecibo(prestamo, false);

// Mejor: dos metodos con nombre explicito
imprimirReciboCompleto(prestamo);
imprimirReciboResumido(prestamo);
  • No mezcles niveles de abstracción. Un método que calcula reglas de negocio no debería además formatear con printf. Es el tema central de la lección 03-08.
  • Documenta con Javadoc lo que no sea obvio: qué devuelve, qué espera y qué pasa en los casos límite.

  1. Recursión: un método que se llama a sí mismo

Un método puede llamarse a sí mismo. A eso se le llama recursión, y toda recursión correcta tiene dos partes: un caso base que la detiene y un caso recursivo que se acerca al caso base.

/** Suma 1 + 2 + ... + n de forma recursiva. */
public static int sumarHasta(int n) {
    if (n <= 0) {          // caso base: detiene la recursion
        return 0;
    }
    return n + sumarHasta(n - 1);   // caso recursivo
}

Traza de sumarHasta(4):

sumarHasta(4) = 4 + sumarHasta(3)
              = 4 + (3 + sumarHasta(2))
              = 4 + (3 + (2 + sumarHasta(1)))
              = 4 + (3 + (2 + (1 + sumarHasta(0))))
              = 4 + 3 + 2 + 1 + 0 = 10

Cada llamada pendiente ocupa un marco en la pila. Si falta el caso base o nunca se alcanza, la pila se agota y obtienes un StackOverflowError, que reconocerás porque la traza repite la misma línea cientos de veces.

En el día a día, la recursión brilla con estructuras que se anidan por naturaleza —árboles, directorios, expresiones— y para el resto suele ser preferible un bucle, que gasta menos memoria. La verás aplicada en la lección 05-09 (búsqueda binaria) y en el módulo 7 (recorrer directorios).

  1. Refactorización guiada de BiblioTech

Llega el momento de aplicar todo esto al proyecto. Lo haremos en dos pasos, porque así se hace en la vida real: primero extraer, después mudar.

Paso 1: extraer los cálculos de main a métodos static

Este es el código que hoy vive dentro del switch de main:

// --- ANTES: dentro de main, en medio del case "1" ---
int diasRetraso = diasTranscurridos - DIAS_PRESTAMO;
if (diasRetraso < 0) {
    diasRetraso = 0;
}

double multa = diasRetraso * TARIFA_DIARIA;
if (multa >= MULTA_MAXIMA) {
    multa = MULTA_MAXIMA;
}

String gravedad;
if (diasRetraso == 0) {
    gravedad = "SIN RETRASO";
} else if (diasRetraso <= UMBRAL_LEVE) {
    gravedad = "LEVE";
} else {
    gravedad = "GRAVE";
}

Primera mejora: sacarlo a métodos con nombre.

// --- DESPUES (paso 1): metodos static en BiblioTechApp ---

/**
 * Calcula los dias de retraso, saturados a cero.
 * @param diasTranscurridos dias desde que se realizo el prestamo
 * @return 0 si aun esta en plazo; los dias de exceso en caso contrario
 */
private static int calcularDiasRetraso(int diasTranscurridos) {
    return Math.max(0, diasTranscurridos - DIAS_PRESTAMO);
}

/**
 * Calcula la multa correspondiente a un retraso, aplicando el tope legal.
 * @param diasRetraso dias de retraso, nunca negativos
 * @return importe en euros, nunca superior a MULTA_MAXIMA
 */
private static double calcularMulta(int diasRetraso) {
    return Math.min(diasRetraso * TARIFA_DIARIA, MULTA_MAXIMA);
}

/**
 * Clasifica la gravedad de un retraso segun la politica de Nexus Software.
 * @param diasRetraso dias de retraso, nunca negativos
 * @return "SIN RETRASO", "LEVE" o "GRAVE"
 */
private static String clasificarGravedad(int diasRetraso) {
    if (diasRetraso == 0)              return "SIN RETRASO";
    if (diasRetraso <= UMBRAL_LEVE)    return "LEVE";
    return "GRAVE";
}

Y el case "1" queda así:

int    diasRetraso = calcularDiasRetraso(diasTranscurridos);
double multa       = calcularMulta(diasRetraso);
String gravedad    = clasificarGravedad(diasRetraso);

Tres líneas legibles en lugar de veinte. Fíjate en el uso de Math.max y Math.min, que sustituyen los if de saturación: Math.max(0, x) es "nunca por debajo de cero" y Math.min(x, tope) es "nunca por encima del tope". Menos código y menos sitios donde equivocarse.

Paso 2: mudar los métodos a Prestamo

El paso 1 mejora la legibilidad, pero deja las reglas de negocio en la clase de arranque. Y esas reglas no son de la aplicación: son del préstamo. Este es el paso realmente orientado a objetos.

package com.nexussoftware.bibliotech.dominio;

/**
 * Registro de un prestamo de un libro a un empleado de Nexus Software.
 * Conoce sus propios datos y sabe aplicar sobre ellos las reglas de negocio.
 */
public class Prestamo {

    public static final int    DIAS_PRESTAMO = 15;
    public static final double TARIFA_DIARIA = 0.25;
    public static final double MULTA_MAXIMA  = 20.0;
    public static final int    UMBRAL_LEVE   = 7;

    public static int prestamosCreados = 0;

    public Libro  libro;
    public String empleado;
    public int    diasTranscurridos;

    // ---------- Comportamiento de instancia ----------

    /** @return dias de retraso de ESTE prestamo, saturados a cero. */
    public int calcularDiasRetraso() {
        return Math.max(0, diasTranscurridos - DIAS_PRESTAMO);
    }

    /** @return multa de ESTE prestamo en euros, con el tope aplicado. */
    public double calcularMulta() {
        return Math.min(calcularDiasRetraso() * TARIFA_DIARIA, MULTA_MAXIMA);
    }

    /** @return "SIN RETRASO", "LEVE" o "GRAVE" segun el retraso de ESTE prestamo. */
    public String clasificarGravedad() {
        int retraso = calcularDiasRetraso();
        if (retraso == 0)            return "SIN RETRASO";
        if (retraso <= UMBRAL_LEVE)  return "LEVE";
        return "GRAVE";
    }

    /** @return true si el plazo de 15 dias ya se ha superado. */
    public boolean estaVencido() {
        return calcularDiasRetraso() > 0;
    }

    /** @return true si la multa ha alcanzado el tope de MULTA_MAXIMA. */
    public boolean tieneMultaTopada() {
        return calcularMulta() >= MULTA_MAXIMA;
    }

    /** @return dias que faltan para vencer; 0 si ya ha vencido. */
    public int diasRestantes() {
        return Math.max(0, DIAS_PRESTAMO - diasTranscurridos);
    }

    // ---------- Utilidad estatica ----------

    /**
     * Indica si un retraso dado se considera leve, sin necesidad de tener
     * un prestamo construido. Util para tablas y simulaciones.
     */
    public static boolean esRetrasoLeve(int diasRetraso) {
        return diasRetraso > 0 && diasRetraso <= UMBRAL_LEVE;
    }
}

Y ahora, el antes y el después del cálculo en main:

Antes (módulo 2) Después (este módulo)
20 líneas dentro de case "1" prestamo.calcularMulta()
Las reglas se repiten en la escala de multas Existen una sola vez, en Prestamo
Cambiar la tarifa obliga a revisar todo main Se cambia una constante
Los datos van sueltos por parámetros Viajan dentro del objeto
Solo se puede calcular el préstamo "en curso" Se puede calcular cualquier préstamo

Uso desde BiblioTechApp:

Prestamo prestamo = new Prestamo();
prestamo.libro             = refactorizacion;
prestamo.empleado          = "Marta Ruiz";
prestamo.diasTranscurridos = 20;
Prestamo.prestamosCreados++;

System.out.printf("Libro:    %s%n",         prestamo.libro.titulo);
System.out.printf("Empleado: %s%n",         prestamo.empleado);
System.out.printf("Retraso:  %d dias%n",    prestamo.calcularDiasRetraso());
System.out.printf("Multa:    %.2f EUR%n",   prestamo.calcularMulta());
System.out.printf("Gravedad: %s%n",         prestamo.clasificarGravedad());
System.out.printf("Vencido:  %b%n",         prestamo.estaVencido());

Salida:

Libro:    Refactorización
Empleado: Marta Ruiz
Retraso:  5 dias
Multa:    1,25 EUR
Gravedad: LEVE
Vencido:  true

Este es el momento en que BiblioTech se vuelve orientado a objetos de verdad: main ya no calcula nada, solo pregunta. Y observa que calcularMulta() llama internamente a calcularDiasRetraso(): un método puede apoyarse en otro del mismo objeto, evitando duplicar la lógica de saturación.

Añadamos también comportamiento a Libro, que hasta ahora era pura data:

public class Libro {

    public static int librosCreados = 0;

    public String  titulo;
    public String  autor;
    public String  isbn;
    public int     anioPublicacion;
    public boolean disponible;

    /** @return true si el ejemplar puede prestarse ahora mismo. */
    public boolean estaDisponible() {
        return disponible;
    }

    /** Marca el ejemplar como prestado. Avisa si ya lo estaba. */
    public void prestar() {
        if (!disponible) {
            System.out.println("AVISO: '" + titulo + "' ya estaba prestado.");
            return;
        }
        disponible = false;
    }

    /** Marca el ejemplar como devuelto. Avisa si ya estaba en la estanteria. */
    public void devolver() {
        if (disponible) {
            System.out.println("AVISO: '" + titulo + "' ya estaba disponible.");
            return;
        }
        disponible = true;
    }

    /** @return descripcion legible del libro para listados. */
    public String describir() {
        return titulo + " - " + autor + " (" + anioPublicacion + "), ISBN " + isbn;
    }
}

Fíjate en prestar() y devolver(): son la razón de ser del encapsulamiento. Con el campo público, cualquiera puede escribir libro.disponible = true; saltándose el aviso; con estos métodos, la operación pasa por un único sitio que puede validar. La lección 03-07 cerrará esa puerta definitivamente.

Errores Comunes y Consejos

  • Olvidar this en un setter. diasTranscurridos = diasTranscurridos; compila, no hace nada y no avisa. Es de los errores más frustrantes: el IDE lo señala como "asignación a sí mismo", hazle caso.
  • Creer que Java pasa objetos por referencia. No: pasa la referencia por valor. La diferencia se nota exactamente cuando reasignas el parámetro, como en el apartado 6.
  • Intentar usar campos de instancia desde un método static. El compilador dirá "non-static variable ... cannot be referenced from a static context". Pregúntate si el método debería ser de instancia o si le falta recibir el objeto por parámetro.
  • Sobrecargar cambiando solo el tipo de retorno. No compila: la firma no incluye el retorno.
  • Métodos que hacen dos cosas. calcularYGuardarMulta() es una señal de alarma en el propio nombre.
  • Devolver códigos numéricos mágicos. Devolver -1 para indicar "no se pudo calcular" obliga a cada llamador a conocer ese convenio. Mientras no dispongas de excepciones (módulo 6) ni de Optional (10-04), documenta el convenio con Javadoc de forma explícita.
  • Consejo: usa "extraer método" del IDE. IntelliJ (Ctrl+Alt+M) y Eclipse (Alt+Shift+M) hacen la refactorización del paso 1 automáticamente y sin errores: seleccionas el bloque, le das nombre y el IDE deduce los parámetros y el retorno.
  • Consejo: escribe primero la firma y el Javadoc, después el cuerpo. Obliga a decidir qué entra y qué sale antes de perderse en la implementación.

Ejercicios

Ejercicio 1: comportamiento en Empleado

Amplía la clase Empleado de la lección anterior con estos métodos:

  • registrarPrestamo(): incrementa prestamosAcumulados en uno. Si el empleado ya está en el límite MAX_PRESTAMOS_SIMULTANEOS, no lo incrementa y avisa por consola.
  • registrarDevolucion(): decrementa prestamosAcumulados, nunca por debajo de cero.
  • puedeTomarPrestado(): devuelve true si aún no ha alcanzado el máximo.
  • getIniciales(): devuelve las iniciales del empleado ("Marta Ruiz" → "M.R.").
  • Una versión sobrecargada registrarPrestamo(int cantidad) que registre varios de golpe respetando el límite.

Pruébalos con Marta Ruiz hasta que alcance el límite.

Ejercicio 2: demostrar el paso por valor

Escribe una clase DemoPasoPorValor con cuatro métodos static que reciban, respectivamente: un int, un String, un Libro que se muta y un Libro que se reasigna. Cada uno debe imprimir el valor al entrar y al salir. Desde main, llama a los cuatro e imprime el estado antes y después de cada llamada. Después responde por escrito: ¿por qué el caso del String se comporta como el del int y no como el del Libro mutado?

Ejercicio 3: simulador de escala de multas con varargs

Escribe en Prestamo un método static simularEscala(int... diasTranscurridos) que reciba cualquier número de valores de días transcurridos e imprima una tabla con: días transcurridos, días de retraso, multa sin tope, multa aplicada y gravedad. Debe marcar con <-- la primera fila donde la multa alcance el tope. Reutiliza los métodos existentes y no dupliques ninguna regla de negocio. Pruébalo con simularEscala(5, 15, 20, 30, 60, 95, 100).

Soluciones

Solución 1

package com.nexussoftware.bibliotech.dominio;

public class Empleado {

    public static final int MAX_PRESTAMOS_SIMULTANEOS = 3;
    public static int empleadosRegistrados = 0;

    public String nombre;
    public String identificador;
    public int    prestamosAcumulados;

    /** @return true si aun puede llevarse otro libro. */
    public boolean puedeTomarPrestado() {
        return prestamosAcumulados < MAX_PRESTAMOS_SIMULTANEOS;
    }

    /** Registra un prestamo si el limite lo permite. */
    public void registrarPrestamo() {
        if (!puedeTomarPrestado()) {
            System.out.printf("AVISO: %s ya tiene %d prestamos (maximo %d).%n",
                              nombre, prestamosAcumulados, MAX_PRESTAMOS_SIMULTANEOS);
            return;                                  // salida anticipada
        }
        prestamosAcumulados++;
    }

    /**
     * Registra varios prestamos de golpe. Version sobrecargada: misma
     * operacion conceptual con distinta entrada.
     */
    public void registrarPrestamo(int cantidad) {
        for (int i = 0; i < cantidad; i++) {
            registrarPrestamo();       // reutiliza la logica y el limite
        }
    }

    /** Registra una devolucion, sin bajar nunca de cero. */
    public void registrarDevolucion() {
        prestamosAcumulados = Math.max(0, prestamosAcumulados - 1);
    }

    /** @return iniciales del nombre completo, por ejemplo "M.R." */
    public String getIniciales() {
        String[] partes = nombre.trim().split(" ");
        StringBuilder sb = new StringBuilder();       // patron de la leccion 02-02
        for (String parte : partes) {
            if (!parte.isEmpty()) {
                sb.append(parte.charAt(0)).append('.');
            }
        }
        return sb.toString().toUpperCase();
    }
}

Prueba:

Empleado marta = new Empleado();
marta.nombre        = "Marta Ruiz";
marta.identificador = "EMP-001";

System.out.println(marta.getIniciales());          // M.R.
marta.registrarPrestamo(2);                        // 2 prestamos
System.out.println(marta.prestamosAcumulados);     // 2
System.out.println(marta.puedeTomarPrestado());    // true
marta.registrarPrestamo();                         // 3
marta.registrarPrestamo();                         // AVISO: limite alcanzado
System.out.println(marta.prestamosAcumulados);     // 3
marta.registrarDevolucion();
System.out.println(marta.prestamosAcumulados);     // 2

Salida:

M.R.
2
true
AVISO: Marta Ruiz ya tiene 3 prestamos (maximo 3).
3
2

Claves: registrarPrestamo(int) no duplica la comprobación del límite, delega en la versión sin parámetros; y Math.max(0, ...) en la devolución evita un contador negativo sin necesidad de un if.

Solución 2

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.dominio.Libro;

public class DemoPasoPorValor {

    static void conPrimitivo(int dias) {
        System.out.println("   entra: " + dias);
        dias = 999;
        System.out.println("   sale:  " + dias);
    }

    static void conString(String titulo) {
        System.out.println("   entra: " + titulo);
        titulo = titulo.toUpperCase();   // crea OTRA cadena; solo cambia la copia local
        System.out.println("   sale:  " + titulo);
    }

    static void mutandoObjeto(Libro libro) {
        System.out.println("   entra: " + libro.titulo);
        libro.titulo = "MUTADO";         // toca el objeto compartido
        System.out.println("   sale:  " + libro.titulo);
    }

    static void reasignandoObjeto(Libro libro) {
        System.out.println("   entra: " + libro.titulo);
        libro = new Libro();             // mueve SOLO la flecha local
        libro.titulo = "NUEVO OBJETO";
        System.out.println("   sale:  " + libro.titulo);
    }

    public static void main(String[] args) {

        int dias = 20;
        System.out.println("1) Primitivo. Antes: " + dias);
        conPrimitivo(dias);
        System.out.println("   Despues: " + dias);          // 20

        String titulo = "Java Efectivo";
        System.out.println("2) String. Antes: " + titulo);
        conString(titulo);
        System.out.println("   Despues: " + titulo);        // Java Efectivo

        Libro libro = new Libro();
        libro.titulo = "Patrones de Diseño";
        System.out.println("3) Objeto mutado. Antes: " + libro.titulo);
        mutandoObjeto(libro);
        System.out.println("   Despues: " + libro.titulo);  // MUTADO

        Libro otro = new Libro();
        otro.titulo = "Refactorización";
        System.out.println("4) Objeto reasignado. Antes: " + otro.titulo);
        reasignandoObjeto(otro);
        System.out.println("   Despues: " + otro.titulo);   // Refactorización
    }
}

Salida:

1) Primitivo. Antes: 20
   entra: 20
   sale:  999
   Despues: 20
2) String. Antes: Java Efectivo
   entra: Java Efectivo
   sale:  JAVA EFECTIVO
   Despues: Java Efectivo
3) Objeto mutado. Antes: Patrones de Diseño
   entra: Patrones de Diseño
   sale:  MUTADO
   Despues: MUTADO
4) Objeto reasignado. Antes: Refactorización
   entra: Refactorización
   sale:  NUEVO OBJETO
   Despues: Refactorización

Respuesta a la pregunta. El String se comporta como el int porque es inmutable: toUpperCase() no modifica la cadena original, crea una nueva y la devuelve. Al asignarla al parámetro, solo se mueve la flecha local, exactamente igual que en el caso 4. No es que los String se pasen de otro modo —se pasan por valor de la referencia, como cualquier objeto—, es que no existe forma de mutarlos, así que el caso 3 es imposible con String. Esa es una de las grandes ventajas de la inmutabilidad, y la razón por la que en 03-07 verás que Libro es un buen candidato a ser inmutable también.

Solución 3

/**
 * Imprime la escala de multas para una serie de valores de dias transcurridos.
 * @param diasTranscurridos cero o mas valores a simular
 */
public static void simularEscala(int... diasTranscurridos) {

    System.out.println("  ESCALA DE MULTAS DE BIBLIOTECH");
    System.out.println("  ==================================================");
    System.out.printf("  %8s %9s %11s %11s  %-11s%n",
                      "TRANSC.", "RETRASO", "SIN TOPE", "APLICADA", "GRAVEDAD");

    boolean topeYaMarcado = false;          // patron bandera (leccion 02-02)

    for (int dias : diasTranscurridos) {

        // Se reutiliza la MISMA logica del dominio: cero duplicacion de reglas.
        int    retraso  = Math.max(0, dias - DIAS_PRESTAMO);
        double sinTope  = retraso * TARIFA_DIARIA;
        double aplicada = Math.min(sinTope, MULTA_MAXIMA);

        String gravedad;
        if (retraso == 0)                   gravedad = "SIN RETRASO";
        else if (esRetrasoLeve(retraso))    gravedad = "LEVE";
        else                                gravedad = "GRAVE";

        String marca = "";
        if (!topeYaMarcado && aplicada >= MULTA_MAXIMA) {
            marca = "  <--";
            topeYaMarcado = true;           // una vez levantada, no vuelve a bajar
        }

        System.out.printf("  %8d %9d %11.2f %11.2f  %-11s%s%n",
                          dias, retraso, sinTope, aplicada, gravedad, marca);
    }
}

Llamada y salida:

Prestamo.simularEscala(5, 15, 20, 30, 60, 95, 100);
  ESCALA DE MULTAS DE BIBLIOTECH
  ==================================================
   TRANSC.   RETRASO    SIN TOPE    APLICADA  GRAVEDAD
         5         0        0,00        0,00  SIN RETRASO
        15         0        0,00        0,00  SIN RETRASO
        20         5        1,25        1,25  LEVE
        30        15        3,75        3,75  GRAVE
        60        45       11,25       11,25  GRAVE
        95        80       20,00       20,00  GRAVE        <--
       100        85       21,25       20,00  GRAVE

Tres detalles que merecen atención. Primero, el método es static porque simula situaciones hipotéticas: no hay ningún préstamo real detrás, solo números. Segundo, reutiliza esRetrasoLeve(int), también static, sin duplicar el umbral. Y tercero, simularEscala() sin argumentos es una llamada perfectamente válida: imprime solo la cabecera, porque un varargs admite cero elementos.

Un detalle a discutir: este método mezcla cálculo y presentación, justo lo que desaconseja el apartado 12. Es aceptable en una utilidad de diagnóstico como esta, pero recuérdalo: en la lección 03-08 verás por qué en el código de negocio esa mezcla se paga cara.

Conclusión

Tus objetos ya saben hacer cosas. Dominas la anatomía completa de un método —modificadores, tipo de retorno, firma, parámetros, cuerpo— y la diferencia entre parámetro y argumento; sabes cuándo un método devuelve valor y cuándo es void, y por qué conviene que no haga ambas cosas. Has entendido la regla que más confusión genera en Java: todo se pasa por valor, incluidos los objetos, cuya referencia se copia; de ahí que mutar el objeto recibido afecte al llamador y reasignar el parámetro no. Sabes sobrecargar métodos y conoces las reglas con que Java elige la versión correcta, distingues métodos static de métodos de instancia y comprendes por qué un static no puede tocar el estado de un objeto que no tiene. Manejas this, los varargs, el ámbito de las variables y el shadowing, y tienes un catálogo de buenas prácticas para escribir métodos que otros puedan leer. Has visto la recursión con su caso base y su riesgo de StackOverflowError.

Y, sobre todo, has hecho la primera gran refactorización de BiblioTech: los cálculos de retraso, multa y gravedad han salido de main, se han convertido en métodos con nombre y después han mudado a la clase Prestamo, que es su dueña legítima. main ya no calcula: pregunta.

Queda un cabo suelto evidente. Tus objetos siguen naciendo vacíos: new Prestamo() produce un préstamo sin libro, sin empleado y con cero días, y hay que rellenarlo campo a campo confiando en no olvidar ninguno. En la lección siguiente, Constructores, cerrarás esa puerta: aprenderás a exigir los datos imprescindibles en el momento de la creación, a sobrecargar y encadenar constructores, a controlar el orden de inicialización y a validar los datos para que ningún objeto de BiblioTech pueda nacer en un estado inválido.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados