Java – Ricerca/rimozione di oggetti non “standard” nelle liste

Nel Java Collections Framework le collezioni di tipo “lista” (tutte le implementazioni della interfaccia java.util.List) hanno alcuni metodi dedicati alla ricerca e rimozione degli oggetti. In questo articolo verrà discusso l’utilizzo di questi metodi con particolare riferimento alla situazione in cui una lista contiene oggetti di classi non “standard”, ovvero scritte da zero.

Premessa

Tutte le liste che derivano da java.util.List hanno in modo specifico i seguenti metodi:

  • metodi di ricerca:
    • boolean contains(Object o)
    • boolean containsAll(Collection<?> c)
    • int indexOf(Object o)
    • int lastIndexOf(Object o)
  • metodi di rimozione:
    • boolean remove(Object o)
    • boolean removeAll(Collection<?> c)

Inoltre le liste hanno ovviamente anche il metodo equals(Object) che serve a verificare se due liste sono di “significato” equivalente, cioè hanno lo stesso contenuto. Tutti i metodi appena citati si basano sul concetto di “uguaglianza” degli oggetti come definito dal metodo equals della classe java.lang.Object.

Quando si chiede ad una lista di ricercare o rimuovere un determinato oggetto, la lista esegue innanzitutto una scansione dei suoi elementi e per verificare se un elemento è quel determinato oggetto richiesto, utilizza appunto il metodo equals. Tutto questo ha una implicazione importante: se la lista contiene oggetti di classi “standard” (es. java.lang.String, java.lang.Integer, java.util.Date, java.time.LocalDate, ecc...) non ci sono problemi né strane sorprese, mentre invece bisogna prestare molta attenzione quando nella lista si inseriscono oggetti di classi proprie, scritte da zero.

Nelle seguenti sezioni verrà prima esposta la questione e poi successivamente mostrata la soluzione corretta per risolvere il problema.

Problema

Supponiamo di dover gestire una lista di oggetti di tipo Persona, dove Persona è una semplice classe che rappresenta una singola persona descritta con nome e cognome.

public class Persona {
    private String nome;
    private String cognome;

    public Persona(String nome, String cognome) {
        this.nome = nome;
        this.cognome = cognome;
    }

    // ... metodi getter/setter omessi per brevità e perché non importanti
}

La classe Persona, così come è riportata sopra, è scritta al minimo indispensabile ma che sia comunque ragionevole (in termini di design). Da notare che i metodi getter/setter sono stati omessi sia per semplicità/brevità, sia perché non sono rilevanti in quanto la loro presenza o assenza non ha, di fatto, implicazioni sul concetto descritto in questo articolo.

Ora si può mettere alla prova la classe Persona con il seguente programma:

import java.util.ArrayList;

public class ProvaListePersona {
    public static void main(String[] args) {
        ArrayList<Persona> lista1 = new ArrayList<Persona>();
        lista1.add(new Persona("Mario", "Rossi"));
        lista1.add(new Persona("Roberto", "Bianchi"));
        lista1.add(new Persona("Giacomo", "Verdi"));

        ArrayList<Persona> lista2 = new ArrayList<Persona>();
        lista2.add(new Persona("Mario", "Rossi"));
        lista2.add(new Persona("Roberto", "Bianchi"));
        lista2.add(new Persona("Giacomo", "Verdi"));

        Persona gv = new Persona("Giacomo", "Verdi");

        System.out.println("lista1.equals(lista2) = " + lista1.equals(lista2));
        System.out.println("lista1.contains(gv) = " + lista1.contains(gv));
        System.out.println("lista1.indexOf(gv) = " + lista1.indexOf(gv));
        System.out.println("lista1.remove(gv) = " + lista1.remove(gv));
    }
}

Eseguendo questo programma, si ottiene il seguente output:

lista1.equals(lista2) = false
lista1.contains(gv) = false
lista1.indexOf(gv) = -1
lista1.remove(gv) = false

