Código Java con errores: los 10 errores más comunes que cometen los desarrolladores de Java

Publicado: 2022-03-11

Java es un lenguaje de programación que se desarrolló inicialmente para la televisión interactiva, pero con el tiempo se ha generalizado en todos los lugares donde se puede utilizar software. Diseñado con la noción de programación orientada a objetos, eliminando las complejidades de otros lenguajes como C o C++, recolección de elementos no utilizados y una máquina virtual arquitectónicamente agnóstica, Java creó una nueva forma de programación. Además, tiene una curva de aprendizaje suave y parece adherirse con éxito a su propia lema: "Escribe una vez, ejecuta en todas partes", lo que casi siempre es cierto; pero los problemas de Java todavía están presentes. Abordaré diez problemas de Java que creo que son los errores más comunes.

Error común #1: Descuidar las bibliotecas existentes

Definitivamente es un error que los desarrolladores de Java ignoren la innumerable cantidad de bibliotecas escritas en Java. Antes de reinventar la rueda, intente buscar las bibliotecas disponibles; muchas de ellas se han pulido a lo largo de los años de su existencia y son de uso gratuito. Estas podrían ser bibliotecas de registro, como logback y Log4j, o bibliotecas relacionadas con la red, como Netty o Akka. Algunas de las bibliotecas, como Joda-Time, se han convertido en un estándar de facto.

La siguiente es una experiencia personal de uno de mis proyectos anteriores. La parte del código responsable del escape de HTML se escribió desde cero. Funcionó bien durante años, pero finalmente se encontró con una entrada del usuario que hizo que girara en un bucle infinito. El usuario, al encontrar que el servicio no respondía, intentó volver a intentarlo con la misma entrada. Eventualmente, todas las CPU en el servidor asignadas para esta aplicación estaban siendo ocupadas por este bucle infinito. Si el autor de esta ingenua herramienta de escape de HTML hubiera decidido utilizar una de las bibliotecas más conocidas disponibles para el escape de HTML, como HtmlEscapers de Google Guava, esto probablemente no habría sucedido. Como mínimo, cierto para la mayoría de las bibliotecas populares con una comunidad detrás, la comunidad habría encontrado y solucionado el error antes para esta biblioteca.

Error común n.º 2: Falta la palabra clave 'romper' en un bloque Switch-Case

Estos problemas de Java pueden ser muy vergonzosos y, a veces, pasan desapercibidos hasta que se ejecutan en producción. El comportamiento fallthrough en las sentencias switch suele ser útil; sin embargo, perder una palabra clave de "romper" cuando no se desea tal comportamiento puede conducir a resultados desastrosos. Si se olvidó de poner un "descanso" en el "caso 0" en el ejemplo de código a continuación, el programa escribirá "Cero" seguido de "Uno", ya que el flujo de control dentro de este recorrerá toda la declaración de "cambio" hasta que llega a una “ruptura”. Por ejemplo:

 public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }

En la mayoría de los casos, la solución más limpia sería usar polimorfismo y mover el código con comportamientos específicos a clases separadas. Los errores de Java como este se pueden detectar utilizando analizadores de código estático, por ejemplo, FindBugs y PMD.

Error común n.º 3: olvidarse de liberar recursos

Cada vez que un programa abre un archivo o una conexión de red, es importante que los principiantes de Java liberen el recurso una vez que terminen de usarlo. Se debe tener una precaución similar si se lanzara alguna excepción durante las operaciones en dichos recursos. Se podría argumentar que FileInputStream tiene un finalizador que invoca el método close() en un evento de recolección de basura; sin embargo, dado que no podemos estar seguros de cuándo comenzará un ciclo de recolección de basura, el flujo de entrada puede consumir recursos de la computadora por un período de tiempo indefinido. De hecho, hay una declaración realmente útil y ordenada introducida en Java 7 particularmente para este caso, llamada prueba con recursos:

 private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }

Esta instrucción se puede utilizar con cualquier objeto que implemente la interfaz AutoClosable. Asegura que cada recurso esté cerrado al final de la instrucción.

Relacionado: 8 preguntas esenciales de la entrevista de Java

Error común n.º 4: fugas de memoria

Java utiliza la gestión automática de memoria, y aunque es un alivio olvidarse de asignar y liberar memoria manualmente, eso no significa que un desarrollador principiante de Java no deba saber cómo se usa la memoria en la aplicación. Los problemas con las asignaciones de memoria todavía son posibles. Mientras un programa cree referencias a objetos que ya no se necesitan, no se liberará. En cierto modo, todavía podemos llamar a esto una fuga de memoria. Las fugas de memoria en Java pueden ocurrir de varias maneras, pero la razón más común son las referencias eternas a objetos, porque el recolector de elementos no utilizados no puede eliminar objetos del montón mientras haya referencias a ellos. Se puede crear una referencia de este tipo definiendo una clase con un campo estático que contenga una colección de objetos y olvidando establecer ese campo estático en nulo después de que la colección ya no sea necesaria. Los campos estáticos se consideran raíces de GC y nunca se recopilan.

Otra posible razón detrás de tales fugas de memoria es un grupo de objetos que se referencian entre sí, lo que provoca dependencias circulares para que el recolector de elementos no utilizados no pueda decidir si estos objetos con referencias de dependencia cruzada son necesarios o no. Otro problema son las fugas en la memoria que no es de montón cuando se usa JNI.

El ejemplo primitivo de fuga podría tener el siguiente aspecto:

 final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }

Este ejemplo crea dos tareas programadas. La primera tarea toma el último número de un deque llamado "números" e imprime el número y el tamaño del deque en caso de que el número sea divisible por 51. La segunda tarea coloca números en el deque. Ambas tareas están programadas a una velocidad fija y se ejecutan cada 10 ms. Si se ejecuta el código, verá que el tamaño de la deque aumenta permanentemente. Eventualmente, esto hará que el deque se llene con objetos que consumen toda la memoria del montón disponible. Para evitar esto mientras preservamos la semántica de este programa, podemos usar un método diferente para tomar números del deque: "pollLast". A diferencia del método "peekLast", "pollLast" devuelve el elemento y lo elimina de la deque, mientras que "peekLast" solo devuelve el último elemento.

Para obtener más información sobre las fugas de memoria en Java, consulte nuestro artículo que desmitificó este problema.

Error común #5: Asignación excesiva de basura

La asignación excesiva de basura puede ocurrir cuando el programa crea muchos objetos de corta duración. El recolector de elementos no utilizados funciona continuamente, eliminando objetos innecesarios de la memoria, lo que afecta negativamente el rendimiento de las aplicaciones. Un ejemplo simple:

 String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));

En el desarrollo de Java, las cadenas son inmutables. Entonces, en cada iteración se crea una nueva cadena. Para abordar esto, deberíamos usar un StringBuilder mutable:

 StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));

Si bien la primera versión requiere bastante tiempo para ejecutarse, la versión que usa StringBuilder produce un resultado en una cantidad de tiempo significativamente menor.

Error común #6: usar referencias nulas sin necesidad

Evitar el uso excesivo de null es una buena práctica. Por ejemplo, es preferible devolver matrices o colecciones vacías de métodos en lugar de valores nulos, ya que puede ayudar a evitar la excepción NullPointerException.

Considere el siguiente método que atraviesa una colección obtenida de otro método, como se muestra a continuación:

 List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }

Si getAccountIds() devuelve nulo cuando una persona no tiene cuenta, se generará NullPointerException. Para solucionar esto, se necesitará una verificación nula. Sin embargo, si en lugar de nulo devuelve una lista vacía, entonces NullPointerException ya no es un problema. Además, el código es más limpio, ya que no es necesario anular la verificación de la variable accountIds.

