Monika Dekster

Java: Spis treści

  1. Historia Javy
  2. Cechy Javy
  3. Struktura Programu
  4. Typy Danych
  5. Operatory
  6. Instrukcje Sterujące
  7. Tablice
  8. Klasy
  9. Dziedziczenie
  10. Interfaces
  11. Nested classes
  12. Wyjątki
  13. Java Generics
  14. Lambda Expressions
  15. Wątki
  16. Reflection
  17. Java Streams
  18. Wejście / wyjście
  19. Pakiety
  20. Wzorce projektowe

Historia Javy

Wersje:

Schemat działania programu w Javie

Java

HotSpot / JiT

HotSpot – maszyna wirtualna Javy, dostarczana przez firmę Oracle Corporation razem z pakietem Java Runtime Environment. Wykorzystuje między innymi takie technologie jak kompilacja w locie oraz optymalizacja adaptacyjna. Maszyna wirtualna HotSpot napisana jest w języku C++. Na stronie internetowej projektu OpenJDK znajduje się informacja, że kod źródłowy projektu to ok. 250 tys. linii kodu. Na program składają się m.in.:

JIT (just-in-time compilation) to metoda wykonywania programów polegająca na kompilacji do kodu maszynowego przed wykonaniem danego fragmentu kodu.

Cała procedura wygląda następująco: kod źródłowy jest kompilowany do kodu pośredniego (bajtowego), program jest rozpowszechniany w postaci kodu pośredniego, na maszynie, na której program zostaje uruchomiony, maszyna wirtualna przeprowadza kompilację kodu pośredniego do kodu maszynowego. Kompilacja może się odbywać w momencie pierwszego dostępu do kodu znajdującego się w pliku lub pierwszego wywołania funkcji (stąd nazwa just-in-time).

Kompilacja JiT pozwala na uruchomienie aplikacji Javy nawet gdy kod wygenerowany przez kompilator nie jest jeszcze zoptymalizowany. Teoretycznie JiT zaczyna działać jak tylko dana metoda jest wywołana i kompiluje kod bajtowy tej metody na odpowiedni kod binarny "just in time to execute").

Po kompilacji JVM uruchamia kod metody bezpośrednio, zamiast ją interpretować, co przyspiesza działanie aplikacji. Jednak, na początku działania czasem zachodzić konieczność kompilacji tysięcy metod, co może spowodować wolny start JVM. Wniosek: JVM bez JiT startuje szybko, ale działa wolniej; JVM z JiT startuje wolniej ale działa szybciej.

Poniższy diagram ilustruje różne poziomy JVM i ich współzależność.

  1. Kod źródłowy Javy jest kompilowany do niezależnego od architektury kodu bajtowego (pliki 'class')
  2. Po uruchomieniu aplikacji, JVM ładuje skompilowane klasy i wykonuje program poprzez interpretera Javy.
  3. Jeżeli działa JiT, JVM analizuje statystykę wywołań poszczególnych metod i kompiluje te z nich, dla których osiągnięto odpowiednią wartość progową, na kod binarny danej platformy. Generalnie priorytet do szybkiej kompilacji uzyskują najczęściej wywoływane metody.
  4. Jeżeli dana metoda jest już skompilowana, JVM wykonuje ją bezpośrednio, bez konieczności interpretacji

JVM JIT

Cechy Javy

  1. Prostota - Java jest podobna do C/C++ ale
  2. Wymuszenie zorientowania obiektowego, bez dziedziczenia wielobazowego, refleksja
  3. Java jest niezależna od architektury - programy w Javie są kompilowane do postaci tzw. byte-codes
  4. Wielowątkowość na poziomie języka
  5. Automatyczne zwalnianie niepotrzebnej pamięci (Garbage collection)
  6. Bezpieczeństwo - kompilator dokonuje dokładnego sprawdzenia poprawności kodu, by uniknąć "niespodzianek" w czasie wykonania (np. sprawdzenie zgodności typów danych)
  7. Zestawy API dla szerokiego spektrum zastosowań
    • Java 2 Micro Edition (urządzenia o ograniczonych zasobach)
    • Java 2 Standard Edition
    • Java 2 Enterprise Edition
Jedyną metodą alokacji pamięci w Javie jest użycie operatora new. Z drugiej strony dealokacja pamięci odbywa sie automatycznie (garbage collector). Można manualnie wywołać funkcję System.gc();, co sugeruje, że GC powinien ruszyć (ale nie musi!). Można też przypisać nieużywanym referencjom wartość null, co ułatwia pracę GC.

Struktura programu

Image jstr

Image jstr

Pierwszy program


class HelloWorld {
    public static void main(String[] args) {
        System.out.println();
        System.out.println("Hello, World!\n");
    }
}

Typy danych

Java jest językiem programowania z silnym systemem typów. Oznacza to, że każda zmienna, atrybut czy parametr ma zadeklarowany typ. Kompilator wylicza typy wszystkich wyrażeń w programie i sprawdza, czy wszystkie operatory i metody są używane zgodnie z ich deklaracjami, czyli z argumentami odpowiednich typów. Także elementy każdej instrukcji muszą mieć właściwe typy, np. warunek w pętli while musi być wyrażeniem o wartości typu logicznego.

Specyficzną cechą Javy jest to, że typy w tym języku są podzielone na dwie kategorie:

  1. typy pierwotne
  2. typy referencyjne

Typy pierwotne to grupa ośmiu typów zawierających wartości proste. Tymi typami są:

  1. typ wartości logicznych: boolean,
  2. typy całkowitoliczbowe:
    • byte, 8-bit
    • short, 16-bit
    • int, 32-bit
    • long, 64-bit
    • char, 16-bit (unsigned)
  3. typy zmiennopozycyjne:
    • float, 32-bit
    • double, 64-bit

Typy referencyjne dzielą się z kolei na następujące kategorie:

  1. typy klasy
  2. typy interfejsy
  3. typy tablice

Wartościami typów referencyjnych są referencje (w pewnym uproszczeniu można o nich myśleć jako o wskaźnikach) do obiektów lub wartość null.

Ponadto istnieje typ o pustej nazwie, będący typem wyrażenia null. Ponieważ nie ma on nazwy, nie da się nawet zadeklarować zmiennej tego typu. Wartość tego typu można rzutować na dowolny typ referencyjny, więc można myśleć o wartości null jako o wartości każdego typu referencyjnego i nie przejmować się istnieniem tego typu.

Stałe

  1. stałe całkowite
    • dziesiętne: 536230
    • dwójkowe: 0b10101010
    • ósemkowe: 012300
    • szesnastkowe: 0xff, 0xab12
  2. stałe zmiennoprzecinkowe
    • float: 1e1f, 2.f, .3f, 0f
    • double: 1e1, .3, 0.0, 2.d, 0d
  3. stałe logiczne: true, false
  4. stałe znakowe:
    • 'a'
    • '\t' tab
    • '\n' linefeed
    • '\f' form feed
    • '\r' carriage return
    • '\"' double quote
    • '\\' backslash
    • '\'' single quote
    • '\uffff' Unicode; 4 cyfry szestnastkowe
    • '\177' kod oktalny, dla spójności z C

Od Java SE 7, w laterałach numerycznych może pojawić się dowolna liczba znaków podkreślnika (_). Można w ten sposób np. grupować cyfry w dużej liczbie w celu poprawienia czytelności.


long creditCardNumber = 1234_5678_9012_3456L;
long hexBytes = 0xFF_EC_DE_5EL;
long hexWords = 0xCAFE_BABEL;
long bytes = 0b11010010_01101001_10010100_10010010L

Zmienne

Zmienne są (zwykle) nazwanymi pojemnikami na pojedyncze wartości typu z jakim zostały zadeklarowane. Zmienne typów pierwotnych przechowują wartości dokładnie tych typów, zmienne typów referencyjnych przechowują wartość null albo referencję do obiektu typu będącego zadeklarowanym typem zmiennej bądź jego podklasą.

Wyróżniamy siedem rodzajów zmiennych:

  1. zmienne klasowe (class variables), statyczne pola klasy
  2. zmienne egzemplarzowe (instance variables), niestatyczne pola klasy
  3. zmienne lokalne (local variables),
  4. elementy tablic,
  5. parametry metod,
  6. parametry konstruktorów,
  7. parametry obsługi wyjątków.

Przy deklaracji czterech rodzajów zmiennych nie będących parametrami można jawnie podawać ich wartości początkowe. To bardzo dobra praktyka. Jeśli w deklaracji zmiennych klasowych, egzemplarzowych lub tablic nie podano ich wartość początkowej, to taka zmienna zostanie automatycznie zainicjalizowana wartością domyślną. Ta wartość zależy od typu zmiennej, ogólnie można by powiedzieć, że odpowiada wartości 0 (dla wartości logicznych będzie to false, a dla typów referencyjnych wartość null). W przypadku zmiennych lokalnych programista musi sam zadbać o zainicjalizowanie zmiennej przed jej odczytaniem. W przeciwnym przypadku kompilator zgłasza błąd.

Operatory

  1. operatory relacyjne (wynik jest typu boolean): <, <=, >, >=, ==, !=
  2. && || ! logiczne operatory warunkowe (tylko dla boolean)
  3. operatory numeryczne:
    • + − unarny
    • * ⁄ % dwuargumentowe
    • + − dwuargumentowe
    • ++ −− inkrementacja i dekrementacja (pre i postfiksowa)
  4. ?: operator warunkowy
  5. operator zmiany typu (cast)
  6. + operator konkatenacji stringów
  7. << >> >>> operatory przesunięcia (tylko dla typów całkowitych),
  8. logiczne operatory bitowe (tylko dla typów całkowitych lub boolean)
    • ~ operator dopełnienia bitowego
    • & | ^ (and, or, xor)
  9. += −= *= ⁄= &= |= ^= %= <<= >>= >>>= operatory modyfikacji

Uwagi dotyczące operatorów

Operator dzielenia 

Dzielenie całkowite daje wynik całkowity, np. 5 ⁄ 2 daje w wyniku 2 (a nie 2.5)

Operator modulo  %

Operator warunkowy

E1 ? E2 : E3

E1 musi być typu boolean

wartością wyrażenia jest E2 jeżeli E1 jest true i E3 jeżeli E1 jest false

np. c = (a > b) ? a : b oblicza maximum liczb a, b

Operatory zwiększania i zmniejszania

n++, n−− postfixowy

++n, −−n prefixowy

n = 5; m = n++; daje m = 5

n = 5; m = ++n; daje m = 6

Złożone operatory przypisania

Postać: E1 op= E2 jest równoważne:

E1 = (T)((E1) op (E2))

T jest typem E1, op jest jednym z:

+, −, *, ⁄, %, &, |, ^, <<, >>, >>>

