Construcciones Java para aplicaciones del mundo real, Parte 1 – IBM Developer

Construcciones Java para aplicaciones del mundo real, Parte 1

Antes de que empieces

Este tutorial de dos partes pertenece a la serie Introducción a Java™ programación.

Aunque los conceptos discutidos en los tutoriales individuales son independientes por naturaleza, el componente práctico se crea a medida que avanzas en la serie. Te recomiendo revisar los requisitos previos, configuración y detalles de la serie antes de continuar.

Objetivos

El lenguaje Java es lo suficientemente maduro y sofisticado para ayudarte a realizar casi cualquier tarea de programación. Este tutorial te presenta las funciones del lenguaje Java necesarias para manejar entornos de programación complejos, que incluyen:

  • Manejo de excepciones
  • Herencia y abstracción
  • Interfaces
  • Clases anidadas

Requisitos previos

El contenido de este tutorial está dirigido a programadores nuevos en el lenguaje Java que no están familiarizados con sus funciones más sofisticadas. El tutorial asume que tienes:

Próximos pasos con objetos

«Conceptos básicos del lenguaje Java» terminaron con una clase Person que era razonablemente útil, pero no tan útil como podría ser. Aquí, aprendes técnicas para mejorar una clase como Person, comenzando con las siguientes técnicas:

  • Métodos de sobrecarga
  • Métodos de anulación
  • Comparación de un objeto con otro
  • Uso de variables de clase y métodos de clase

Empezarás a mejorar Person sobrecargando uno de tus métodos.

Métodos de sobrecarga

Cuando creas dos métodos con el mismo nombre, pero con diferentes listas de argumentos (es decir, diferentes números o tipos de parámetros), tienes un método sobrecargado. En runtime, el JRE decide qué variación de tu método sobrecargado llamar, en función de los argumentos que le pasaron.

Suponiendo que Person necesita un par de métodos para imprimir una auditoría de su estado actual. Llamo a ambos métodos printAudit(). Pega el método sobrecargado en el Listado 1 en la vista del editor de Eclipse en la clase Person:

Listing 1. printAudit(): Un método sobrecargado

public void printAudit(StringBuilder buffer) {
   buffer.append("Name=");
   buffer.append(getName());
   buffer.append(",");
   buffer.append("Age=");
   buffer.append(getAge());
   buffer.append(",");
   buffer.append("Height=");
   buffer.append(getHeight());
   buffer.append(",");
   buffer.append("Weight=");
   buffer.append(getWeight());
   buffer.append(",");
   buffer.append("EyeColor=");
   buffer.append(getEyeColor());
   buffer.append(",");
   buffer.append("Gender=");
   buffer.append(getGender());
}

public void printAudit(Logger l) {
   StringBuilder sb = new StringBuilder();
   printAudit(sb);
   l.info(sb.toString());
}

Tienes dos versiones sobrecargadas de printAudit() y una incluso usa la otra. Al proporcionar dos versiones, le permites a la persona que llama formatear una auditoría de la clase. Según los parámetros que se pasen, el runtime de Java llama al método correcto.

Recuerda dos reglas importantes cuando utilices métodos sobrecargados:

  • No es posible sobrecargar un método simplemente cambiando su tipo de retorno.
  • No es posible tener dos métodos con el mismo nombre con la misma lista de parámetros.

Si no respetas estas reglas, el compilador marcará un error.

Anulación de métodos

Cuando una subclase proporciona su propia implementación de un método que está definido en una de sus clases principales, se denomina anulación de método. Para ver la utilidad de la anulación de métodos, es necesario trabajar un poco en una clase Employee. Mira el siguiente video para ver cómo configurar la clase Employee y realizar la anulación de métodos en esa clase. Después de verlo, recapitularé esos pasos mientras analizas más de cerca el código. El código está disponible en GitHub como referencia.

Employee: una subclase de Person

Recuerda del tutorial donde aprendiste sobre principios básicos de programación orientada a objetos que una clase Employee podría ser una subclase (o secundario) de Person que tiene atributos adicionales como número de identificación del contribuyente, número de empleado, fecha de contratación y salario.

Para declarar la clase Employee, haz clic con el botón derecho en el paquete com.jstevenperry.intro en Eclipse. Haz clic en Nuevo > Clase… para abrir el cuadro de diálogo Nueva clase Java, que se muestra en la Figura 1.

Figure 1. Cuadro de diálogo Nueva clase Java
Captura de pantalla del cuadro de diálogo Nueva clase Java en el Explorador de proyectos

Ingresa Employee como el nombre de la clase y Person como su superclase. Haz clic en Finalizar y podrás ver el código de la clase Employee en una ventana de edición.

Pasa el cursor del mouse sobre el nombre de la clase Employee en la ventana del editor y observa que Eclipse muestra que la clase Employee tiene un error con este mensaje: El súper constructor implícito Person() no está definido para el constructor predeterminado. Debe definir un constructor explícito.

Para corregir el error, en la clase Person, agrega el siguiente constructor justo antes del constructor que agregaste anteriormente en el tutorial:

public Person() {

}

Este constructor es el constructor predeterminado y el compilador lo genera de forma automática a menos que la superclase de la clase tenga un constructor personalizado (como lo hace Person) y no implementa explícitamente el constructor predeterminado (como no lo hace Person). El constructor predeterminado a menudo se denomina constructor sin argumentos, porque no acepta argumentos.

Cada clase necesita al menos un constructor.

Como Person tiene un constructor personalizado, el compilador no genera el constructor predeterminado, y cuando creaste la subclase Employee de Person, Eclipse no aceptó (con razón) de que Employee no tiene un constructor.

Arreglaste ese error agregando explícitamente el constructor predeterminado (sin argumentos) a Person, que hereda Employee, y el compilador está satisfecho.

Hablaré más sobre constructores y herencia más adelante en el tutorial.

Con la ventana de edición de la clase Employee teniendo el foco, ve a Fuente > Generar constructores desde superclase…. En el cuadro de diálogo Generar constructores desde superclase (ver Figura 2), selecciona ambos constructores y haz clic en Generar.

Figure 2. Cuadro de diálogo Generar constructores desde superclase

Eclipse genera los constructores por ti. Ahora tienes una clase Employee como la del Listado 2.

Listing 2. La clase Employee

package com.jstevenperry.intro;

public class Employee extends Person {

    public Employee() {
        super();
        // TODO Auto-generated constructor stub
    }

    public Employee(String name, int age, int height, int weight, String eyeColor, String gender) {
        super(name, age, height, weight, eyeColor, gender);
        // TODO Auto-generated constructor stub
    }

}

Employee como secundario de Person

Employee hereda los atributos y el comportamiento de su principal, Person. Agrega los siguientes atributos propios de Employee, como se muestra en las líneas 7 a 9 del Listado 3:

  • taxpayerIdNumber de tipo String
  • employeeNumber de tipo String
  • salary de tipo BigDecimal (no olvides agregar la declaración import java.math.BigDecimal)

Listing 3. La clase Employee con sus propios atributos

package com.jstevenperry.intro;

import java.math.BigDecimal;

public class Employee extends Person {

  private String taxpayerIdNumber;
  private String employeeNumber;
  private BigDecimal salary;

  public Employee() {
    super();
  }

  public Employee(String name, int age, int height, int weight, String eyeColor, String gender) {
      super(name, age, height, weight, eyeColor, gender);
      // TODO Auto-generated constructor stub
    }

  public String getTaxpayerIdNumber() {
    return taxpayerIdNumber;
  }

  public void setTaxpayerIdNumber(String taxpayerIdNumber) {
    this.taxpayerIdNumber = taxpayerIdNumber;
  }

  // Other getter/setters...
}

No olvides generar captadores y definidores para los nuevos atributos. Recomiendo usar el generador de código de Eclipse para esto, como lo hiciste con los atributos de Person en la sección «Conceptos básicos del lenguaje Java» > «Tu primera clase de Java«.

Anulación del método printAudit()

Dos o más métodos con el mismo nombre, pero con diferentes firmas, se denominan métodos sobrecargados. Cuando creas una nueva versión de un método en una subclase que está definida en una superclase, se llama anulación de método.

Ahora anularás el método printAudit() (ver Listado 1) que usaste para formatear el estado actual de una instancia de Person. Employee hereda ese comportamiento de Person. Si creas una instancia de Employee, establece sus atributos y llama a cualquiera de las sobrecargas de printAudit(), la llamada es exitosa. Sin embargo, la auditoría que se produce no representa completamente a un Employee. El método printAudit() no puede formatear los atributos específicos de un Employee, porque Person (donde se declara printAudit) no los conoce.

La solución es anular la sobrecarga de printAudit() que toma un StringBuilder como parámetro y agregar código para imprimir los atributos específicos de Employee.

