DevCezz

Programistyczny blog dla Ciebie

zawiłości-java
Java

Zawiłości języka Java #4

Zapraszam Cię do czwartej i już ostatniej części zawiłości języka Java! Na sam koniec tej serii wpisów chciałbym się podzielić zagadnieniami związanymi z blokiem try-with-resources, klasą wewnętrzną, zasadami związanymi z metodami hashCode i equals, klasami generycznymi oraz jak działa method reference. Znowu poruszymy wiele zagadnień, ale mam nadzieje, że będą one dla Ciebie przydatne tak jak poprzednie wpisy! Jeśli nie czytałeś bądź nie czytałaś moich wcześniejszych artykułów o Javie to serdecznie zapraszam Cię do zapoznania się z nimi!

Lista wszystkich wpisów dotyczących Zawiłości języka Java:
#1 – Zawiłości języka Java #1
#2 – Zawiłości języka Java #2
#3 – Zawiłości języka Java #3
#4 – Zawiłości języka Java #4

Zawiłości try-with-resources

Gdy kończymy pracę z zewnętrznym zasobem (np. bazą danych czy plikiem) musimy pamiętać, aby zamknąć połączenie umożliwiające nam wymianę danych ze „światem”. We wcześniejszym podejściu try-catch taką operację należało zdefiniować w bloku finally. Wraz z Javą 7 doszła ciekawa alternatywa w postaci try-with-resources, która wyręcza dewelopera z konieczności zamykania połączenia. Sprawdźmy jak to wygląda na przykładzie.

public static void copyLine(Path pathIn, Path pathOut) throws IOException {
    BufferedReader reader = null;
    BufferedWriter writer = null;

    try {
        reader = Files.newBufferedReader(pathIn);
        writer = Files.newBufferedWriter(pathOut);
        writer.write(reader.readLine());
    } finally {
        if (reader != null) {
            reader.close();
        }

        if (writer != null) {
            writer.close();
        }
    }
}

Jak na prostą operację kopiowania linijki z jednego pliku do drugiego trzeba napisać sporo kodu (nawet przy delegacji obsługi wyjątku IOException). Oczywiście wraz z Javą 11 doszło API ułatwiające tego typu operacje na plikach, ale nie na tym się teraz skupiamy. Wracając do przykładu sprawdźmy jak będzie on wyglądał przy użyciu try-with-resources.

public static void copyLine(Path pathIn, Path pathOut) throws IOException {
    try (
        BufferedReader reader = Files.newBufferedReader(pathIn);
        BufferedWriter writer = Files.newBufferedWriter(pathOut)
    ) {
        writer.write(reader.readLine());
    }
}

Prawda, że widać różnice? Ten zabieg oczywiście jest automatycznie podpowiadany przez IntelliJ: „’try’ can use automatic resource management – Replace with ‘try’ with resources”. Jedynym haczykiem jest fakt, że klasa obsługująca połączenie z zewnętrznymi zasobami musi implementować interfejs AutoCloseable. Jest to interfejs funkcyjny (posiada tylko jedną metodę), którego zadaniem jest wymuszenie na klasie napisania sposobu zamknięcia komunikacji czy to np. z bazą danych bądź plikiem.

public interface AutoCloseable {
    void close() throws Exception;
}

Dodatkowo warto nadmienić, że w bloku try-with-resources nie ma potrzeby definiowania czy to bloku catch bądź też finally jak to miało miejsce dla try-catch. Zawsze programista ma mniej boilerplate kodu do napisania.

Zawiłości klas wewnętrznych

W Javie możemy rozróżnić cztery typy klas wewnętrznych:

  • Klasa wewnętrzna, która nie jest statyczna, zdefiniowana na tym samym poziomie co zmienne instancyjne.
  • Lokalna klasa wewnętrzna zdefiniowana na poziomie metody.
  • Anonimowa klasa wewnętrzna, która jest specjalnym typem klasy lokalnej (nie posiada nazwy).
  • Statyczna klasa wewnętrzna zdefiniowana na tym samym poziomie co zmienne statyczne.

Po przedstawieniu różnych rodzajów klas wewnętrznych chciałbym przejść do dwóch przykładów, które mogą wydawać się najbardziej interesujące. Pierwszym z nich jest tworzenie instancji klasy wewnętrznej, która nie została zdefiniowana jako statyczna.

public class OuterMatrioshka {
    
    private String greeting = "Zdrastwujtie!";

    class InnerMatrioshka {