Typy E1 i E2 muszą być typami prostymi (z wyjątkiem operatora +=, kiedy E2 może być dowolnym typem gdy E1 jest typu String

Konwersja typów

konwersje rozszerzające (nigdy nie tracą informacji, ale może nastąpić strata dokładności)

konwersje zawężające (mogą powodować utratę precyzji a także wartości zmiennej)

konwersje w instrukcji przypisania

przypisanie wyrażenia do zmiennej jest mozliwe jeżeli:

Przy wywoływaniu funkcji argumenty muszą być zgodne co do typu lub może zachodzić konwersja rozszerzająca. Konwersja zawężająca powoduje błąd kompilacji.

Promocje numeryczne

Unarne promocje numeryczne

Jeżeli argument jednego z operatorów unarnych (+, −, ~) jest byte, short, char to zostaje skonwertowany do typu int. W przeciwnymprzypadku typ nie jest zmieniany. Unarna promocja jest również dokonywana dla: każdego z operandów operatorów <<, >>, >>> osobno (by drugi operand typu long nie promował konwersji pierwszego operandu) oraz wyrażenia określającego wymiar lub indeks tablicy.

Binarne promocje numeryczne

Dokonywane dla operandów następujących operatorów dwuargumentowych:

Zasady konwersji

Formatowanie wyjścia: funkcja System.out.format()

Specifier Applies to Output
%a floating point (except BigDecimal) Hex output of floating point number
%b Any type "true” if non-null, "false” if null
%c character Unicode character
%d integer (incl. byte, short, int, long, bigint) Decimal Integer
%e floating point decimal number in scientific notation
%f floating point decimal number
%g floating point decimal number, possibly in scientific notation depending on the precision and value.
%h any type Hex String of value from hashCode() method.
 %n none Platform-specific line separator.
%o integer (incl. byte, short, int, long, bigint) Octal number
%s any type String value
%t Date/Time (incl. long, Calendar, Date and TemporalAccessor) %t is the prefix for Date/Time conversions. More formatting flags are needed after this. See Date/Time conversion below.
%x integer (incl. byte, short, int, long, bigint)

Hex string.

Date and Time Formatting

 Flag Notes
 %tA Full name of the day of the week, e.g. "Sunday", "Monday"
 %ta Abbreviated name of the week day e.g. "Sun", "Mon", etc.
 %tB Full name of the month e.g. "January", "February", etc.
 %tb Abbreviated month name e.g. "Jan", "Feb", etc.
 %tC Century part of year formatted with two digits e.g. "00” through "99”.
 %tc Date and time formatted with "%ta %tb %td %tT %tZ %tY” e.g. "Fri Feb 17 07:45:42 PST 2017"
 %tD Date formatted as "%tm/%td/%ty"
 %td Day of the month formatted with two digits. e.g. "01” to "31".
 %te Day of the month formatted without a leading 0 e.g. "1” to "31”.
%tF ISO 8601 formatted date with "%tY-%tm-%td".
%tH Hour of the day for the 24-hour clock e.g. "00” to "23".
%th Same as %tb.
%tI Hour of the day for the 12-hour clock e.g. "01” – "12".
%tj Day of the year formatted with leading 0s e.g. "001” to "366".
%tk Hour of the day for the 24 hour clock without a leading 0 e.g. "0” to "23".
%tl Hour of the day for the 12-hour click without a leading 0 e.g. "1” to "12".
%tM Minute within the hour formatted a leading 0 e.g. "00” to "59".
%tm Month formatted with a leading 0 e.g. "01” to "12".
%tN Nanosecond formatted with 9 digits and leading 0s e.g. "000000000” to "999999999”.
%tp Locale specific "am” or "pm” marker.
%tQ Milliseconds since epoch Jan 1 , 1970 00:00:00 UTC.
%tR Time formatted as 24-hours e.g. "%tH:%tM".
%tr Time formatted as 12-hours e.g. "%tI:%tM:%tS %Tp".
%tS Seconds within the minute formatted with 2 digits e.g. "00” to "60”. "60” is required to support leap seconds.
%ts Seconds since the epoch Jan 1, 1970 00:00:00 UTC.
%tT Time formatted as 24-hours e.g. "%tH:%tM:%tS".
%tY Year formatted with 4 digits e.g. "0000” to "9999".
%ty Year formatted with 2 digits e.g. "00” to "99".
%tZ Time zone abbreviation. e.g. "UTC", "PST", etc.
%tz Time Zone Offset from GMT e.g. "-0800"

Indeks argumentu

Indeks argumentu jest podawany jako liczba zakończona znakiem "$" po znaku "%" i oznacza numer argumentu na liście argumentów funkcji format().

Na przykład:


String.format("%2$s", 32, "Hello"); // prints: "Hello"

Sterowanie przebiegiem programu

instrukcja - wyrażenie zakończone średnikiem;

blok - grupa instrukcji ujęta w nawiasy klamrowe {} (składniowo równoważna jednej instrukcji)

Instrukcja warunkowa (if)


if (wyrażenie boolean)
    instrukcja-1
else
    instrukcja-2

Operator warunkowy

E1 ? E2 : E3

E1 musi być typu boolean

wartością wyrażenia jest E2 jeżeli E1 jest true i E3 jeżeli E1 jest false

np. c = (a > b) ? a : b oblicza maximum liczb a, b

Instrukcja rozgałęzienia (switch)


switch (wyrażenie) {
    case stała-1: instrukcja-1
    case stała-2: instrukcja-2
    ... 
    case stała-n: instrukcja-n
    default: instrukcja
}

Od Javy 12 możliwości switcha zostały poszerzone. Do tej pory w blokach case można było wykonać obliczenia, czy wywołać metodę. Od Javy 12 poszczególne bloki case mogą zwrócić wynik, który następnie można przypisać np.


String season = "Winter";
String translation = switch (season) {
    case "Spring" -> "wiosna";
    case "Summer" -> "lato";
    case "Autumn" -> "jesień";
    case "Winter" -> "zima";
    default -> "nieznany";
};

W jednym bloku case można obsłużyć kilka przypadków na raz:


String season = "Winter";
String temperature = switch (season) {
    case "Spring", "Summer" -> "ciepło";
    case "Autumn", "Winter" -> "zimno";
    default -> "nieznany";
};

Pętle


while (wyrażenie)
    instrukcja;

do {
    instrukcja;
} while (warunek);

for (wyrażenie1; warunek; wyrażenie2) 
    instrukcja;

Równoważne:


wyrażenie1;
while (warunek) { 
    instrukcja; 
    wyrażenie2;
}

Tablice

Deklaracja tablicy


int[] ia; 
char[] ca;

Tworzenie tablicy:

  1. Operator new
    
    int[] ia = new int[100];
    char[] ca = new char[20];
    
  2. Inicjalizacja
    
    int[] silnia = { 1, 1, 2, 6, 24};
    char ac[] = {'N', 'i', 'e', ' ', 'S', 't', 'r', 'i', 'n', 'g'};
    String[] aas = {"Tablica", "Stringów"};
    

Tablice muszą być indeksowane wyrażeniami całkowitymi (int, short, byte, char). Tablica o długości n może być indeksowana od 0 do n-1.

Tablice wielowymiarowe są "tablicami tablic"


class Array2DNew {
    public static void main (String[] args) {
        final int nr = 3, nc = 4;
        int[][] array2D = new int[nr][nc];

        for (int i = 0; i < array2D.length; i++) {
            for (int j = 0; j < array2D[i].length; j++)
                array2D[i][j] = i + j;
        }

        System.out.println();
        for (int[] row : array2D) {
            for (int anInt : row) System.out.format("%4d", anInt);
            System.out.println();
        }
        System.out.println();
    }
}

Tablice o zmiennej długości wierszy


class Array2DCol {
    public static void main (String[] args) {
        int nr = args.length;
        int[][] array2D = new int[nr][];
        for (int i = 0; i < array2D.length; i++) {
            array2D[i] = new int[Integer.parseInt(args[i])];
            for (int j = 0; j < array2D[i].length; j++)
                array2D[i][j] = i + j;
        }

        System.out.println();
        for (int[] row : array2D) {
            for (int anInt : row) System.out.format("%4d", anInt);
            System.out.println();
        }
        System.out.println();
    }
}

Deklaracja klasy


<modyfikatory> class nazwa <extends nazwa_superklasy> <interfaces> {
    // zawartość klasy
}

Modyfikatory:

Struktura klasy:

Deklaracje pól


<modyfikatory> typ nazwy_pól;

<modyfikatory>: public protected private final static

Pola statyczne (static): jedno pole dla wszystkich obiektów danej klasy

Pola dynamiczne: każdy obiekt ma swoją kopię

Pola final: stałe (muszą być inicjowane w deklaracji)

Metody


<modyfikatory> typ nazwa(lista_parametrów) { ... }

<modyfikatory>: public protected private abstract static final synchronized native

Tworzenie obiektów


Point p = new Point (1,2);

Point(...) - konstruktor

Wywołania metod dynamicznych odnoszą się do konkretnego obiektu, np.


p.move (4,2); 
q.move (1,3);

i są traktowane jako komunikaty do danego obiektu (więc muszą znać "adresata" tego komunikatu).

Java Record

Często mamy do czynienia z klasami przeznaczonymi tylko do przechowywania danych (np. pobranych z bazy). W większości przypadków dane te są niezmiennicze, ponieważ to zapewnia ich spójność bez konieczności synchronizacji.

Aby to osiągnąć, tworzymy klasy zawierające:

  1. pole z atrybutami private final dla każdej danej
  2. getter dla każdego pola
  3. publiczny konstruktor z odpowiednim argumentem dla każdego pola
  4. metodę equals() zwracającą true dla takich samych wartości wszystkich pól
  5. metodę hashCode() zwracającą tę samą wartość gdy wszystkie pola są takie same
  6. metodę toString() zawierającą nazwę klasy oraz nazwy i wartości pól
Przykładowa klasa może wyglądać następująco:

public class Person {

    private final String name;
    private final String address;

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

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

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (!(obj instanceof Person)) {
            return false;
        } else {
            Person other = (Person) obj;
            return Objects.equals(name, other.name)
              && Objects.equals(address, other.address);
        }
    }

    @Override
    public String toString() {
        return "Person [name=" + name + ", address=" + address + "]";
    }

    // standard getters
}

Powyższy kod jest poprawny, ale zawiera bardzo dużo "boilerplate code". Podobne metody musimy stworzyć dla wszystkich klas. Wiele IDE potrafi automatycznie generować za nas te metody, ale nie dopasowuje ich automatycznie w przypadku np. dodania nowego pola.

Records

Java 14 wprowadza pojęcie rekordu jako klas niezmienniczych danych, które wymagają tylko typu i nazw swoich pól składowych. Metody equals(), hashCode(), toString() oraz pola z atrybutami private final i publiczny konstruktor są generowane przez kompilator.

Aby stworzyć rekord Person jak w przykładzie powyżej wystarczy napisać:


public record Person (String name, String address) {}

Możemy do naszego rekordu dopisać / nadpisać odpowiednie metody (np. kolejny konstruktor, który sprawdza poprawność argumentów lub metodę toString(), która prezentuje dane w odpowiadającym nam formacie).

Dziedziczenie

Dziedziczenie jest procesem, w którym obiekt otrzymuje właściwości innego obiektu. Mechanizm ten wspiera tworzenie hierarchicznych klasyfikacji. Na przykład jabłko odmiany Złota Reneta należy do ogólniejszej klasyfikacji jabłka, która z kolei jest częścią klasy owoc, która należy do ogólniejszej klasy żywność. Klasa żywność posiada określone właściwości (np. wartość odżywcza), które odnoszą się do jej klas pochodnych, w tym klasy owoc. Oprócz właściwości dziedziczonych po klasie żywność klasa owoc może posiadać własne, specyficzne właściwości (soczystość, słodkość itd.), które odróżniają ją od innych podklas klasy żywność. Klasa jabłko definiuje z kolei właściwości specyficzne dla jabłek (rośnie na drzewach, w klimacie umiarkowanym itd.). Klasa Złota Reneta dziedziczy właściwości wszystkich wymienionych klas i dodaje własne, które czynią ją unikatową.

Dziedziczenie w językach obiektowych to tworzenie hierarchii klas. Kolejne klasy w hierarchii posiadają zarówno pola, jak i metody, klas, które je poprzedzają, oraz mogą zawierać nowe pola i metody.

