Codice Java buggy: i 10 errori più comuni commessi dagli sviluppatori Java

Pubblicato: 2022-03-11

Java è un linguaggio di programmazione inizialmente sviluppato per la televisione interattiva, ma nel tempo si è diffuso ovunque il software possa essere utilizzato. Progettato con la nozione di programmazione orientata agli oggetti, abolendo le complessità di altri linguaggi come C o C++, garbage collection e una macchina virtuale architettonicamente agnostica, Java ha creato un nuovo modo di programmare. Inoltre, ha una curva di apprendimento delicata e sembra aderire con successo alla propria moto - "Scrivi una volta, corri ovunque", che è quasi sempre vero; ma i problemi Java sono ancora presenti. Affronterò dieci problemi Java che penso siano gli errori più comuni.

Errore comune n. 1: trascurare le biblioteche esistenti

È sicuramente un errore per gli sviluppatori Java ignorare l'innumerevole quantità di librerie scritte in Java. Prima di reinventare la ruota, prova a cercare le librerie disponibili: molte di esse sono state rifinite nel corso degli anni della loro esistenza e sono gratuite. Potrebbero essere librerie di registrazione, come logback e Log4j, o librerie relative alla rete, come Netty o Akka. Alcune delle librerie, come Joda-Time, sono diventate uno standard de facto.

Quella che segue è un'esperienza personale di uno dei miei progetti precedenti. La parte del codice responsabile dell'escape dell'HTML è stata scritta da zero. Ha funzionato bene per anni, ma alla fine ha incontrato un input dell'utente che lo ha fatto girare in un ciclo infinito. L'utente, trovando che il servizio non rispondeva, ha tentato di riprovare con lo stesso input. Alla fine, tutte le CPU sul server allocate per questa applicazione sono state occupate da questo ciclo infinito. Se l'autore di questo ingenuo strumento di escape HTML avesse deciso di utilizzare una delle ben note librerie disponibili per l'escape HTML, come HtmlEscapers di Google Guava, questo probabilmente non sarebbe successo. Per lo meno, vero per le librerie più popolari con una community alle spalle, l'errore sarebbe stato trovato e corretto in precedenza dalla community per questa libreria.

Errore comune n. 2: mancare la parola chiave "break" in un blocco Switch-Case

Questi problemi di Java possono essere molto imbarazzanti e talvolta rimangono sconosciuti fino a quando non vengono eseguiti in produzione. Il comportamento fallthrough nelle istruzioni switch è spesso utile; tuttavia, la mancanza di una parola chiave "break" quando tale comportamento non è desiderato può portare a risultati disastrosi. Se hai dimenticato di inserire una "interruzione" in "caso 0" nell'esempio di codice seguente, il programma scriverà "Zero" seguito da "Uno", poiché il flusso di controllo all'interno qui passerà attraverso l'intera istruzione "switch" fino a quando raggiunge una "pausa". Per esempio:

 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"); } }

Nella maggior parte dei casi, la soluzione più pulita sarebbe usare il polimorfismo e spostare il codice con comportamenti specifici in classi separate. Errori Java come questo possono essere rilevati utilizzando analizzatori di codice statico, ad esempio FindBugs e PMD.

Errore comune n. 3: dimenticare le risorse gratuite

Ogni volta che un programma apre un file o una connessione di rete, è importante che i principianti Java liberino la risorsa una volta che hai finito di usarla. Analoga cautela dovrebbe essere adottata se dovesse essere generata un'eccezione durante le operazioni su tali risorse. Si potrebbe obiettare che FileInputStream ha un finalizzatore che invoca il metodo close() su un evento di garbage collection; tuttavia, poiché non possiamo essere sicuri di quando verrà avviato un ciclo di raccolta dei rifiuti, il flusso di input può consumare risorse del computer per un periodo di tempo indefinito. In effetti, c'è un'istruzione davvero utile e pulita introdotta in Java 7 in particolare per questo caso, chiamata try-with-resources:

 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(); } } }

Questa istruzione può essere utilizzata con qualsiasi oggetto che implementa l'interfaccia AutoClosable. Garantisce che ogni risorsa sia chiusa entro la fine dell'istruzione.

