Elementarz Java #2 – Typy danych w języku Java
Wstęp
Dzisiaj w drugim artykule z serii #ElementarzJava, przedstawię Ci dość krótki, ale bardzo ważny temat związany z typami danych. Na samym początku zacznę oczywiście od omówienia sposobów deklaracji oraz inicjalizacji zmiennych, stałych oraz ich statycznych odpowiedników. W dalszej kolejności przedstawiam definicje zmiennych lokalnych, instancyjnych oraz klasy i wyjaśniam jakie są między nimi różnice. Oczywiście nie mogło również zabraknąć tematu związanego z wartościami domyślnymi, typami opakowującymi i Garbage Collection. Mimo, że jest to jeden z krótszych (o ile nie najkrótszych) artykułów z tej serii to jednak trochę nowej wiedzy na pewno zdobędziesz. Zapraszam do dalszej lektury i dyskusji w sekcji komentarzy.
Seria #ElementarzJava składa się z następujących artykułów:
- Podstawy języka Java (kompilacja, zmienne, struktura klasy, pakiety),
- Typy danych w języku Java (deklaracja i inicjalizacja zmiennych, różnica między typami, garbage collection, typy opakowujące),
- Operatory i konstrukcje warunkowe w Java (użycie operatorów, porównywanie obiektów, instrukcje:
if
,if/else
,switch
), - Tablice (charakterystyka tablic, tablice jedno i wielowymiarowe),
- Pętle (tworzenie pętli, instrukcje
break
icontinue
, etykiety), - Metody oraz hermetyzacja (metody statyczne, przeciążanie metod, konstruktory, modyfikatory widoczności, hermetyzacja elementów klasy, parametry),
- Dziedziczenie (implementacja, przysłanianie metod, polimorfizm, rzutowanie),
- Obsługa wyjątków (kategorie wyjątków, łapanie wyjątków, klasy wyjątków),
- API Java (
String
,StringBuilder
, data i czas, kolekcje, wyrażenia lambda).
Jesteś tu pierwszy raz? Polecam rozpocząć lekturę od materiału: Elementarz Java #0 – wprowadzenie.
Deklaracja i inicjalizacja zmiennych
Sama deklaracja i inicjalizacja zmiennych w Javie nie różni się niczym od innych języków programowania. Warto jednak zwrócić uwagę na kilka istotnych szczegółów.
Pierwsze pytanie: czy taki kod skompiluje się poprawnie?
int count = 3/4;
Odpowiedź brzmi – tak. Wartość zmiennej count
będzie w tym wypadku wynosiła po prostu 0
.
Druga ciekawostka, czy ten fragment będzie działał?
int val = 4; switch (val % 2.) { case 0: System.out.println("It is ok"); }
Tutaj sprawa nie jest już tak prosta jak wcześniej. Przede wszystkim w instrukcji switch
mamy wyrażenie val % 2.
. Dzielimy więc wartość zmiennej val
, która ma typ int
przez double
. Wynik tego wyrażenia będzie doublem
. Zgodnie ze specyfikacją Javy instrukcja wielokrotnego wyboru switch
może przyjmować wyrażenia typu: char
, byte
, short
, int
, Character
, Byte
, Short
, Integer
, String
lub enum
. Nie mamy tutaj dozwolonego double
. Dostaniemy błąd kompilacji.
Nazwy zmiennych
Specyfikacja Javy definiuje kilka reguł dotyczących nazewnictwa zmiennych:
- nazwa powinna zaczynać się od litery (a-z lub A-Z), znaku $, lub „_”. Nie ma limitu długości,
- nazwa można zawierać cyfry, ale nie może się od nich zaczynać,
- znak $ oraz „_” może występować w dowolnym miejscu,
- nie możemy używać słów kluczowych Javy (np.
switch
), - nie możemy używać znaków specjalnych takich jak: !, @, #, %, ^, &, *, (, ), ‘, :, ;, [, ], /, \ oraz {, }.
Zmienne statyczne
W artykule na temat podstaw Javy opisywałem już w jakie sposób działają zmienne statyczne. Posługując się nimi tak naprawdę operujemy na referencji do adresu w pamięci komputera, gdzie przechowywana jest ich zawartość. Oznacza to dla nas tyle, że dowolna modyfikacja zwartości takiego pola w dowolnym momencie oraz kontekście cały czas wpływa na tą samą wartość. Popatrzmy na przykład.
class Company { public static String name = "My company"; } public class Employee { public static void main(String[] args) { Company company1 = new Company(); Company company2 = new Company(); company1.name = "It is not my company"; company2.name = "It is my company"; System.out.print(company1.name); } }
Efekt działania powyższego kodu będzie następujący:
It is my company
Bierzemy tutaj pod uwagę ostatnią modyfikacje zmiennej statycznej. Nie interesuje nas w jakim kontekście została ona wywołana.
Kiedy mają być inicjalizowane finalne pola statyczne?
Ciekawy problem powstaje, kiedy nasze pole statyczne będzie równocześnie stałą. Jak wiemy stałe to zmienne, które po inicjalizacji nie mogę być modyfikowane. Jeśli myślisz, że pole statyczne może zostać zainicjalizowane choćby poprzez konstruktor to muszę wyprowadzić Ciebie jak najszybciej z tego błędu. Do pola statycznego możemy mieć przecież dostęp bez tworzenia instancji obiektu, więc konstruktor nie będzie tutaj uruchamiany. W grę nie wchodzi również definicja takiego pola bez przypisania wartości. Przy takiej próbie otrzymamy błąd kompilacji. Kiedy zatem pole statyczne może zostać zainicjowane?
Na to pytanie są dwie odpowiedzi. Po pierwsze, podczas jego definiowania, po drugie, przez statyczny blok inicjalizujący (pisałem o nich w poprzednim artykule).
Pobawmy się trochę taką zmienną i przenalizujmy taki oto przykład.
public class Camel { public static final String name; //to pole i tak ma modyfikator package-private private static final int age = 10; private static final String placeOfBirth; private static final int numberOfHumps; //to nigdzie nie jest inicjalizowane (błąd kompilacji) static { name = "Agnieszka"; placeOfBirth = "Kraków"; } static { placeOfBirth = "Warszawa"; //to jest niepoprawne } { numberOfHumps = 2; //to nie jest inicjalizacja zmiennej statycznej z klasy Camel (zwykły blok inicjalizujący) } public static void main(String[] args) { name = "Łukasz"; //to jest niepoprawe } }
Na początek małe wyjaśnienie. Przy definicji pola name
dopisałem komentarz, że to pole i tak ma modyfikator package-private
(czyli domyślny). Po lewej stronie jest jednak napisane dużymi literami słowo „public
” o co więc tutaj chodzi? Otóż wszystkie pola statyczne w danej klasie mogą mieć modyfikator domyślny lub bardziej restrykcyjny, czyli prywatny. Nawet gdy wpiszemy tam słowo public
to i tak nie będzie dostępu do tej zmiennej z poziomu innego pakietu (chyba, że wykonamy odpowiedni import).
W kolejnej linijce mamy zdefiniowane pole age
, które jest poprawnie zainicjalizowane. Kolejny element naszej klasy, czyli zmienna placeOfBirth
jest również poprawnie zainicjalizowana tym razem przez statyczny blok inicjalizujący. Pole numberOfHumps
nie jest już jednak nigdzie inicjalizowane. Miejsce, w którym przypisujemy do tej zmiennej wartość dwa to zwykły blok inicjalizujący. Otrzymujemy więc błąd kompilacji.
Stałe jak sama nazwa wskazuje nie mogą być ponownie modyfikowane. Tak więc linijka w metodzie main
gdzie przypisujemy do zmiennej name
słówko „Łukasz
” powoduje kolejny błąd kompilacji.
Zmienne lokalne, instancyjne i klasy
Zmienne mogą mieć kilka zakresów. Specyfikacja Javy wyróżnia: zmienne klasy (ang. class variable), zmienne instancyjne (ang. instance variable) oraz zmienne lokalne (ang. local variable). Zmienne lokalne definiowane są wewnątrz metod. Zaliczają się do nich również zmienne definiowane w pętlach. Dostęp do nich (co opisywałem w artykule na temat podstaw Javy) możliwy jest tylko w metodzie, w której taka zmienna została zdefiniowana. Zmienne lokalne muszą zostać zainicjalizowane przez programistę. Nie jest dopuszczalne na przykład takie rozwiązanie.
public static void main(String[] args) { int variable; if(variable == 0) { System.out.println("Hello"); } }
Przy takim kodzie dostajemy błąd kompilacji. Zanim użyjemy zmiennej variable
(jest ona lokalna) musimy ją zainicjalizować.
Instance variable są definiowane wewnątrz klasy poza metodami (są inicjalizowane, kiedy obiekt danej klasy zostanie stworzony).
Class variable są wspólne dla wszystkich obiektów klasy. Można do nich uzyskać dostęp nawet jeśli nie ma utworzonej instancji.
public class MyClass { int variable1; // instance variable public int variable2; // instance variable static int variable3; // class variable public MyClass() { int variable4; // local variable } }
Jak wspomniałem wcześniej. Przed użyciem zmiennych lokalnych musimy je najpierw zainicjalizować. W przypadku zmiennych klasowych i instancyjnych nie musimy tego robić. Przyjmują one domyślne wartości.
public class MyClass { public MyClass() { int num = 3; String arg; if(num == 3) { arg = "Hello"; } System.out.print(arg); } }
Taki kod nie zostanie skompilowany poprawnie. Zmienna arg
jest zmienną lokalną więc przed użyciem musi zostać zainicjalizowana. Co prawda jej inicjalizacja odbywa się w instrukcji if
, ale jest ona uzależniona od wartości zmiennej num
. Problem polega na tym, że kompilator w momencie kompilacji „nie wie”, że warunek ten zostanie spełniony. Dostaniemy więc błąd kompilacji. Moglibyśmy go uniknąć, gdyby zmienna num
była stałą (ale tylko wtedy, jeśli była by ona inicjalizowana w momencie deklaracji).
Wartości domyślne
- byte:
0
, - short:
0
, - int:
0
, - long:
0L
, - float:
0.0f
, - double:
0.0d
, - char:
'u0000'
, - String (lub inny typ obiektowy):
null
, - boolean:
false
.
Czytelniejszy zapis liczb
Java również umożliwia nam zastosowanie znaku „_
” do czytelniejszego zapisu liczb. Jak go używać? Tutaj zasada jest prosta. Wstawiamy go tam, gdzie nie jest „nadmiarowy”. Zobaczmy kilka przykładów.
int i1 = 1_234; double d0 = 1_234.0; double d1 = 1_234_.0; //to jest niepoprawne double d2 = 1_234._0; //to jest niepoprawne double d3 = 1_234.0_; //to jest niepoprawne
Wartości binarne, szesnastkowe…
Możemy też za pomocą specjalnego prefixu przypisywać do zmiennych wartości binarne lub też szesnastkowe. Jak to wygląda w praktyce?
int int1 = 0b101; int int2 = 0xE; double double1 = 0xE; long long1 = 9L;
Zastosowane prefixy:
0b
– prefix liczby binarnej,
0x
– prefix liczby szesnastkowej (hex),
9L
– to long.
Różnica między typami prostymi a obiektowymi
Podstawowa różnica między typami prostymi, a obiektowymi polega na tym, że typy proste takie jak byte
, short
, int
, long
… w przeciwieństwie do typów obiektowych nie przyjmują wartości null
. Wiąże się z tym takie pojęcie jak boxing i autoboxing o którym piszę dokładniej nieco niżej. To jednak nie wszystko. Typy opakowujące traktowane są jak obiekty. Oznacza to, że mając zmienną na przykład typu Long
(pisane z dużej litery) możemy traktować ją jako instancję obiektu, a co za tym idzie wywoływać szereg metod, które ten obiekt dostarcza.
public class MyClass { public static void main(String... args) { Long long1 = 300l; int a = long1.intValue(); // użycie metody intValue() na zmiennej typu Long int b = (int)long1; // to jest niepoprawne long long2 = 300l; int c = long2.intValue(); // to jest niepoprawne – typ prosty to nie typ opakowujący int d = (int)long2; // a to już jest poprawne } }
Na powyższym przykładzie widać, że rzutowanie obiektu typu Long
odbywa się przez dostarczoną przez ten obiekt metodę intValue()
. Nie możemy zrobić tego w taki sposób jak odbywa się to w przypadku prymitywów. To samo działa zresztą w drugą stronę. Typy prymitywne nie dostarczają nam żadnych metod, które moglibyśmy wywołać.
Cykl życia obiektu oraz Garbage Collection
Java w przeciwieństwie do C++ nie udostępnia takiego rozwiązania jak destruktor. Aby odpowiednio zarządzać dostępną pamięcią został w tym celu stworzony specjalny mechanizm sterowany przez maszynę wirtualną Javy, a odpowiedzialny za usuwanie niepotrzebnych danych z pamięci. Mowa oczywiście o Garbage Collection. Oznacza to tyle, że podczas pracy nad kodem mamy o jedno zmartwienie mniej, nie musimy samodzielnie zarządzać pamięcią. Zobaczmy na krótki przykład.
Car one = new Car(); Car two = new Car(); Car tree = one; one = null; Car four = one; tree = null; two = null; two = new Car(); System.gc();
Kiedy może zostać uruchomiony garbage collection? Odpowiedź na to pytanie jest dość prosta. Stanie się to po linijce 6 i 7 (oczywiście jak JVM stwierdzi, że jest taka potrzeba). Dlaczego wtedy? Mamy obiekt Car
, stworzony w linii 1, którego referencja kopiowana jest również w linii 3. W następnej linijce do one
przypisujemy null
, ale cały czas obiekt ten jest używany. Dopiero w linii 6 tracimy do niego dostęp. Wtedy gc
może zadziałać. Analogiczny tok rozumowania dotyczy linijki 7.
Wywołanie gc bezpośrednio z kodu
Garbage Collection można wywołać bezpośrednio z kodu Javy. Służy do tego polecenie System.gc()
. Sugeruje ono, że Java chce uruchomić garbage collection ale pamiętajmy, że to nie programista decyduje czy tak się faktycznie stanie.
Metoda finalize()
Metoda finalize()
jest wywoływana w momencie w którym zostanie uruchomiony garbage collection. Dotyczy to oczywiście obiektu w którym została ona nadpisana.
protected void finalize() { System.out.println("Goodbye!"); }
Popatrzmy na mały przykład.
public class Main { public static void main(String[] args) { Main main1 = new Main(); Main main2 = new Main(); main1 = main2; main2 = null; main1 = null; } protected void finalize() { System.out.println("Finalize was run"); } }
Co tutaj się stanie? Po pierwsze dwukrotnie zadziała garbage collection. To już powinno być jasne z poprzednich przykładów. Ale drugi element jest ciekawszy. Wydawać by się mogło, że metoda finalize()
zostanie uruchomiona dwa razy. Za każdym razem, kiedy gc działał. Nieprawda. Zgodnie ze specyfikacją Javy dla danego typu obiektu może być ona wywołana tylko jeden raz w trakcie działania programu.
Użycie klas opakowujących typy proste
Pisałem już o różnicy między typami prostymi i obiektowymi. Nad tym tematem warto się jednak nieco bardziej pochylić. Takim klasycznym przykładem są tutaj prymitywy i klasy opakowujące. Spójrzmy na poniższy kod.
Long number1 = null; long number2 = null; //błąd kompilacji – typ prosty nie przyjmuje null’a
Na tym prostym przykładzie widać bardzo dobrze różnicę między typem prostym long
, a typem opakowującym Long
.
Autoboxing i unboxing
Wspomniałem już kilka razy na temat autoboxingu. Dochodzi do niego, jeśli do typu opakowującego próbujemy przypisać prymitywa. Zobaczmy na krótki przykład:
List list<Integer> = new ArrayList<Integer>(); list.add(Integer.parseInt("1")); //parseInt zwraca int - autoboxing list.add(Integer.valueOf("2")); //valueOf zwraca Integer list.add(3); //autoboxing list.add(null); for(int item : list) System.out.print(item);
Mamy tutaj kolekcję, która przyjmuje obiekty typu Integer
. Przypisanie int’a
spowoduje uruchomienie autoboxingu – co zresztą widzimy na przykładzie. Kod ten skompiluje się poprawnie ale podczas jego działania zobaczymy wyjątek NullPointerException
. Dzieje się tak dlatego, że pętla for
, odpowiedzialna za iteracje kolekcji, przyjmuje obiekt typu int
. Przy każdym przejściu będzie więc dochodziło do unboxingu. Oczywiście do momentu kiedy nie natrafimy na ostatnim element. Wielokrotnie już o tym pisałem, int
nie może przyjmować wartości null
. Stąd więc wspomniany wyjątek.
Typy proste (primitive type) i ich odpowiedniki obiektowe (wrapper class)
Typy proste | Typy opakowujące |
boolean | Boolean |
byte | Byte |
char | Character |
float | Float |
int | Integer |
long | Long |
short | Short |
double | Double |
UWAGA: Jeśli interesuje Ciebie temat rzutowania typów oraz ich dziedziczenia, to zapraszam do materiału – Elementarz Java #7 – Dziedziczenie – gdzie zostało to szczegółowo omówione.
Hej! we fragmencie: „Jak wspomniałem wcześniej. Przed użyciem zmiennych lokalnych musimy je najpierw zainicjalizować. W przypadku zmiennych LOKALNYCH i instancyjnych nie musimy tego robić. Przyjmują one domyślne wartości.” jest błąd, powinno być „klasowych” ;)
Cały elementarz to ogromna kopalnia wiedzy, bardzo mi się przydała :) Pozdrawiam
Hej Paulina,
Serdeczne podziękowanie, za docenienie mojej pracy!
Błąd w artykule został już poprawiony :)