Gdyby nie hierarchie dziedziczenia, każdy obiekt musiałby jawnie definiować całą swoją charakterystykę. Zastosowanie mechanizmu dziedziczenia powoduje, że obiekt definiuje jedynie te właściwości, które czynią go unikatowym w klasie. Pozostałe atrybuty może dziedziczyć po klasie nadrzędnej. W ten sposób mechanizm dziedziczenia w języku Java sprawia, że obiekt może być traktowany jako specyficzna instancja ogólniejszej klasy.

Gdy klasa dziedziczy po innej klasie, mówimy, że klasa ta rozszerza tę klasę. Taką klasę będziemy nazywać klasą podrzędną. Klasę rozszerzaną będziemy nazywać klasą bazową bądź klasą nadrzędną. W języku Java do zaznaczenia, że dana klasa ma dziedziczyć po innej klasie, używamy słowa kluczowego extends, po którym następuje nazwa klasy nadrzędnej.

Podklasy dziedziczą wszystkie pola i metody ze swojej bezpośredniej nadklasy i nadinterfejsów.

Konstruktory i statyczne inicjalizery nie są dziedziczone.

Dostęp do metod i pól klasy:

  klasa pakiet podklasa świat
private X      
  X X    
protected X X X  
public X X X X

Polimorfizm

Polimorfizm (z greckiego: wiele form) oznacza w programowaniu obiektowym możliwość posługiwania się pewnym zbiorem akcji za pomocą jednego interfejsu. Konkretna akcja zostaje wybrana w konkretnej sytuacji. Najprostszym przykładem polimorfizmu może być kierownica samochodu. Kierownica (czyli interfejs) wygląda zawsze tak samo niezależnie od tego, jaki mechanizm kierowania zastosowano w aucie. Kierownicy używa się zawsze w taki sam sposób niezależnie od tego czy samochód posiada zwykłą przekładnię kierowniczą, czy wyposażony jest we wspomaganie. Jeśli umiesz posługiwać się kierownicą, możesz jeździć dowolnym typem auta.

Polimorfizm to najważniejsza cecha, która umożliwia dostosowanie działania obiektów do własnych oczekiwań poprzez łączenie funkcjonalności zarówno dziedziczonej, jak i implementowanej samodzielnie. Idea polimorfizmu bazuje na tym, że użytkownik obiektu nie wie i nie musi wiedzieć, czy konkretne zachowanie wykorzystywanego obiektu zostało zrealizowane bezpośrednio w tym obiekcie czy też w tym, po którym dziedziczy on swoje właściwości. Ponadto może się okazać (i często tak się dzieje), że takie samo odwołanie do metody za każdym razem dotyczy innej akcji (inaczej zdefiniowanej). Może się też okazać, że w zależności od poziomu dziedziczenia pozornie ta sama metoda (nazywająca się tak samo) wykonuje inną akcję.

Koncepcję polimorfizmu można przekazać w najbardziej ogólny sposób jako "jeden interfejs, wiele metod". Oznacza to możliwość zaprojektowania ogólnego interfejsu dla grupy powiązanych akcji. Polimorfizm pozwala ograniczyć złożoność programu poprzez zastosowanie tego samego interfejsu dla określenia ogólnej klasy akcji. Zadaniem kompilatora jest wybranie określonej akcji (czyli metody), którą należy zastosować w konkretnej sytuacji. Programista nie musi dokonywać tego wyboru. Wystarczy, że używa ogólnego interfejsu.

Dziedziczenie a kompozycja

Może się wydawać, że dziedziczenie jest dobrym sposobem na reużycie kodu, jednak nie zawsze jest to polecane rozwiązanie. Używane nieodpowiednio skutkuje oprogramowaniem słabej jakości. Dziedziczenie jest bezpieczne, jeśli jest używane tylko w obrębie pakietu, gdzie nadklasa i wszystkie podklasy są pod kontrolą.

Gdy klasa nie jest odpowiednio zaprojektowana do dziedziczenia, możemy natrafić na następujące problemy:

  1. Naruszona jest enkapsulacja - poprawność działania podklasy zależy od detali implementacyjnych nadklasy. Implementacja nadklasy może się zmienić w następnej wersji i może to popsuć naszą podklasę, mimo że nie była nawet dotknięta. Załóżmy, że potrzebujemy HashSet, który będzie zliczał, ile było prób dodania elementu. HashSet zawiera dwie metody, które są w stanie dodawać elementy: add() i addAll(), więc możemy je obie nadpisać, używając dodatkowo licznika:
    
    // Broken - Inappropriate use of inheritance!
    class InstrumentedHashSet<E> extends HashSet<E> {
        // The number of attempted element insertions
        private int addCount = 0;
    
    
        public InstrumentedHashSet() {
        }
    
    
        public InstrumentedHashSet(int initCap, float loadFactor) {
            super(initCap, loadFactor);
        }
    
        @Override
        public boolean add(E e) {
            addCount++;
            return super.add(e);
        }
    
        @Override
        public boolean addAll(Collection<? extends E> c) {
            addCount += c.size();
            return super.addAll(c);
        }
    
        public int getAddCount() {
            return addCount;
        }
    }
    

    Jeżeli dodamy elementy używając metody addAll() to wynik będzie niepoprawny!!!

    
    InstrumentedHashSet<String> s = new InstrumentedHashSet<>();  
    s.addAll(List.of("elem1", "elem2", "elem3"));
    

    Metoda getAddCount() zwróci 6 (choć powinna 3!!!), ponieważ metoda addAll w HashSet pod spodem wykorzystuje metodę add dla każdego elementu. Moglibyśmy to "naprawić" nie nadpisując metody addAll, ale wtedy nasz klasa zależałaby od tego szczegółu implementacyjnego, który może się zmienić w kolejnej wersji i byłaby niestabilna.

  2. Podklasy nie są stabilne i bezpieczne w użyciu - załóżmy, że bezpieczeństwo programu zależy od tego, że wszystkie elementy dodane do kolekcji spełniają określony warunek. Można by to zaimplementować, rozszerzając klasę kolekcji i nadpisać wszystkie metody, które umożliwiają dodawanie elementów, tak by sprawdzały ten warunek. To będzie działać tylko do momentu, gdy do klasy w kolejnej wersji nie zostanie dodana nowa metoda, pozwalająca dodawać elementy. Kiedy to się stanie, będzie można dodać nielegalne elementy do kolekcji, używając nowej metody (dopóki dziura ta nie zostanie załatana).

  3. Rozbudowane hierarchie dziedziczenia utrudniają również analizę kodu i testowanie.

Kompozycja

Zamiast rozszerzać klasę, możemy zadeklarować ją jako pole prywatne w naszej nowej klasie i wywoływać jej odpowiadające metody. Jest to znane jako delegacja (forwarding). W rezultacie otrzymamy solidną klasę, która nie zależy od detalów implementacyjnych istniejącej klasy. Również dodawanie do niej nowych metod nie będzie miało wpływu na naszą klasę.

Wnioski

Dziedziczenie jest odpowiednie tylko wtedy, kiedy podklasa naprawdę jest podtypem danej klasy. Tzn. między nimi jest relacja "jest". Jeśli zastanawiamy się, czy klasa B powinna rozszerzać A, powinniśmy sobie zadać pytanie, czy na pewno B jest też A. Jeśli mamy ku temu wątpliwości to powinniśmy zastosować kompozycję.

Kolejną sprawą, nad którą warto się zastanowić jest to czy potrzebujemy rzeczywiście wszystkich metod z nadklasy?

Interfaces

Interface jest typem referencyjnym, zawierającym definicje stałych i abstrakcyjnych metod.

Cel: nadanie nie związanym ze sobą klasom pewnej wspólnej cechy.

Deklaracja:


interface nazwa <extends interface ...> {
    deklaracje stałych 
    deklaracje metod
}

Pola mają domyślne atrybuty public static final. Wszystkie pola muszą być zainicjalizowane.

Metody mają domyślne atrybuty public abstract. Nie może być metod statycznych bo statyczna metoda nie może być abstract.

Podobieństwa z klasami:
  1. interfejs może zawierać dowolną liczbę metod
  2. interfejs jest definiowany w pliku z rozszerzeniem ".java"; nazwa interfejsu jest zgodna z nazwą pliku
  3. bajtkod interfejsu znajduje się w pliku z rozszerzeniem ".class"
  4. interfejsy mogą być zawarte w pakietach
Różnice w stosunku do klas:
  1. nie istnieją obiekty typu "interfejs"
  2. interfejs nie może zawierać konstruktora / ów
  3. metody interfejsu mają domyślne arybuty public abstract
  4. interfejs nie może zawierać niestatycznych pól; jedynymi polami, które mogą pojawić się w interfejsie są pola statyczne zdefiniowane z atrybutami static final
  5. w stosunku do interfejsów nie używamy słowa extends lecz implements

Kiedy klasa implementuje dany interfejs, można to przedstawić jako "kontrakt" zobowiązujący klasę do wykonania czynności określonych w interfejsie. Jeżeli klasa nie implementuje wszystkich metod zadeklarowanych w interfejsie, musi być zadeklarowana jako abstrakcyjna.

Klasy zagnieżdżone

Java tutorial::

Nested classes are divided into two categories: static and non-static. Nested classes that are declared static are simply called static nested classes. Non-static nested classes are called inner classes.

Klasy w Javie mogą być zagnieżdżane na dowolną głębokość, tzn. Klasa A może zawierać klasę B, która zawiera klasę C, itd. Jednak więcej niż jeden poziom zagnieżdżenia jest rzadko spotykany i jest uważany za błąd projektowy.

Można wyróżnić kilka przyczyn, dla jakich stosuje się zagnieżdżanie klas:

Rodzaje klas zagnieżdżonych

Drobne wyjaśnienia

Zobaczmy prosty przykład przedstawiający różne typy klas zagnieżdżonych:


class NestedTest {

// 1. Creating static class in Java
    static class StaticNested {
        public void name(){
            System.out.println("static nested class");
        }
    }

// 2. Creating inner class in Java
    class Inner {
        public void name() {
            System.out.println("inner class");
        }
    }

// 3. Creating local inner class inside class method
    static void funStatic() {
        class LocalInStatic {
            public void name() {
                System.out.println("local class in class method");
            }
        }
        //creating instance of local inner class
        LocalInStatic local = new LocalInStatic();
        local.name();    //    calling method from local inner class 
    }

// 4. Creating local inner class inside instance method
    void fun() {
        class Local {
            public void name() {
                System.out.println("local class in instance method");
            }
        }
        //creating instance of local inner class
        Local local = new Local();
        local.name();    //    calling method from local inner class 
    }
}

public class NestedClassTest {
    public static void main(String args[]) {

        // creating instance of static class (no instance of outer class needed)
        NestedTest.StaticNested nested = new NestedTest.StaticNested();
        nested.name();

        // creating instance of inner class (outer class instance necessary)
        NestedTest.Inner inner = new NestedTest().new Inner();
        inner.name();

        // creating instance of local class in class method
        NestedTest.funStatic();

        // creating instance of local class in instance method
        new NestedTest().fun();
    }
}

static classes (example 1.)

Klasy statyczne są najprostsze, ponieważ nie mają nic wspólnego z instancjami (obiektami) klasy zewnętrznej.

Klasa statyczna jest deklarowana jako składowa statyczna innej klasy. Podobnie jak inne składowe statyczne, taka klasa jest praktycznie doczepką, używającą przestrzeni nazw klasy otaczającej. Do stworzenia odpowiedniej instancji nie jest potrzebny obiekt klasy zewnętrznej.

