W poprzednim wpisie omówiliśmy sobie jakie mamy kolekcje, oraz w telegraficznym skrócie przedstawiłem najpopularniejsze implementacje. W tym wpisie omówimy sobie temat troszkę bardziej szczegółowo na prostych przykładach! Na pierwszy ogień idą sety! Zapraszam do lektury! 😊

01. Kolekcje są generyczne!

Pierwszą sprawa jaką musimy sobie powiedzieć to że kolekcje są generyczne. Nie przejmuj się jeśli zupełnie nie wiesz o co chodzi, już tłumaczę. W prostych słowach można powiedzieć że kolekcje przechowują jeden określony typ danych, podobnie jak tablice. Podczas deklaracji określamy jakiego typu będą elementy 😊

Set<String> stringList = new HashSet<>();
Set<Integer> integerList = new LinkedHashSet<>();
Set<Person> personList = new TreeSet<>();

Powyższy przykład przedstawia deklarację oraz stworzenie setów, pierwsza może przechowywać Stringi, drugi liczby całkowite, natomiast trzeci naszą klasę którą nazwałem Person. Ogólny zapis to Set<E> gdzie E określa nam dowolny element.

02. HashSet

Okej! Tak jak w poprzednim wpisie zaczynamy od Setów. Podstawową, oraz najczęściej wykorzystywaną implementacją jest HashSet. Jego właściwości omówimy sobie na prostych przykładach. Po pierwsze utworzymy sobie bardzo prostą klasę Person:

public class Person  {

    private String name;

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

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

    @Override
    public String toString() {
        return name;
    }
    
}

Kod dla metody equals oraz hashCode został wygenerowany! Odsyłam do wpisu o equals oraz hashCode gdzie wspominałem o tym aby podczas nadpisywania tych metod korzystać z pomocy naszego IDE!

Zabieramy się za prosty przykład, tworzymy kilka obiektów, oraz dodajemy je do HashSetu:

Person person1 = new Person("Arek");
Person person2 = new Person("Konrad");
Person person3 = new Person("Amigo");

Set<Person> personSet = new HashSet<>();
personSet.add(person1);
personSet.add(person2);
personSet.add(person3);

for (Person person : personSet) {
    System.out.println(person);
}

Zauważ że wynik na konsoli może różnić się od tego w jakiej kolejności elementy zostały dodane. W moim przypadku wynik jest następujący:

Konrad
Amigo
Arek

Tak jak zostało to powiedziane w poprzednim wpisie, HashSet nie zapewnia nam specyficznej kolejności, przechowuje elementy według własnej wewnętrznej implementacji bazując na metodzie hashCode.

O HashSecie możemy myśleć jak o worku do którego rzucamy elementy tego samego typu. Wrzucamy wszystko do jednego wora, ale nie wiemy w jakiej kolejności będziemy z tego wora wyciągać.

W poprzednim wpisie powiedziałem o tym że Set nie może przechować duplikatów, rozważmy taki przykład:

Set<Person> personSet = new HashSet<>();
personSet.add(person1);
personSet.add(person1);
personSet.add(person2);
personSet.add(person3);

for (Person person : personSet) {
    System.out.println(person);
}

Zauważ że obiekt person1 (Arek) został dodany dwukrotnie do Setu, a oto co się stanie w momencie kiedy wpiszemy jego elementy na konsolę:

Konrad
Amigo
Arek

Arek – został wypisany tylko raz, stało się tak dlatego że próbowaliśmy dodać dwukrotnie ten sam obiekt do setu, drugi obiekt nie został dodany. Cała sekwencja wyglądała następująco: podczas dodawania elementu do Setu, pobierany jest hashCode elementu, Set widzi że już posiada taki hashCode, wywołuje wówczas metodę equals aby sprawdzić czy obiekty są takie same. Jeśli equals zwróci true, obiekt nie jest dodawany do Setu.

Okej, ale co w przypadku gdy dwa różne obiekty posiadają taki sam hashCode? Tak swoją drogą jest to częste pytanie na rozmowach rekrutacyjnych, czy można dodać dwa obiekty do hashSetu o takim samym hashCodzie? Można! Pod warunkiem że equals dla tych obiektów zwraca false! 😊

Małe ostrzeżenie! Nie wystarczy stworzyć nowy obiekt person i nadać mu takie samo imię (np. Arek). Przeanalizujmy sobie ten przykład. Tworzymy nowy obiekt person (person4), dajemy mu imię Arek oraz dodajemy do setu:

Person person1 = new Person("Arek");
Person person2 = new Person("Konrad");
Person person3 = new Person("Amigo");
Person person4 = new Person("Arek");

Set<Person> personSet = new HashSet<>();
personSet.add(person1);
personSet.add(person1);
personSet.add(person2);
personSet.add(person3);
personSet.add(person4);

for (Person person : personSet) {
    System.out.println(person);
}
Konrad
Amigo
Arek

Na konsoli ciągle widzimy tylko 3 unikalne imiona! W skrócie przypomnę że equals sprawdza czy obiekty są „takie same.” Więc dla dwóch różnych obiektów klasy Person o takich samym imionach metoda equals zwróci true. Po więcej odsyłam do lekcji na temat equals oraz hashCode.

Dobrze! Jak zatem osiągnąć stan w którym w HashSecie posiadamy dwa obiekty o takim samym hashCodzie?  😉 Całkiem prosto! Utwórzmy sobie pusty interfejs, nazwijmy go Hero 😉 oraz zaimplementujmy go w naszej klasie Person:

public interface Hero {}
public class Person implements Hero

Na pierwszy rzut oka nic się nie zmieniło prawda? Okej! Tworzymy nową klasę np. SuperPerson, która jest praktycznie taka sama jak klasa Person, posiada tylko pole o nazwie imię. Nasza nowa klasa SuperPerson również implementuje interfejs Hero.

public class SuperPerson implements Hero {

    private String name;

    public SuperPerson(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        SuperPerson superPerson = (SuperPerson) o;
        return Objects.equals(name, superPerson.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

    @Override
    public String toString() {
        return name;
    }
}

Teraz możemy utworzyć HashSet który będzie przechowywać elemnty Hero!

Person person = new Person("Arek");
SuperPerson superPerson = new SuperPerson("Arek");

Set<Hero> heroSet = new HashSet<>();
heroSet.add(person);
heroSet.add(superPerson);

for (Hero hero : heroSet) {
    System.out.println(hero);
}

Mamy tutaj przykład w którym dwa różne obiekty (wywołanie dla nich metody equals zwróci false), posiadają taki sam hashCode są w tym samym Secie 😊

03. LinkedHashSet

Główną różnicą pomiędzy LinkedHashSetem a HashSetem jest zachowanie kolejności podczas dodawania do Setu. LinkedHashSet przechowuje elementy w kolejności ich dodania. Powróćmy do pierwszego przykładu, z wyjątkiem iż zamiast HahsSetu utworzymy LinkedHashSet

Person person1 = new Person("Arek");
Person person2 = new Person("Konrad");
Person person3 = new Person("Amigo");

Set<Person> personSet = new LinkedHashSet<>();
personSet.add(person1);
personSet.add(person2);
personSet.add(person3);

for (Person person : personSet) {
    System.out.println(person);
}

Elementy na konsoli zostaną wypisane w kolejności w jakiej zostały dodane do LinkedHashSet.

Arek
Konrad
Amigo

04. TreeSet

TreeSet zapewnia nam że elementy będą posortowane w odpowiedni sposób. Elementy w TreeSecie powinny implementować interfejs Comparable, lub powinniśmy podać podczas tworzenia TreeSetu comperator według którego obiekty będą sortowane. Dodajemy do klasy Person interfejs Comperable:

public class Person implements Hero, Comparable<Person> {

    private String name;

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

    public String getName() {
        return name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

    @Override
    public String toString() {
        return name;
    }

    @Override
    public int compareTo(Person o) {
        return this.name.compareTo(o.getName());
    }
}

Nasz klasa implementuje teraz interfejs Comperable, wraz z odpowiednią metodą compareTo. Dodaliśmy również getter dla pola name.

Oraz tworzymy TreeSet:

Set<Person> personTreeSet = new TreeSet<>();
personTreeSet.add(person1);
personTreeSet.add(person2);
personTreeSet.add(person3);

for (Person person : personTreeSet) {
    System.out.println(person);
}
Amigo
Arek
Konrad

Podsumowanie

Uf! Sety mamy za sobą! Chociaż jeśli miałbym być szczery to i tak wierzchołek góry lodowej. Jednak tematy które zostały tu opisane dają solidną podstawę aby temat explorować dalej samemu, do czego serdecznie zachęcam!