Correlati: 8 domande essenziali per l'intervista a Java

Errore comune n. 4: perdite di memoria

Java utilizza la gestione automatica della memoria e, sebbene sia un sollievo dimenticare l'allocazione e la liberazione manuale della memoria, non significa che uno sviluppatore Java principiante non debba essere a conoscenza di come viene utilizzata la memoria nell'applicazione. Sono ancora possibili problemi con le allocazioni di memoria. Finché un programma crea riferimenti a oggetti che non sono più necessari, non verrà liberato. In un certo senso, possiamo ancora chiamare questa perdita di memoria. Le perdite di memoria in Java possono verificarsi in vari modi, ma il motivo più comune sono i riferimenti a oggetti eterni, perché il Garbage Collector non può rimuovere oggetti dall'heap mentre ci sono ancora riferimenti ad essi. È possibile creare un tale riferimento definendo una classe con un campo statico contenente una raccolta di oggetti e dimenticando di impostare quel campo statico su null dopo che la raccolta non è più necessaria. I campi statici sono considerati radici GC e non vengono mai raccolti.

Un altro potenziale motivo alla base di tali perdite di memoria è un gruppo di oggetti che fanno riferimento a vicenda, causando dipendenze circolari in modo che il Garbage Collector non possa decidere se questi oggetti con riferimenti di dipendenza incrociata sono necessari o meno. Un altro problema sono le perdite di memoria non heap quando viene utilizzato JNI.

L'esempio di perdita primitiva potrebbe essere simile al seguente:

 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(); }

Questo esempio crea due attività pianificate. La prima attività prende l'ultimo numero da una deque chiamata "numeri" e stampa il numero e la dimensione della deque nel caso in cui il numero sia divisibile per 51. La seconda attività inserisce i numeri nella deque. Entrambe le attività sono pianificate a una velocità fissa e vengono eseguite ogni 10 ms. Se il codice viene eseguito, vedrai che la dimensione della deque aumenta in modo permanente. Ciò alla fine farà sì che la deque venga riempita con oggetti che consumano tutta la memoria heap disponibile. Per evitare ciò preservando la semantica di questo programma, possiamo utilizzare un metodo diverso per prendere i numeri dalla deque: "pollLast". Contrariamente al metodo "peekLast", "pollLast" restituisce l'elemento e lo rimuove dalla deque mentre "peekLast" restituisce solo l'ultimo elemento.

Per ulteriori informazioni sulle perdite di memoria in Java, fare riferimento al nostro articolo che ha demistificato questo problema.

Errore comune n. 5: eccessiva distribuzione dei rifiuti

Un'eccessiva allocazione dei rifiuti può verificarsi quando il programma crea molti oggetti di breve durata. Il Garbage Collector funziona continuamente, rimuovendo gli oggetti non necessari dalla memoria, il che influisce negativamente sulle prestazioni delle applicazioni. Un semplice esempio:

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

Nello sviluppo Java, le stringhe sono immutabili. Quindi, ad ogni iterazione viene creata una nuova stringa. Per risolvere questo problema dovremmo usare un StringBuilder mutabile:

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

Mentre la prima versione richiede un po' di tempo per l'esecuzione, la versione che utilizza StringBuilder produce un risultato in una quantità di tempo notevolmente inferiore.

Errore comune n. 6: utilizzo di riferimenti nulli senza necessità

Evitare l'uso eccessivo di null è una buona pratica. Ad esempio, è preferibile restituire matrici o raccolte vuote da metodi anziché null, poiché può aiutare a prevenire NullPointerException.

Considera il seguente metodo che attraversa una raccolta ottenuta da un altro metodo, come mostrato di seguito:

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

Se getAccountIds() restituisce null quando una persona non ha un account, verrà sollevata NullPointerException. Per risolvere questo problema, sarà necessario un controllo nullo. Tuttavia, se invece di un null restituisce un elenco vuoto, NullPointerException non è più un problema. Inoltre, il codice è più pulito poiché non è necessario eseguire il controllo nullo della variabile accountIds.