inner classes (example 2.)

Klasa wewnętrzna to klasa zadeklarowana jako niestatyczna składowa innej klasy

W odróżnieniu od klas statycznych, każda instancja klasy wewnętrznej jest związana z instancją klasy zewnętrznej (dlatego jest ona konieczna). W powyższym przykładzie obiekt Inner jest związany z instancją this klasy NestedTest.

Co nam daje taka konstrukcja? Instancja klasy wewnętrznej ma dostęp do składowych klasy otaczającej, do których odnosimy się po prostu przez ich nazwy, ale bez użycia referencji this. Należy pamiętać, że this w klasie wewnętrznej odnosi się do instancji tej klasy a nie instancji skojarzonej z nią klasy zewnętrznej.

local inner classes (example 3. i 4.)

Klasa lokalna jest klasą zadeklarowaną wewnątrz metody i jest znana tylko wewnątrz tej metody. W związku z tym tylko tam mogą być tworzone jej instancje. Zaletą jest fakt, że klasa lokalna ma dostęp do lokalnych zmiennych metody, które są albo zdefiniowane z atrybutem final bądź są effectively final (czyli nie zmieniają swojej wartości, ale nie mają jawnej specyfikacji final). Java zachowuje wartość takiej zmiennej (taką, jaką miała w momencie tworzenia obiektu), nawet jeżeli zakres życia zmiennej sie skończył.

Ponieważ klasa lokalna nie jest składową klasy czy pakietu, nie posiada atrybutów dostępu (ale jej składowe już tak).

Jeżeli klasa lokalna jest zadeklarowana w metodzie obiektowej (niestatycznej), instancja tej klasy jest związana z referencją this tej metody. W związku z tym składowe klasy zewnętrznej są dostępne tak jak w przypadku klas wewnętrznych.

anonymous inner classes

Klasa anonimowa jest wygodnym w użyciu wariantem klasy lokalnej. W większości przypadków instancja klasy lokalnej jest tworzona raz, gdy uruchamiamy zawierającą ją metodę. Byłoby więc sensownie połączyć definicję klasy lokalnej i jej instancjację. Można też pominąć nazwę klasy lokalnej, która tak naprawdę nie jest potrzebna. Obie te funkcje spełniają klasy anonimowe.

Na ogół wykorzystuje się w tym przypadku interfejs, który klasa anonimowa powinna implementować.


Runnable action = new Runnable() {
    public void run() {
        ...
    }
};

Taka deklaracja tworzy nową instancję nienazwanej klasy, która implementuje interfejs Runnable (odpowiedzialny za tworzenie wątków; jego merodą jest run()).

Należy pamiętać, że klasa anonimowa jest uproszczoną wersją klasy lokalnej z jedną instancją. Jeżeli potrzebna jest klasa lokalna, która implementuje kilka interfejsów i dziedziczy po klasie innej niż Object, to na ogół musimy wrócić do koncepcji nazwanej klasy lokalnej.

Wyjątki

Wyjątek powinien być generowany przez metodę gdy jest zmuszona do wykonania czegoś niemożliwego lub naruszającego stabilność systemu.

Generowanie wbudowanych wyjątków (Throwing built-in exceptions)

Wyjątek jest obiektem klasy Exception lub jej podklasy. By wygenerować wyjątek musimy stworzyć nowy obiekt odpowiedniego typu i użyć instrukcji throw.


throw new ArithmeticException();
lub

throw new ArrayIndexOutOfBoundsException("Integer array index out of bounds");

Wszystkie wbudowane wyjątki Javy mają 2 konstruktory: jeden bez parametrów i drugi akceptujący String (który może być wypisany, np.)


catch(ArrayIndexOutOfBoundsException e) { 
    System.out.println(e.toString());
}

Struktura klas wyjątków

Exceptions

Dlaczego Java wyróżnia trzy podstawowe typy wyjątków? Odpowiedź sprowadza się do tego, w jaki sposób program powinien obsłużyć (lub nie!) daną sytuację.

  1. Wyjątki z klasy Exception i klas pochodnych (ale nie RuntimeException) ogólnie reprezentują błędy, które są poza bezpośrednią kontrolą programu i na ogół oznaczają problem z zasobami zewnętrznymi. Na przykład błąd w połączeniu sieciowym, błąd systemu plików, itp. Kompilator Javy zmusza nas do obsługi takich sytuacji (stąd nazwa "checked exceptions"). Możemy "oszukać" kompilator, ale nie jest to dobra praktyka.
  2. Wyjątki z klasy Error i klas pochodnych odpowiadają "poważnym" błędom, których program raczej nie wyłapuje (ponieważ nie ma możliwości sensownego wyjścia z tych sytuacji a kompilator tego nie wymaga). Na przykład: brakujący plik class, OutOfMemoryError, itp.
  3. RuntimeExeptions i klasy pochodne są nieco inne: reprezentują wyjątki, które nie powinny się zdarzyć i na ogół są spowodowane błędami programistycznymi. Na przykład: przekroczenie zakresu tablicy, dzielenie przez zero, ujemna długość tablicy, itp. Wystąpienie wyjątków tego typu (unchecked exceptions) nie jest sprawdzane przez kompilator, więc w tym sensie są one podobne do wyjątków z klasy Error.

Zasada ogólna: Jeżeli program ma szansę "podnieść się" po wystąpieniu wyjątku, powinien to być wyjątek typu "checked". Jeżeli nic sensownego nie da się zrobić - stosujemy wyjątek "unchecked".

Przykłady Non-Runtime Exceptions

ClassNotFoundException
CloneNotSupportedException
InstantiationException
InterruptedException
IOException
EOFException
FileNotFoundException

Przykłady Runtime Exceptions

ArithmeticException
ClassCastException
IllegalArgumentException
NumberFormatException
IndexOutOfBoundsException
ArrayIndexOutOfBoundsException
StringIndexOutOfBoundsException
NegativeArraySizeException
NoSuchFieldException
NoSuchMethodException
NullPointerException

Blok finally


try {
    // instrukcje ktore moga potencjalnie zakonczyc sie wyjatkiem
} catch (TypWyjatku dowolnaNazwa) {
    // instrukcje, gdy zajdzie wyjątek TypWyjatku
} finally {
    // instrukcje, ktore maja byc wykonane niezaleznie od tego,
    // czy wyjatek zostal zlapany, czy nie
}

Blok try-catch-finally może zawierać:

  1. Tylko try-catch
  2. Tylko try-finally
  3. Wszystkie trzy części

W drugim z przypadków specyfikacja Javy gwarantuje, że blok finally będzie wykonany zawsze, nawet jeżeli blok try spowoduje wygenerowanie (ale nie wyłapanie) wyjątku. W tym przypadku blok finally jest jedyną szansą na wykonanie clean-up'u (np. zamknięcie plików). Kod po bloku finally może nigdy nie zostać wykonany.

Jednak nawet jeżeli wyjątek zostanie wyłapany, częstą praktyką jest umieszczenie w bloku catch zdania return (lub innego, które zmienia sekwencyjne wykonanie instrukcji). W tym przypadku także blok finally zostanie wykonany. Jedynym wyjątkiem jest użycie funkcji System.exit(), która kończy program od razu (bez skoku do finally).

Umieszczenie kodu clean-up'u w bloku try-finally jest zalecane zawsze (nawet jeżeli nie przewidujemy wystąpienia wyjątków) ponieważ inaczej możliwe jest przypadkowe ominięcie tego kodu (np. poprzez użycie return lub break)

try-with-resources

Konstrukcja try-with-resources (od Java 8) wygląda jak try/catch z tym, że przez blokiem objętym try możemy zainicjalizować zmienne, które zostaną automatycznie zamknięte.

Konstrukcję try-with-resources możemy nazwać także "menadżerem kontekstu", automatycznie zarządza ona za nas kontekstem, w ramach którego dostępne są zmienne zdefiniowane wewnątrz nawiasów (). Co więcej, wewnątrz tych nawiasów możemy zainicjalizować więcej zmiennych, każda z nich zostanie poprawnie zamknięta (zostanie na nich wywołana metoda close(), dlatego typy tu użyte muszą implementować interfejs AutoCloseable). W przykładzie poniżej odczytujemy kolejne linie z pliku wejściowego i zapisujemy je do pliku wyjściowego.


try(BufferedReader fileReader = new BufferedReader(new FileReader("input.txt"));
    BufferedWriter fileWriter = new BufferedWriter(new FileWriter("output.txt"))) {
    String line;
    while((line = fileReader.readLine()) != null) {
        fileWriter.write(line);
        fileWriter.newLine();
    }
} catch(IOException exception) {
    exception.printStackTrace();
}

Zalety używania wyjątków

  1. Oddzielenie obsługi błędów od regularnego kodu.
  2. Przekazywanie błędów poprzez stos wywołań.
  3. Grupowanie podobnych błędów i rozróżniane błędów.

Ad.1


readFile {
    open the file;
    determine its size;
    allocate that much memory;
    read the file into memory;
    close the file;
}

Potencjalne problemy:


errorCodeType readFile {
    initialize errorCode = 0;
    open the file;
    if (theFileIsOpen) {
        determine the length of the file;
        if (gotTheFileLength) {
                allocate that much memory;
                if (gotEnoughMemory) {
                    read the file into memory;
                    if (readFailed) {
                        errorCode = -1;
                    }
                } else {
                    errorCode = -2;
                }
        } else {
            errorCode = -3;
        }
        close the file;
        if (FileDidntClose && errorCode == 0) {
            errorCode = -4;
        } else {
            errorCode = errorCode and -4;
        }
    } else { errorCode = -5; }
    return errorCode;
}

readFile {
    try {
        open the file;
        determine its size;
        allocate that much memory;
        read the file into memory;
        close the file;
    } catch (fileOpenFailed) {
        doSomething;
    } catch (sizeDeterminationFailed) {
        doSomething;
    } catch (memoryAllocationFailed) {
        doSomething;
    } catch (readFailed) {
        doSomething;
    } catch (fileCloseFailed) {
        doSomething;
    }
}

Ad. 2. Propagacja błędów:

Tylko method1 jest zainteresowana w ewentualnej obsłudze sytuacji awaryjnej


method1 {
    call method2;
}
method2 {
    call method3;
}
method3 {
    call readFile;
}

method1 {
    errorCodeType error;
    error = call method2;
    if (error)
        doErrorProcessing;
    else
        proceed;
}
errorCodeType method2 {
    errorCodeType error;
    error = call method3;
    if (error)
        return error;
    else
        proceed;
}
errorCodeType method3 {
    errorCodeType error;
    error = call readFile;
    if (error)
        return error;
    else
        proceed;
}

method1 {
    try {
        call method2;
    } catch (exception) {
        doErrorProcessing;
    }
}
method2 throws exception {
    call method3;
}
method3 throws exception {
    call readFile;
}

Możliwości:

Wątki (threads)

Threads

Klasa Thread

Konstruktory:


Thread()
Thread(Runnable target)
Thread(String name)
Thread(Runnable target, String name)

Możliwości wygenerowania nowego wątku do wykonania:

Cykl życia wątku

Threads

  1. Tworzenie wątku:
    
    simple = new Thread(this, str);
    
  2. Startowanie wątku
    
    simple.start();
    
  3. Przechodzenie do stanu Not Runnable
    • wywołanie metody sleep()
      
      Thread.sleep((long)(Math.random()*1000));
      
    • wywołanie metody wait()
    • oczekiwanie na we/wy
  4. Przywracanie wątku do stanu Runnable
    • jeżeli wywołano metodę sleep() musi upłynąć wyznaczony czas (w milisec)
    • jeżeli wywołano metodę wait() inny obiekt musi zawiadomić wątek o zmianie warunku oczekiwania wywołując notify() lub notifyAll()
    • jeżeli wątek oczekuje na we/wy, to ta operacja musi się zakończyć
  5. Zatrzymywanie wątku (przejście do stanu Dead). Zwykle wątek zatrzymuje się gdy zakończy się jego metoda run()
    
    System.out.println("DONE! " + simple.getName());
    