Con Employee abierto en la ventana del editor o seleccionado en la vista Explorador de proyectos, ve a Fuente > Anular/Implementar métodos…. En el cuadro de diálogo Anular/Implementar métodos, que se muestra en la Figura 3, selecciona el método sobrecargado printAudit() que toma un parámetro StringBuilder y haz clic en Aceptar.

Figure 3. Cuadro de diálogo Anular/Implementar métodos
Captura de pantalla del cuadro de diálogo Anular/Implementar métodos

Eclipse genera el código auxiliar del método por ti y es posible completar el resto así:

@Override
public void printAudit(StringBuilder buffer) {
  // Call the superclass version of this method first to get its attribute values
  super.printAudit(buffer);

  // Now format this instance's values
  buffer.append("TaxpayerIdentificationNumber=");
  buffer.append(getTaxpayerIdNumber());
  buffer.append(",");
  buffer.append("EmployeeNumber=");
  buffer.append(getEmployeeNumber());
  buffer.append(",");
  buffer.append("Salary=");
  buffer.append(getSalary().setScale(2).toPlainString());
}

Observa la llamada a super.printAudit(). Lo que estás haciendo aquí es pedirle a la superclase (Person) que muestre su comportamiento para printAudit() y luego lo aumentas con el comportamiento printAudit() del tipo de Employee.

La llamada a super.printAudit() no necesita ser la primera; simplemente parecía una buena idea imprimir esos atributos primero. De hecho, no es necesario llamar a super.printAudit() en absoluto. Si no llamas, es necesario que tú mismo formatees los atributos de Person en el método Employee.printAudit() o no se incluirán en la salida de auditoría.

Comparar objetos

El lenguaje Java proporciona dos formas de comparar objetos:

  • El operador ==
  • El método equals()

Comparando objetos con ==

La sintaxis == compara la igualdad de los objetos de modo que a == b muestra verdadero solo si a yb tienen el mismo valor. Para los objetos, este será el caso si los dos se refieren a la misma instancia de objeto. Para primitivas, si los valores son idénticos.

Suponiendo que generas una prueba JUnit para Employee (que viste cómo hacer en la sección «Tu primera clase Java«, en «Conceptos básicos del lenguaje Java«). La prueba JUnit se muestra en el Listado 4.

Comparar objetos con ==

class EmployeeTest {

    @Test
    void test() {
        int int1 = 1;
        int int2 = 1;
        Logger l = Logger.getLogger(EmployeeTest.class.getName());

        l.info("Q: int1 == int2?           A: " + (int1 == int2));
        Integer integer1 = Integer.valueOf(int1);
        Integer integer2 = Integer.valueOf(int2);
        l.info("Q: Integer1 == Integer2?   A: " + (integer1 == integer2));
        integer1 = Integer.valueOf(int1);
        integer2 = Integer.valueOf(int2);
        l.info("Q: Integer1 == Integer2?   A: " + (integer1 == integer2));
        Employee employee1 = new Employee();
        Employee employee2 = new Employee();
        l.info("Q: Employee1 == Employee2? A: " + (employee1 == employee2));
    }

}

Ejecuta el código del Listado 4 en Eclipse (selecciona Employee en la vista Explorador de proyectos, luego elige Ejecutar como > Prueba JUnit) para generar la siguiente salida:

May 09, 2020 10:38:23 AM com.jstevenperry.intro.EmployeeTest test
INFO: Q: int1 == int2?           A: true
May 09, 2020 10:38:23 AM com.jstevenperry.intro.EmployeeTest test
INFO: Q: Integer1 == Integer2?   A: true
May 09, 2020 10:38:23 AM com.jstevenperry.intro.EmployeeTest test
INFO: Q: Integer1 == Integer2?   A: false
May 09, 2020 10:38:23 AM com.jstevenperry.intro.EmployeeTest test
INFO: Q: Employee1 == Employee2? A: false

En el primer caso en Listado 4, los valores de las primitivas son los mismos, por lo que el operador == muestra true. En el segundo caso, los objetos Integer se refieren a la misma instancia, así que nuevamente == muestra true.

En el tercer caso, aunque los objetos Integer parecen contener el mismo valor, porque los objetos son diferentes, == muestra false ya que los objetos son diferentes. Este comportamiento potencialmente confuso puede ser la razón por la que la creación de un tipo de contenedor de objeto fundamental de Java como Integer pasando un valor a su constructor ha quedado obsoleta desde Java 9.

Piensa en == como una prueba para «la misma instancia de objeto». Teniendo esto en cuenta, obviamente, en el cuarto caso, debido a que Employee1 y Employee2 son objetos diferentes, == muestra false.

Comparar objetos con equals()

equals() es un método que todos los objetos del lenguaje Java obtienen de forma gratuita, porque se define como un método de instancia de java.lang.Object (del cual todos los objetos Java heredan).

Llamas a ‘equals ()’ así:

a.equals(b);

Esta declaración solicita el método equals () del objeto a, pasándole una referencia al objeto b. Por defecto, un programa Java simplemente comprobaría si los dos objetos son iguales utilizando la sintaxis ==. equals() es un método, sin embargo, se puede anular. Compara el caso de prueba JUnit de Listado 4 con el del Listado 5 (que he llamado anotherTest()), que usa equals() para comparar los dos objetos.

Listing 5. Comparar objetos con equals()

@Test
public void anotherTest() {
  Logger l = Logger.getLogger(Employee.class.getName());
  Integer integer1 = Integer.valueOf(1);
  Integer integer2 = Integer.valueOf(1);
  l.info("Q: integer1 == integer2 ? A: " + (integer1 == integer2));
  l.info("Q: integer1.equals(integer2) ? A: " + integer1.equals(integer2));
  integer1 = new Integer(integer1);
  integer2 = new Integer(integer2);
  l.info("Q: integer1 == integer2 ? A: " + (integer1 == integer2));
  l.info("Q: integer1.equals(integer2) ? A: " + integer1.equals(integer2));
  Employee employee1 = new Employee();
  Employee employee2 = new Employee();
  l.info("Q: employee1 == employee2 ? A: " + (employee1 == employee2));
  l.info("Q: employee1.equals(employee2) ? A : " + employee1.equals(employee2));
}

La ejecución del código del Listado 5 produce esta salida:

May 09, 2020 10:43:36 AM com.jstevenperry.intro.EmployeeTest anotherTest
INFO: Q: integer1 == integer2 ? A: true
May 09, 2020 10:43:36 AM com.jstevenperry.intro.EmployeeTest anotherTest
INFO: Q: integer1.equals(integer2) ? A: true
May 09, 2020 10:43:36 AM com.jstevenperry.intro.EmployeeTest anotherTest
INFO: Q: integer1 == integer2 ? A: false
May 09, 2020 10:43:36 AM com.jstevenperry.intro.EmployeeTest anotherTest
INFO: Q: integer1.equals(integer2) ? A: true
May 09, 2020 10:43:36 AM com.jstevenperry.intro.EmployeeTest anotherTest
INFO: Q: employee1 == employee2 ? A: false
May 09, 2020 10:43:36 AM com.jstevenperry.intro.EmployeeTest anotherTest
INFO: Q: employee1.equals(employee2) ? A : false

Una nota sobre la comparación de Integers

En el Listado 5, no debería sorprender que el método equals() de Integer muestra true si == muestra true. Pero observa lo que sucede en el segundo caso, donde creas objetos separados que contienen el valor 1: == muestra false porque integer1 e integer2 se refieren a objetos diferentes; pero equals() muestra true.

Los escritores del JDK decidieron que para Integer, el significado de equals() sería diferente del predeterminado (que, como recuerdas, es comparar las referencias de objetos para ver si se refieren al mismo objeto). Para Integer, equals() muestra true en los casos en los que el valor fundamental (en caja) de int es el mismo.

Para Employee, no anulaste equals(), por lo que el comportamiento predeterminado (de usar ==) muestra lo que esperabas, porque employee1 y employee2 se refieren a objetos diferentes.

Entonces, para cualquier objeto que escribas, es posible definir qué significa equals() según sea apropiado para la aplicación que estás escribiendo.

Anulación de equals()

Es posible definir qué significa equals() para los objetos de tu aplicación anulando el comportamiento predeterminado de Object.equals()— y es posible hacerlo en Eclipse. Con Employee concentrándose en la ventana de origen del IDE, selecciona Fuente > Anular/Implementar métodos para abrir el cuadro de diálogo que se muestra en la Figura 4.

Figure 4. Cuadro de diálogo Anular/Implementar métodos
Captura de pantalla del cuadro de diálogo Anular/Implementar métodos en Eclipse.