Per affrontare altri casi in cui si vogliono evitare i null, possono essere utilizzate strategie diverse. Una di queste strategie consiste nell'utilizzare il tipo Optional che può essere un oggetto vuoto o un wrap di un certo valore:

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

Infatti, Java 8 fornisce una soluzione più concisa:

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

Il tipo opzionale fa parte di Java dalla versione 8, ma è noto da molto tempo nel mondo della programmazione funzionale. Prima di questo, era disponibile in Google Guava per le versioni precedenti di Java.

Errore comune n. 7: ignorare le eccezioni

Spesso si è tentati di lasciare le eccezioni non gestite. Tuttavia, la migliore pratica sia per gli sviluppatori Java principianti che per quelli esperti è gestirli. Le eccezioni vengono generate apposta, quindi nella maggior parte dei casi è necessario affrontare i problemi che causano queste eccezioni. Non trascurare questi eventi. Se necessario, puoi rilanciarlo, mostrare una finestra di errore all'utente o aggiungere un messaggio al registro. Per lo meno, dovrebbe essere spiegato perché l'eccezione è stata lasciata non gestita per far conoscere il motivo ad altri sviluppatori.

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

Un modo più chiaro per evidenziare l'insignificanza di un'eccezione è codificare questo messaggio nel nome della variabile delle eccezioni, in questo modo:

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

Errore comune n. 8: eccezione di modifica simultanea

Questa eccezione si verifica quando una raccolta viene modificata durante l'iterazione su di essa utilizzando metodi diversi da quelli forniti dall'oggetto iteratore. Ad esempio, abbiamo un elenco di cappelli e vogliamo rimuovere tutti quelli che hanno paraorecchie:

 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); } }

Se eseguiamo questo codice, verrà sollevata "ConcurrentModificationException" poiché il codice modifica la raccolta durante l'iterazione. La stessa eccezione può verificarsi se uno dei più thread che lavorano con lo stesso elenco tenta di modificare la raccolta mentre altri eseguono l'iterazione su di essa. La modifica simultanea di raccolte in più thread è una cosa naturale, ma dovrebbe essere trattata con i soliti strumenti della casella degli strumenti di programmazione simultanea come blocchi di sincronizzazione, raccolte speciali adottate per la modifica simultanea, ecc. Esistono sottili differenze su come questo problema Java può essere risolto nei casi a thread singolo e nei casi multithread. Di seguito è riportata una breve discussione su alcuni modi in cui ciò può essere gestito in un unico scenario a thread:

Raccogli gli oggetti e rimuovili in un altro ciclo

Raccogliere cappelli con paraorecchie in un elenco per rimuoverli in seguito da un altro anello è una soluzione ovvia, ma richiede una raccolta aggiuntiva per riporre i cappelli da rimuovere:

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

Usa il metodo Iterator.remove

Questo approccio è più conciso e non richiede la creazione di una raccolta aggiuntiva:

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

Usa i metodi di ListIterator

L'uso dell'iteratore di elenco è appropriato quando la raccolta modificata implementa l'interfaccia di elenco. Gli iteratori che implementano l'interfaccia ListIterator supportano non solo le operazioni di rimozione, ma anche le operazioni di aggiunta e impostazione. ListIterator implementa l'interfaccia Iterator in modo che l'esempio appaia quasi uguale al metodo di rimozione Iterator. L'unica differenza è il tipo di iteratore cappello e il modo in cui otteniamo quell'iteratore con il metodo "listIterator()". Lo snippet seguente mostra come sostituire ogni cappello con paraorecchie con sombrero usando i metodi "ListIterator.remove" e "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, le chiamate al metodo remove e add possono essere sostituite con una singola chiamata per impostare:

 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 } }

Utilizzare i metodi di flusso introdotti in Java 8 Con Java 8, i programmatori hanno la possibilità di trasformare una raccolta in un flusso e di filtrare tale flusso in base ad alcuni criteri. Ecco un esempio di come l'API di streaming potrebbe aiutarci a filtrare i cappelli ed evitare "ConcurrentModificationException".

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