        public void sayHello() {
            System.out.println(greeting);
        }
    }

    public static void main(String[] args) {
        OuterMatrioshka outer = new OuterMatrioshka();
        InnerMatrioshka inner = outer.new InnerMatrioshka();
        inner.sayHello();
    }
}

Klasa wewnętrzna bez problemów może korzystać z pól naszej klasy. W sumie nie powinno to dziwić, ponieważ jest ona „własnością” klasy OuterMatrioshka. Ciekawy jest za to sposób tworzenia nowego obiektu InnerMatrioshka. W celu jego utworzenia należy najpierw stworzyć obiekt OuterMatrioshka i poprzez niecodzienny zapis dopiero można zabrać się za kreacje obiektu InnerMatrioshka InnerMatrioshka inner = outer.new InnerMatrioshka();

Drugim zagadnieniem jest odwoływanie się do zmiennych klas zewnętrznych o tej samej nazwie co klas wewnętrznych. W tym przypadku należy w specjalny sposób używać słowa kluczowego this, zobaczmy.

public class OuterMatrioshka {
    private int level = 1;

    class InnerMatrioshka {
        private int level = 2;

        class InnermoreMatrioshka {
            private int level = 3;

            public void showLevels() {
                System.out.println(level);                          // 3
                System.out.println(this.level);                     // 3
                System.out.println(InnermoreMatrioshka.this.level); // 3
                System.out.println(InnerMatrioshka.this.level);     // 2
                System.out.println(OuterMatrioshka.this.level);     // 1
            }
        }
    }

    public static void main(String[] args) {
        OuterMatrioshka outer = new OuterMatrioshka();
        OuterMatrioshka.InnerMatrioshka inner = outer.new InnerMatrioshka();
        OuterMatrioshka.InnerMatrioshka.InnermoreMatrioshka innermore = inner.new InnermoreMatrioshka();
        innermore.showLevels();
    }
}

Myślę, że ten przykład prawidłowo odzwierciedla ideę matrioszki 😉. Skupmy się na rezultacie uzyskanym z metody showLevels. Mamy w niej pięć linijek odnoszących się do pól każdej z klas, które mają taką samą nazwę. Trzy pierwsze linijki pokazują sposób w jaki można odwołać się pola level klasy InnermoreMatrioshka. Następnie poprzez konstrukcję InnerMatrioshka.this.level wyświetlana jest wartość 2 identyfikująca się z klasą InnerMatrioshka oraz poprzez OuterMatrioshka.this.level uzyskujemy 1 odzwierciedlając wartość pola klasy OuterMatrioshka. Jest to niecodzienny sposób pisania kodu i mam nadzieje, że taki pozostanie 😉.

Zawiłość equals

Do sprawdzenia równości typów prymitywnych bądź też tego czy dwie zmienne referują do tego samego obiektu używamy zapisu `==`. Jednak, aby sprawdzić czy dwa obiektu są identyczne należy zaimplementować metodę equals, ponieważ domyślnie zachowuje się ona jak `==`. Można wykonać następujący eksperyment, aby się o tym przekonać.