Ubica Object en la lista de métodos para anular o implementar, selecciona el método equals(Object) y haz clic en Aceptar. Eclipse genera el código y lo coloca en tu archivo de origen:

    @Override
    public boolean equals(Object obj) {
        // TODO Auto-generated method stub
        return super.equals(obj);
    }

Ejecuta la prueba JUnit nuevamente y observa que la salida no cambia. ¿Qué está pasando?

Bueno, el comportamiento predeterminado de equals() es (esencialmente) comparar instancias de objetos, por lo que simplemente anular equals() para usar la sobrecarga de Object de equals() no es lo que queremos y tenemos que pensar en lo que significa que dos objetos Employee sean «iguales».

En general, tiene sentido que los dos objetos Employee sean «iguales» si los estados (es decir, sus valores de atributo) de esos objetos son iguales.

¿Cómo se ve un método equals() que refleja esta nueva definición de «iguales»?

Generación automática de equals()

Eclipse puede generar un método equals() por ti, basado en las variables de instancia (atributos) que definas para una clase.

Employee es una subclase de Person, así que primero genera equals() para Person, luego genera equals() para Employee.

En la vista del Explorador de proyectos de Eclipse, haz clic con el botón derecho en Person y elige Generar hashCode() y equals(). En el cuadro de diálogo que se abre (ver Figura 5), haz clic en Seleccionar todo para incluir todos los atributos en los métodos hashCode() y equals(), y haz clic en Generar.

Figure 5. Cuadro de diálogo Generar hashCode() y equals()
Captura de pantalla del cuadro de diálogo para generación automática de equals()

Eclipse genera un método equals() que se parece al del Listado 6.

Listing 6. Un método equals() generado por Eclipse

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (!super.equals(obj))
            return false;
        if (getClass() != obj.getClass())
            return false;
        Employee other = (Employee) obj;
        if (employeeNumber == null) {
            if (other.employeeNumber != null)
                return false;
        } else if (!employeeNumber.equals(other.employeeNumber))
            return false;
        if (salary == null) {
            if (other.salary != null)
                return false;
        } else if (!salary.equals(other.salary))
            return false;
        if (taxpayerIdNumber == null) {
            if (other.taxpayerIdNumber != null)
                return false;
        } else if (!taxpayerIdNumber.equals(other.taxpayerIdNumber))
            return false;
        return true;
    }

El método equals() generado por Eclipse parece complicado, pero lo que hace es simple: Si el objeto pasado es el mismo objeto que el del Listado 6, equals() muestra true. Si el objeto pasado es nulo (lo que significa que falta), muestra false.

A continuación, el método comprueba si los objetos Class son iguales (lo que significa que el objeto pasado debe ser un objeto Person). Si son iguales, se comprueba cada valor de atributo del objeto pasado para ver si coincide valor por valor con el estado de la instancia de Person dada. Si los valores de los atributos son nulos, equals() verifica tantos como puede, y si coinciden, los objetos se consideran iguales.

Ejercicios

Ahora, trabaja con un par de ejercicios guiados para hacer aún más con Person y Employee en Eclipse.

Ejercicio 1: Genera un equals() para Employee

Sigue los pasos de «Generación automática deequals()» para generar un equals() para Employee. Una vez que hayas generado tu equals(), agrega el siguiente caso de prueba JUnit (que he llamado yetAnotherTest()):

@Test
public void yetAnotherTest() {
  Logger l = Logger.getLogger(Employee.class.getName());
  Employee employee1 = new Employee();
  employee1.setName("J Smith");
  Employee employee2 = new Employee();
  employee2.setName("J Smith");
  l.info("Q: employee1 == employee2?      A: " + (employee1 == employee2));
  l.info("Q: employee1.equals(employee2)? A: " + employee1.equals(employee2));    
}

Si ejecutas el código, deberías ver el siguiente resultado:

May 09, 2020 11:21:11 AM com.jstevenperry.intro.EmployeeTest yetAnotherTest
INFO: Q: employee1 == employee2?      A: false
May 09, 2020 11:21:12 AM com.jstevenperry.intro.EmployeeTest yetAnotherTest
INFO: Q: employee1.equals(employee2)? A: true

Como era de esperarse, dado que employee1 y employee2 se refieren a objetos diferentes, == muestra false. En la segunda prueba, una coincidencia en Name solo fue suficiente para convencer a equals ()de que los dos objetos son iguales. Intenta agregar más atributos a este ejemplo y ve lo que sucede.

Ejercicio 2: Anular toString()

¿Recuerdas el método printAudit() del principio de esta sección? Si pensabas que estaba trabajando bastante, tenías razón. Formatear el estado de un objeto en un String es un patrón tan común que los diseñadores del lenguaje Java lo incorporaron en el propio Object, en un método llamado (no es sorprendente) toString(). La implementación predeterminada de toString() no es realmente útil, pero cada objeto tiene uno. En este ejercicio, anulas toString() para hacerlo un poco más útil.

Si piensas que Eclipse puede generar un método toString() por ti, tienes razón. Vuelve a tu Explorador de proyectos y haz clic con el botón derecho en la clase Person, luego elige Fuente > Generar toString()…. En el cuadro de diálogo, selecciona todos los atributos y haz clic en Aceptar. Ahora haz lo mismo con Employee. El código generado por Eclipse para Employee se muestra en el Listado 7.

Listing 7. Un método toString() generado por Eclipse

    @Override
    public String toString() {
        return "Employee [taxpayerIdNumber=" + taxpayerIdNumber + ", employeeNumber=" + employeeNumber + ", salary="
                + salary + "]";
    }

El código que genera Eclipse para toString no incluye el toString()de la superclase (la superclase de Employee es Person). Es posible solucionar esa situación de forma rápida, utilizando Eclipse, con esta anulación:

@Override
public String toString() {
  return super.toString() + "Employee [taxpayerIdentificationNumber=" + taxpayerIdentificationNumber +
    ", employeeNumber=" + employeeNumber + ", salary=" + salary + "]";
}

La adición de toString() hace que printAudit() sea mucho más simple:

@Override
  public void printAudit(StringBuilder buffer) {
  buffer.append(toString());
}

toString() ahora hace el trabajo pesado de formatear el estado actual del objeto, y tú simplemente completas lo que muestra en el StringBuilder y vuelve.

Recomiendo siempre implementar toString() en tus clases, aunque solo sea con fines de soporte. Es virtualmente inevitable que en algún momento, querrás ver cuál es el estado de un objeto mientras tu aplicación se está ejecutando y toString() es un gran enganche para hacer eso.

Miembros de clase

Cada instancia de objeto tiene variables y métodos, y para cada uno, el comportamiento exacto es diferente, porque se basa en el estado de la instancia del objeto. Las variables y métodos que tienes en Person y Employee son variables y métodos instancia. Para usarlos, es necesario crear una instancia de la clase que necesitas o tener una referencia a la instancia.

Las clases también pueden tener variables y métodos clase — conocidos como miembros de clase. Declaras variables de clase con la palabra clave static. Las diferencias entre las variables de clase y las variables de instancia son:

  • Cada instancia de una clase comparte una sola copia de una variable de clase.
  • Es posible llamar a métodos de clase en la propia clase, sin tener una instancia.
  • Los métodos de clase solo pueden acceder a variables de clase.
  • Los métodos de instancia pueden acceder a variables de clase, pero los métodos de clase no pueden acceder a variables de instancia.

¿Cuándo tiene sentido agregar variables y métodos de clase? La mejor regla general es hacerlo con poca frecuencia, para no abusar de ellos. Dicho esto, es una buena idea usar variables y métodos de clase:

  • Para declarar constantes que cualquier instancia de la clase puede usar (y cuyo valor se fija en tiempo de desarrollo)
  • En una clase con métodos de utilidad que nunca necesitan una instancia de clase (como Logger.getLogger())

Variables de clase

Para crear una variable de clase, usa la palabra clave static cuando la declares:

accessSpecifier static variableName [= initialValue];

Nota: Los corchetes aquí indican que su contenido es opcional. Los corchetes no forman parte de la sintaxis de declaración.

El JRE crea espacio en la memoria para almacenar cada una de las variables instancia de una clase para cada instancia de esa clase. Por el contrario, el JRE crea una única copia de cada variable clase, independientemente del número de instancias. Lo hace la primera vez que se carga la clase (es decir, la primera vez que encuentra la clase en un programa). Todas las instancias de la clase comparten esa única copia de la variable. Eso hace que las variables de clase sean una buena opción para las constantes que todas las instancias podrían usar.

Por ejemplo, declaraste que el atributo Gender de Person es una String, pero no le impusiste ninguna restricción. El Listado 8 muestra un uso común de variables de clase.

Listing 8. Uso de variables de clase


public class Person {
  //. . .
  public static final String GENDER_MALE = "MALE";
  public static final String GENDER_FEMALE = "FEMALE";
  public static final String GENDER_PREFER_NOT_TO_SAY = "PNTS";