Metoda isAlive()

Metoda zwraca true jeżeli wątek został wystartowany i jeszcze nie został zatrzymany (jest w stanie Runnable lub Not Runnable ale nie wiadomo w którym).

Metoda zwraca false jeżeli jest to nowy wątek nie został jeszcze wystartowany) lub wątek został już zatrzymany (jest w stanie Dead).

Pakiet java.util.concurrent

Race conditions.

Race

Pakiet java.util.concurrent jest narzędziem do tworzenia aplikacji współbieżnych. Do najistotniejszych klas pakietu należą:

  1. Executor
  2. ExecutorService
  3. ScheduledExecutorService
  4. Future
  5. CountDownLatch
  6. CyclicBarrier
  7. Semaphore
  8. ThreadFactory
  9. BlockingQueue
  10. DelayQueue
  11. Locks
  12. Phaser

Threads

Powyższy schemat przedstawia działanie egzekutora.

Wejście / wyjście

Byte Streams

IO

Character Streams

IO

Porównanie strumieni bajtowych i znakowych

IO

Podstawowe klasy z pakietu java.io

Przegląd potoków we/wy.

Potoki proste (sink streams)

Nośnik Potok znakowy Potok bajtowy
  CharArrayReader ByteArrayInputStream
Pamięć CharArrayWriter ByteArrayOutputStream
  StringReader StringBufferInputStream
  StringWriter  
Pipe PipedReader PipedInputStream
  PipedWriter PipedOutputStream
Plik FileReader FileInputStream
  FileWriter FileOutputStream

Potoki konwertujące (processing streams)

Funkcja Potok znakowy Potok bajtowy
Buforowanie BufferedReader BufferedInputStream
  BufferedWriter BufferedOutputStream
Filtrowanie FilterReader FilterInputStream
  FilterWriter FilterOutputStream
Konwersja między
bajtami i znakami
InputStreamReader
OutputStreamWriter
 
konkatenacja   SequenceInputStream
Konwersja danych   DataInputStream
    DataOutputStream
Cofanie znaku PushbackReader PushbackInputStream
Drukowanie PrintWriter PrintStream

Pliki o dostępie swobodnym (Random Access Files)

IO

Zachowują się jak duża tablica (zapisana na dysku). Indeksem tej tablicy jest wskaźnik pliku (filepointer). Operacje odczytu zaczynają sie od miejsca wskazywanego przez wskaźnik. Jeżeli plik jest otwarty także do zapisu to możliwy jest zapis do pliku (podobnie od miejsca wskazywanego przez wskaźnik). Operacje odczytu i zapisu modyfikują wskaźnik pliku. Wskażnik pliku może być odczytany metodą getFilePointer() i ustawiony metodą seek().

Konstruktory:


RandomAccessFile (String name, String mode)
RandomAccessFile (File file, String mode)

Podstawowe metody:


public native long getFilePointer()
public native void seek(long pos)
public native long length()

Klasa RandomAccessFile implementuje interfejsy DataInput, DataOutput, dostępne są więc metody odczytu/zapisu prostych typów danych read(), readByte(), readInt, readLine(), ... write(), writeByte(), writeInt() ...

java.nio

The Java NIO provides:
  1. bulk access to raw bytes and only raw bytes, so we can leverage the functionalities of file systems and operating system to speed up read and write operations
  2. bidirectional channels with a single channel, that is with single Java object, we can both read and write data to and from the disk or to and from the network.
  3. off-heap buffering. It means that it can create buffer outside of the central memory of the JVM, in portions of memory not handled by the garbage collector. So, we can create very large buffers. Think of multi-gigabytes or even multi-terabytes of size without any impact on the performance of the garbage collector.
  4. Proper support for charsets directly inside the JDK. So the JDK defines standard charsets objects. Objects for the standard, well-known and most widely used charsets around and those charsets provide encode and decode methods to convert a stream of characters expressed in a given charset to another charset.
  5. support for asynchronous operations.
Buffer
A buffer can be seen as a space in memory. It can reside in the main memory of the JVM, the heap or off-heap, which is very useful for very large buffers.
Channel
The channel is where the data comes from. The channel object is an object that connect to a file or to a socket, for instance. A channel can write the buffer to the medium or can read data from that medium to a buffer. A channel only knows bytes buffers, so it can only read and write bytes from files or for socket, for instance. Afterwards, we have to convert the content of this buffer to characters if this is a character buffer or to data or object if it is raw data or raw objects.
Selector
A selector has been introduced to handle asynchronous operations. A write operation takes data from a buffer and writes it to a channel. A read operation reads data from a channel and writes it into a buffer. Once the data is in the buffer, we can read it and interpret it as raw bytes, data types, objects, or characters as we need it. A FileChannel can be mapped to a memory array for direct access. This allows for much faster operation than accessing to the disk directly. It is built on native features provides by the different operating system and this is the reason why the concrete implementation of FileChannel are hidden just because they are different depending on the machine we are working on.

nio channels

Java NIO Channels are similar to streams with a few differences:

  1. You can both read and write to a Channels. Streams are typically one-way (read or write).
  2. Channels can be read and written asynchronously.
  3. Channels always read to, or write from, a Buffer.
As mentioned above, you read data from a channel into a buffer, and write data from a buffer into a channel. Here is an illustration of that:

IO

Here are the most important Channel implementations in Java NIO:

  1. FileChannel reads data from and to files
  2. DatagramChannel can read and write data over the network via UDP
  3. SocketChannel can read and write data over the network via TCP
  4. ServerSocketChannel allows to listen for incoming TCP connections, like a web server does. For each incoming connection a SocketChannel is created

Basic Buffer Usage

Using a Buffer to read and write data typically follows this little 4-step process:

  1. Write data into the Buffer,
  2. Call buffer.flip(),
  3. Read data out of the Buffer,
  4. Call buffer.clear() or buffer.compact(),
When you write data into a buffer, the buffer keeps track of how much data you have written. Once you need to read the data, you need to switch the buffer from writing mode into reading mode using the flip() method call. In reading mode the buffer lets you read all the data written into the buffer.

Once you have read all the data, you need to clear the buffer, to make it ready for writing again. You can do this in two ways: By calling clear() or by calling compact(). The clear() method clears the whole buffer. The compact() method only clears the data which you have already read. Any unread data is moved to the beginning of the buffer, and data will now be written into the buffer after the unread data.

A Buffer has three properties you need to be familiar with, in order to understand how a Buffer works. These are:

  1. capacity
  2. position
  3. limit
The meaning of position and limit depends on whether the Buffer is in read or write mode. Capacity always means the same, no matter the buffer mode.

IO

In write mode the limit of a Buffer is the limit of how much data you can write into the buffer. In write mode the limit is equal to the capacity of the Buffer.

When flipping the Buffer into read mode, limit means the limit of how much data you can read from the Buffer. Therefore, when flipping a Buffer into read mode, limit is set to write position of the write mode. In other words, you can read as many bytes as were written (limit is set to the number of bytes written, which is marked by position).

Gather / scatter operations: one channel and many buffers, as below

gather scatter

Java Generics