Para tratar otros casos en los que se quiere evitar nulos, se pueden utilizar diferentes estrategias. Una de estas estrategias es usar el tipo opcional que puede ser un objeto vacío o una envoltura de algún valor:

 Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }

De hecho, Java 8 proporciona una solución más concisa:

 Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);

El tipo opcional ha sido parte de Java desde la versión 8, pero ha sido bien conocido durante mucho tiempo en el mundo de la programación funcional. Antes de esto, estaba disponible en Google Guava para versiones anteriores de Java.

Error común #7: Ignorar excepciones

A menudo es tentador dejar las excepciones sin manejar. Sin embargo, la mejor práctica para los desarrolladores de Java principiantes y experimentados es manejarlos. Las excepciones se lanzan a propósito, por lo que en la mayoría de los casos debemos abordar los problemas que causan estas excepciones. No pase por alto estos eventos. Si es necesario, puede volver a generarlo, mostrar un cuadro de diálogo de error al usuario o agregar un mensaje al registro. Como mínimo, se debe explicar por qué la excepción no se manejó para que otros desarrolladores sepan el motivo.

 selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }

Una forma más clara de resaltar la insignificancia de una excepción es codificar este mensaje en el nombre de la variable de la excepción, así:

 try { selfie.delete(); } catch (NullPointerException unimportant) { }

Error común n.º 8: excepción de modificación concurrente

Esta excepción ocurre cuando se modifica una colección mientras se itera sobre ella utilizando métodos distintos a los proporcionados por el objeto iterador. Por ejemplo, tenemos una lista de sombreros y queremos eliminar todos los que tienen orejeras:

 List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }

Si ejecutamos este código, se generará "ConcurrentModificationException" ya que el código modifica la colección mientras la itera. La misma excepción puede ocurrir si uno de los múltiples subprocesos que trabajan con la misma lista intenta modificar la colección mientras otros iteran sobre ella. La modificación concurrente de colecciones en varios subprocesos es algo natural, pero debe tratarse con las herramientas habituales de la caja de herramientas de programación concurrente, como bloqueos de sincronización, colecciones especiales adoptadas para la modificación concurrente, etc. Hay diferencias sutiles en la forma en que se puede resolver este problema de Java. en casos de un solo subproceso y casos de subprocesos múltiples. A continuación se muestra una breve discusión de algunas formas en que esto se puede manejar en un escenario de un solo subproceso:

Recoge objetos y retíralos en otro bucle.

Recolectar sombreros con orejeras en una lista para luego quitarlos de otro bucle es una solución obvia, pero requiere una recolección adicional para almacenar los sombreros que se quitarán:

 List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }

Usar el método Iterator.remove

Este enfoque es más conciso y no necesita crear una colección adicional:

 Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }

Usa los métodos de ListIterator

El uso del iterador de lista es apropiado cuando la colección modificada implementa la interfaz de lista. Los iteradores que implementan la interfaz ListIterator admiten no solo operaciones de eliminación, sino también operaciones de adición y configuración. ListIterator implementa la interfaz de Iterator, por lo que el ejemplo se vería casi igual que el método de eliminación de Iterator. La única diferencia es el tipo de iterador hat y la forma en que obtenemos ese iterador con el método “listIterator()”. El fragmento a continuación muestra cómo reemplazar cada sombrero con orejeras con sombreros utilizando los métodos "ListIterator.remove" y "ListIterator.add":

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }

Con ListIterator, las llamadas al método remove y add se pueden reemplazar con una sola llamada para configurar:

 IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }

Usar métodos de flujo introducidos en Java 8 Con Java 8, los programadores tienen la capacidad de transformar una colección en un flujo y filtrar ese flujo de acuerdo con algunos criterios. Aquí hay un ejemplo de cómo la API de transmisión podría ayudarnos a filtrar sombreros y evitar la "ConcurrentModificationException".

 hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));