  // . . .
  public static void main(String[] args) {
  Person p = new Person("Joe Q Author", 42, 173, 82, "Brown", GENDER_MALE);
    // . . .
  }
  //. . .
}

Declaración de constantes

Normalmente, las constantes son:

  • Nombradas en mayúsculas
  • Nombradas como varias palabras, separadas por guiones bajos
  • Declaradas final (para que sus valores no se puedan modificar)
  • Declaradas con un especificador de acceso public (para que otras clases que necesitan hacer referencia a sus valores por nombre puedan acceder a ellos)

En el Listado 8, para usar la constante para MALE en la llamada al constructor Person, simplemente es necesario hacer referencia a su nombre. Para usar una constante fuera de la clase, es necesario anteponerla con el nombre de la clase donde fue declarada:

String genderValue = Person.GENDER_MALE;

Métodos de clase

Si lo has estado siguiendo desde «Conceptos básicos del lenguaje Java,» ya has llamado al método Logger.getLogger() estático varias veces — cada vez que recuperaste una instancia de Logger para escribir la salida en la consola. Sin embargo, recuerda que para hacerlo no era necesario una instancia de Logger. En su lugar, hiciste referencia a la clase Logger, que es la sintaxis para realizar una llamada método de clase. Al igual que con las variables de clase, la palabra clave static identifica a Logger (en este ejemplo) como un método de clase. Los métodos de clase a veces también se denominan métodos estáticos por este motivo.

Ahora es posible combinar lo que aprendiste sobre métodos y variables estáticas para crear un método estático en Employee. Declaras una variable private static final para contener un Logger, que todas las instancias comparten, y al que se puede acceder llamando a getLogger() en la clase Employee. El Listado 9 muestra cómo.

Listing 9. Crear un método de clase (o estático)

public class Employee extends Person {
  private static final Logger logger = Logger.getLogger(Employee.class.getName());

  //. . .
  public static Logger getLogger() {
    return logger;
  }

}

En el Listado 9 hay dos cosas importantes:

  • La instancia de Logger se declara con acceso private, por lo que ninguna clase fuera de Employee puede acceder a la referencia directamente.
  • El Logger se inicializa cuando se carga la clase — porque usas la sintaxis del inicializador de Java para darle un valor.

Para recuperar el objeto Logger de la clase Employee, realiza la siguiente llamada:

Logger employeeLogger = Employee.getLogger();

Excepciones

Ningún programa funciona el 100 por ciento del tiempo, y los diseñadores del lenguaje Java lo sabían. En esta sección, aprende sobre los mecanismos integrados de la plataforma Java para manejar situaciones en las que tu código no funciona exactamente como estaba planeado.

Conceptos básicos sobre el manejo de excepciones

Una excepción es un evento que ocurre durante la ejecución del programa que interrumpe el flujo normal de las instrucciones del programa. El manejo de excepciones es una técnica esencial de la programación Java. Tú empaquetas tu código en un bloque try (que significa «prueba esto y avísame si causa una excepción») y lo usas para catch varios tipos de excepciones.

Para comenzar con el manejo de excepciones, echa un vistazo al código del Listado 10.

Listing 10. ¿Ves el error?

@Test
public void yetAnotherTest() {
  Logger l = Logger.getLogger(Employee.class.getName());
//    Employee employee1 = new Employee();
  Employee employee1 = null;
  employee1.setName("J Smith");
  Employee employee2 = new Employee();
  employee2.setName("J Smith");
  l.info("Q: employee1 == employee2?      A: " + (employee1 == employee2));
  l.info("Q: employee1.equals(employee2)? A: " + employee1.equals(employee2));
}

Observa que la referencia Employee se establece en null.

Si ejecutas este código, la prueba falla inmediatamente con una NullPointerException, que indica que estás intentando hacer referencia a un objeto a través de una referencia null (puntero), que es un error de desarrollo bastante grave. (Probablemente hayas notado que Eclipse te advierte del posible error con el mensaje: Acceso de puntero nulo: la variable employee1 solo puede ser nula en esta ubicación. Eclipse te advierte sobre muchos errores de desarrollo potenciales — otra ventaja más de usar un IDE para el desarrollo de Java.)

Afortunadamente, es posible usar los bloques try y catch (junto con un poco de ayuda de finally) para detectar el error.

Uso de try, catch y finally

El Listado 11 muestra el código defectuoso del Listado 10 limpiado con los bloques de código estándar para el manejo de excepciones: try, catch y finally.

Listing 11. Capturar una excepción

@Test
public void yetAnotherTest() {
  Logger l = Logger.getLogger(Employee.class.getName());

  //    Employee employee1 = new Employee();
  try {
    Employee employee1 = null;
    employee1.setName("J Smith");
    Employee employee2 = new Employee();
    employee2.setName("J Smith");
    l.info("Q: employee1 == employee2?      A: " + (employee1 == employee2));
    l.info("Q: employee1.equals(employee2)? A: " + employee1.equals(employee2));
  } catch (Exception e) {
    l.severe("Caught exception: " + e.getMessage());
  } finally {
    // Always executes
  }
}

Juntos, los bloques try, catch y finally forman una red para capturar excepciones. Primero, la afirmación try empaqueta el código que podría generar una excepción. En ese caso, la ejecución cae inmediatamente al bloque catch, o manejo de excepciones. Cuando se hace todo el intentar y capturar, la ejecución continúa hasta el bloque finally, haya ocurrido una excepción o no. Cuando capturas una excepción, es posible intentar recuperarse sin problemas o es posible salir del programa (o método).

En el Listado 11, el programa se recupera del error y luego imprime el mensaje de la excepción:

May 09, 2020 11:33:44 AM com.jstevenperry.intro.EmployeeTest yetAnotherTest
SEVERE: Caught exception: null

La jerarquía de excepciones

El lenguaje Java incorpora una jerarquía de excepciones completa que consta de muchos tipos de excepciones agrupadas en dos categorías principales:

  • Las excepciones marcadas son verificadas por el compilador (lo que significa que el compilador se asegura de que se manejen en algún lugar de tu código). En general, estas son subclases directas de java.lang.Exception.
  • Las excepciones no comprobadas (también llamadas excepciones en runtime) no son comprobadas por el compilador. Estas son subclases de java.lang.RuntimeException.

Cuando un programa causa una excepción, se dice que echa la excepción. Una excepción marcada es declarada al compilador por cualquier método con la palabra clave throws en su firma de método. A continuación, se muestra una lista de excepciones separadas por comas que el método puede generar durante su ejecución. Si tu código llama a un método que especifica que echa uno o más tipos de excepciones, es necesario manejarlo de alguna manera, o agregar un throws a la firma de tu método para pasar ese tipo de excepción.

Cuando ocurre una excepción, el runtime de Java busca un gestor de excepciones en algún lugar de la pila. Si no encuentra uno cuando llega a la parte superior de la pila, detiene el programa bruscamente, como viste en el Listado 10.

Múltiples bloques de captura

Es posible tener varios bloques catch, pero deben estar estructurados de una manera particular. Si las excepciones son subclases de otras excepciones, las clases secundarias se colocan por delante de las clases principales en el orden de los bloques catch. El Listado 12 muestra un ejemplo de diferentes tipos de excepciones estructuradas en su secuencia jerárquica correcta.

Listing 12. Ejemplo de jerarquía de excepciones

@Test
public void exceptionTest() {
  Logger l = Logger.getLogger(Employee.class.getName());
  File file = new File("file.txt");
  BufferedReader bufferedReader = null;
  try {
    bufferedReader = new BufferedReader(new FileReader(file));
    String line = bufferedReader.readLine();
    while (line != null) {
      // Read the file
    }
  } catch (FileNotFoundException e) {
    l.severe(e.getMessage());
  } catch (IOException e) {
    l.severe(e.getMessage());
  } catch (Exception e) {
    l.severe(e.getMessage());
  } finally {
    // Close the reader
  }
}

En este ejemplo, FileNotFoundException es una clase secundaria de IOException, por lo que debe colocarse delante del bloque IOException catch. Y IOException es una clase secundaria de Exception, por lo que debe colocarse antes del bloque Exception catch.

Bloques de intenta con recursos

El código en Listado 12 debe declarar una variable para contener la referencia bufferedReader, y luego en el finally debe cerrar el BufferedReader.

La sintaxis alternativa más compacta (disponible desde JDK 7) cierra automáticamente los recursos cuando el bloque try está fuera de alcance. El Listado 13 muestra esta sintaxis más nueva.

Listing 13. Sintaxis de gestión de recursos

