Zawiłości Javy #3

Zapraszam Cię do trzeciej już części zawiłości języka Javy! 🏆 Dzisiaj przyjrzymy się dziedziczeniu, konstruktorom, importom, inkrementacji oraz przeciążaniu metod. Jest to sporo zagadnień, ale mam nadzieję, że chociaż jedna rzecz będzie dla Ciebie przydatna.

Jeśli nie czytałeś bądź nie czytałaś moich wcześniejszych wpisów to serdecznie zapraszam Cię do zapoznania się z nimi! Linki znajdziesz poniżej:

Domyślne konstruktory a dziedziczenie

Każda klasa, gdy jest tworzona, uzyskuje domyślnie bezargumentowy konstruktor jeśli żaden nie zostanie dla niej jasno zdefiniowany. W przypadku, gdy podamy chociaż jeden konstruktor z parametrami to nie będzie już dostępu do takiego bez argumentów (chyba, że sami go napiszemy). Java dba o to, aby zawsze istniała chociaż jedna możliwość stworzenia naszego obiektu. Jednak jak to się ma do dziedziczenia? Przyjrzyjmy się następującemu przypadkowi.

public class ConstructorExample {
    
    private String name;
    private int age;

    public ConstructorExample(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

class SubConstructorExample extends ConstructorExample {
    
    private String nationality;

    public SubConstructorExample(String nationality) { // BŁĄD KOMPILACJI!
        this.nationality = nationality;
    }
}

Nasz kod się nie skompiluje, ponieważ nie jest możliwe zdefiniowanie klasy SubConstructorExample w taki sposób. Gdy dziedziczymy po klasie, która nie posiada bezargumentowego konstruktora, ale ma zdefiniowany konstruktor z parametrami to musimy jawnie wywołać super() w konstruktorze podklasy podając wymagane argumenty!

class SubConstructorExample extends ConstructorExample {
    
    private String nationality;

    public SubConstructorExample(String nationality) {
        super("Class", 10);
        this.nationality = nationality;
    }
}

Teraz nasz kod bez problemu zostanie skompilowany. Akurat w Javie wydaje mi się, że stosunkowo rzadko korzysta się z dziedziczenia na rzecz interfejsów. Dodatkowo takie rzeczy wyłapie za nas środowisko, w którym programujemy. Jednak ma to znaczenie podczas przygotowań do egzaminu OCA.

Konstruktor w abstrakcji

Ciekawym faktem jest to, że nie możemy stworzyć instancji klasy abstrakcyjnej, ale możemy zdefiniować w niej konstruktor! Oczywiście ma to sens, ponieważ każda klasa, nawet abstrakcyjna, dziedziczy po klasie Object. Należy podkreślić również, że klasa dziedzicząca w swoich konstruktorach zawsze domyślnie wywołuje konstruktor nadklasy super() bez argumentów. Z tego powodu podklasa z poniższego przykładu wywoła bezargumentowy konstruktor klasy abstrakcyjnej.

public abstract class AbstractConstructor {

    public AbstractConstructor() {
        System.out.println("Hello from AbstractConstructor!");
    }

    public abstract void goodbye();
}

class ExtensionClass extends AbstractConstructor {

    public ExtensionClass() {
        System.out.println("Hello from ExtensionClass!");
    }

    @Override
    public void goodbye() {
        System.out.println("Bye from ExtensionClass!");
    }

    public static void main(String[] args) {
        AbstractConstructor abstractConstructor = new ExtensionClass();
        abstractConstructor.goodbye();
    }
}

Na potwierdzenie wstawiam tutaj wynik działania powyższego programu.

Hello from AbstractConstructor!
Hello from ExtensionClass!
Bye from ExtensionClass!

Importowanie klas o takiej samej nazwie

W jaki sposób Java rozwiązuje przypadki, gdy do jednej klasy chcemy zaimportować dwie klasy o takiej samej nazwie? W takiej sytuacji jesteśmy zmuszeni do podania pełnej nazwy pakietowej, ale tylko dla jednej, wybranej klasy. Nie jest to zbyt eleganckie, ale niestety nie ma innego rozwiązania. Java wymusza, aby w ramach jednego pakietu nie można było stworzyć dwóch takich samych klas o tych samych nazwach. Z tego powodu każda klasa ma zapewnioną unikatową nazwę pakietową.

package examples.importing.a;

public class Foo {}
package examples.importing.b;

public class Foo {}
package examples.importing;

import examples.importing.a.Foo;

public class ImportClass {

    private Foo fooA;
    private examples.importing.b.Foo fooB;
}

Tak to się przedstawia. Nie jest zbyt okazałe dla oka, ale trzeba jakoś z tym żyć. Oczywiście możemy też zastosować pełną nazwę pakietową dla fooA. Warto dodatkowo zwrócić uwagę na fakt, że dla klasy zmiennej fooB nie musimy dodawać importu, pełna nazwa pakietowa nas z tego zwalnia.

Wspomniałem, że pełna nazwa pakietowa jest zawsze unikatowa, ale czy na pewno? Co w przypadku, gdy nasz pakiet nazwiemy tak samo jak ma się to w zewnętrznej bibliotece? Tutaj niestety nie będziemy mogli skorzystać z klasy z biblioteki, będziemy zdani na naszą klasę. Tworzymy taką o to pustą klasę w naszym projekcie znajdującą się w specjalnym pakiecie, który popatrzyliśmy z framework’a Spring.

package org.springframework.boot;

public class SpringApplication {}

Następnie chcemy utworzyć także typową klasę rozruchową dla Spring’a.

package examples.importing.samename;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ExampleApplication {

	public static void main(String[] args) {
		SpringApplication.run(ExampleApplication.class, args); // BŁĄD KOMPILACJI!
	}
}

Otrzymamy błąd kompilacji mówiący o tym, że klasa SpringApplication nie zawiera metody run, chociaż wiemy, że taka standardowa formuła działa w przypadku Spring Boot’a! Jest ona automatycznie generowana wraz z projektem i powinna działać bez zarzutów. Jednak nie jest to możliwe, gdy sami zdefiniujemy w naszym projekcie pakiet org.springframework.boot z klasą SpringBootApplication. Wtedy ma ona pierwszeństwo przed jarką Spring’a. Myślę, że to jest jeden z ważnych powodów, dlaczego nazwa startowa pakietu powinna zaczynać się od odwróconej domeny. Czyli dla strony nauka.javy.pl nazwa pakietu w której powinniśmy trzymać nasze klasy powinna nazywać się pl.javy.nauka. Nazwy domen są na pewno unikatowe i w ten sposób unikniemy dublowania nazw i takich o to komplikacji.

Tajniki pre i post inkrementacji

Muszę przyznać, że podczas nauki do egzaminu OCA natrafiłem na bardzo ciekawy przypadek z inkrementacją. Nie wiedziałem, że w ten sposób można napisać kod, zatem przejdźmy do przykładu.

public class Operators {

    public void preCount() {
        int i = 4;

        while (i < 7) {
            i = ++i;

            if (i == 4) {
                break;
            }

            System.out.println("PreCount");
        }
    }

    public void postCount() {
        int i = 4;

        while (i < 7) {
            i = i++;

            if (i == 4) {
                break;
            }

            System.out.println("PostCount");
        }
    }

    public static void main(String[] args) {
        Operators operators = new Operators();
        operators.preCount();
        operators.postCount();
    }
}

Jako rezultat uzyskujemy taki oto output.

PreCount
PreCount
PreCount

Jak widać napis PostCount nie wyświetlił się ani razu. Ciekawe prawda? Mam nadzieję, że nikt tak nie pisze kodu, bo potrafi wprowadzić w konsternację 😵. Dlaczego, więc tak się stało? Ogólnie preinkrementacja działa w taki sposób, że najpierw zwiększa wartość zmiennej o 1, a potem wyrażenie jest ewaluowane. Natomiast w postinkrementacji jest odwrotnie. Najpierw wywoływane jest wyrażenie, a następnie zwiększana jest wartość zmiennej o 1. No dobrze, ale to nie powinno mieć wpływu na nasz program, a jednak ma. Wynika to z faktu kolejności operacji dla postinkrementacji:

  • przed wejściem do pętli zmienna i jest równa 4;
  • potem z racji postinkrementacji wartość zmiennej i (wynoszącej 4) jest zapisywana w pamięci w celu wykorzystania jej później;
  • przypisywana jest nowa wartość 5 do zmiennej i (z racji inkrementacji i++);
  • następnie zwracana jest wartość 4, która została zachowana w pamięci i przypisywana jest do zmiennej i!

Tym właśnie różni się postinkrementacja od preinkrementacji. Wartością zwracaną z wyrażenia i++ jest i, natomiast dla ++i jest to już i+1. Dlatego właśnie nie otrzymaliśmy PostCount w wyniku ani razu. Trzeba mieć się cały czas na baczności. Kolejnym ciekawym przykładem jest sumowanie zmiennych, które są inkrementowane.

int a = 1;
int b = 2;
System.out.println("a = " + a);
System.out.println("b = " + b);
System.out.println("a + b = " + (a + b));
System.out.println("a++ + ++b = " + (a++ + ++b));
System.out.println("a = " + a);
System.out.println("b = " + b);

W rezultacie otrzymujemy.

a = 1
b = 2
a + b = 3
a++ + ++b = 4
a = 2
b = 3

Wartość zmiennych a i b uległa zmianie o 1. Jednak przy dodawaniu tych liczb do siebie podczas ich preinkrementacji oraz postinkrementacji uzyskujemy wynik 4. Wynika to z tego samego faktu co wcześniejszy przykład. Do sumy używana jest stara wartość a, a dopiero po wykonaniu wyrażenia zwiększana jest jej wartość o 1.

Bonus!

To jeszcze na koniec bonusowa zagadka! Co by się stało, gdybyśmy usunęli te nawiasy przy sumowaniu. Sprawdźmy!

int a = 1;
int b = 2;
System.out.println("a = " + a);
System.out.println("b = " + b);
System.out.println("a + b = " + a + b);
System.out.println("a++ + ++b = " + a++ + ++b);
System.out.println("a = " + a);
System.out.println("b = " + b);
a = 1
b = 2
a + b = 12
a++ + ++b = 13
a = 2
b = 3

Co to za wynik?! 1 + 2 = 12?! Dzieje się to z powodu tego, że wyrażenia w Javie wykonywane są od lewej do prawej. Gdy pierwszy w wyrażeniu jest String to następne wartości do niego dodawana są zamieniane na literał i do niego dodawane. Stąd mamy taki oto kwiatek! Dlatego dla takiego przypadku:

System.out.println(a + b + ", a + b = " + a + b);

Uzyskamy taki wynik:

3, a + b = 12

Prawda, że szokujące? 😎

Przeciążanie metod dla typów prymitywnych

Załóżmy, że posiadamy 3 przeciążone metody przyjmujące po jednym parametrze: short, Byte oraz int. Wywołując tą metodę ze zmienną typu byte jaki rezultat uzyskamy? Sprawdźmy!

public class Overloading {

    public void method(short number) {
        System.out.println("short");
    }

    public void method(Byte number) {
        System.out.println("Byte");
    }

    public void method(int number) {
        System.out.println("int");
    }

    public static void main(String[] args) {
        byte number = 2;

        Overloading overloading = new Overloading();
        overloading.method(number);
    }
}

Wynikiem tego wywołania jest short. Okazuje się, że byte będzie promowany do „najbliższego” dla siebie typu prymitywnego, a nie jakby mogło się wydawać do klasy Byte. Trzeba zapamiętać, że Java jest leniwa i to dla niej łatwiejsza operacja niż wywołanie autoboxing’u. Inaczej sprawa wygląda jeśli mamy do czynienia z dodawaniem:

byte a = 2;
short b = 3;

short result = a + b; // BŁĄD KOMPILACJI!

Kompilacja w takim przypadku nie powiedzie się. Przy dodawaniu dwóch mniejszych typów prostych od int, czyli byte i short, nie możemy przypisać rezultatu do zmiennej typu short. Okazuje się, że musi ona większego typu. Ma to uzasadnienie takie, że te typy są dosyć małe i łatwiej jest przewidzieć, że przy ich dodawaniu zakres short zostanie przekroczony. Dlatego dla bezpieczeństwa Java wymaga od nas przypisania rezultatu do int. Natomiast można założyć, że dodawanie zmiennych int do siebie jest bezpieczne i rezultat tej operacji na spokojnie można przypisać od zmiennej int, a nie do większej long. Przy okazji należy podkreślić fakt, że do zmiennej typu char możemy przypisywać liczby a do zmiennej byte czy short bez problemu przypiszemy znak.

char a = 2;
byte b = 't';

int result = a + b;

Oczywiście na koniec ich sumę trzeba przypisać do zmiennej typu int. W ten sposób result będzie miał wartość 118!

Podsumowanie

Sporo materiału za nami, ale mam nadzieje, że przyda Ci się chociaż część z tego co napisałem. Dla mnie to były naprawdę ciekawe łamigłówki. Może sam również wpadłeś na jakieś ciekawe zachowania Javy podczas pracy? Podziel się nimi ze mną za pośrednictwem maila bądź napisz o nich w komentarzu!

Podziel się tym z innymi!

Może Ci się również spodoba