Buenas prácticas de «Effective Java (3rd Edition)»

Extracto de «Effective Java (3rd Edition) », de Joshua Bloch

1. Devuelve colecciones o arrays vacíos, no null

Si el método devuelve un Set, no hagas return null; para un resultado vacío; usa return Set.of(); en su lugar.

2. Considera métodos factoría estáticos en lugar de constructores

public static Boolean valueOf(boolean b) {
    return b ? Boolean.TRUE : Boolean.FALSE;
}

No siempre son mejores, pero en algunos casos de uso ofrecen numerosas ventajas:

Desventajas:

3. Considera un builder cuando haya muchos parámetros en el constructor

Las factorías estáticas y los constructores comparten una limitación: no escalan bien cuando hay muchos parámetros opcionales. Esto ha golpeado muy fuerte a algunos proyectos en los que he trabajado.

4. Garantiza la propiedad singleton con un constructor privado o un tipo enum

Todo lo que no necesite varias instancias debería instanciarse una sola vez, ya sea con @ApplicationScoped o con otro patrón singleton.

5. Impide la instanciación con un constructor privado

De vez en cuando querrás escribir una clase que no sea más que una agrupación de métodos y campos estáticos. Estas clases se han ganado mala fama porque hay quien abusa de ellas para no tener que pensar en términos de objetos, pero tienen usos legítimos.

private MyStaticClass() {
    // private constructor to prevent instantiation
}

6. Evita crear objetos innecesarios

String s = new String("bikini");  // DON'T DO THIS!

O bien:

// Hideously slow! Can you spot the object creation?
private static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++)
        sum += i;
    return sum;
}

Solución: Este programa da la respuesta correcta, pero es mucho más lento de lo que debería por culpa de una errata de un solo carácter. La variable sum está declarada como Long en lugar de long, lo que significa que el programa construye unas 2^31 instancias de Long innecesarias (aproximadamente una cada vez que se suma el long i al Long sum).

7. Evita los finalizadores y los cleaners

Los finalizadores son impredecibles, a menudo peligrosos y, en general, innecesarios. Su uso puede provocar un comportamiento errático, un mal rendimiento y problemas de portabilidad.

8. Prefiere try-with-resources a try-finally

// try-with-resources - the best way to close resources!
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    return br.readLine();
}

9. Considera implementar Comparable

Al implementar Comparable, permites que tu clase interactúe con los numerosos algoritmos genéricos e implementaciones de colecciones que dependen de esta interfaz.

10. Minimiza la accesibilidad de clases y miembros

El factor más importante que distingue un componente bien diseñado de uno mal diseñado es el grado en que el componente oculta sus datos internos y otros detalles de implementación a los demás componentes. Un componente bien diseñado oculta todos sus detalles de implementación y separa limpiamente su API de su implementación. Así, los componentes se comunican solo a través de sus API y desconocen el funcionamiento interno de los demás. Este concepto, conocido como ocultación de información o encapsulamiento, es un principio fundamental del diseño de software.

11. En las clases públicas, usa métodos de acceso, no campos públicos

De vez en cuando, puede que te tiente escribir clases degeneradas que no sirven para nada más que para agrupar campos de instancia:

// Degenerate classes like this should not be public!
class Point {
    public double x;
    public double y;
}

Como a los campos de datos de estas clases se accede directamente, no ofrecen las ventajas del encapsulamiento. No puedes cambiar la representación sin cambiar la API, no puedes imponer invariantes y no puedes realizar acciones adicionales cuando se accede a un campo. Los programadores orientados a objetos más estrictos consideran que estas clases son un anatema y que siempre deberían sustituirse por clases con campos privados y métodos de acceso públicos (getters) y, en el caso de las clases mutables, mutadores (setters):

// Encapsulation of data by accessor methods and mutators
class Point {

    private double x;
    private double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double getX() { return x; }
    public double getY() { return y; }

    public void setX(double x) { this.x = x; }
    public void setY(double y) { this.y = y; }
}

Sin duda, los más estrictos tienen razón en lo que respecta a las clases públicas: si una clase es accesible fuera de su paquete, ofrece métodos de acceso para conservar la flexibilidad de cambiar su representación interna. Si una clase pública expone sus campos de datos, se pierde toda esperanza de cambiar su representación, porque el código cliente puede estar repartido por todas partes.