@Test
public void exceptionTestTryWithResources() {
  Logger l = Logger.getLogger(Employee.class.getName());
  File file = new File("file.txt");
    try (BufferedReader bufferedReader = new BufferedReader(new FileReader(file))) {
      String line = bufferedReader.readLine();
      while (line != null) {
// Read the file
      }
    } catch (Exception e) {
      l.severe(e.getMessage());
    }
}

Básicamente, asignas variables de recursos después de try entre paréntesis, y cuando el bloque try está fuera de alcance, esos recursos se cierran automáticamente. Los recursos deben implementar la interfaz java.lang.AutoCloseable; si intentas utilizar esta sintaxis en una clase de recurso que no lo hace, Eclipse te envía una advertencia.

Creación de aplicaciones Java

En esta sección, continuarás creando Person como una aplicación Java. Sobre la marcha, es posible tener una mejor idea de cómo un objeto, o una colección de objetos, evoluciona hacia una aplicación.

El punto de entrada de la aplicación

Todas las aplicaciones Java necesitan un punto de entrada donde el runtime de Java sepa comenzar a ejecutar código. Ese punto de entrada es el método main(). Objetos de dominio — es decir, objetos (Person y Employee, por ejemplo) que forman parte del dominio empresarial de tu aplicación — normalmente no tienen métodos main(), pero al menos una clase en cada aplicación debería.

Como sabes, Person y su subclase Employee son conceptualmente parte de una aplicación de recursos humanos. Ahora agregarás una nueva clase a la aplicación para darle un punto de entrada.

Creación de una clase de controlador

El propósito de una clase de controlador (como su nombre lo indica) es «impulsar» una aplicación. Observa que este sencillo controlador para una aplicación de recursos humanos contiene un método main():

package com.jstevenperry.intro;
public class HumanResourcesApplication {
  public static void main(String[] args) {
  }
}

Ahora, crea una clase de controlador en Eclipse usando el mismo procedimiento que usaste para crear Person y Employee. Nombra la clase HumanResourcesApplication, asegurándote de seleccionar la opción para agregar un método main() a la clase. Eclipse generará la clase por ti.

Luego, agrega algún código a tu nuevo método main() para que se vea así:

package com.jstevenperry.intro;
import java.util.logging.Logger;

public class HumanResourcesApplication {
  private static final Logger log = Logger.getLogger(HumanResourcesApplication.class.getName());
  public static void main(String[] args) {
    Employee e = new Employee();
    e.setName("J Smith");
    e.setEmployeeNumber("0001");
    e.setTaxpayerIdNumber("123‑45‑6789");
    e.setSalary(BigDecimal.valueOf(45000.0));
    e.printAudit(log);
  }
}

Finalmente, inicia la clase HumanResourcesApplication y observa cómo se ejecuta. Deberías ver este resultado:

May 10, 2020 9:42:25 PM com.jstevenperry.intro.Person printAudit
INFO: Name=J Smith,Age=0,Height=0,Weight=0,EyeColor=null,Gender=nullTaxpayerIdentificationNumber=123‑45‑6789,EmployeeNumber=0001,Salary=45000.00

Eso es todo lo que hay que hacer para crear una aplicación Java sencilla. En la siguiente sección, verás algunas de las sintaxis y bibliotecas que pueden ayudarte a desarrollar aplicaciones más complejas.

Herencia

Ya has encontrado algunos ejemplos de herencia en esta serie. Esta sección explica con más detalle cómo funciona la herencia — incluyendo la jerarquía de herencia, constructores y herencia, así como la abstracción de la herencia.

Cómo funciona la herencia

Las clases en código Java existen en jerarquías. Las clases por encima de una clase determinada en una jerarquía son superclases de esa clase. Esa clase en particular es una subclase de cada clase superior en la jerarquía. Una subclase hereda de sus superclases. La clase java.lang.Object está en la parte superior de la jerarquía de clases — así que cada clase de Java es una subclase de, y hereda de, Object.

Por ejemplo, suponiendo que tienes una clase Person que se parece a la del Listado 14.

Listing 14. Clase de persona pública

public class Person {

  public static final String STATE_DELIMITER = "~";

  public Person() {
    // Default constructor
  }

  public enum Gender {
    MALE,
    FEMALE,
    PREFER_NOT_TO_SAY
  }

  public Person(String name, int age, int height, int weight, String eyeColor, Gender gender) {
    this.name = name;
    this.age = age;
    this.height = height;
    this.weight = weight;
    this.eyeColor = eyeColor;
    this.gender = gender;
  }


  private String name;
  private int age;
  private int height;
  private int weight;
  private String eyeColor;
  private Gender gender;

La clase Person del Listado 14 hereda implícitamente de Object. Como se asume que cada clase hereda de Object, no es necesario escribir extends Object para cada clase que definas. Pero ¿qué significa decir que una clase hereda de su superclase? Simplemente significa que Person tiene acceso a las variables y métodos expuestos en sus superclases. En este caso, Person puede ver y usar los métodos y variables públicos y protegidos de Object.

Definición de una jerarquía de clases

Ahora suponiendo que tienes una clase Employee que hereda de Person. La definición de clase de Employee se vería así:

public class Employee extends Person {

  private String taxpayerIdentificationNumber;
  private String employeeNumber;
  private BigDecimal salary;
  // . . .
}

La relación de herencia de Employee con todas sus superclases (su gráfico de herencia) implica que Employee tiene acceso a todas las variables y métodos públicos y protegidos en Person (porque Employee abarca directamente Person), así como en Object (porque Employee en realidad también abarca Object, aunque indirectamente). Sin embargo, debido a que Employee y Person están en el mismo paquete, Employee también tiene acceso a las variables y métodos de paquete privado (a veces llamado amistoso) en Person.

Para profundizar un paso más en la jerarquía de clases, es posible crear una tercera clase que abarque Employee:

public class Manager extends Employee {
  // . . .
}

En el lenguaje Java, una clase puede tener como máximo una superclase directa, pero una clase puede tener cualquier número de subclases. Eso es lo más importante que se debe recordar sobre la jerarquía de herencia en el lenguaje Java.

Herencia individual comparada con múltiple

Los lenguajes como C++ admiten el concepto de herencia múltiple: en cualquier punto de la jerarquía, una clase puede heredar directamente de una o más clases. El lenguaje Java solo admite herencia individual — lo que significa que solo es posible usar la palabra clave extends con una sola clase. Así que la jerarquía de clases para cualquier clase de Java siempre consiste en una línea recta hasta llegar a java.lang.Object. Sin embargo, como aprenderás en la siguiente sección principal, «Interfaces«, el lenguaje Java admite la implementación de múltiples interfaces en una sola clase, lo que te brinda una solución alternativa a una sola herencia.

Constructores y herencia

Los constructores no son miembros totalmente orientados a objetos, por lo que no se heredan; en su lugar, es necesario implementarlos explícitamente en subclases. Antes de entrar en ese tema, revisaré algunas reglas básicas sobre cómo se definen y solicitan los constructores.

Conceptos básicos del constructor

Recuerda que un constructor siempre tiene el mismo nombre que la clase que se usa para construir y no tiene tipo de retorno. Por ejemplo:

public class Person {
  public Person() {
  }
}

Recuerda que mencioné que cada clase tiene al menos un constructor, y si no defines de manera explícita un constructor para tu clase, el compilador genera uno en tu lugar, llamado constructor predeterminado. La definición de clase anterior y esta son idénticas en cómo funcionan:

public class Person {
}

Solicitar un constructor de superclase

Para solicitar un constructor de superclase que no sea el constructor predeterminado, es necesario hacerlo de manera explícita. Por ejemplo, suponiendo que Person tiene un constructor que solo toma el nombre del objeto Person que se está creando. Desde el constructor predeterminado de Employee, es posible solicitar el constructor Person que se muestra en el Listado 15.

Listing 15. Inicialización de un nuevo Employee

public class Person {
  private String name;
  public Person() {
  }
  public Person(String name) {
    this.name = name;
  }
}

// Meanwhile, in Employee.java
public class Employee extends Person {
  public Employee() {
    super("Elmer J Fudd");
  }
}

Probablemente, nunca querrás inicializar un nuevo objeto Employee de esta manera. Hasta que te sientas más cómodo con los conceptos orientados a objetos y la sintaxis de Java en general, es una buena idea implementar constructores de superclase en subclases solo si estás seguro de que los necesitará. El Listado 16 define un constructor en Employee que se parece al de Person para que coincidan. Este enfoque es mucho menos confuso desde el punto de vista del mantenimiento.

Listing 16. Solicitud de una superclase


public class Person {
  private String name;
  public Person(String name) {
    this.name = name;
  }
}
// Meanwhile, in Employee.java
public class Employee extends Person {
  public Employee(String name) {
    super(name);
  }
}

Declaración de un constructor

Lo primero que hace un constructor es solicitar el constructor predeterminado de su superclase inmediata, a menos que — en la primera línea de código en el — del constructor; recurre un constructor diferente. Por ejemplo, las dos declaraciones siguientes son funcionalmente idénticas:

public class Person {
  public Person() {
  }
}
// Meanwhile, in Employee.java
public class Employee extends Person {
  public Employee() {
  }
}
public class Person {
  public Person() {
  }
}
// Meanwhile, in Employee.java
public class Employee extends Person {
  public Employee() {
  super();
  }
}

Constructores sin argumentos

Si ofreces un constructor alternativo, es necesario proporcionar explícitamente el constructor predeterminado; de lo contrario, no estará disponible. Por ejemplo, el siguiente código te dará un error de compilación:

public class Person {
  private String name;
  public Person(String name) {
    this.name = name;
  }
}
// Meanwhile, in Employee.java
public class Employee extends Person {