Java Generics vs. C++ templates

  1. In C++ generic functions/classes can only be defined in headers, since the compiler generates different functions for different types (that it's invoked with). So the compilation is slower. Java uses a technique called "erasure" where the generic type is erased at runtime.
  2. In C++ templates, parameters can be any type or integral but in Java, parameters can only be reference types.
  3. For C++ templates, separate copies of the class or function are likely to be generated for each type parameter when compiled. However for Java generics, only one version of the class or function is compiled and it works for all type parameters.
  4. C++ templates can be specialized - a separate implementation could be provided for a particular template parameter. In Java, generics cannot be specialized.
In summary: Generics in Java
  1. are generated at runtime
  2. use Object substitution and casting instead of multiple native versions of the class
Templates in C++
  1. are generated at compile time
  2. create native codebase per template parameter

Type Erasure, [https://www.baeldung.com/java-type-erasure]

Type erasure can be explained as the process of enforcing type constraints only at compile time and discarding the element type information at runtime.

For example:


public static<E> boolean containsElement(E[] elements, E element) {
    for (E e : elements) {
        if(e.equals(element)) {
            return true;
        }
    }
    return false;
}

When compiled, the unbound type E gets replaced with an actual type of Object:


public static boolean containsElement(Object[] elements, Object element) {
    for (Object e : elements) {
        if(e.equals(element)) {
            return true;
        }
    }
    return false;
}

The compiler ensures type safety of our code and prevents runtime errors.

Class Type Erasure

At the class level, type parameters on the class are discarded during code compilation and replaced with its first bound, or Object if the type parameter is unbound. Let’s implement a Stack using an array:


public class Stack<E> {
    private E[] stackContent;
    public Stack(int capacity) {
        this.stackContent = (E[]) new Object[capacity];
    }
    public void push(E data) {
        // ...
    }
    public E pop() {
        // ...
    }
}

Upon compilation, the unbound type parameter E is replaced with Object:


public class Stack {
    private Object[] stackContent;
    public Stack(int capacity) {
        this.stackContent = (Object[]) new Object[capacity];
    }
    public void push(Object data) {
        // ...
    }
    public Object pop() {
        // ...
    }
}

In a case where the type parameter E is bound:


public class BoundStack<E extends Comparable<E>> {
    private E[] stackContent;
    public BoundStack(int capacity) {
        this.stackContent = (E[]) new Object[capacity];
    }
    public void push(E data) {
        // ...
    }
    public E pop() {
        // ...
    }
}

When compiled, the bound type parameter E is replaced with the first bound class, Comparable in this case:


public class BoundStack {
    private Comparable [] stackContent;
    public BoundStack(int capacity) {
        this.stackContent = (Comparable[]) new Object[capacity];
    }
    public void push(Comparable data) {
        // ...
    }
    public Comparable pop() {
        // ...
    }
}

Method Type Erasure

For method-level type erasure, the method’s type parameter is not stored but rather converted to its parent type Object if it’s unbound or it’s first bound class when it’s bound.

Let’s consider a method to display the contents of any given array:


public static<E> void printArray(E[] array) {
    for (E element : array) {
        System.out.printf("%s ", element);
    }
}

Upon compilation, the type parameter E is replaced with Object:


public static void printArray(Object[] array) {
    for (Object element : array) {
        System.out.printf("%s ", element);
    }
}

For a bound method type parameter:


public static<E extends Comparable<E>> void printArray(E[] array) {
    for (E element : array) {
        System.out.printf("%s ", element);
    }
}

We’ll have the type parameter E erased and replaced with Comparable:


public static void printArray(Comparable[] array) {
    for (Comparable element : array) {
        System.out.printf("%s ", element);
    }
}

Unbounded Wildcards

The unbounded wildcard type is specified using the wildcard character (?), for example, List<?>. This is called a list of unknown type. There are two scenarios where an unbounded wildcard is a useful approach:

Upper bounded Wildcards

You can use an upper bounded wildcard to relax the restrictions on a variable. For example, say you want to write a method that works on List<Integer>, List<Double>, and List<Number>; you can achieve this by using an upper bounded wildcard.

To declare an upper-bounded wildcard, use the wildcard character ('?'), followed by the extends keyword, followed by its upper bound. Note that, in this context, extends is used in a general sense to mean either "extends" (as in classes) or "implements" (as in interfaces).

To write the method that works on lists of Number and the subtypes of Number, such as Integer, Double, and Float, you would specify List<? extends Number>. The term List<Number> is more restrictive than List<? extends Number> because the former matches a list of type Number only, whereas the latter matches a list of type Number or any of its subclasses.

Lower bounded Wildcards

A lower bounded wildcard is expressed using the wildcard character ('?'), following by the super keyword, followed by its lower bound: <? super A>.

Note: You can specify an upper bound for a wildcard, or you can specify a lower bound, but you cannot specify both.

Say you want to write a method that puts Integer objects into a list. To maximize flexibility, you would like the method to work on List<Integer>, List<Number>, and List<Object> - anything that can hold Integer values.

To write the method that works on lists of Integer and the supertypes of Integer, such as Integer, Number, and Object, you would specify List<? super Integer>. The term List<Integer> is more restrictive than List<? super Integer> because the former matches a list of type Integer only, whereas the latter matches a list of any type that is a supertype of Integer.

WildCards

Lambda Expresions

Wyrażenia lambda są pierwszym krokiem zbliżającym Javę do paradygmatu programowania funkcjonalnego. Lambda jest funkcją, która może być stworzona bez konieczności przynależenia do klasy, przekazywana jak obiekt i wykonywana na żądanie.

Wyrażenia lambda można także porównać do klas anonimowych. Mają one jednak dużo bardziej czytelną i zwięzłą składnię.

Na przykład wyrażenie lambda, które podnosi do kwadratu przekazaną liczbę wygląda następująco:


x -> x * x

Składnia wyrażeń lambda

Wyrażenie lambda ma następującą składnię


<lista parametrów> -> <ciało wyrażenia>

Lista parametrów zawiera wszystkie parametry przekazane do "ciała" wyrażenia lambda. W szczególności lista ta może być pusta. Wyrażenie lambda poniżej nie przyjmuje żadnych argumentów, zwraca natomiast instancję klasy String:


() -> "some return value"

Podawanie typów parametrów jest opcjonalne. Kompilator jest w stanie wydedukować te parametry z kontekstu w którym znajduje się dane wyrażenie lambda. W razie potrzeby można je także podać:


(Integer x, Long y) -> System.out.println(x*y)

Nawiasy otaczające listę parametrów są opcjonalne jeśli wyrażenie ma wyłącznie jeden parametr bez określonego typu.

W wielu przypadkach wyrażenia lambda składaja się z pojedynczego wyrażenia (jak w powyższych przykładach).

Może się jednak zdarzyć, że wyrażenie lambda będzie zawierało więcej linii. W takim przypadku musi być otoczone nawiasami {} jak w przykładzie poniżej:


x -> {
    if (x != null) {
	     return x * x;
	} else {
		return 0;
	}
}

Lambda bez parametrów


() -> System.out.println("Zero parameter lambda");

Puste nawiasy sygnalizują lambdę bez paramertów, podobnie jak w przypadku zwykłych metod.

Lambda z jednym parametrem


(param) -> System.out.println("One parameter: " + param);
param -> System.out.println("One parameter(no parentheses): " + param);

Pojedynczy parametr można umieścić w nawiasach lub bez nich.

Lambda z wieloma parametrami


(p1, p2) -> System.out.println("Multiple parameters: " + p1 + ", " + p2);

W typ przypadku nawiasy są konieczne.

Typy parametrów

Jeżeli kompilator nie jest w stanie wywnioskować typu / typów parametrów, to podajemy je jawnie (jak w deklaracji metody):


(Car car) -> System.out.println("The car is: " + car.getName());

Zawartość lambdy

Pojedyncze wyrażenie:


(oldState, newState) -> System.out.println("State changed")

Wyrażenie złożone umieszczamy w nawiasach klamrowych { }, podobnie jak ciało funkcji:


(oldState, newState) -> {
    System.out.println("Old state: " + oldState);
    System.out.println("New state: " + newState);
}

Zwracanie wartości z lambdy

Używamy instrukcji return jak w funkcji:


(param) -> {
    System.out.println("param: " + param);
    return "return value";
}

(a1, a2) -> { return a1 > a2; }

W tym ostatnim przykładzie, kiedy lambda tylko oblicza i zwraca wartość w pojedynczym wyrażeniu, zdanie return można pominąć:


(a1, a2) -> a1 > a2;

Lambdy jako obiekty

Dopasowanie lambdy do interfejsu

Jeżeli odpowiedź na wszystkie trzy pytania jest pozytywna, to lambda jest dopasowywana do interfejsu.

Definiując klasę anonimową implementującą interfejs, musimy jawnie zdefiniować, który interfejs implementujemy. W przypadku wyrażeń lambda ten typ często jest wnioskowany (inferred) z kontekstu. Na przykład typ parametru funkcji można wywnioskować z jej deklaracji.

Podobnie kompilator może wywnioskować typy parametrów lambdy z deklaracji metody

Lambda jest praktycznie obiektem (klasy implementującej interfejs). Wyrażenie lambda można więc przypisać do zmiennej i przekazywać jak każdy inny obiekt:


public interface MyComparator {
    public boolean compare(int a1, int a2);
}

MyComparator myComparator = (a1, a2) -> a1 > a2;
boolean result = myComparator.compare(2, 5);

Od klasy anonimowej do wyrażenia lambda

Zamiana klasy anonimowej na wyrażenie lambda.


public interface Checker<T> {
    boolean check(T object);
}
 
Checker<Integer> isOddAnonymous = new Checker<>() {
    @Override
    public boolean check(Integer object) {
        return object % 2 != 0;
    }
};
 
System.out.println(isOddAnonymous.check(123));
System.out.println(isOddAnonymous.check(124));

W przykładzie tym zdefiniowano interfejs Checker, który posiada jedną metodę check(). Metoda ta zwraca wartość logiczną na podstawie przekazanego argumentu.

Fragment kodu robiący to samo jednak przy użyciu składni wyrażeń lambda wygląda następująco:


Checker<Integer> isOddLambda = object -> object % 2 != 0;
 
System.out.println(isOddLambda.check(123));
System.out.println(isOddLambda.check(124));

Każde wyrażenie lambda jest instancją dowolnego interfejsu funkcyjnego. Interfejs funkcyjny to interfejs, który ma dokładnie jedną abstrakcyjną metodę. Wprowadzono adnotację @FunctionalInterface, którą zaleca dodać do interfejsów tego typu. Adnotacja ta zapewnia, że kompilator upewni się, że dany interfejs jest interfejsem funkcyjnym. Jeśli nie, wówczas kompilacja się nie powiedzie. Przykładem interfejsu funkcyjnego może być zdefiniowany wcześniej interfejs Checker. Zawiera on wyłącznie jedną metodę check().

Przykładowe interfejsy funkcyjne

W biblotece Javy znajduje się zestaw interfejsów funkcyjnych do wykorzystania. W większości przypadków w zupełności wystarczy ich użycie. Część z nich znajduje się w pakiecie java.util.function. Najważniejsze z nich to:

Wyrażenia lambda zdefiniowane wcześniej można przypisać do tych właśnie interfejsów:


UnaryOperator square = x -> x * x;

Supplier someString = () -> "some return value";

BiConsumer multiplier = (Integer x, Long y) -> System.out.println(x * y);

Function multiline = x -> {
    if (x != null) {
        return (long) x * x;
    } else {
        return 0L;
    }
};

Refleksja w Javie

Dynamiczne ładowanie klas

W klasie Object zdefiniowano metodę getClass. Zastosowana wobec dowolnego obiektu zwraca odnośnik do jego klasy, do obiektu klasy java.lang.Class.

Obiekty klasy Class są klasami (to ważne: tu same klasy są obiektami)

Możemy wobec takich obiektów stosować różne metody klasy Class z pakietu java.lang, np.

  1. getSuperClass() - zwracającą obiekt klasy Class oznaczający klasę bazową danej klasy,
  2. getInterfaces() - zawracającą tablicę obiektów, zawierającą interfejsy danej klasy,
  3. newInstance() - tworzącą nowy obiekt danej klasy.

Obiektów klasy Class nie możemy tworzyć za pomocą wyrażenia new, ponieważ ta klasa nie posiada publicznego konstruktora. Jedynie poprzez użycie odpowiednich metod uzyskujemy referencje do tych obiektów, np.:


Class c = Class.forName("java.lang.Integer"); 
lub

Class c = java.lang.Integer.class; 

uzyskujemy referencję do klasy Integer i możemy się nim posłużyć przy tworzeniu obiektu:


Integer i = (Integer) c.newInstance();

W statycznym przypadku, gdy wszystko jest ustalone "w źródle" programu, sens takich konstrukcji jest niewielki. Ale czasem warto odłożyć pewne ustalenia do fazy wykonania programu, zwiększając jego elastyczność i uniwersalność. Wtedy dynamiczna reprezentacja obiektów-klas bardzo się przydaje.

Mechanizm refleksji

Podstawowy programistyczny interfejs refleksji (Core Reflection API) realizowany jest przez klasy pakietu java.lang.reflect oraz rozbudowaną klasę Class z pakietu java.lang.

Refleksja oznacza, że w trakcie wykonania programu możliwe są następujące działania:

  1. uzyskiwanie pełnej informacji o charakterystykach klasy (pola, metody, ich charakterystyki)
  2. operacje na polach danego obiektu, poprzez ich nazwy,
  3. aktywowanie metod na rzecz danego obiektu poprzez ich nazwy i z podaniem argumentów.

Użycie refleksji pozwala m.in. na:

  1. stwierdzenie jakie i z jakimi argumentami metody występują w danej klasie (np. podawanej dynamicznie w trakcie wykonania programu)
  2. dynamiczne wywoływanie metod (specyfikowanych w trakcie wykonania programu) na rzecz jakiegoś obiektu (też dynamicznie ustalanego),
  3. dynamiczne uzyskiwanie i modyfikacje wartości pól obiektu.

Uzywając mechanizmów refleksji do metod i pól odwołujemy się poprzez ich nazwy, a nie identyfikatory. Na czym polega różnica wobec statycznego przypadku?

W statyce odwołania są skonkretyzowane na etapie kompilacji. Piszemy np. b.getText(). I tak już zostanie na zawsze. W dynamice konstrukcja jest całkiem inna - właśnie posługująca się nazwą metody. Piszemy raczej tak: b.invokeMethod("getText"). Tu invokeMethod() jest naszą własną metodą. Jako argument podajemy nazwę metody klasy, a ponieważ jest to String, możemy go zmieniać w każdym momencie wykonania programu. Np. możemy napisać: b.invokeMethod(s), a kolejne podstawienia pod s różnych nazw metod będzie zmieniać znaczenie tej linii programu. Właśnie w invokeMethod() używamy środków refleksji.

Klasy pakietu java.lang.reflect

KlasaPrzeznaczenie
Array Tworzenie tablic, uzyskiwanie i ustalanie wartości elementów
Constructor Informacja i dostęp do danego konstruktora danej klasy. W szczególności wykorzystanie dla tworzenia obiektu.
Field Informacja i dostęp do pola obiektu. Pobranie i zmiana wartości pola.
Method Informacja o danej metodzie danej klasy. Dynamiczne wywołanie metody na rzecz danego obiektu.
Modifier Uzyskiwanie informacji o modyfikatorach składowej obiektu lub klasy.

Użyteczne metody klasy java.lang.Class

Metoda Przeznaczenie
getClasses()
getDeclaredClasses()
Zwraca tablicę obiektów klasy Class, które są składowymi danej klasy.
getConstructors()
getDeclaredConstructors()
Zwraca tablicę obiektów klasy Constructor; są to konstruktory danej klasy
getConstructor(Class[])
getDeclaredConstructor(Class[])
Zwraca obiekt konstruktor (obiekt klasy konstruktor), który ma podane typy argumentów
getMethods()
getDeclaredMethods()
Zwraca tablicę, zawierającą odnośniki do metod klasy. Metody są obiektami klasy Method.
getMethod(String, Class[])
getDeclaredMethod(String, Class[])
Zwraca metodę o podanej nazwie i podanych argumentach jako obiekt klsy Method.

Rozróżnienie pomiędzy metodami mającymi i nie mającymi w nazwie tekstu "Declared" jest następujące:

  1. metody bez "Declared" zwracają składowe tylko publiczne, ale jednocześnie również dziedziczone,
  2. metody z "Declared" zwracają wszystkie składowe (również prywatne i zabezpieczone), ale bez dziedziczonych

Refleksja w uniwersalnych modelach danych

Ważne zastosowanie znajduje refleksja w budowie uniwersalnych modeli danych. Zobaczymy to na przykładzei modelu danych dla tabeli, który może być zastosowane dla dwolnych tabel dowolnych obiektów. Dane są reprezentowane jako lista obiektów - każdy obiekt-element listy jest pokazywany w tabeli jako wiersz, a wartościami kolumn są wartości przekazanych przy konstrukcji modelu pól obiektu.

Pakiety

Program w języku Java to, w pewnym uproszczeniu, zbiór klas. Nie jest to jednak zbiór bezładny. Klasy pogrupowane są w tzw. pakiety. Klasy dzielimy na pakiety by pogrupować je według ich znaczenia, analogicznie do tego, jak dzielimy pliki na katalogi. Pakiety, podobnie jak katalogi, mają strukturę hierarchiczną, tj. każdy z pakietów może zawierać kolejne pakiety, podobnie jak katalogi mogą zawierać podkatalogi. Każdy pakiet, oprócz dowolnej liczby innych pakietów może zawierać także dowolną liczbę klas, podobnie do katalogu, który oprócz innych katalogów może zawierać dowolnie wiele plików.

Zadania pakietów:

  1. Zapobieganie konfliktom nazw. Wśród tysięcy programistów piszących programy w Javie jest całkiem prawdopodobne, że wielu użyje tej samej nazwy dla różnych typów. Kompilator pozwala na to pod warunkiem, że klasy te należą do różnych pakietów. To działa dopóki dwóch programistów nie nazwie tak samo swojego pakietu / pakietów. Aby tego uniknąć przyjęto konwencję, według której:
    • nazwy pakietów składają się z małych liter, by uniknąć konfliktu z nazwami klas i interfejsów
    • nazwy pakietów zbudowane są na zasadzie "reversed Internet domain name", np. com.example.mypackage dla pakietu mypackage stworzonego przez programistę w domenie example.com. Pakiety z biblioteki Javy zaczynają się od java. lub javax.
  2. Grupowanie klas w logiczne całości, co pozwala na łatwiejsze zrozumienie struktury projektu i odnalezienie jego składowych (klas, interfejsów, enumeracji).
  3. Enkapsulacja danych i kontrola dostępu.

Pakiety i katalogi

Jeśli implementujemy klasę, to musimy jej kod źródłowy umieścić w osobnym pliku, który to plik nazywa się dokładnie tak samo jak ta klasa i posiada rozszerzenie .java. Podobnie, jeśli deklarujemy, że klasa należy do pewnego pakietu, to plik zawierający implementację tej klasy musi znajdować się w katalogu odpowiadającym temu pakietowi. Przykładowo, jeśli plik zawierający definicję klasy HelloWorld znajduje się w katalogu o nazwie naukajavy, który to katalog z kolei znajduje się w katalogu o nazwie pl, tj. plik o nazwie HelloWorld.java znajduje się w katalogu pl/naukajavy, to klasa ta należy do pakietu pl.naukajavy.

Implementując klasę musimy określić w jakim znajduje się ona pakiecie. W tym celu, na samym początku pliku umieszczamy instrukcję:


package nazwa_pakietu;

gdzie zamiast ciągu nazwa_pakietu wpisujemy nazwę pakietu. Przykładowo, implementacja klasy HelloWorld z pakietu pl.naukajavy, tj. klasy pl.naukajavy.HelloWorld wygląda następująco:


package pl.naukajavy;
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

Kod ten umieszczamy w pliku pl/naukajavy/HelloWorld.java. Jeśli klasę umieszczamy w pakiecie domyślnym (tj. poza jakimkolwiek pakietem), to deklarację pakietu w pliku - tj. instrukcję package - pomijamy.

Dostęp do klas składowych pakietów

Zdanie import ma dwie podstawowe formy:


// import the Vector class from java.util package.
import java.util.Vector;

// import all the classes from java.util package
import java.util.*;

Pierwsza wersja importuje pojedynczą klasę (w tym przypadku Vector) z pakietu (np. java.util), druga udostępnia wszystkie klasy z wymienionego pakietu (ale nie podpakietów, które muszą być importowane osobno).

Dostęp do składowych statycznych

Aby używać statycznych składowych musimy odpowiednie nazwy kwalifikować nazwą klasy, która je zawiera.


double r = Math.cos(Math.PI * theta);

static import pozwala na niekwalifikowany dostęp do składowych statycznych albo poszczególnych


import static java.lang.Math.PI;
albo wszystkich

import static java.lang.Math.*;
Wtedy możemy napisać

double r = cos(PI * theta);

static import nie powinien być nadużywany. Jest dopuszczalny przy częstym dostępie do statycznych składowych jednej lub dwóch klas. Jeżeli program importuje dużo składowych z wielu klas to staje się nieczytelny i przestrzeń nazw jest zanieczyszczona przez wszystkie zaimportowane składowe. Szczególnie szkodliwy może być import "globalny". Lepiej jest zaimportować tylko potrzebne składowe.

Wzorce projektowe

Singleton (wzorzec kreacyjny)

Singleton jest kreacyjnym wzorcem projektowym, który:

  1. Pozwala zapewnić istnienie wyłącznie jednej instancji danej klasy.
  2. Daje globalny punkt dostępowy do tejże instancji.

Wszystkie implementacje wzorca Singleton współdzielą poniższe dwa etapy:

  1. Ograniczenie dostępu do domyślnego konstruktora przez uczynienie go prywatnym, aby zapobiec stosowaniu operatora new w stosunku do klasy Singleton.
  2. Utworzenie statycznej metody kreacyjnej, która będzie pełniła rolę konstruktora. Za kulisami, metoda ta wywoła prywatny konstruktor, aby utworzyć instancję obiektu i umieści go w polu statycznym klasy. Wszystkie kolejne wywołania tej metody zwrócą już istniejący obiekt.

Adapter (wzorzec strukturalny)

Adapter jest strukturalnym wzorcem projektowym pozwalającym na współdziałanie ze sobą obiektów o niekompatybilnych interfejsach.

Załózmy, że nasza aplikacja pobiera dane z wielu źródeł w formacie XML. Postanawiamy wzbogacić aplikację poprzez dodanie biblioteki graficznej prezentacji danych. Ale jest haczyk: biblioteka ta działa wyłącznie z danymi w formacie JSON.

Aby rozwiązać dylemat niezgodności formatów, można stworzyć adapter XML-do-JSON. Potem zaś możemy dostosować kod tak, aby komunikował się z biblioteką wyłącznie za pomocą adaptera. Gdy adapter otrzyma wywołanie, tłumaczy przychodzące dane XML na strukturę JSON i przekazuje wywołanie dalej, do odpowiedniej metody opakowywanego obiektu biblioteki.

Adapter można rozpoznać po konstruktorze przyjmującym instancję innego typu abstrakcji/interfejsu. Gdy adapter otrzymuje wywołanie kierowane do którejś z jego metod, tłumaczy parametry wywołania do stosownego formatu i przekazuje je do jednej lub wielu metod opakowanego obiektu.

Metoda szablonowa (wzorzec behawioralny)

Metoda szablonowa to behawioralny wzorzec projektowy według którego definiuje się szkielet algorytmu w klasie bazowej i pozwala klasom pochodnym nadpisać poszczególne jego etapy bez zmiany ogólnej struktury.

Przykład

Wyobraź sobie, że tworzysz aplikację zbierającą dane, która analizuje dokumenty w korporacji. Użytkownicy przesyłają do niej dokumenty w różnych formatach (PDF, DOC, CSV), a ta próbuje wydobyć z nich istotne dane i przedstawić je w jednym formacie.

Wszystkie klasy odpowiedzialne za przetwarzanie różnych typów dokumentów mają sporo podobnego kodu. Fragmenty odpowiedzialne za pracę z różnymi formatami danych są odmienne, ale kod odpowiedzialny za obróbkę i analizę danych jest niemal identyczny. Chcemy się tych powtórzeń pozbyć nie naruszając przy tym struktury algorytmów.

Dodatkowo nasz kod miał sporo instrukcji warunkowych służących wybieraniu odpowiedniego sposobu działania zależnie od klasy obiektu przetwarzającego. Jeśli wszystkie trzy klasy przetwarzające miałyby jeden wspólny interfejs lub wspólną klasę bazową, byłoby możliwe usunięcie instrukcji warunkowych w kodzie klienckim i zastosowanie polimorfizmu.

Mediator (wzorzec behawioralny)

Mediator to behawioralny wzorzec projektowy pozwalający zredukować chaos zależności pomiędzy obiektami. Wzorzec ten ogranicza bezpośrednią komunikację pomiędzy obiektami i zmusza je do współpracy wyłącznie za pośrednictwem obiektu mediatora.

Przykład

Piloci statków powietrznych zbliżający się do lotniska lub je opuszczający nie rozmawiają ze sobą nawzajem, lecz za pośrednictwem kontrolera lotów, siedzącego w wieży z widokiem na lądowisko. Bez kontrolera, piloci musieliby wiedzieć o każdym samolocie lub śmigłowcu w okolicy lotniska, dyskutować na temat pierwszeństwa lądowania. Mogłoby to niekorzystnie wpłynąć na statystyki bezpieczeństwa.

Wieża nie musi kontrolować całego lotu. Jej funkcja służy nakładaniu ograniczeń w obszarze terminala aby żaden z pilotów nie był przytłoczony dużą ilością aktorów biorących udział w funkcjonowaniu lotniska.

Dependency injection

Wstrzykiwanie zależności jest techniką, która pozwala nam unikać silnych powiązań pomiędzy klasami. W Javie istotną rolę odgrywa tutaj korzystanie z interfejsów oraz zalet jakie daje nam polimorfizm.

Zalety

  1. Niezależne moduły - oba moduły (MessageService i Client) nic tak naprawdę o sobie nie wiedzą. Client nic nie wie o implementacji, zna tylko interfejs.
  2. Łatwość w testowaniu - dzięki temu, że moduły są niezależne są prostsze w testowaniu. W środowisku testowym możemy bez problemu podmieniać nasze "klocki" i testować na różne sposoby - książkowym przykładem jest podmiana bazy danych na czas testów.
  3. Luźne połączenia. Żadna klasa nie jest ściśle związana z implementacją drugiej.

Dependency Injection w Javie wymaga następujących elementów

DI

  1. Klasy serwisu z bazowym interfejsem
  2. Klasy klienta, napisane w oparciu o interfejs serwisowy
  3. Klasy typu Injector (również implementujące wspólny interfejs), które inicjalizują serwisy a następnie klientów (przekazując im odpowiednie obiekty serwisowe) oraz służą jako jedyny łącznik między nimi.

Jedna z zasad projektowania obiektowego mówi, że "Klasy powinny być otwarte na rozszerzenia, ale zamknięte na modyfikacje".

Co to oznacza w praktyce?

Aby rozszerzyć funkconalność o kolejny typ komunikacji (np. wysłanie komunikatu przez messengera) wystarczy dodać nową klasę implementującą MessageService i odpowiadający jej MessageServiceInjector (tworzący odpowiedni serwis i klienta). Implementacja klasy ClientApplication zostaje niezmieniona.

Streams

Czym są strumienie

Strumień reprezenuje sekwencję elementów i umożliwia wykonywanie szeregu operacji na tych elementach

Prosty przykład

Niech będzie dana następująca definicja klasy BoardGame:


public class BoardGame {
    public final String name;
    public final double rating;
    public final BigDecimal price;
    public final int minPlayers;
    public final int maxPlayers;

    public BoardGame(String name, double rating, BigDecimal price, int minPlayers, int maxPlayers) {
        this.name = name;
        this.rating = rating;
        this.price = price;
        this.minPlayers = minPlayers;
        this.maxPlayers = maxPlayers;
    }
}

Tworzymy listę obiektów tej klasy:


List<BoardGame> games = Arrays.asList(
    new BoardGame("Terraforming Mars", 8.38, new BigDecimal("123.49"), 1, 5),
    new BoardGame("Codenames", 7.82, new BigDecimal("64.95"), 2, 8),
    new BoardGame("Puerto Rico", 8.07, new BigDecimal("149.99"), 2, 5),
    new BoardGame("Terra Mystica", 8.26, new BigDecimal("252.99"), 2, 5),
    new BoardGame("Scythe", 8.3, new BigDecimal("314.95"), 1, 5),
    new BoardGame("Power Grid", 7.92, new BigDecimal("145"), 2, 6),
    new BoardGame("7 Wonders Duel", 8.15, new BigDecimal("109.95"), 2, 2),
    new BoardGame("Dominion: Intrigue", 7.77, new BigDecimal("159.95"), 2, 4),
    new BoardGame("Patchwork", 7.77, new BigDecimal("75"), 2, 2),
    new BoardGame("The Castles of Burgundy", 8.12, new BigDecimal("129.95"), 2, 4)
);

Naszym zadaniem jest wybrać te gry, które spełniają następujące warunki:

  1. Powinna pozwolić na grę w więcej niż 4 osoby
  2. powinna mieć ocenę wyższą niż 8
  3. powinna kosztować mniej niż 150 zł
A następnie wyświetlić ich nazwy dużymi literami.

Podejście klasyczne:


for (BoardGame game : games) {
    if (game.maxPlayers > 4) {
        if (game.rating > 8) {
            if (new BigDecimal(150).compareTo(game.price) > 0) {
                System.out.println(game.name.toUpperCase());
            }
        }
    }
}

Taka struktura ma swoją nazwę: Arrow Anti-Pattern. Jednym ze sposobów uniknięcia tego antywzorca może być użycie strumieni:


games.stream()
    .filter(g -> g.maxPlayers > 4)
    .filter(g -> g.rating > 8)
    .filter(g -> new BigDecimal(150).compareTo(g.price) > 0)
    .map(g -> g.name.toUpperCase())
    .forEach(System.out::println);

Operacje związane ze strumieniami można podzielić na trzy rozłączne grupy:

  1. tworzenie strumienia,
  2. przetwarzanie danych wewnątrz strumienia,
  3. zakończenie strumienia.

Każdy strumień ma dokładnie jedną metodę, która go tworzy na podstawie danych źródłowych. Następnie dane te są przetwarzane przez dowolną liczbę operacji. Każda z tych operacji tworzy nowy strumień danych wywodzący się z poprzedniego. Na samym końcu strumień może mieć dokładnie jedną metodę kończącą pracę ze strumieniem.

Większość operacji na strumieniach musi być nieinterferująca (non-interfering) i bezstanowa (stateless). Funkcja jest nieinterferująca gdy nie modyfikuje podstawowego źródła danych strumienia (w naszym przypadku listy games). Funkcja jest bezstanowa jeżeli wykonanie operacji jest deterministyczne, czyli nie zależy od zewnętrznych zmiennych lub stanów, które mogą się zmieniać w czasie wykonania.

Tworzenie strumieni

Strumień na podstawie kolekcji:


Stream<Integer> stream1 = new LinkedList<Integer>().stream();

Strumień na podstawie tablicy:


Stream<Integer> stream2 = Arrays.stream(new Integer[]{});

Strumień na podstawie łańcucha znaków rozdzielanego przez wyrażenie regularne:


Stream<String> stream3 = Pattern.compile(".").splitAsStream("some longer sentence");

Strumień typów prostych:


DoubleStream doubles = DoubleStream.of(1, 2, 3);
IntStream ints = IntStream.range(0, 123);
LongStream longs = LongStream.generate(() -> 1L);

Strumień danych losowych:


DoubleStream randomDoubles = new Random().doubles();
IntStream randomInts = new Random().ints();
LongStream randomLongs = new Random().longs();

Pusty strumień:


Stream.empty();

Strumień danych z pliku:


try (Stream<String> lines = new BufferedReader(new FileReader("file.txt")).lines()) {
    // do something
}

Tryb wykonywania operacji

Istotną cechą operacji pośrednich jest "laziness". W następującym przykładzie nie ma operacji terminalnej.


Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return true;
    });