Sin embargo, si una clase es package-private o es una clase anidada privada, no hay nada intrínsecamente malo en exponer sus campos de datos, siempre que describan adecuadamente la abstracción que ofrece la clase.

12. Minimiza la mutabilidad

Una clase inmutable es simplemente una clase cuyas instancias no se pueden modificar. Toda la información que contiene cada instancia es fija durante toda la vida del objeto, de modo que nunca se puede observar ningún cambio. Las librerías de la plataforma Java contienen muchas clases inmutables, entre ellas String, las clases de primitivos encapsulados (boxed), BigInteger y BigDecimal. Hay muchas buenas razones para ello: las clases inmutables son más fáciles de diseñar, implementar y usar que las mutables. Son menos propensas a errores y más seguras. Para hacer que una clase sea inmutable, sigue estas cinco reglas:

  1. No ofrezcas métodos que modifiquen el estado del objeto (los llamados mutadores).
  2. Asegúrate de que la clase no se pueda extender. Así se evita que subclases descuidadas o maliciosas comprometan el comportamiento inmutable de la clase actuando como si el estado del objeto hubiera cambiado. Normalmente, la herencia se impide declarando la clase final, por ejemplo.
  3. Declara todos los campos como final. Así expresas claramente tu intención de una forma que el propio sistema hace cumplir. Además, es necesario para garantizar un comportamiento correcto si una referencia a una instancia recién creada pasa de un hilo a otro sin sincronización, tal como establece el modelo de memoria.
  4. Declara todos los campos como privados. Así se impide que los clientes obtengan acceso a objetos mutables referenciados por los campos y los modifiquen directamente. Aunque técnicamente está permitido que las clases inmutables tengan campos public final que contengan valores primitivos o referencias a objetos inmutables, no se recomienda, porque impide cambiar la representación interna en una versión posterior.
  5. Garantiza el acceso exclusivo a cualquier componente mutable. Si tu clase tiene campos que hacen referencia a objetos mutables, asegúrate de que los clientes de la clase no puedan obtener referencias a esos objetos. Nunca inicialices un campo así con una referencia a un objeto proporcionada por el cliente ni devuelvas el campo desde un método de acceso. Haz copias defensivas en los constructores, en los métodos de acceso y en los métodos readObject.

13. Prefiere la composición a la herencia

La herencia es una forma potente de reutilizar código, pero no siempre es la mejor herramienta para el trabajo. Usada de forma inadecuada, da lugar a software frágil. Es seguro usar la herencia dentro de un paquete, donde las implementaciones de la subclase y de la superclase están bajo el control de los mismos programadores. También es seguro usar la herencia al extender clases diseñadas y documentadas específicamente para ser extendidas. En cambio, heredar de clases concretas normales más allá de los límites del paquete es peligroso.

A diferencia de la invocación de métodos, la herencia viola el encapsulamiento. Podrías pensar que extender una clase es seguro si te limitas a añadir métodos nuevos y no sobrescribes los existentes. Aunque este tipo de extensión es mucho más segura, no está exenta de riesgos. Si la superclase incorpora un método nuevo en una versión posterior y has tenido la mala suerte de darle a la subclase un método con la misma firma y un tipo de retorno distinto, tu subclase dejará de compilar. Si le has dado a la subclase un método con la misma firma y el mismo tipo de retorno que el nuevo método de la superclase, ahora lo estás sobrescribiendo, así que te expones a los problemas descritos antes. Además, es dudoso que tu método cumpla el contrato del nuevo método de la superclase, porque ese contrato aún no se había escrito cuando escribiste el método de la subclase.

Por suerte, hay una forma de evitar todos los problemas descritos. En lugar de extender una clase existente, dale a tu nueva clase un campo privado que haga referencia a una instancia de la clase existente. Este diseño se llama composición, porque la clase existente pasa a ser un componente de la nueva. Cada método de instancia de la nueva clase invoca el método correspondiente de la instancia contenida de la clase existente y devuelve los resultados. Esto se conoce como reenvío (forwarding), y los métodos de la nueva clase se llaman métodos de reenvío. La clase resultante será sólida como una roca, sin dependencias de los detalles de implementación de la clase existente. Ni siquiera añadir métodos nuevos a la clase existente afectará a la nueva clase.

14. Prefiere las interfaces a las clases abstractas