Il metodo "Collectors.toCollection" creerà un nuovo ArrayList con cappelli filtrati. Questo può essere un problema se la condizione di filtro dovesse essere soddisfatta da un numero elevato di elementi, risultando in un ArrayList di grandi dimensioni; quindi, dovrebbe essere usato con cura. Usa il metodo List.removeIf presentato in Java 8 Un'altra soluzione disponibile in Java 8, e chiaramente la più concisa, è l'uso del metodo “removeIf”:

 hats.removeIf(IHat::hasEarFlaps);

Questo è tutto. Sotto il cofano, utilizza "Iterator.remove" per eseguire il comportamento.

Usa raccolte specializzate

Se all'inizio avessimo deciso di utilizzare "CopyOnWriteArrayList" invece di "ArrayList", non ci sarebbero stati problemi, poiché "CopyOnWriteArrayList" fornisce metodi di modifica (come set, add e remove) che non cambiano l'array di supporto della raccolta, ma piuttosto crearne una nuova versione modificata. Ciò consente l'iterazione sulla versione originale della raccolta e le modifiche su di essa contemporaneamente, senza il rischio di "ConcurrentModificationException". Lo svantaggio di quella collezione è evidente: generazione di una nuova collezione ad ogni modifica.

Esistono altre raccolte ottimizzate per casi diversi, ad esempio "CopyOnWriteSet" e "ConcurrentHashMap".

Un altro possibile errore con le modifiche simultanee della raccolta consiste nel creare un flusso da una raccolta e, durante l'iterazione del flusso, modificare la raccolta di supporto. La regola generale per i flussi è evitare la modifica della raccolta sottostante durante l'esecuzione di query sui flussi. L'esempio seguente mostrerà un modo errato di gestire uno stream:

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

Il metodo peek raccoglie tutti gli elementi ed esegue l'azione fornita su ciascuno di essi. Qui, l'azione sta tentando di rimuovere elementi dall'elenco sottostante, il che è errato. Per evitare ciò, prova alcuni dei metodi sopra descritti.

Errore comune n. 9: rompere i contratti

A volte, il codice fornito dalla libreria standard o da un fornitore di terze parti si basa su regole che dovrebbero essere rispettate per far funzionare le cose. Ad esempio, potrebbe essere hashCode and equals contract che, se seguito, garantisce il funzionamento per un insieme di raccolte dal framework di raccolta Java e per altre classi che utilizzano hashCode e metodi equals. La disobbedienza ai contratti non è il tipo di errore che porta sempre a eccezioni o interrompe la compilazione del codice; è più complicato, perché a volte cambia il comportamento dell'applicazione senza alcun segno di pericolo. Il codice errato potrebbe scivolare nella versione di produzione e causare un sacco di effetti indesiderati. Ciò può includere un comportamento errato dell'interfaccia utente, rapporti di dati errati, prestazioni dell'applicazione scadenti, perdita di dati e altro ancora. Fortunatamente, questi bug disastrosi non si verificano molto spesso. Ho già menzionato l'hashCode e il contratto equals. Viene utilizzato nelle raccolte che si basano sull'hashing e sul confronto di oggetti, come HashMap e HashSet. In poche parole, il contratto contiene due regole:

  • Se due oggetti sono uguali, i loro codici hash dovrebbero essere uguali.
  • Se due oggetti hanno lo stesso codice hash, possono essere uguali o meno.

Infrangere la prima regola del contratto porta a problemi durante il tentativo di recuperare oggetti da una hashmap. La seconda regola indica che gli oggetti con lo stesso codice hash non sono necessariamente uguali. Esaminiamo gli effetti della violazione della prima regola:

 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); } }

Come puoi vedere, la classe Boat ha sovrascritto i metodi equals e hashCode. Tuttavia, ha rotto il contratto, perché hashCode restituisce valori casuali per lo stesso oggetto ogni volta che viene chiamato. Il codice seguente molto probabilmente non troverà una barca denominata "Enterprise" nell'hashset, nonostante il fatto che abbiamo aggiunto quel tipo di barca in precedenza:

 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"))); }

Un altro esempio di contratto riguarda il metodo finalize. Ecco una citazione dalla documentazione java ufficiale che ne descrive la funzione:

Il contratto generale di finalize è che viene invocato se e quando la macchina virtuale JavaTM ha stabilito che non c'è più alcun mezzo con cui questo oggetto è accessibile da qualsiasi thread (che non è ancora morto), se non come risultato di un azione intrapresa dalla finalizzazione di qualche altro oggetto o classe che è pronto per essere finalizzato. Il metodo finalize può eseguire qualsiasi azione, incluso rendere nuovamente disponibile questo oggetto ad altri thread; il solito scopo della finalizzazione, tuttavia, è eseguire azioni di pulizia prima che l'oggetto venga eliminato irrevocabilmente. Ad esempio, il metodo finalize per un oggetto che rappresenta una connessione di input/output potrebbe eseguire transazioni di I/O esplicite per interrompere la connessione prima che l'oggetto venga eliminato definitivamente.

Si potrebbe decidere di utilizzare il metodo finalize per liberare risorse come gestori di file, ma sarebbe una cattiva idea. Questo perché non ci sono garanzie di tempo su quando verrà invocata la finalizzazione, poiché viene invocata durante la raccolta dei rifiuti e il tempo di GC è indeterminabile.

Errore comune n. 10: utilizzare il tipo non elaborato anziché uno parametrizzato

I tipi grezzi, secondo le specifiche Java, sono tipi che non sono parametrizzati o membri non statici della classe R che non vengono ereditati dalla superclasse o dalla superinterfaccia di R. Non c'erano alternative ai tipi grezzi fino a quando i tipi generici non sono stati introdotti in Java . Supporta la programmazione generica dalla versione 1.5 e i generici sono stati senza dubbio un miglioramento significativo. Tuttavia, per motivi di compatibilità con le versioni precedenti, è stata lasciata una trappola che potrebbe potenzialmente interrompere il sistema dei tipi. Diamo un'occhiata al seguente esempio:

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

Qui abbiamo un elenco di numeri definiti come ArrayList grezzo. Poiché il suo tipo non è specificato con il parametro type, possiamo aggiungere qualsiasi oggetto al suo interno. Ma nell'ultima riga eseguiamo il cast di elementi su int, lo raddoppiamo e stampiamo il numero raddoppiato sullo standard output. Questo codice verrà compilato senza errori, ma una volta eseguito genererà un'eccezione di runtime perché abbiamo tentato di eseguire il cast di una stringa su un numero intero. Ovviamente, il sistema dei tipi non è in grado di aiutarci a scrivere codice sicuro se nascondiamo le informazioni necessarie da esso. Per risolvere il problema dobbiamo specificare il tipo di oggetti che andremo a memorizzare nella collezione:

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

L'unica differenza rispetto all'originale è la linea che definisce la collezione:

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

Il codice fisso non veniva compilato perché stiamo cercando di aggiungere una stringa in una raccolta che dovrebbe memorizzare solo numeri interi. Il compilatore mostrerà un errore e punterà alla riga in cui stiamo cercando di aggiungere la stringa "Twenty" all'elenco. È sempre una buona idea parametrizzare i tipi generici. In questo modo, il compilatore è in grado di eseguire tutti i controlli di tipo possibili e le possibilità di eccezioni di runtime causate da incoerenze del sistema di tipi sono ridotte al minimo.

Conclusione

Java come piattaforma semplifica molte cose nello sviluppo del software, basandosi sia su JVM sofisticate che sul linguaggio stesso. Tuttavia, le sue funzionalità, come la rimozione della gestione manuale della memoria o degli strumenti OOP decenti, non eliminano tutti i problemi e i problemi che un normale sviluppatore Java deve affrontare. Come sempre, la conoscenza, la pratica e i tutorial Java come questo sono i mezzi migliori per evitare e risolvere gli errori delle applicazioni, quindi conosci le tue librerie, leggi java, leggi la documentazione JVM e scrivi programmi. Non dimenticare nemmeno gli analizzatori di codice statico, in quanto potrebbero indicare i bug effettivi ed evidenziare potenziali bug.

Correlati: Tutorial avanzato di classe Java: una guida al ricaricamento della classe