StringBuilder stringBuilder1 = new StringBuilder("aaaa");
StringBuilder stringBuilder2 = new StringBuilder("aaaa");
System.out.println(stringBuilder1.equals(stringBuilder2); // false

Dzieje się tak, ponieważ właśnie StringBuilder nie nadpisuje metody equals. W tej klasie możemy przeczytać odpowiedni komentarz.

{@code StringBuilder} implements {@code Comparable} but does not override {@link Object#equals equals}. Thus, the natural ordering of {@code StringBuilder} is inconsistent with equals.

Czym jest równość obiektów?

No dobrze, ale wracając do sedna. Jak prawidłowo zaimplementować metodę equals? O to pięć punktów, które składają się na definicję równości.

  • Ten sam obiekt jest sobie równy. Jeśli istnieje zmienna x, która nie jest null to wynik operacji x.equals(x) powinien być zawsze true.
  • Równość powinna być symetryczna. Jeśli istnieją zmienne x i y, które nie są null to wynik operacji x.equals(y) powinien zwrócić true tylko wtedy, gdy y.equals(x) też zwraca true.
  • Równość powinna być przechodnia. Jeśli istnieją zmienne x, y i z, które nie są null to jeżeli wynik operacji x.equals(y) zwraca true i y.equals(z) też zwraca true to z.equals(x) powinien zwrócić true.
  • Równość powinna być konsekwentna. Jeśli istnieją zmienne x i y, które nie są null to wielokrotne wywołanie operacji x.equals(y) powinno zawsze zwrócić ten sam wynik, czyli true lub false, oczywiście jeśli żaden z obiektów nie został zmodyfikowany w trakcie.
  • Sprawdzenie równości z null powinno zawsze zwrócić false. Jeśli istnieje zmienna x, która nie jest null to wynik operacji x.equals(null) powinien być zawsze false.

Te zasady wydają się mieć sens. Równość obiektów nie może zależeć od losowości albo te same obiekty nie mogą być tylko czasem sobie równe.

Poprawne nadpisanie metody equals

Dodatkowo nadpisując metodę equals warto mieć na uwadze to czy faktycznie ją nadpisaliśmy. Sygnatura metody equals w klasie Object przedstawia się następująco.

public boolean equals(Object obj)

Gdy w naszej klasie zmienimy typ argumentu to nie będziemy już mogli umieścić nad tą metodą adnotacji @Override. Trzeba mieć się na baczności!

public boolean equals(Lion o) { ... }

Zamiast Object została użyta klasa Lion przez co metoda equals nie jest nadpisana. Może to doprowadzić do takiej nieścisłości.

Lion a = new Lion();
Lion b = new Lion();
System.out.println(a.equals(b)); // true
Lion oa = a;
Lion ob = b;
System.out.println(oa.equals(ob)); // false

Z tego powodu warto zwrócić uwagę na fakt czy na pewno nadpisujemy metodę equals czy jednak ją przeciążamy.

Zawiłość hashCode

Dobrą praktyką jest, aby wraz z nadpisaniem metody equals nadpisać także metodę hashCode, która używana jest do wyliczenia wartości klucza w celu zapisania obiektu w mapie. Jednak co łączy te dwie metody? Zacznijmy od przedstawienia punktów kontraktu jaki musi spełniać metoda hashCode.

  • Podczas działania programu rezultat zwracany z hashCode nie może się zmienić. Z tego powodu wartość uzyskiwana z tej metody nie może zawierać zmiennych, które mogą ulec zmianie np. dla klasy konta bankowego na pewno nie może to być suma pieniędzy, ponieważ często jest ona inna.
  • W przypadku, gdy metoda equals dla danej klasy zwraca true przy porównywaniu dwóch obiektów to wywołanie hashCode dla nich musi zwrócić ten sam rezultat. Oznacza to, że hashCode to wyliczenia wartości powinno używać podzbioru zmiennych, które wykorzystywane są w equals.
  • W przypadku, gdy metoda equals dla danej klasy zwraca false przy porównywaniu dwóch obiektów to wywołanie hashCode na nich niekoniecznie musi zwracać różne wyniki. Oznacza to, że hashCode nie musi być unikalny, gdy wywoływany jest na nierównych sobie obiektach.

Stąd wniosek, że praktycznie konieczne jest nadpisywanie hashCode, gdy implementujemy equals.

public class BankAccount {
    
    private final String name;
    private final String kind;
    private BigDecimal amount;

    public BankAccount(String name, String kind) {
        this.name = name;
        this.kind = kind;
        this.amount = BigDecimal.ZERO;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        BankAccount that = (BankAccount) o;
        return name.equals(that.name) &&
                kind.equals(that.kind) &&
                amount.equals(that.amount);
    }

    @Override
    public int hashCode() {
        return name.hashCode() + 3 * kind.hashCode();
    }
}

W przykładzie widać, że przy metodzie equals ważne jest dla nas, aby wszystkie zmienne klasy były równe ze zmiennymi obiektu, z którym porównujemy nasz obiekt. Natomiast w hashCode użyliśmy podzbioru, który uznaliśmy, że posiada niezmienniki typu nazwa czy rodzaj konta.

Złożoności klas generycznych

Klasy generyczne zostały stworzone, aby zaspokoić potrzebę kontroli typów. Wcześniej, czyli przed Javą 5, musieliśmy mieć nadzieję, że programiści będą pamiętać co znajduje się np. w danej liście.

List animals = new ArrayList();

Taki zapis nic nam tak naprawdę nie mówi. Wewnątrz zespołu możemy ustalić, że w tej liście będą przechowywane obiekty klasy Animal, ale osoba z zewnątrz bez większego problemu może dodać np. String. Dla kompilatora nie ma przy takim zapisie żadnej różnicy, dopuszcza każdy obiekt dowolnej klasy do tej listy. Dopiero podczas działania aplikacji można otrzymać informację zwrotną w postaci wyjątku ClassCastException. Java 5 była pod tym kątem przełomowa, a Java 7 jeszcze ulepszyła to podejście.

List<Animal> animals = new ArrayList<>();

Dzięki operatorowi <> możemy przy deklaracji zmiennej wymusić, aby do listy dodawać tylko jeden typ oraz jego pochodne. W tym przypadku w animals możemy umieszczać tylko obiekty klasy Animal lub jej podklasy. Od Javy 7 przy inicjalizacji nie ma już potrzeby podawania tego samego zapisu co przy deklaracji, wystarczy sam operator <>. Teraz kompilator już nie popuścić, będzie pilnował czy dodawane typy są ze sobą zgodne.

Sama idea klas generycznych pomaga podczas tworzenia bibliotek, więc podejrzewam, że będziesz z nich głównie tylko korzystał bądź korzystała. Jednak celem tej sekcji jest zagłębienie się w sposób ograniczania typów podczas deklaracji zmiennej używającej klasy generycznej. Służą do tego tak zwane „wildcards”, które reprezentowane są przez „?”. Można z nich korzystać w trzech różnych wariantach.

  • nieograniczone użycie (List<?>)
  • ograniczenie z góry (List<? extends type>)
  • ograniczenie z dołu (List<? super type>)

Nieograniczone użycie

Zapisując „?” w np. liście zgadzamy się na każdy typ danych. Spójrzmy co to oznacza.

List<Object> kinds = new ArrayList<String>(); // BŁĄD KOMPILACJI
List<?> names = new ArrayList<String>();

Kompilator zabroni nam przypisać listę String do listy Object. W ten sposób kompilator chroni nas przed nami samymi. Gdyby to było możliwe to klasy generyczne straciły by sens. Natomiast jeśli świadomie zgadzamy się, że przyjmujemy dowolny typ do listy możemy użyć właśnie „wildcard”. Co ciekawe dalej będziemy chronieni przed nieprawidłowym użyciem, a nawet przed dodaniem prawidłowej wartości.

List<?> names = new ArrayList<String>();
names.add(„Andrzej”); // BŁĄD KOMPILACJI

Podczas kompilacji otrzymamy błąd.

incompatible types: String cannot be converted to CAP#1
where CAP#1 is a fresh type-variable:
CAP#1 extends Object from capture of ?

Taki scenariusz zwany jest jako wildcard capture. Kompilator nie jest w stanie potwierdzić jaki typ obiektu jest dodawany do listy, więc rzuca błąd. Uważa on, że przypisujemy zły typ danych do zmiennej. Taki mechanizm właśnie dodają klasy generyczne, który został zaimplementowany przez deweloperów Oracle. Uzyskujemy w ten sposób zapewnienie bezpieczeństwa już na poziomie kompilacji.

Ograniczenie z góry

Przyjrzyjmy się następującemu zapisowi.

List<? extends Number> numbers = new ArrayList<Integer>();

Taka deklaracja i inicjalizacja jest jak najbardziej prawidłowa. Integer jest podklasą Number, czyli warunek jest spełniony. Jednak i tutaj kompilator nie pozwoli nam na dodawanie elementów do listy.

List<? extends Number> numbers = new ArrayList<Integer>();
numbers.add(Integer.valueOf(10)); // BŁĄD KOMPILACJI

Uzyskamy błąd podobny to tego co w poprzedniej sekcji. Dzieje się tak z powodu tego, że Java wymazuje typ generyczny przez kompatybilność wsteczna i uznaje go za Object.

List<? extends Number> numbers = Arrays.asList(1, 2, 3);
for (Number number : numbers) {
    System.out.println(number.longValue());
}

Taki kod zostaje przez Javę zamieniany do czegoś podobnego poniżej.

List<? extends Number> numbers = Arrays.asList(1, 2, 3);
for (Object obj : numbers) {
    Number number = (Number) obj;
    System.out.println(number.longValue());
}

Jest to bardzo interesujące, że klasa generyczna w tym przypadku jest traktowana praktycznie jako immutable. Technicznie rzecz ujmując możliwe jest usunięcie elementu z listy, ale nie ma możliwości dodawania nowych. Java nie wie czym tak naprawdę jest typ List<? extends Number>, więc woli nie ryzykować i nie pozwala na dodawanie teoretycznie poprawnych obiektów.

Ograniczenie z dołu

Inaczej sytuacja wygląda jeśli chodzi o ograniczenie typu generycznego z dołu.

List<? super IOException> exceptions = new ArrayList<Exception>();
exceptions.add(new IOException());

Taki kod w ogóle nie przeszkadza kompilatorowi. Możemy do niego dodawać obiekty typu IOException bez problemu. Jednak najbardziej mylący może być kod poniżej.

List<? super IOException> exceptions = new ArrayList<Exception>();
exceptions.add(new IOException());
exceptions.add(new FileNotFoundException());
exceptions.add(new Exception()); // BŁAD KOMPILACJI

Dlaczego kompilator nie pozwala nam dodać nadklasy Exception, ale pozwala na dodanie podklasy FileNotFoundException mimo tego, że przypisaliśmy listę Exception? Dzieje się tak, ponieważ kompilator nie wie tak naprawdę jaką listę będziemy chcieli przypisać do zmiennej exceptions. Jeśli przypiszemy listę Exception to jak najbardziej byśmy mogli dodać obiekty Exception oraz IOException. Natomiast, gdyby była to lista IOException, którą także możemy przypisać do exceptions, to już nie można byłoby dodać nowego obiektu Exception do tej listy.

Dla kompilatora dodanie obiektu FileNotFoundException jest jak najbardziej w porządku, bo jest podklasą IOException. Może to być jednak mylące przez to, że widzimy słowo kluczowe super.

Ten sam problem uwidacznia się nawet dla odczytywania wartości z listy.

List<? super IOException> exceptions = new ArrayList<Exception>();
for (Object exception : exceptions) {
	System.out.println(exception);
}
for (Exception exception : exceptions) { // BŁĄD KOMPILACJI
	System.out.println(exception);
}

Ograniczenie z dołu pozwala nam na przypisywanie dowolnej listy do zmiennej z typem generycznym, która spełnia warunek określony w operatorze <>.

List<? super IOException> exceptionsObject = new ArrayList<Object>();
List<? super IOException> exceptionsException = new ArrayList<Exception>();
List<? super IOException> exceptionsIOException = new ArrayList<IOException>();
List<? super IOException> exceptionsFileNotFoundException = new ArrayList<FileNotFoundException>(); // BŁAD KOMPILACJI

Na tym przykładzie widać jakie obiekty możemy przypisać do zmiennej typu List<? super IOException>. Z tego właśnie wynika ograniczenie co do odczytywania obiektów z listy. W sytuacji gdybyśmy przypisali new ArrayList<Object>() do zmiennej to nie moglibyśmy dodać Object do Exception przed czym broni nas kompilator! Ale bez problemu możemy napisać exceptionsObject.add(new IOException()).

Zawiłość Method reference

Method reference jest to naprawdę fajny mechanizm, która skraca zapis, jednak trzeba się do niego przyzwyczaić. Na pierwszy rzut oka nie widać co się kryje pod takim zapisem.

Set<Player> players = new TreeSet<>(Player::compareByPoints);

TreeSet może przyjmować w konstruktorze Comparator, który będzie instruował w jaki sposób sortować dodawane obiekty do zbioru. Jest on równoznaczny z poniższym zapisem.

Set<Player> players = new TreeSet<>(new Comparator<Player>() {
    @Override
    public int compare(Player p1, Player p2) {
        return p1.points - p2.points;
    }
});

Prawda, że pierwszy jest o wiele zwięźlejszy? Można oczywiście zastosować też wyrażenie lambda.

Set<Player> players = new TreeSet<>((p1, p2) -> p1.points – p2.points);

Chyba taki zapis jest najbardziej oczywisty, widać ile argumentów jest przekazywanych oraz w jaki sposób są one ze sobą porównywane. Jest to oczywiście kwestia dyskusyjna czy lepiej jest wynieść dany zapis do osobnej metody i użyć method reference czy jednak trzymać się wcześniejszych zapisów. Dla mnie jeśli ktoś korzysta z Javy 8 to powinien nauczyć się korzystać z wyrażeń lambda, ponieważ kod jest o wiele czytelniejszy!

Podsumowanie

To byłoby na tyle jeśli chodzi o serię wpisów związanych z zawiłościami języka Java. Mam nadzieję, że czytając te artykuły bawiłeś się tak samo świetnie jak ja przy ich pisaniu. Prosiłbym Cię o komentarz bądź też wiadomość mailową czy podobały Ci się zagadnienia tutaj poruszane. Nie zatrzymuje się i będę dalej wypuszczał kolejne wpisy poruszający inne kwestie programistyczne 💪!

Podziel się tym z innymi!