Java tiene dos mecanismos para definir un tipo que admite varias implementaciones: las interfaces y las clases abstractas. Desde que se introdujeron los métodos por defecto en las interfaces en Java 8, ambos mecanismos permiten proporcionar implementaciones para algunos métodos de instancia.

Una diferencia importante es que, para implementar el tipo definido por una clase abstracta, una clase tiene que ser subclase de esa clase abstracta. Como Java solo permite herencia simple, esta restricción de las clases abstractas limita mucho su uso como definiciones de tipos. Cualquier clase que defina todos los métodos necesarios y cumpla el contrato general puede implementar una interfaz, independientemente de dónde se encuentre en la jerarquía de clases. Las clases existentes se pueden adaptar fácilmente para que implementen una interfaz nueva.

15. Genéricos: no uses tipos raw

No descubres el error hasta el tiempo de ejecución, mucho después de que se haya producido, y en un código que puede estar lejos del que contiene el error. Cuando ves la ClassCastException, tienes que rebuscar en todo el código fuente la invocación del método que metió la moneda en la colección de sellos. El compilador no puede ayudarte.

16. Genéricos: elimina los avisos unchecked

Cuando programas con genéricos, verás muchos avisos del compilador:

Cuanta más experiencia adquieras con los genéricos, menos avisos recibirás, pero no esperes que el código recién escrito compile limpiamente. Muchos avisos unchecked son fáciles de eliminar. Por ejemplo, supón que escribes por descuido esta declaración:

Set<Lark> exaltation = new HashSet();

Cámbiala por:

Set<Lark> exaltation = new HashSet<>();

Algunos avisos serán mucho más difíciles de eliminar. Cuando te encuentres con avisos que requieran pensar un poco, ¡no te rindas! Elimina todos los avisos unchecked que puedas.

Si eliminas todos los avisos, tienes la garantía de que tu código es seguro en cuanto a tipos, lo cual es muy bueno. Significa que no te encontrarás una ClassCastException en tiempo de ejecución y aumenta tu confianza en que tu programa se comportará como pretendías. Si no puedes eliminar un aviso, pero puedes demostrar que el código que lo provoca es seguro en cuanto a tipos, entonces (y solo entonces) suprime el aviso con una anotación @SuppressWarnings("unchecked"). Si suprimes avisos sin demostrar antes que el código es seguro en cuanto a tipos, te estás dando una falsa sensación de seguridad. Puede que el código compile sin emitir ningún aviso, pero aun así puede lanzar una ClassCastException en tiempo de ejecución.

Cada vez que uses una anotación @SuppressWarnings("unchecked"), añade un comentario que explique por qué es seguro hacerlo.

17. Genéricos: cuándo tienen sentido

Ay. Una clase que pide genéricos a gritos:

Ejemplo de una clase que necesita genéricos

¡Algo así debería detectarse, como muy tarde, en la merge request!

18. Genéricos: cómo no hacerlo

Lo siento, sobran las palabras. Por favor, NUNCA hagas merge de esto:

public abstract class JpaLearningCardBaseContentService<LC extends LearningCardBaseContent<L, LC, AC>, J extends JpaLearningCardBaseContent<L, LC, AC>, L extends
        LearningCardBase<LC, A>, A extends AnswerOptionBase<L, AC>, AC extends AnswerOptionBaseContent<LC, AC, A>, AS extends AnswerOptionBaseService<A, AC, L, LC>, ACS extends
        AnswerOptionBaseContentService<AC, A, L, LC>, LS extends LearningCardBaseService<L>>
    extends JpaLightPersistenceService<LC, J, Long> implements LearningCardBaseContentService<LC, L, A, AC> {
    // ...
}

Entender una simple declaración de clase nunca debería llevar 5 minutos.

19. Prefiere las listas a los arrays

Los arrays se diferencian de los tipos genéricos en dos aspectos importantes. En primer lugar, los arrays son covariantes. Esta palabra que suena tan aterradora solo significa que, si Sub es un subtipo de Super, el tipo array Sub[] es un subtipo del tipo array Super[]. Los genéricos, en cambio, son invariantes: para dos tipos distintos cualesquiera Type1 y Type2, List<Type1> no es ni subtipo ni supertipo de List<Type2>.

20. Prefiere las lambdas a las clases anónimas

// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

Haz esto en su lugar:

// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

21. Minimiza el ámbito de las variables locales

La técnica más eficaz para minimizar el ámbito de una variable local es declararla donde se usa por primera vez...