Powyższy fragment programu nie drukuje niczego, ponieważ operacje pośrednie są wykonywane tylko w obecności operacji terminalnej. Jeżeli dodamy operację terminalną (forEach):


Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return true;
    })
    .forEach(s -> System.out.println("forEach: " + s));

to uzyskamy następujący wynik

filter:  d2
forEach: d2
filter:  a2
forEach: a2
filter:  b1
forEach: b1
filter:  b3
forEach: b3
filter:  c
forEach: c

Kolejność linii wydruku może być zasakująca. Można się spodziewać wykonania kolejnych operacji na każdym elemencie strumienia i dopiero wtedy przejścia do następnej operacji. Okazuje się jednak, że każdy element przesuwa się wzdłuż łańcucha. Dopiero kiedy "d2" przejdzie operację filter i forEach, strumień zaczyna obsługę elementu "a2". Takie podejście może zmniejszyć liczbę operacji wykonywanych na każdym elemencie


Stream.of("d2", "a2", "b1", "b3", "c")
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .anyMatch(s -> {
        System.out.println("anyMatch: " + s);
        return s.startsWith("A");
    });

// map:      d2
// anyMatch: D2
// map:      a2
// anyMatch: A2

Operacja anyMatch zwraca true jak tylko napotka pierwszy element spełniający dane kryterium. Dzięki 'pionowemu' wykonywaniu operacji w strumieniu, map działa tylko dla dwóch pierwszych elementów.