  public Employee() {
  }
}

La clase Person en este ejemplo no tiene un constructor predeterminado, porque proporciona un constructor alternativo sin incluir explícitamente el constructor predeterminado.

Cómo los constructores solicitan constructores

Un constructor puede solicitar otro constructor en la misma clase a través de la palabra clave this, junto con una lista de argumentos. Como super(), la llamada a this() debe ser la primera línea en el constructor, como en este ejemplo:

public class Person {
  private String name;
  public Person() {
    this("Some reasonable default?");
  }
  public Person(String name) {
    this.name = name;
  }

}

Ves esta expresión con frecuencia. Un constructor delega en otro, pasando un valor predeterminado si se solicita ese constructor. Esta técnica también es una excelente manera de agregar un nuevo constructor a una clase mientras minimiza el impacto en el código que ya usa un constructor más antiguo.

Niveles de acceso de constructor

Los constructores pueden tener cualquier nivel de acceso que quieras y se aplican algunas reglas de visibilidad. La Tabla 1 resume las reglas de acceso al constructor.

Table 1. Reglas de acceso del constructor
Modificador de acceso del constructor Descripción
público Cualquier clase puede solicitar el constructor.
protegido El constructor puede ser solicitado por una clase en el mismo paquete o cualquier subclase.
Sin modificador ( package-private) El constructor puede ser solicitado por cualquier clase en el mismo paquete.
privado El constructor solo puede ser solicitado por la clase en la que está definido el constructor.

Puede que pienses en casos de uso en los que los constructores se declararían protected o incluso como paquetes privados, pero ¿cómo es útil un constructor private? He usado constructores privados cuando no quería permitir la creación directa de un objeto a través de la palabra clave new al implementar, digamos, el Patrón de fábrica. En ese caso, usaría un método estático para crear instancias de la clase, y ese método — estar incluido en la clase — se le permitiría solicitar el constructor privado.

Herencia y abstracción

Si una subclase anula un método de una superclase, el método está básicamente oculto porque llamarlo a través de una referencia a la subclase solicita la versión del método de la subclase, no la versión de la superclase. Sin embargo, el método de superclase sigue siendo accesible. La subclase puede solicitar el método de superclase anteponiendo el nombre del método con la palabra clave super (y a diferencia de las reglas del constructor, esto se puede hacer desde cualquier línea en el método de subclase o incluso en un método completamente diferente). De forma predeterminada, un programa Java llama al método de subclase si se solicita a través de una referencia a la subclase.

Esta capacidad también se aplica a las variables, siempre que la persona que llama tenga acceso a la variable (es decir, la variable sea visible para el código que intenta acceder a ella). Este detalle puede causarte un sinfín de problemas a medida que adquieres competencia en la programación Java. Eclipse proporciona amplias advertencias — por ejemplo, que estás ocultando una variable de una superclase, o que una llamada a un método no llamará cuando crees que lo hará.

En un contexto de programación orientada a objetos, abstracción se refiere a la generalización de datos y comportamiento a un tipo más alto en la jerarquía de herencia que la clase actual. Cuando mueves variables o métodos de una subclase a una superclase, se dice que estás abstrayendo esos miembros. La razón principal para abstraer es reutilizar código común impulsándolo lo más alto posible en la jerarquía. Tener un código común en un solo lugar facilita el mantenimiento.

Clases y métodos abstractos

A veces, quieres crear clases que solo sirvan como abstracciones y que no necesariamente necesitan instanciarse. Estas clases se denominan clases abstractas. De la misma manera, a veces, ciertos métodos deben implementarse de manera diferente para cada subclase que implementa la superclase. Estos métodos son métodos abstractos. Aquí hay algunas reglas básicas para clases y métodos abstractos:

  • Cualquier clase puede declararse abstract.
  • No se pueden crear instancias de clases abstractas.
  • Un método abstracto no puede contener un cuerpo de método.
  • Cualquier clase con un método abstracto debe declararse abstract.

Uso de abstracción

Suponiendo que no quieres permitir que la clase Employee sea instanciada directamente. Simplemente lo declaras usando la palabra clave abstract y listo:

public abstract class Employee extends Person {
  // etc.
}

Si intentas ejecutar este código, aparecerá un error de compilación:

public void someMethodSomwhere() {
  Employee p = new Employee();// compile error!!
}

El compilador muestra que Employee es abstracto y no se puede crear una instancia.

El poder de la abstracción

Suponiendo que necesitas un método para revisar el estado de un objeto Employee y estar seguro de que sea válido. Esta necesidad parecería ser común a todos los objetos de Employee, pero tendría potencial cero de reutilización porque se comportaría de manera diferente entre todas las subclases potenciales. En ese caso, declaras el método validate() abstract (obligando a todas las subclases a implementarlo):

public abstract class Employee extends Person {
  public abstract boolean validate();
}

Cada subclase directa de Employee (como Manager) ahora es necesaria para implementar el método validate(). Sin embargo, una vez que una subclase implementa el método validate(), ninguna de sus subclases necesita implementarlo.

Por ejemplo, suponiendo que tienes un objeto Executive que abarca Manager. Esta definición sería válida:

public class Executive extends Manager {
  public Executive() {
  }
}

Cuándo (no) abstraer: dos reglas

Como primera regla general, no abstraigas en tu diseño inicial. El uso de clases abstractas al inicio del diseño te obliga a tomar un camino que puede limitar tu aplicación. Siempre es posible refactorizar el comportamiento común (que es el objetivo de tener clases abstractas) más arriba en el gráfico de herencia — y casi siempre es mejor refactorizar después de saber que es necesario. Eclipse tiene un excelente soporte para la refactorización.

En segundo lugar, por más eficientes que sean las clases abstractas, evita su uso. A menos que tus superclases contengan muchos comportamientos comunes y no sean significativas por sí mismas, deja que continúen no abstractas. Los gráficos de herencia profunda pueden dificultar el mantenimiento del código. Considera la compensación entre clases que son código demasiado grande y fácil de mantener.

Tareas: Clases

Es posible asignar una referencia de una clase a una variable de un tipo que pertenezca a otra clase, pero se aplican algunas reglas. Mira este ejemplo:

Manager m = new Manager();
Employee e = new Employee();
Person p = m; // okay
p = e; // still okay
Employee e2 = e; // yep, okay
e = m; // still okay
e2 = p; // wrong!

La variable de destino debe ser de un supertipo de la clase que pertenece a la referencia de origen o, de lo contrario, el compilador mostrará un error. Lo que esté en el lado derecho de la tarea debe ser una subclase o la misma clase que el elemento de la izquierda. En otras palabras: una subclase tiene un propósito más específico que su superclase, así que piensa en una subclase como más limitada que su superclase. Y una superclase, siendo más general, es más extensa que su subclase. La regla es que no es posible hacer una tarea que limite la referencia.

Ahora mira este ejemplo:

Manager m = new Manager();
Manager m2 = new Manager();
m = m2; // Not narrower, so okay
Person p = m; // Widens, so okay
Employee e = m; // Also widens
Employee e = p; // Narrows, so not okay!

Aunque un Employee es una Person, definitivamente no es un Manager y el compilador hace cumplir esta distinción.

Interfaces

En esta sección, aprenderás sobre las interfaces y comenzarás a usarlas en tu código Java.

Mira el siguiente video para entender sobre interfaces (0:16), cómo definir e implementar una interfaz (1:08) y cómo usar los métodos de interfaz predeterminados (6:45). Después de verlos, recapitularé los pasos mientras observas más de cerca el código. El código está disponible en GitHub como referencia.

Interfaces: ¿Para qué sirven?

Como sabes de la sección anterior, los métodos abstractos, por diseño, especifican un contrato& mdash; mediante el nombre del método, parámetros y tipo de retorno — pero no ofrecen ningún código reutilizable. Métodos abstractos — definidos en clases abstractas — son útiles cuando es posible que la forma en que se implementa el comportamiento cambie comparado con la forma en que se implementa en una subclase de la clase abstracta a otra.

Cuando veas un conjunto de comportamientos comunes en tu aplicación (piensa en java.util.List) que se pueden agrupar y nombrar, pero para los cuales existen dos o más implementaciones, podrías considerar definir ese comportamiento con una interface — y es por eso que el lenguaje Java proporciona esta característica. Sin embargo, esta característica bastante avanzada se abusa, ofusca y distorsiona fácilmente en las formas más terribles (como he presenciado personalmente), así que usa las interfaces con cuidado.

Puede ser útil pensar en las interfaces de esta manera: son como clases abstractas que contienen solo métodos abstractos; definen solo el contrato, pero nada de la implementación.

Definición de una interfaz

La sintaxis para definir una interfaz es sencilla:

public interface InterfaceName {
    returnType methodName(argumentList);
  }

Una declaración de interfaz se ve como una declaración de clase, excepto que usa la palabra clave interface. Es posible nombrar la interfaz como quieras (sujeto a las reglas del idioma), pero por convención, los nombres de las interfaces parecen nombres de clases.

Los métodos definidos en una interfaz no tienen cuerpo de método. El implementador de la interfaz es responsable de proveer el cuerpo del método (como con los métodos abstractos).

Tú defines jerarquías de interfaces, como lo haces para las clases, excepto que una sola clase puede implementar la cantidad de interfaces que quieras. Recuerda que una clase solo puede abarcar una clase. Si una clase abarca otra e implementa una interfaz o interfaces, enumera las interfaces después de la clase abarcada, así:

public class Manager extends Employee implements BonusEligible, StockOptionEligible {
  // And so on
}

Una interfaz no necesita tener ningún cuerpo. La siguiente definición, por ejemplo, es perfectamente aceptable:

public interface BonusEligible {
}

En términos generales, dichas interfaces se denominan interfaces de marcador, porque marcan una clase como implementadora de esa interfaz, pero no ofrecen ningún comportamiento (es decir, ningún método).

Una vez que sepas todo eso, definir realmente una interfaz es fácil:

public interface StockOptionEligible {
  void processStockOptions(int numberOfOptions, BigDecimal price);
}

Implementación de interfaces

Para definir una interfaz en tu clase, es necesario implementar la interfaz, lo que significa que provees un cuerpo de método que proporciona el comportamiento para cumplir con el contrato de la interfaz. Usa la palabra clave implements para implementar una interfaz:

public class ClassName extends SuperclassName implements InterfaceName {
  // Class Body
}

Suponiendo que implementas la interfaz StockOptionEligible en la clase Manager, como se muestra en el Listado 17:

Listing 17. Implementación de una interfaz

public class Manager extends Employee implements StockOptionEligible, BonusEligible {

