Wybierz region
pl
  • PL
  • EN
Wydrukuj

Co nowego w Javie?

W dzisiejszym wpisie postaram się przybliżyć najważniejsze funkcjonalności, które pojawiły się w ostatnich wersjach Javy. Pisząc o „ostatnich” wersjach przyjmuję, że są nimi wszystkie, które ukazały się po wydaniu Javy 11, czyli na przestrzeni trzech ostatnich lat.

Zgodnie z badaniem [1] przeprowadzonym przez firmę Snyk pośród użytkowników Javy, na początku bieżącego roku na produkcji dominowała właśnie 11. wersja języka. Równocześnie warto zwrócić uwagę, że badanie z tego roku jest pierwszym, w którym Java 11 nieznacznie wyprzedziła w zastosowaniach produkcyjnych wersję 8. (mimo że Java 11 została wydana już trzy lata temu). Mając to na uwadze, zasadne wydaje się uznanie wszystkich funkcjonalności wprowadzonych po Javie 11 za nowości, z którymi większość użytkowników języka nie miała się jeszcze okazji zetknąć – przynajmniej w zastosowaniach produkcyjnych. Korzystając z okazji, którą jest wydanie kolejnej, 17. wersji Javy, przyjrzyjmy się zatem, jakie najważniejsze nowe funkcjonalności pojawiły się w tym języku na przestrzeni ostatnich trzech lat.

Pierwszą z nich są Switch Expressions. Funkcjonalność ta pojawiła się ostatecznie w Javie 14, w poprzednich dwóch wersjach obecna zaś była jako preview [2]. Istotą tej zmiany jest umożliwienie zapisu wyrażenia switch w prostszy, bardziej zwięzły i mniej błędogenny sposób. Przyjrzyjmy się przykładom. Dotychczas, jeżeli chcieliśmy skorzystać z instrukcji switch, musieliśmy robić to mniej więcej w taki sposób:

switch (month) {

case JANUARY:

case FEBRUARY:

case MARCH:

commentary = "cold";

break;

case APRIL:

case MAY:

case JUNE:

commentary = "nasty weather, but don't have to scrape ice off the car";

break;

case JULY:

case AUGUST:

commentary = "hot";

break;

case SEPTEMBER:

commentary = "raining";

break;

case OCTOBER:

case DECEMBER:

commentary = "raining and cold again";

break;

default:

commentary = "nasty weather";

Analizując powyższy zapis dostrzec można kilka słabych stron. Pierwszą z nich jest „rozwlekłość” tradycyjnego wyrażenia switch w Javie. Zwróćmy uwagę, że nawet jeśli do kilku przypadków chcielibyśmy przypisać taki sam wynik, to konieczne jest każdorazowe tworzenie odrębnej klauzuli case. Kolejną niedogodnością jest konieczność powtarzania słowa break każdorazowo, jeżeli chcemy zatrzymać wykonywanie kodu. Patrząc na przedstawiony wyżej przykład, gdybyśmy pozbyli się wszystkich słówek break, to do zmiennej commentary w każdym przypadku, niezależnie od tego, jaki month trafiłby do switcha, przypisana zostałaby ostatnia wartość, czyli "nasty weather". Stałoby się tak właśnie dlatego, że po wykonaniu kodu przypisanego do właściwej klauzuli case nastąpiłoby „przejście” dalej, do kolejnych linii kodu. Powyższy przykład jest dosyć prosty, ale możemy sobie wyobrazić, że w ramach któregoś z case’ów znalazłaby się bardziej złożona logika, obejmująca wiele instrukcji warunkowych itp. W takim przypadku pilnowanie, by w wielu miejscach właściwie umieścić słowo break staje się uciążliwe, zaś sam kod traci na czytelności.

Oba opisane problemy rozwiązane zostały przez Switch Expressions. Korzystając z niego, poprzedni przykład zapisać możemy w następujący sposób:

switch (month) {

case JANUARY, FEBRUARY, MARCH -> "cold";

case APRIL, MAY, JUNE -> "nasty weather, but don't have to scrape ice off the car";

case JULY, AUGUST -> "hot";

case SEPTEMBER -> "raining";

case OCTOBER, DECEMBER -> "raining and cold again";

default -> "nasty weather";

};

Taki zapis jest zdecydowanie bardziej czytelny. Pozbyliśmy się nadmiarowych klauzul case – jeśli dany rezultat ma być przypisany dla więcej niż jednego przypadku, wystarczy, że użyjemy jej jeden raz. Ponadto, nie jest już konieczne każdorazowe zatrzymywanie przejścia do kolejnych linii kodu słówkiem break – takie zatrzymanie nastąpi samo, jeśli spełniony zostanie warunek przypisany do któregoś z case’ów.

Kolejną korzyścią związaną z Switch Expressions jest fakt, że jego „wynik” możemy np. przypisać bezpośrednio do zmiennej czy zwrócić jako rezultat wykonania metody (o ile oczywiście z każdym z case’ów powiążemy wartość odpowiedniego typu). Patrząc na powyższy przykład widzimy, że do każdego case’a zawracana jest wartość typu String, zatem całą instrukcję switch przypisać moglibyśmy właśnie do zmiennej tego typu. Wyglądać mogłoby to w następujący sposób:

String commentary = switch (month) {

case JANUARY, FEBRUARY, MARCH -> "cold";

case APRIL, MAY, JUNE -> "nasty weather, but don't have to scrape ice off the car";

case JULY, AUGUST -> "hot";

case SEPTEMBER -> "raining";

case OCTOBER, DECEMBER -> "raining and cold again";

default -> "nasty weather";

};

Patrząc na przedstawiony przykład możemy wyobrazić sobie, że dla danego case’a chcielibyśmy zwrócić wartość, ale wcześniej wykonać na niej pewne działania. W tym celu w ramach Switch Expressions wprowadzono słowo yeld, które ma na celu wskazanie, którą wartość chcemy zwrócić w przypadku bardziej rozbudowanych klauzul case. Dla zobrazowania posłużmy się przykładem:

int j = switch (month) {

case JANUARY -> 0;

case FEBRUARY -> 1;

default -> {

int k = month.toString().length();

int result = f(k);

yield result;

}

};

Widzimy, że do dwóch pierwszych case’ów po prostu zwracamy liczby, które mają zostać dalej przypisane do zmiennej j. W przypadku domyślnym, opisanym w klauzuli default, dokonujemy pewnych obliczeń, dzięki którym uzyskujemy wartość przypisaną do zmiennej result. Korzystając ze słówka yeld wskazujemy, że to właśnie ta wartość powinna zostać przypisana do zmiennej j, jeżeli wykonanie switcha wejdzie do klauzuli default.

Kolejną dużą funkcjonalnością, które pojawiła się ostatnio w Javie są Text Blocks. Wprowadzono ją ostatecznie w 15. wersji języka, choć pierwsze przymiarki (jeszcze pod inną nazwą) pojawiły się już w wersji 12. Podstawowym celem bloków tekstu jest podniesienie czytelności Stringów, które zapisywane są w więcej niż jednej linii. Spójrzmy na poniższy przykład:

String html = "<html>\n" +

" <body>\n" +

" <p>Hello, world</p>\n" +

" </body>\n" +

"</html>\n";

Do zmiennej typu String przypisać chcemy wielowierszowy ciąg znaków o określonym formatowaniu. Wymaga to od nas wielokrotnego otwierania i zamykania cudzysłowów, wstawiania znaków nowej linii oraz konkatenowania poszczególnych wierszy. Jest to nie tylko uciążliwe, ale także nieszczególnie czytelne. Od Javy 15 ten sam łańcuch znaków zapisać możemy także w następujący sposób, korzystając z Text Blocks:

String html = """

<html>

<body>

<p>Hello, world</p>

</body>

</html>

""";

Jak widać na powyższym przykładzie, blok tekstu rozpoczynany jest oraz zamykany potrójnymi cudzysłowami. Wszystko, co znajdzie się pomiędzy nimi, traktowane jest jako String sformatowany dokładnie tak, jak widać to w edytorze tekstu.

Oprócz ulepszonej formy zapisu wielowierszowych Stringów, w ramach funkcjonalności Text Blocks wprowadzono również dwa nowe escape characters: ukośnik bez żadnych dodatkowych liter: „\” oraz ukośnik z literą s: „\s”. Pierwszy z nich użyty może być w tylko w bloku tekstu – jego wstawienie na końcu wiersza sprawia, że kolejny wiersz nie będzie traktowany jako nowa linia. Drugi zaś jest odpowiednikiem pojedynczej spacji. Jeżeli zatem wypisalibyśmy na konsoli tak zapisane Stringi:

String lorem = """

Lorem ipsum dolor sit amet, consectetur adipiscing \

elit, sed do eiusmod tempor incididunt ut labore \

et dolore\

""";

String space = "\smagna\saliqua.";

 

System.out.println(lorem + space);

to otrzymamy następujący rezultat (tekst zapisany cięgiem, bez nowych linii):

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Kolejną funkcjonalnością, o której warto wspomnieć są rekordy (Records), wprowadzone w Javie 16, czyli quasi klasy, pozwalające na wyrażenie prostego zbioru wartości, automatycznie implementujące większość boilerplate’owych metod. Aby zobrazować, czym są rekordy oraz jakie korzyści płyną z ich stosowania, spójrzmy na przykład:

record Person(String name) {}
Powyższym zapisem utworzyliśmy rekord Person. Rekord ten jest ekwiwalentem finalnej klasy Person posiadającej konstruktor, prywatne finalne pole name typu String, metodę dostępową do tego pola, a także sensownie nadpisane metody equals, hashCode oraz toString. Wszystkie te brakujące elementy zostaną automatycznie wygenerowane przez kompilator. Innymi słowy, jednolinijkowy zapis z poprzedniego przykładu równoważny jest utworzeniu tak wyglądającej klasy:

final class Person {

private final String name;

 

Person(String name) {

this.name = name;

}

 

public String name() {

return name;

}

 

@Override

public boolean equals(Object obj) {

if (obj == this) return true;

if (obj == null || obj.getClass() != this.getClass()) return false;

var that = (Person) obj;

return Objects.equals(this.name, that.name);

}

 

@Override

public int hashCode() {

return Objects.hash(name);

}

 

@Override

public String toString() {

return "Person[" +

"name=" + name + ']';

}

}

Odnosząc się do rekordów warto jeszcze wskazać kilka zasad, którymi kierować się trzeba przy ich tworzeniu. Przede wszystkim, wszystkie pola, które chcemy umieścić w wynikowej klasie, musimy zawrzeć w deklaracji rekordu. Pola te znajdą się później w wygenerowanym automatycznie konstruktorze wygenerowanej klasy. Zarówno rekordy, jak i posiadane przez nie pola są finalne, ze wszystkimi tego konsekwencjami. Nie będzie zatem możliwe dziedziczenie po rekordzie czy zmiana wartości pola za pomocą settea. Również rekord nie może rozszerzać żadnej klasy (ponieważ dziedziczy on już niejawnie po klasie java.lang.Record). Rekordy mogą natomiast implementować interfejsy. Dopuszczalne jest także nadpisywanie czy tworzenie dodatkowych konstruktorów.

Kolejną istotną funkcjonalnością, dodaną w 16. wersji Javy było wprowadzenie Pattern Matchingu dla instanceof. Funkcjonalność ta pozwoliła na skrócenie zapisu oraz wyeliminowanie nadmiarowego rzutowania, które pojawiało się przy okazji korzystania ze „starego” instanceof. Spójrzmy na przykład:

if (oldString instanceof String) {

 

String newString = (String) oldString;

System.out.println(newString);

}

Widzimy na nim instrukcję warunkową, w której sprawdzamy, czy wartość przypisana do zmiennej oldString jest instancją klasy String. Jeżeli okaże się, że tak, to w ciele instrukcji tworzymy nową zmienną, do której przypisujemy wartość oldString, przy czym dokonać musimy tutaj jawnego rzutowania na Stringa. Konieczność jawnego rzutowania wydaje się tutaj nadmiarowa, ponieważ już na wejściu do instrukcji warunkowej umieściliśmy sprawdzenie, czy przekazywana wartość jest Stringiem. Gdyby nie był to String, kod z ciała instrukcji warunkowej nie zostałby wykonany – a jednak kompilator wymaga od nas rzutowania.

Problem ten został wyeliminowany wraz z wprowadzeniem Pattern Matchingu dla instanceof. Od Javy 16 poprzedni przykład zapisać możemy w następujący sposób:

if (oldString instanceof String newString) {

 

System.out.println(newString);

}

Już na wejściu do instrukcji warunkowej sprawdzane jest, czy przekazana wartość jest typu String. Jeśli tak, o od razu tworzona jest zmienna typu String, z której możemy dalej korzystać bez konieczności rzutowania.

Ciekawą nowością, wprowadzoną w Javie 17, są także klasy zapieczętowane (Sealed Classes). Pozwalają one na wskazania przez autora klasy lub interfejsu, jaki kod ma być odpowiedzialny za ich implementację (rozszerzenie). Tworzenie zapieczętowanych klas (interfejsów) odbywa się w następujący sposób:

sealed interface Aircraft permits Plane, Helicopter {

}

 

sealed class Plane implements Aircraft permits LightAircraft {

}

Aby określić, że dana klasa ma być „zapieczętowana” należy użyć słówka sealed. Dalej pojawić musi się słowo permits, po nim zaś wskazanie, jakie klasy dopuszczalne są w strukturze dziedziczenia. W powyższym przykładzie widzimy zapieczętowany interfejs Aircraft, który implementować mogą wyłącznie klasy Plane lub Helicopter. Z kolei po zapieczętowanej klasie Plane dziedziczyć może wyłącznie klasa LightAircraft. Próby rozszerzania zapieczętowanych typów innymi klasami skończyłyby się błędem kompilacji. Dodatkowo zaznaczyć należy, że każda klasa, która pojawiła się po słówku permits musi zostać zadeklarowana:

  • również jako zapieczętowana (czyli dalej ograniczać możliwość dziedziczenia) albo
  • jako finalna (czyli całkiem wyłączać możliwość dziedziczenia), albo
  • opatrzona musi być słówkiem non-sealed, co oznacza powrót do pełnej dowolności, jeśli chodzi o strukturę dziedziczenia.

Przedstawione wyżej funkcjonalności to tylko kilka z wielu pojawiających się w ostatnich latach ulepszeń Javy. Pokazują jednak wyraźnie, że Java stale rozwija się, stając się językiem bardziej czytelnym, przyjaznym dla użytkowników oraz dającym wiele możliwości.

 

[1] snyk.io/jvm-ecosystem-report-2021

[2] Status preview oznacza, że określona zmiana już kompletną, w pełni wyspecyfikowaną i zaimplementowaną funkcjonalnością języka, jednak nie jest jeszcze oficjalną i domyślnie dostępną jego częścią. Funkcjonalności preview podlegają ocenie społeczności i w zależności od tej oceny, w kolejnych wersjach mogą zostać już oficjalnie włączone do Javy, poprawione lub usunięte.


Michał Karmelita

Z wykształcenia prawnik i informatyk. Zawodowo i z zamiłowania programista Java. W wolnym czasie stara się oddawać przyjemnościom i unikać przykrości. Zapalony podróżnik.


Wydrukuj