Kolejność operacji


Stream.of("d2", "a2", "b1", "b3", "c")
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("A");
    })
    .forEach(s -> System.out.println("forEach: " + s));

// map: d2
// filter: D2
// map: a2
// filter: A2
// forEach: A2
// map: b1
// filter: B1
// map: b3
// filter: B3
// map: c
// filter: C

Jak widać map i filter zostały wykonane pięć razy (dla każdego obiektu kolekcji), natomiast forEach tylko raz. Jednakże możemy zredukować liczbę operacji, zmieniając ich kolejność


Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("A");
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .forEach(s -> System.out.println("forEach: " + s));

// filter:  d2
// filter:  a2
// map:     a2
// forEach: A2
// filter:  b1
// filter:  b3
// filter:  c

Jak widać map jest teraz wykonywane tylko raz. Taka optymalizacja może być bardzo istotna dla dużej liczby elementów w strumieniu.


Stream.of("d2", "a2", "b1", "b3", "c")
    .sorted((s1, s2) -> {
        System.out.printf("sort: %s; %s\n", s1, s2);
        return s1.compareTo(s2);
    })
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("A");
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .forEach(s -> System.out.println("forEach: " + s));

Sortowanie jest specjalnym rodzajem operacji pośredniej. Jest to tak zwana operacja ze stanem, ponieważ w czasie sortowania kolekcji elementów, musimy pamiętać aktualny stan. Wyniki powyższego programu są następujące

sort:    a2; d2
sort:    b1; a2
sort:    b1; d2
sort:    b1; a2
sort:    b3; b1
sort:    b3; d2
sort:    c; b3
sort:    c; d2
filter:  a2
map:     a2
forEach: A2
filter:  b1
filter:  b3
filter:  c
filter:  d2

Operacja sorted jest wykonywana "poziomo", na całej kolekcji. Wprowadzamy jeszcze jedną modyfikację optymalizyjącą


Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("A");
    })
    .sorted((s1, s2) -> {
        System.out.printf("sort: %s; %s\n", s1, s2);
        return s1.compareTo(s2);
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .forEach(s -> System.out.println("forEach: " + s));

// filter:  d2
// filter:  a2
// filter:  b1
// filter:  b3
// filter:  c
// map:     a2
// forEach: A2

Tym razem sorted nie zostało wykonana, ponieważ filter ograniczył liczbę elementów do jednego.

Powielanie strumieni

Strumienie nie mogą być wykorzystywane wielokrotnie. Po wywołaniu dowolnej operacji terminalnej strumień jest zamykany. Aby obejść to ograniczenie tworzymy nowy strumień dla każdej operacji terminalnej.


Supplier<Stream<String>> streamSupplier =
    () -> Stream.of("d2", "a2", "b1", "b3", "c")
            .filter(s -> s.startsWith("a"));

streamSupplier.get().anyMatch(s -> true);   // ok
streamSupplier.get().noneMatch(s -> true);  // ok

Każde wywołanie get() tworzy nowy strumień, na którym możemy wywoływać operacje terminalne.

Czy strumienie są szybsze?

Java nie została zaprojektowana z myślą o programowaniu funkcyjnym. Dlatego przetwarzanie danych wykorzystując streamy w większości przypadków jest mniej wydajne. Ten fakt musimy mieć na uwadze zwłaszcza przetwarzając duże zestawy danych. Jednak korzyścią płynącą z programowania funkcyjnego jest mniejsze zaangażowanie pamięci.

Przykład w języku Haskell


main = do

-- map:
-- anonymous functions are created with a backslash followed by
-- all the arguments.
    putStrLn "\nmap"
    print $ map (* 5) [1..5] -- [ 5, 10, 15, 20, 25]
    print $ map (\x -> x*x) [1..5] -- [ 1, 4, 9, 16, 25]

-- filter: (according to some predicate)
    putStrLn "\nfilter"
-- print odd
    print $ [ x | x <- [2, 3, 4, 5, 8, 10, 11], (\y -> y `mod` 2 /= 0)  x] -- [3, 5, 11]
-- print greater than 5
    print $ [ x | x <- [2, 3, 4, 5, 8, 10, 11], (\y -> y > 5)  x] -- [8, 10, 11]

-- filter & map together:
    putStrLn "\nfilter & map"
    print $ map (\x -> x*x) (filter (\x -> x `mod` 2 == 0 && x > 0) [0..6]) -- [4, 16, 36]

-- collect:
-- using fold with an anonymous function. foldl1 means fold left,
-- and use the first value in the list as the initial value for the accumulator.
    putStrLn "\ncollect"
    print $ foldl1 (\acc x -> acc + x) [1..5] -- 15
    putStrLn ""