    private static Logger log = Logger.getLogger(Manager.class.getName());


    public Manager() {
        super();
    }

    @Override
    public void processStockOptions(int numberOfOptions, BigDecimal price) {
        log.info("I can't believe I got " + numberOfOptions + " options at $" + price.toPlainString() + " each!");
    }
}

Al implementar la interfaz, proporcionas el comportamiento del método o métodos en la interfaz. Es necesario implementar los métodos con firmas que coincidan con las de la interfaz, con la adición del modificador de acceso public. Recuerda que no necesitas proporcionar una implementación para la interfaz del marcador BonusEligible porque no tiene métodos declarados.

Una clase abstracta puede declarar que implementa una interfaz en particular, pero no es necesario que implementes todos los métodos en esa interfaz. No se requiere que las clases abstractas proporcionen implementaciones para todos los métodos que aseguran implementar. Sin embargo, la primera clase concreta (es decir, la primera clase no abstracta en la jerarquía) debe implementar todos los métodos que las clases superiores a ella en la jerarquía de clases no implementan.

Nota: Las subclases de una clase concreta que implementa una interfaz no necesitan proporcionar su propia implementación de esa interfaz.

Generación de interfaces en Eclipse

Eclipse puede generar fácilmente la firma de método correcta si decides que una de tus clases debe implementar una interfaz. Simplemente cambia la firma de clase para implementar la interfaz. Eclipse pone una línea ondulada roja debajo de la clase, marcándola como un error porque la clase no proporciona los métodos en la interfaz. Haz clic en el nombre de la clase, presiona Ctrl + 1 y Eclipse te sugerirá «soluciones rápidas». Entre estas, elige Agregar métodos no implementados, y Eclipse genera los métodos por ti, colocándolos en la parte inferior del archivo fuente.

Uso de interfaces

Una interfaz define un nuevo tipo de datos de referencia, que es posible usar para hacer referencia a una interfaz en cualquier lugar donde te refieras a una clase. Esta habilidad incluye cuando declaras una variable de referencia, o lanzas de un tipo a otro, como se muestra en el Listado 18.

Listing 18. Asignación de una nueva instancia de Manager a una referencia de StockOptionEligible

package com.jstevenperry.intro;
import java.math.BigDecimal;
import org.junit.Test;
public class ManagerTest {
  @Test
  public void testCalculateAndAwardStockOptions() {
    StockOptionEligible soe = new Manager();// perfectly valid
    calculateAndAwardStockOptions(soe);
    calculateAndAwardStockOptions(new Manager());// works too
  }

  private static void calculateAndAwardStockOptions(StockOptionEligible soe) {
    BigDecimal reallyCheapPrice = BigDecimal.valueOf(0.01);
    int numberOfOptions = 10000;
    soe.awardStockOptions(numberOfOptions, reallyCheapPrice);
  }
}

Como es posible ver, es válido asignar una nueva instancia de Manager a una referencia de StockOptionEligible y pasar una nueva instancia de Manager a un método que espera una referencia de StockOptionEligible.

Tareas: Interfaces

Es posible asignar una referencia de una clase que implementa una interfaz a una variable de un tipo de interfaz, pero se aplican algunas reglas. Desde el Listado 18, es posible ver que la asignación de una instancia de Manager a una referencia variable de StockOptionEligible es válida. La razón es que la clase Manager implementa esa interfaz. Sin embargo, la siguiente asignación no sería válida:

Manager m = new Manager();
  StockOptionEligible soe = m; //okay
  Employee e = soe; // Wrong!

Debido a que Employee es un supertipo de Manager, este código puede parecer correcto al principio, pero no lo es. ¿Por qué no? Porque Manager implementa la interfaz StockOptionEligible, mientras que Employee no.

Las asignaciones como estas siguen las reglas de asignación que viste en la sección «Herencia«. Y al igual que con las clases, solo es posible asignar una referencia de interfaz a una variable del mismo tipo o un tipo de superinterfaz.

Métodos predeterminados

Recuerda la interfaz BonusEligible de antes. Era una interfaz de marcador y, por eso, no tenía métodos declarados. Suponiendo que escribes una aplicación donde Manager implementa la interfaz BonusEligible libera ese código y se despliega a producción. ¡Excelente!

Ahora, suponiendo que recibes un nuevo requisito de que todas las implementaciones de la interfaz BonusEligible deben proporcionar una forma de calcular la bonificación. Esto significa que el contrato BonusEligible tendrá que cambiar, y el cambio será agregar un método llamado getSalary() para recuperar el salario del objeto de implementación, y otro método llamado calculateBonus() a la interfaz calcula y devuelve el monto de la bonificación como un valor BigDecimal:

public interface BonusEligible {
  BigDecimal getSalary();
  BigDecimal calculateBonus();
}

Aprendiste anteriormente en la unidad que se debe implementar una interfaz, por lo que si agregas un método a la interfaz BonusEligible, el compilador del lenguaje Java requerirá que todas las implementaciones (como Manager) deben implementar el nuevo método para el código a compilar.

En los ejemplos simples de esta unidad, no es difícil. Simplemente cambia las implementaciones, recompila el código y libera el cambio a producción. Pero en la práctica, una interfaz como BonusEligible puede ser utilizada por docenas de otras clases, y el impacto de cambiar la interfaz puede tener un enorme despliegue que puede afectar a los equipos de desarrollo de toda tu organización.

Afortunadamente, a través de métodos de interfaz predeterminados, el lenguaje Java proporciona un mecanismo para abordar la situación en la que es necesario cambiar una interfaz, pero de una manera compatible con versiones anteriores.

Agrega la palabra clave default al método calculateBonus(), junto con el cuerpo del método (la implementación predeterminada) y no se requieren interfaces para cambiar. Las implementaciones existentes de BonusEligible se compilarán bien, y pueden anular el método predeterminado calulateBonus() solo si la implementación predeterminada no es adecuada según el caso:

public interface BonusEligible {
  BigDecimal getSalary();