Questo risultato evidenzia i seguenti aspetti:

  • le due liste risultano non “uguali” (però, come si vede, hanno lo stesso contenuto!)
  • la persona «Giacomo Verdi» non è stata trovata e non è stata rimossa (però un «Giacomo Verdi» esiste nella lista!)

Tutto questo avviene perché all’interno della classe Persona non è stato ridefinito appropriatamente il metodo equals, pertanto rimane solo quello “ereditato” dalla classe java.lang.Object che si basa unicamente sulla “identità” degli oggetti. Il metodo equals di Object applica semplicemente un banale confronto con l’operatore ==, quindi in pratica verifica solo se due reference hanno lo stesso valore, cioè fanno riferimento allo stesso oggetto.

Come si vede, nel codice di prova tutti gli oggetti Persona sono distinti, creati ex-novo usando l’operatore new. L’oggetto Persona «Mario Rossi» della lista2 è un oggetto distinto dal Persona «Mario Rossi» della lista1. E allo stesso modo, l’oggetto Persona «Giacomo Verdi» usato per la ricerca/rimozione (quello assegnato alla variabile gv) è un oggetto ben distinto dagli oggetti Persona «Giacomo Verdi» contenuti nella lista1 e lista2.

In uno scenario di questo tipo e avendo solo il equals di Object negli oggetti Persona, il confronto con equals tra due oggetti Persona darà come risultato che sono oggetti “diversi”, anche se noi abbiamo chiara evidenza che hanno lo stesso contenuto.

Soluzione

La soluzione per risolvere la questione esposta sopra è chiaramente quella di ridefinire appropriatamente il metodo equals nella classe Persona. In generale l’obiettivo di equals è di stabilire se due oggetti sono di “significato” (contenuto) equivalente. Pertanto il equals di Persona dovrà semplicemente verificare se due oggetti Persona hanno lo stesso contenuto andando a confrontare in maniera più puntuale il nome e cognome tra i due oggetti.

public class Persona {
    // ... resto identico a prima ...

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof Persona)) {
            return false;
        }

        Persona other = (Persona) obj;

        if (nome == null) {
            if (other.nome != null) {
                return false;
            }
        } else if (!nome.equals(other.nome)) {
            return false;
        }

        if (cognome == null) {
            if (other.cognome != null) {
                return false;
            }
        } else if (!cognome.equals(other.cognome)) {
            return false;
        }

        return true;
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((cognome == null) ? 0 : cognome.hashCode());
        result = prime * result + ((nome == null) ? 0 : nome.hashCode());
        return result;
    }
}

La implementazione di equals potrebbe sembrare un po’ lunga per trattare solo due attributi ma è il metodo basilare/standard per farlo (da notare che il confronto è null-safe). In realtà è possibile applicare una serie di ottimizzazioni al equals e inoltre per ridurre il codice di confronto si possono usare varie tecniche, ad esempio da Java 7 è possibile sfruttare il metodo statico equals(Object, Object) della nuova classe java.util.Objects oppure si può sfruttare la classe StringUtils della ben nota libreria Apache Commons Lang.

Le ottimizzazioni comunque non sono rilevanti ai fini di tutta questa discussione. Se dopo l’aggiunta di equals nella classe Persona (e la sua ricompilazione, ovviamente) si riesegue il programma di prova, si ottiene il seguente risultato:

lista1.equals(lista2) = true
lista1.contains(gv) = true
lista1.indexOf(gv) = 2
lista1.remove(gv) = true

Da questo nuovo risultato è quindi evidente che:

  • le due liste risultano “uguali”
  • l’oggetto Persona «Giacomo Verdi» è contenuto nella lista, è stato trovato all’indice 2 ed è stato rimosso correttamente

Conclusioni

La conclusione di questo articolo è quindi molto semplice: quando si vuole usare un metodo di ricerca/rimozione di un oggetto su una lista oppure verificare se due liste sono “uguali”, è fondamentale che la classe degli oggetti trattati abbia il metodo equals ridefinito appropriatamente.

Nota: se si ridefinisce equals, si dovrebbe anche ridefinire hashCode (come mostrato sopra), poiché esiste un “contratto” ben preciso tra i due metodi.