22. Prefiere los bucles for-each a los bucles for tradicionales

// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
    ... // Do something with e
}

23. Conoce y usa las librerías

Supón que quieres generar enteros aleatorios entre cero y un límite superior. Ante esta tarea tan habitual, muchos programadores escribirían un pequeño método parecido a este:

// Common but deeply flawed!
static Random rnd = new Random();

static int random(int n) {
    return Math.abs(rnd.nextInt()) % n;
}

Puede que este método parezca correcto, pero tiene tres fallos. El primero es que, si n es una potencia de dos pequeña, la secuencia de números aleatorios se repetirá tras un periodo bastante corto. El segundo fallo es que, si n no es una potencia de dos, algunos números se devolverán, de media, con más frecuencia que otros. Si n es grande, este efecto puede ser bastante pronunciado.

El tercer fallo del método random es que, en raras ocasiones, puede fallar de forma catastrófica y devolver un número fuera del rango especificado. Esto se debe a que el método intenta convertir el valor devuelto por rnd.nextInt() en un int no negativo llamando a Math.abs. Si nextInt() devuelve Integer.MIN_VALUE, Math.abs también devolverá Integer.MIN_VALUE, y el operador de resto (%) devolverá un número negativo, suponiendo que n no sea una potencia de dos. Esto hará casi con toda seguridad que tu programa falle, y puede que el fallo sea difícil de reproducir.

24. Evita float y double si necesitas resultados exactos

Usa BigDecimal para importes monetarios. float y double producen errores de redondeo.

Por ejemplo, supón que tienes 1,03 $ en el bolsillo y te gastas 42 ¢. ¿Cuánto dinero te queda? Aquí tienes un fragmento de programa ingenuo que intenta responder a esta pregunta:

System.out.println(1.03 - 0.42);

Por desgracia, imprime 0.6100000000000001. Y no es un caso aislado. Supón que tienes un dólar en el bolsillo y compras nueve arandelas a diez centavos cada una. ¿Cuánto cambio te devuelven?

System.out.println(1.00 - 9 * 0.10);

Según este fragmento de programa, te devuelven $0.09999999999999998.

Podrías pensar que el problema se resuelve simplemente redondeando los resultados antes de imprimirlos, pero, por desgracia, eso no siempre funciona. Por ejemplo, supón que tienes un dólar en el bolsillo y ves una estantería con una fila de deliciosos caramelos que cuestan 10 ¢, 20 ¢, 30 ¢ y así sucesivamente, hasta un dólar. Compras un caramelo de cada, empezando por el de 10 ¢, hasta que ya no te llega para comprar el siguiente caramelo de la estantería.

25. Prefiere los tipos primitivos a los primitivos encapsulados

// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);

26. Haz referencia a los objetos mediante sus interfaces

Buen ejemplo:

Set<Son> sonSet = new LinkedHashSet<>();

Mal ejemplo:

LinkedHashSet<Son> sonSet = new LinkedHashSet<>();

27. Prefiere las interfaces a la reflexión

Si no:

28. Respeta las convenciones de nomenclatura generalmente aceptadas

La plataforma Java tiene un conjunto bien establecido de convenciones de nomenclatura, muchas de las cuales se recogen en The Java Language Specification [JLS, 6.1].

29. Usa las excepciones solo para situaciones excepcionales

// Horrible abuse of exceptions. Don't ever do this!
try {
    int i = 0;
    while (true) {
        range[i++].climb();
    }
} catch (ArrayIndexOutOfBoundsException e) {
    // catch it
}

¿Qué hace este código? No es nada evidente a simple vista, y eso ya es motivo suficiente para no usarlo. Resulta ser un modismo horriblemente mal concebido para recorrer los elementos de un array. El bucle infinito termina lanzando, capturando e ignorando una ArrayIndexOutOfBoundsException cuando intenta acceder al primer elemento fuera de los límites del array. Se supone que equivale al modismo estándar para recorrer un array, que cualquier programador de Java reconoce al instante:

for (Mountain m : range) {
    m.climb();
}

Las excepciones, como su nombre indica, deben usarse solo para situaciones excepcionales; nunca deben usarse para el flujo de control normal.

30. Usa excepciones checked para situaciones recuperables y excepciones de runtime para errores de programación