El método "Collectors.toCollection" creará una nueva ArrayList con sombreros filtrados. Esto puede ser un problema si la condición de filtrado se cumpliera con una gran cantidad de elementos, lo que daría como resultado una ArrayList grande; por lo tanto, debe usarse con cuidado. Usar el método List.removeIf presentado en Java 8 Otra solución disponible en Java 8, y claramente la más concisa, es el uso del método “removeIf”:

 hats.removeIf(IHat::hasEarFlaps);

Eso es todo. Bajo el capó, utiliza "Iterator.remove" para lograr el comportamiento.

Usar colecciones especializadas

Si al principio decidiéramos usar "CopyOnWriteArrayList" en lugar de "ArrayList", entonces no habría habido ningún problema, ya que "CopyOnWriteArrayList" proporciona métodos de modificación (como establecer, agregar y eliminar) que no cambian la matriz de respaldo de la colección, sino crear una nueva versión modificada de la misma. Esto permite la iteración sobre la versión original de la colección y las modificaciones al mismo tiempo, sin el riesgo de "ConcurrentModificationException". El inconveniente de esa colección es obvio: generación de una nueva colección con cada modificación.

Hay otras colecciones ajustadas para diferentes casos, por ejemplo, "CopyOnWriteSet" y "ConcurrentHashMap".

Otro posible error con las modificaciones de colección simultáneas es crear una secuencia a partir de una colección y, durante la iteración de la secuencia, modificar la colección de respaldo. La regla general para las secuencias es evitar la modificación de la colección subyacente durante la consulta de secuencias. El siguiente ejemplo mostrará una forma incorrecta de manejar un flujo:

 List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));

El método peek reúne todos los elementos y realiza la acción prevista sobre cada uno de ellos. Aquí, la acción intenta eliminar elementos de la lista subyacente, lo cual es erróneo. Para evitar esto, pruebe algunos de los métodos descritos anteriormente.

Error Común #9: Romper Contratos

A veces, el código proporcionado por la biblioteca estándar o por un proveedor externo se basa en reglas que deben obedecerse para que las cosas funcionen. Por ejemplo, podría ser un contrato hashCode and equals que, cuando se sigue, garantiza el funcionamiento de un conjunto de colecciones del marco de colección de Java y de otras clases que utilizan métodos hashCode and equals. Desobedecer los contratos no es el tipo de error que siempre conduce a excepciones o rompe la compilación del código; es más complicado, porque a veces cambia el comportamiento de la aplicación sin ningún signo de peligro. El código erróneo podría colarse en la versión de producción y causar una gran cantidad de efectos no deseados. Esto puede incluir un mal comportamiento de la interfaz de usuario, informes de datos incorrectos, rendimiento deficiente de la aplicación, pérdida de datos y más. Afortunadamente, estos errores desastrosos no ocurren muy a menudo. Ya mencioné el contrato hashCode y equals. Se usa en colecciones que se basan en el hash y la comparación de objetos, como HashMap y HashSet. En pocas palabras, el contrato contiene dos reglas:

  • Si dos objetos son iguales, entonces sus códigos hash deberían ser iguales.
  • Si dos objetos tienen el mismo código hash, entonces pueden o no ser iguales.

Romper la primera regla del contrato genera problemas al intentar recuperar objetos de un hashmap. La segunda regla significa que los objetos con el mismo código hash no son necesariamente iguales. Examinemos los efectos de romper la primera regla:

 public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }

Como puede ver, la clase Boat ha anulado los métodos equals y hashCode. Sin embargo, ha roto el contrato, porque hashCode devuelve valores aleatorios para el mismo objeto cada vez que se llama. Lo más probable es que el siguiente código no encuentre un barco llamado "Enterprise" en el hashset, a pesar de que agregamos ese tipo de barco antes:

 public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }

Otro ejemplo de contrato implica el método de finalización. Aquí hay una cita de la documentación oficial de Java que describe su función:

El contrato general de finalizar es que se invoca si y cuando la máquina virtual JavaTM ha determinado que ya no hay ningún medio por el cual ningún subproceso pueda acceder a este objeto (que aún no haya muerto), excepto como resultado de un acción tomada por la finalización de algún otro objeto o clase que está listo para ser finalizado. El método finalize puede realizar cualquier acción, incluso hacer que este objeto vuelva a estar disponible para otros subprocesos; Sin embargo, el propósito habitual de finalizar es realizar acciones de limpieza antes de que el objeto se descarte de manera irrevocable. Por ejemplo, el método de finalización para un objeto que representa una conexión de entrada/salida podría realizar transacciones de E/S explícitas para interrumpir la conexión antes de que el objeto se descarte de forma permanente.

Uno podría decidir utilizar el método de finalización para liberar recursos como los controladores de archivos, pero sería una mala idea. Esto se debe a que no hay garantías de tiempo sobre cuándo se invocará la finalización, ya que se invoca durante la recolección de elementos no utilizados y el tiempo de GC es indeterminable.

Error común n.º 10: usar un tipo sin procesar en lugar de uno parametrizado

Los tipos sin procesar, de acuerdo con las especificaciones de Java, son tipos que no están parametrizados o son miembros no estáticos de la clase R que no se heredan de la superclase o la superinterfaz de R. No hubo alternativas a los tipos sin procesar hasta que se introdujeron los tipos genéricos en Java. . Es compatible con la programación genérica desde la versión 1.5 y, sin duda, los genéricos fueron una mejora significativa. Sin embargo, debido a razones de compatibilidad con versiones anteriores, se ha dejado una trampa que podría potencialmente romper el sistema de tipos. Veamos el siguiente ejemplo:

 List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

Aquí tenemos una lista de números definidos como ArrayList sin procesar. Dado que su tipo no se especifica con el parámetro de tipo, podemos agregarle cualquier objeto. Pero en la última línea convertimos elementos en int, lo duplicamos e imprimimos el número duplicado en la salida estándar. Este código se compilará sin errores, pero una vez que se ejecute generará una excepción de tiempo de ejecución porque intentamos convertir una cadena en un número entero. Obviamente, el sistema de tipos no puede ayudarnos a escribir código seguro si le ocultamos la información necesaria. Para solucionar el problema, debemos especificar el tipo de objetos que vamos a almacenar en la colección:

 List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));

La única diferencia con el original es la línea que define la colección:

 List<Integer> listOfNumbers = new ArrayList<>();

El código fijo no se compilaría porque estamos tratando de agregar una cadena a una colección que se espera que almacene solo números enteros. El compilador mostrará un error y apuntará a la línea donde estamos tratando de agregar la cadena "Twenty" a la lista. Siempre es una buena idea parametrizar tipos genéricos. De esa forma, el compilador puede realizar todas las comprobaciones de tipos posibles y se minimizan las posibilidades de excepciones en tiempo de ejecución causadas por inconsistencias en el sistema de tipos.

Conclusión

Java como plataforma simplifica muchas cosas en el desarrollo de software, basándose tanto en JVM sofisticados como en el propio lenguaje. Sin embargo, sus funciones, como la eliminación de la gestión manual de la memoria o las herramientas de programación orientada a objetos decentes, no eliminan todos los problemas y cuestiones a los que se enfrenta un desarrollador de Java normal. Como siempre, los conocimientos, la práctica y los tutoriales de Java como este son los mejores medios para evitar y abordar los errores de la aplicación, así que conozca sus bibliotecas, lea Java, lea la documentación de JVM y escriba programas. Tampoco se olvide de los analizadores de código estático, ya que podrían señalar los errores reales y resaltar los errores potenciales.

Relacionado: Tutorial avanzado de clases de Java: una guía para la recarga de clases