  default BigDecimal calculateBonus() {
    // Default bonus is 10% of salary
    return getSalary().multiply(BigDecimal.valueOf(0.1));
  }
}

Las implementaciones existentes de BonusEligible como Manager no tendrán que cambiar y, por defecto, recibirán una bonificación del diez por ciento de su salario anual.

Los métodos predeterminados son un tema bastante avanzado que implica sutiles matices del uso de interfaces Java, pero quería presentártelos para que sepas que existen.

Clases anidadas

En esta sección, aprenderás sobre clases anidadas, dónde y cómo usarlas.

Dónde usar clases anidadas

Como sugiere su nombre, una clase anidada (o clase interior) es una clase definida dentro de otra clase:

public class EnclosingClass {
  . . .
  public class NestedClass {
  . . .

  }
}

Al igual que los métodos y las variables miembro, las clases Java también se pueden definir en cualquier ámbito, incluido public, private o protected. Las clases anidadas pueden ser útiles cuando quieres manejar el procesamiento interno dentro de tu clase de una manera orientada a objetos, pero limita la funcionalidad a la clase donde la necesitas.

Normalmente, usas una clase anidada cuando necesitas una clase que esté estrechamente junto con la clase en la que está definida. Una clase anidada tiene acceso a los datos privados dentro de su clase adjunta, pero esta estructura tiene efectos secundarios que no son obvios cuando comienzas a trabajar con clases anidadas.

Alcance en clases anidadas

Como una clase anidada tiene alcance, está sujeta a las reglas de alcance. Por ejemplo, solo se puede acceder a una variable miembro a través de una instancia de la clase (un objeto). Lo mismo ocurre con una clase anidada.

Suponiendo que tienes la siguiente relación entre un Manager y una clase anidada llamada DirectReports, que es una colección de los Employees que reportan a ese Manager:

public class Manager extends Employee {
  private DirectReports directReports;
  public Manager() {
    this.directReports = new DirectReports();
  }
  . . .
  private class DirectReports {
  . . .
  }
}

Así como cada objeto Manager representa un ser humano único, el objeto DirectReports representa una colección de personas reales (colaboradores) que reportan a un gerente. Los DirectReports difieren de un Manager a otro. En este caso, tiene sentido que solo haga referencia a la clase anidada DirectReports en el contexto de su instancia adjunta de Manager, por lo que la he hecho private.

Clases públicas anidadas

Debido a que es private, solo Manager puede crear una instancia de DirectReports. Pero suponiendo que quieres darle a una entidad externa la capacidad de crear instancias de DirectReports. En este caso, parece que podrías darle alcance public a la clase DirectReports, y luego cualquier código externo puede crear instancias de DirectReports, como se muestra en el Listado 19.

Listing 19. Creación de instancias de DirectReports: primer intento

public class Manager extends Employee {
  public Manager() {
  }
  . . .
  public class DirectReports {
  . . .
  }
}
//
public static void main(String[] args) {
  Manager.DirectReports dr = new Manager.DirectReports();// This won't work!
}

El código del Listado 19 no funciona y probablemente quieras saber por qué. El problema (y también su solución) está en la forma en que se define DirectReports en Manager y con las reglas de alcance.

Las reglas del alcance, revisadas

Si tuvieras una variable miembro de Manager, esperarías que el compilador exija tener una referencia a un objeto Manager antes de poder hacer referencia a él, ¿verdad? Bueno, lo mismo se aplica a DirectReports, al menos como se define en el Listado 19.

Para crear una instancia de una clase pública anidada, usa una versión especial del operador new. Combinado con una referencia a una instancia adjunta de una clase externa, new te ofrece una forma de crear una instancia de la clase anidada:

public class Manager extends Employee {
  public Manager() {
  }
  . . .
  public class DirectReports {
  . . .
  }
  }
// Meanwhile, in another method somewhere...
public static void main(String[] args) {
  Manager manager = new Manager();
  Manager.DirectReports dr = manager.new DirectReports();
}

Observa en la línea 12 que la sintaxis requiere una referencia a la instancia adjunta, más un punto y la palabra clave new, seguida de la clase que quieres crear.

Clases internas estáticas

A veces, quieres crear una clase que esté estrechamente acoplada (conceptualmente) a una clase, pero donde las reglas de alcance son algo relajadas y no requieren una referencia a una instancia adjunta. Ahí es donde entran en juego las clases internas estáticas. Un ejemplo común es implementar un Comparator, que se usa para comparar dos instancias de la misma clase, generalmente con el propósito de ordenar (o clasificar) las clases:

public class Manager extends Employee {
  . . .
  public static class ManagerComparator implements Comparator<Manager> {
  . . .
  }
  }
// Meanwhile, in another method somewhere...
public static void main(String[] args) {
  Manager.ManagerComparator mc = new Manager.ManagerComparator();
  . . .
}

En este caso, no es necesario una instancia adjunta. Las clases internas estáticas actúan como sus contrapartes regulares de la clase Java, y debes usarlas solo cuando necesites acoplar muy bien una clase con su definición. Claramente, en el caso de una clase de utilidad como ManagerComparator, la creación de una clase externa es innecesaria y potencialmente satura tu base de código. Definir estas clases como clases internas estáticas es el camino a seguir.

Clases internas anónimas

Con el lenguaje Java, es posible implementar clases e interfaces abstractas prácticamente en cualquier lugar, incluso en medio de un método si es necesario, o sin ponerle un nombre a la clase. Esta capacidad es básicamente un truco del compilador, pero hay ocasiones en las que es útil tener clases internas anónimas.

El Listado 20 crea el Listado 17, agregando un método predeterminado para manejar los tipos de Employee que no son StockOptionEligible. La lista comienza con un método en HumanResourcesApplication para procesar las opciones sobre acciones, seguido de una prueba JUnit para impulsar el método.

Listing 20. Manejo de tipos de Employee que no son StockOptionEligible

// From HumanResourcesApplication.java
    public void handleStockOptions(final Person person, StockOptionProcessingCallback callback) {
        if (person instanceof StockOptionEligible) {
            // Eligible Person, invoke the callback straight up
            callback.process((StockOptionEligible)person);
        } else if (person instanceof Employee) {
            // Not eligible, but still an Employee. Let's create an
            /// anonymous inner class implementation for this
            callback.process(new StockOptionEligible() {
                @Override
                public void processStockOptions(int number, BigDecimal price) {
                // This employee is not eligible
                log.warning("It would be nice to award " + number + " of shares at $" +
                    price.setScale(2, RoundingMode.HALF_UP).toPlainString() +
                    ", but unfortunately, Employee " + person.getName() +
                    " is not eligible for Stock Options!");
                }
            });
        } else {
            callback.process(new StockOptionEligible() {
                @Override
                public void processStockOptions(int number, BigDecimal price) {
                log.severe("Cannot consider awarding " + number + " of shares at $" +
                    price.setScale(2, RoundingMode.HALF_UP).toPlainString() +
                    ", because " + person.getName() +
                    " does not even work here!");
                }
            });
        }
    }
// JUnit test to drive it (in HumanResourcesApplicationTest.java):
    @Test
    public void testHandleStockOptions() {

        HumanResourcesApplication classUnderTest = new HumanResourcesApplication();

        List<Person> people = HumanResourcesApplication.createPeople();

        StockOptionProcessingCallback callback = new StockOptionProcessingCallback() {
            @Override
            public void process(StockOptionEligible stockOptionEligible) {
                BigDecimal reallyCheapPrice = BigDecimal.valueOf(0.01);
                int numberOfOptions = 10000;
                stockOptionEligible.processStockOptions(numberOfOptions, reallyCheapPrice);
            }
        };
        for (Person person : people) {
            classUnderTest.handleStockOptions(person, callback);
        }
    }

En el ejemplo del Listado 20, proporciono implementaciones de dos interfaces que usan clases internas anónimas. Primero hay dos implementaciones separadas de StockOptionEligible — una para Employee y otra para Person (para obedecer la interfaz). Luego viene una implementación de StockOptionProcessingCallback que se usa para manejar el procesamiento de opciones sobre acciones para las instancias de Manager.

Puede llevar tiempo comprender el concepto de clases internas anónimas, pero son muy útiles. Las uso todo el tiempo en mi código Java. Y a medida que avanzas como desarrollador de Java, creo que también las usarás.

Conclusión

En este tutorial, aprendiste sobre algunas de las construcciones más avanzadas de la programación Java, aunque la discusión general aún tiene un alcance introductorio. Los temas de programación de Java vistos en este tutorial incluyen:

  • Manejo de excepciones
  • Herencia y abstracción
  • Interfaces
  • Clases anidadas

Lo que sigue

En el siguiente tutorial, continúa aprendiendo sobre algunas construcciones avanzadas adicionales de la programación Java, que incluyen:

  • Expresiones regulares
  • Genéricos
  • Tipos de enumeración
  • E/S
  • Serialización