Usa excepciones checked para situaciones de las que cabe esperar razonablemente que el llamante se recupere. Al enfrentar al usuario a una excepción checked, el diseñador de la API le impone la obligación de recuperarse de esa situación. El usuario puede desoír esa obligación capturando la excepción e ignorándola, pero normalmente es mala idea.

Usa excepciones de runtime para indicar errores de programación. La gran mayoría de las excepciones de runtime indican violaciones de precondiciones. Una violación de precondición no es más que un incumplimiento, por parte del cliente de una API, del contrato establecido por la especificación de la API. Por ejemplo, el contrato del acceso a arrays especifica que el índice del array debe estar entre cero y la longitud del array menos uno, ambos incluidos. ArrayIndexOutOfBoundsException indica que se ha violado esta precondición.

31. Evita el uso innecesario de excepciones checked

En resumen, usadas con moderación, las excepciones checked pueden aumentar la fiabilidad de los programas; usadas en exceso, hacen que las API sean un suplicio.

Pair<Boolean, LearningCardGroup> answerLearningCard(@NotNull LearningCardGroup learningCardGroup,
                                                      LearningCardAssignment learningCardAssignment,
                                                      @NotNull Collection<AnswerOption> answers)
      throws InvalidTrainingStateException, PersistenceException, LearningCardGroupAlreadyCompletedException,
             ConsecutiveCorrectCountExceededException,
             PreviousLearningCardGroupUncompletedException, LearningCardGroupCooldownException,
             LearningCardGroupHasUncompletedLearningCardsException, InvalidParticipantException, TrainingNotSignedException; // but why ???
     // this is a mistake seen a lot with junior devs
     // exceptions are exceptionally slow and should be avoided for mere validation
     // the performance gets even worse if a stacktrace is printed, since logging and printing is one of the slowest possible actions

32. Favorece el uso de excepciones estándar

Un rasgo que distingue a los programadores expertos de los menos experimentados es que los expertos se esfuerzan por conseguir, y normalmente consiguen, un alto grado de reutilización del código.

Las excepciones no son una excepción a la regla de que reutilizar código es bueno. Las librerías de Java ofrecen un conjunto de excepciones que cubre la mayoría de las necesidades de la mayoría de las API. Reutilizar excepciones estándar tiene varias ventajas. La principal es que hace que tu API sea más fácil de aprender y usar, porque sigue las convenciones establecidas que los programadores ya conocen. La segunda, muy cerca, es que los programas que usan tu API son más fáciles de leer, porque no están plagados de excepciones desconocidas. Por último (y lo menos importante), menos clases de excepción implican menos consumo de memoria y menos tiempo dedicado a cargar clases.

El tipo de excepción más reutilizado es IllegalArgumentException. Por lo general, es la excepción que hay que lanzar cuando el llamante pasa un argumento con un valor inadecuado. Por ejemplo, sería la excepción que habría que lanzar si el llamante pasara un número negativo en un parámetro que representa cuántas veces debe repetirse una acción. Otra excepción muy reutilizada es IllegalStateException. Por lo general, es la excepción que hay que lanzar si la invocación no es válida debido al estado del objeto receptor. Por ejemplo, sería la excepción que habría que lanzar si el llamante intentara usar un objeto antes de que se hubiera inicializado correctamente.

Podría decirse que toda invocación de método errónea se reduce a un argumento o un estado ilegal, pero para ciertos tipos de argumentos y estados ilegales se usan habitualmente otras excepciones. Si un llamante pasa null en un parámetro en el que los valores null están prohibidos, la convención dicta que se lance NullPointerException en lugar de IllegalArgumentException. Del mismo modo, si un llamante pasa un valor fuera de rango en un parámetro que representa un índice de una secuencia, debería lanzarse IndexOutOfBoundsException en lugar de IllegalArgumentException.

No reutilices directamente Exception, RuntimeException, Throwable ni Error. Trata estas clases como si fueran abstractas. Aunque RuntimeException puede usarse si quieres convertir una excepción checked en una excepción de runtime y sabes muy bien lo que estás haciendo.

33. No ignores las excepciones

// Empty catch block ignores exception - Highly suspect!
try {
    ...
} catch (SomeException e) {
}

Un bloque catch vacío anula el propósito de las excepciones, que es obligarte a gestionar las situaciones excepcionales. Ignorar una excepción es como ignorar una alarma de incendios… y además apagarla para que nadie más tenga la oportunidad de comprobar si hay fuego de verdad.