Wstęp
W książce omówiono konstrukcje oraz wybrane narzędzia języka Java stosowane we wszystkich rodzajach programowania. Szczegółowa dyskusja o fundamentalnych elementach języka (przydatna przede wszystkim dla początkujących) szybko przeradza się w prezentację praktycznych recept dotyczących m.in.:
przetwarzania tablic i kolekcji; działania na plikach za pomocą klasy Scanner, klas strumieniowych oraz narzędzi NIO2; działania na napisach z użyciem wyrażeń regularnych; operowania na datach i czasie z użyciem klasy Calendar oraz nowego w JDK 8 Date and Time API; obliczeń matematycznych; narzędzi formatowania danych; narzędzi sortowania i wyszukiwania.
Chcący poszerzyć swą wiedzę znajdą w dalszej części książki dogłębne omówienie ważnych koncepcji i niuansów programowania obiektowego związanych z dziedziczeniem, polimorfizmem, klasami wewnętrznymi, interfejsami, typami sparametryzowanymi (generics). Na szczególną uwagę zasługuje prezentacja zmian w wersji 8 języka Java dotycząca koncepcji interfejsów (m.in. umożliwienie wielodziedziczenia implementacji metod i tworzenia tzw. mixinów), a także dokładne wyjaśnienie trudnego zagadnienia uniwersalnych argumentów typu (wildcards). Podsumowaniem wątku programowania obiektowego jest praktyczne omówienie
dobrych praktyk stosowania interfejsów kolekcyjnych, definiowania komparatorów, tworzenia iterowalnych obiektów niekolekcyjnych, wizytowania drzew katalogowych (FileVisitor).
W realnym programowaniu nie sposób uniknąć zagadnienia równoległego przetwarzania danych. Czytelnik znajdzie więc w książce wprowadzenie do tej tematyki zawierające omówienie podstawowych technik i narzędzi programowania współbieżnego oraz ich praktycznego zastosowania dotyczące zwłaszcza:
zlecania zadań (FutureTask) wykonawcom (ExecutorService) do współbieżnego wykonania i asynchronicznego odbierania ich wyników, synchronizacji działania współbieżnie wykonujących się kodów (z użyciem słowa kluczowego synchronized i za pomocą jawnego blokowania z użyciem obiektów klas ReentrantLock i ReadWriteLock), koordynacji działania współbieżnie wykonujących się kodów (wait-notify, kolejki blokujące), obiektów wspierających atomistyczność operacji (atomics).
Czytelnicy zainteresowani nowościami w wersji 8 języka Java znajdą w książce omówienie elementów programowania funkcyjnego:
interfejsów funkcyjnych i lambda-wyrażeń, referencji do metod i konstruktorów, przetwarzania strumieniowego z zastosowaniem techniki filter-map-reduce.
Pokazano także, jak łatwo i efektywnie za pomocą tych narzędzi można sekwencyjnie lub równolegle przetwarzać różnorodne dane (kolekcje, pliki, napisy).
Prezentacja wybranych nowości Javy 8 i to jeszcze przed jej publiczną premierą należy niewątpliwie do zalet książki. Jednak, by korzystać z zawartych w niej treści, wcale nie trzeba od razu ,,przesiadać się na ósemkę''. Większość materiału dotyczy Javy w ogóle, a przy omawianiu poszczególnych zagadnień są sygnalizowane różnice między wersjami języka (5, 7, 8). Należy również zaznaczyć, że kody przykładowych programów ilustrujących nowości Javy 8 były tworzone na wersji Java 8 Early Access Release M8 Developer Preview i jeszcze raz testowane w wersji Early Access Release build 124.
Na koniec kilka słów o sposobie prezentacji treści zawartych w książce. Autor starał się, by książka charakteryzowała się szczegółowym i przystępnym wyjaśnianiem poszczególnych zagadnień oraz spójnym, logicznym prezentowaniem bloków tematycznych (w każdym momencie używane są wyłącznie pojęcia i konstrukcje już wcześniej przedstawione). W opisie elementów i narzędzi platformy Java Czytelnik znajdzie odpowiedzi na pytania: co to jest, po co to jest, kiedy i jak to stosować. Kilkaset przykładowych kodów nie tylko ilustruje omawiane koncepcje, ale często przedstawia rozwiązania praktycznych problemów.
W doborze treści i sposobu jej prezentacji autor wykorzystał swoje doświadczenia z prowadzenia wykładów i zajęć programistycznych w Polsko-Japońskiej Wyższej Szkole Technik Komputerowych. Liczne fragmenty książki powstały na ich podstawie.
Książka jest przeznaczona zarówno dla Czytelników, którzy chcą samodzielnie, od podstaw opanować umiejętność programowania, jak i tych, którzy pragną te umiejętności rozwijać. Z powodzeniem może być też używana np. na jedno- lub dwusemestralnych kursach programowania na studiach wyższych.
ROZDZIAŁ 3 Definiowanie klas
3.1. Do czego służą klasy?
W programowaniu obiektowym posługujemy się obiektami. Obiekty charakteryzują się
cechami (atrybutami lub stanami), operacjami, które na nich można wykonywać (czyli usługami, które są obowiązane świadczyć; poleceniami czy komunikatami, które można im wydawać lub do nich posyłać).
Obiekty w programie odzwierciedlają rzeczywiste obiekty, które mogą być konkretne (fizyczne) lub abstrakcyjne. Gdyby nasz program symulował ruch uliczny, to musielibyśmy zapewne odzwierciedlić w nim obiekty takie jak samochody. Każdy z obiektów-samochodów ma jakieś cechy (atrybuty, stany), np.
ciężar, wysokość, aktualną prędkość jazdy,
oraz udostępnia jakieś usługi, których wykonanie możemy mu zlecić za pomocą odpowiednich poleceń:
włącz się do ruchu, zatrzymaj się, zwiększ prędkość, skręć w lewo itp.
Skąd wiemy, jakie atrybuty mają obiekty-samochody? Skąd wiemy, jakie polecenia możemy do nich posyłać? O tym decyduje definicja klasy samochodów, którą nasz program musi albo pobrać, albo sam dostarczyć.
Klasa to opis takich cech grupy podobnych obiektów, które są dla nich niezmienne (np. zestaw atrybutów i usług, które mogą świadczyć).
Można więc symbolicznie zapisać:
Klasa Samochod
atrybuty:
ciężar
wysokość
akualna prędkość
usługi - operacje:
włącz_się_do_ruchu zatrzymaj_się zwiększ_prędkość skręć_w_lewo
Dopiero teraz będziemy wiedzieć, co charakteryzuje każdy obiekt-samochód w programie i co możemy z każdym takim obiektem robić. Ale skąd się biorą obiekty-samochody? Otóż musimy je tworzyć.
Obiekty tworzymy za pomocą wyrażenia new, które ma postać:
new NazwaKlasy(parametry)
gdzie parametry (wyrażenia rozdzielone przecinkami) zazwyczaj określają początkowe wartości wszystkich lub wybranych atrybutów.
Skąd wiadomo, jakie parametry i w jakiej kolejności podać? Otóż każda klasa może zdefiniować specjalne operacje inicjacji obiektu (zwane konstruktorami), których można użyć w trakcie jego tworzenia. Załóżmy, że w klasie Samochod jest zdefiniowana taka operacja, która nadaje atrybutom obiektów kolejne - przekazane przez parametry - wartości (ciężar, wysokość, aktualna prędkość). Możemy więc stworzyć obiekty:
samA = new Samochod(500, 1.5, 0); samB = new Samochod(1000, 2.2, 60);
Teraz mamy dwa obiekty-samochody, oznaczane przez zmienne samA i samB. Wartości ich atrybutów zostały zainicjowane i mają następująca postać:
Samochód A(oznaczony w programie samA)
Samochód B(oznaczony w programie samB)
ciężar = 500wysokość = 1.5aktualna prędkość = 0
ciężar = 1000wysokość = 2.2aktualna prędkość = 60
Na obiektach-samochodach możemy wykonywać operacje, posyłać do nich polecenia.
Do obiektów posyłamy polecenia za pomocą kropki.
Na przykład
samA.włącz_się do_ruchu();
samB.zatrzymaj_się();
Warto zwrócić uwagę, że dzięki zastosowaniu klas mamy możliwość programowania w języku problemu (np. symulacji ruchu samochodów). Klasy pozwalają wprowadzać do języka nowe typy danych (takie jak Samochod) z właściwymi dla nich zestawami dopuszczalnych wartości (możliwe wartości atrybutów, takich jak ciężar, wysokość, aktualna prędkość jazdy) i dopuszczalnymi operacjami. Pamiętajmy, że np. definicja klasy samochodów nie jest ustalona raz na zawsze. Konkretne obiekty-samochody możemy przecież opisywać bardzo różnie w zależności od tego, jaki problem mamy do rozwiązania. W przypadku symulacji ruchu ulicznego nie będzie pewnie nas interesować taka cecha samochodu jak kolor (zatem ten atrybut nie znajdzie się w definicji klasy jako wspólna cecha wszystkich obiektów-samochodów). Ale być może gdyby program dotyczył sprzedaży samochodów, to cecha "kolor" znalazłaby się jako istotny atrybut w definicji klasy. A zamiast operacji włącz się_do ruchu itp. potrzebne byłyby całkiem inne operacje na obiektach (np. sprzedaż). Korzyść w przypadku odzwierciedlania rzeczywistych obiektów jest oczywista: piszemy program w języku problemu, który mamy rozwiązać.
A co z obiektami abstrakcyjnymi? Rozważmy np. pary liczb całkowitych. Niewątpliwie para liczb całkowitych jest obiektem abstrakcyjnym (bowiem nie istnieje fizycznie). W programie przedstawiamy właściwości obiektów abstrakcyjnych za pomocą definicji klasy par liczb całkowitych. Taka definicja określa atrybuty pary oraz operacje, które można na nich wykonywać.
Klasa Para
atrybuty:
pierwsza_liczba_pary druga_liczba_pary
usługi - operacje:
operacja_inicjacji // zainicjuj parę dwoma podanymi liczbami set // ustal wartość pary na podstawie wartości innej pary add // dodaj do pary inną parę show // pokaż parę
Ta definicja nie określa wartości cech pojedynczego obiektu. Możemy mieć wiele obiektów par-liczb całkowitych, z których każdy ma podane atrybuty (ale np. różne ich wartości) oraz na każdym z nich możemy wykonywać podane operacje (set, add itd). Przy czym niekiedy możemy zdefiniować klasę Para w taki sposób, że dopuszczalne jest odejmowanie par; a innym razem ta operacja będzie niepotrzebna i wtedy definicja klasy nie będzie jej zawierać.
Zobaczmy teraz na co w ogóle może się przydać definicja klasy Para. Wyobraźmy sobie, że w programie mamy zapisać dodawanie par liczb calkowitych. Możemy to zrobić, korzystając z prostych typów danych:
// Niech a1 i a2 oznaczają składniki pierwszej pary// b1 i b2 - składniki drugiej pary// c1 i c2 - składniki pary wynikowej (sumy par)int a1 = 1;int a2 = 2;int b1 = 3;int b2 = 4;int c1;int c2;c1 = a1 + b1;c2 = a2 + b2;System.out.println(c1 + " " + c2);
Jednak mając definicję klasy Para, możemy zapisać tę operację w dużo prostszy i bardziej zrozumiały sposób:
Para a = new Para(1,2); // utwórz i zainicjuj parę dwoma podanymi liczbami
Para b = new Para(3,4); // i drugą też
Para c = a.add(b); // dodaj do siebie dwie pary; wynikowa para będzie
// oznaczana przez zmienną c
c.show(); // pokaż wynik
To, że można użyć takiego zapisu, zależy od definicji klasy Para. Zatem definicja klasy określa
zestaw cech (atrybutów) obiektów klasy; zestaw operacji, które można wykonywać na obiektach klasy; specjalne operacje, które pozwalają na inicjowanie obiektów przy ich tworzeniu.
W wielu językach obiektowych (w tym w Javie)
wspólne cechy (atrybuty) obiektów nazywają się polami klasy, operacje (polecenia) - metodami, specjalne operacje inicjacji - konstruktorami.
Definicja klasy stanowi zatem definicję
pól, metod, i konstruktorów.
Klasę winniśmy traktować jako swoisty wzorzec, szablon opisujący powstawanie obiektów (konstruktory), ich cechy (pola) oraz sposób komunikowania się z obiektami (metody).
W Javie do definiowania klas używa się słowa kluczowego class. Samą definicję umieszcza się w następujących po nim nawiasach klamrowych. Kod definicji (między nawiasami klamrowymi) nazywa się ciałem klasy:
[ public ] class NazwaKlasy {
// definicje pól // definicje konstruktorów // definicje metod}
Przy czym
Słowo kluczowe public jest nieobowiązkowe (dlatego jest w nawiasach kwadratowych) i określa dostępność klasy z innych klas (klasa zdefiniowana ze słowem public jest dostępna zewsząd). Uwaga; w pliku źródłowym może występować tylko jedna klasa publiczna i jeśli występuje, to plik musi mieć taką samą nazwę jak ta klasa. Nazwa klasy musi spełniać ograniczenia dotyczące identyfikatorów i (zgodnie z konwencjami nazewniczymi) powinna zaczynać się dużą literą i być pisana w notacji węgierskiej.
Przykłady szablonów definicji klas:
public class Para { // definicja klasy par liczb całkowitych
// ciało klasy
}
public class Car { // ciało klasy
}
class TestPara { // ciało klasy}
Pola i metody klasy nazywają się składowymi klasy.
Składowe klasy = pola + metody
3.2. Definiowanie pól
Pola klasy określają (zazwyczaj), z jakich elementów będą się składać obiekty tej klasy. Na przykład obiekty-pary liczb całkowitych składają się z dwóch liczb całkowitych. W definicji klasy Para trzeba to jakoś zapisać. Naturalnym sposobem jest zadeklarowanie zmiennych odpowiednich typów.
public class Para {
int a; int b;
// dalej będą następować definicje konstruktorów i metod klasy...
}
Taki zapis oznacza, że każdy z obiektów klasy Para będzie zawierał dwie liczby całkowite. Będzie się składał z dwóch elementów - liczb całkowitych. Identyfikatory zmiennych (a i b) są dowolne, a potrzebne są po to, by do tych zmiennych móc odwoływać się w metodach klasy.
Jedną z ważnych cech programowania obiektowego jest hermetyzacja. Polega ona m.in. na tym, że działając na obiektach jakiejś klasy, powinniśmy posługiwać się wyłącznie dostępnymi dla nas jej metodami, a nie "grzebać w środku obiektów". Dlatego pola klasy deklaruje się zwykle ze specyfikatorem dostępu private, co oznacza, że dostęp do nich jest możliwy tylko z wnętrza danej klasy (m.in z jej metod), a odwołania spoza klasy są niedopuszczalne.
Definiowanie pól klasy
[public] class NazwaKlasy {
[specyfikator_dostępu] [static] nazwa_typu nazwa_zmiennej [inicjator]; //...
}
Uwagi
Nawiasy kwadratowe oznaczają opcjonalność elementów definicji. O specyfikatorach dostępu - zobacz w rozdz. 8.1. Znaczenie słowa static zostanie wyjaśnione wkrótce. Inicjator ma znaną nam postać wyrażenia po znaku =, nadającego początkową wartość zmiennej.
Na przykład
public class Para { private int a; private int b; // ...}
Polami klasy mogą być zmienne obiektowe (zmienne oznaczające obiekty). Zobaczmy, jak mógłby wyglądać fragment definicji klasy Book, która opisuje książki:
public class Book { private String author; // autor private String title; // tytuł private double price; // cena // ...}
Należy dostrzegać różnicę między definicją pól klasy, a elementami obiektów. Zestaw pól klasy określa, jakie elementy mogą mieć obiekty tej klasy. Elementy są natomiast konkretnymi obszarami pamięci alokowanymi wewnątrz konkretnych obiektów.
Na przykład definicja klasy Para mówi o tym, że każdy jej obiekt zawiera dwa elementy - liczby całkowite. Po utworzeniu obiektu i jego inicjacji obiekt będzie zawierał dwa elementy - liczby całkowite o konkretnych wartościach. Inny obiekt klasy Para będzie też zawierał dwie liczby całkowite, ale (być może) o innych wartościach niż ten pierwszy (rys. 3.1).
Rys. 3.1. Pola klasy a elementy obiektów
Nie każde pole klasy określa elementy zawarte w obiektach. Pola deklarowane ze specyfikatorem static są polami statycznymi - nie są zawarte w obiektach, dla każdej zmiennej statycznej wydzielany jest tylko jeden obszar pamięci na całą klasę. Więcej na temat składowych statycznych znajduje się w p. 3.6.
Przy tworzeniu obiektów pola klasy zawsze uzyskują początkowe, domyślne wartości (powiemy: mają zagwarantowaną inicjalizację). Ogólnie są to wartości ZERO (np. dla liczb całkowitych - całkowite zero, dla typu boolean - wartość false).
3.3. Definiowanie metod
Zestaw operacji na obiektach jest określany przez definicję metod klasy. Pojęcie metody jest zbliżone do znanego z innych języków programowania pojęcia funkcji lub procedury.
Metoda - tak samo jak funkcja - to wyodrębniony zestaw czynności, zapisywany jednorazowo w postaci fragmentu kodu, który może być wywoływany wielokrotnie z innych miejsc programu.
Metody służą głównie (ale nie tylko i niekoniecznie) do wykonywania operacji na obiektach. Zatem - w odróżnieniu od funkcji - metody zwykle wywoływane są na rzecz konkretnych obiektów. Wywołania na rzecz obiektu (jak już widzieliśmy) dokonuje się za pomocą operatora kropka. Jeśli p oznacza obiekt klasy Para, a w klasie tej zdefiniowano metodę show, to wywołanie tej metody na rzecz tego obiektu zapisujemy jako
p.show();
Wywołanie na rzecz obiektu oznacza to samo, co posłanie polecenia do obiektulub komunikatu do obiektu lub wykonanie operacji na obiekcie.
Schematyczna postać definicji metody jest następująca:
class NazwaKlasy { // ...
[specyfikator_dostępu] [static] typ_wyniku nazwa_metody(lista_parametrów) {
// ... instrukcje wykonywane po wywołaniu metody }
}
Uwagi
Nawiasy kwadratowe oznaczają opcjonalność. Kod zawarty między nawiasami klamrowymi jest nazywany ciałem metody.
Specyfikator dostępu określa, czy metoda może być wywołana spoza klasy, w której jest zdefiniowana. W szczególności:
specyfikator public mówi o tym, że dana metoda może być wywołana z dowolnej innej klasy; a private oznacza, że metoda może być wywołana tylko w tej klasie, w której została zdefiniowana.
Metody, które mają być ogólnie dostępnymi operacjami na obiektach, oznaczamy słowem public; metody "robocze", które mają znaczenie tylko dla twórców klasy, i nie powinny być dostępne dla innych użytkowników klasy, oznaczamy słowem private.
Nazwa metody zaczyna się małą literą, a dalej stosuje się notację węgierską, np. count, setPrice, getAuthor.
Lista parametrów zawiera rozdzielone przecinkami deklaracje parametrów, które metoda otrzymuje przy wywołaniu. Lista może być pusta (brak parametrów).
Metoda może zwracać wynik (wtedy w jej definicji musimy podać konkretny typ wyniku, a zakończenie działania metody powinno następować na skutek instrukcji return zwracającej dane podanego typu). Jeśli metoda nie zwraca żadnego wyniku, to jej typ wyniku określamy słowem kluczowym void, a metoda może skończyć działanie po dotarciu do zamykającego nawiasu klamrowego lub po wykonaniu instrukcji return bez argumentów.
Instrukcja return ma postać:
return [ wyrażenie ];
Metoda zwracająca sumę dwóch liczb całkowitych może wyglądać tak:
int suma(int x, int y) { int z = x + y; return z;}
lub tak:
int suma(int x, int y) { return x + y;}
Przykład wywołania metody suma:
int sum = suma(10,11);
Przy wywołaniu metoda suma uzyskuje dwa przekazane jej argumenty (10 i 11) jako parametry x i y. Jej działanie polega na dodaniu obu wartości parametrów i zwróceniu wyniku (do miejsca wywołania). Obowiązkowo w definicji metody trzeba było podać typ zwracanego wyniku. Po wywołaniu zmienna sum będzie miała wartość 21.
Przykład innej metody:
void say(String s) { System.out.println(s);}
Wywołanie metody say spowoduje wyprowadzenie na konsolę napisu przekazanego jako argument. Metoda nie zwraca żadnego wyniku, mimo to trzeba było określić typ wyniku słowem kluczowym void.
W Javie argumenty są przekazywane metodom wyłącznie przez wartość. Oznacza to, że w samej metodzie odwołujemy się nie do faktycznego argumentu, ale do jego kopii. Zatem zmiany przekazanego metodzie argumentu są lokalne, dotyczą wyłącznie kopii a nie oryginału. Mając metodę
void incr(int x) { ++ }
i wywołując ją w następującym kontekście:
int z = 1;incr(z);System.out.println(z);
otrzymamy w wyniku to, że w samej metodzie zmienna (parametr) x uzyska wartość 2,ale po zakończeniu działania metody i powrocie sterowania do punktu wywołania zmienna z będzie miała nadal wartość 1 i ta wartość zostanie wyprowadzona na konsolę.
Metody statyczne (definiowane ze specyfikatorem static) nie są wywoływane na rzecz obiektów, mogą więc być wywoływane nawet wtedy, gdy nie istnieje żaden obiekt. Więcej na temat składowych statycznych znajduje się w p. 3.6.
3.4. Definiowanie konstruktorów
Specjalną operacją jest operacja tworzenia obiektu. Jak wiemy, jest ona wykonywana za pomocą wyrażenia new. Okazuje się, że to, co w nim zapisujemy, oznacza wywołanie konstruktora klasy.
Konstruktor służy (głównie) do inicjowania pól obiektów. Można o nim myśleć jako o specjalnej metodzie, która
zawsze ma nazwę taką samą jak nazwa klasy, nie ma żadnego typu wyniku (nawet void!), ma listę parametrów (w szczególności może być pusta).
Podobnie jak przy definicji metod w definicji konstruktora możemy podać specyfikator dostępu, który określa, czy konstruktor może być wywołany spoza klasy. Definicja konstruktora ma postać:
[ public] class nazwa_klasy {
// Definicja konstruktora
[ specyfikator_dostępu ] nazwa_klasy(lista_parametrów) {
// czynności wykonywane przez konstruktor
}
}
W klasie Para możemy mieć np. takie konstruktory:
public class Para {
private int a;
private int b;
public Para(int x, int y) { // Nadaje polom a i b wartości
a = // przekazane konstruktorowi jako
b = y; // argumenty
}
...
}
albo
public class Para {
private int a, b;
public Para(int x) { // Konstruktor ma jeden parametr:
a = // oba pola są nim inicjowane
b =
}
...
}
Mając tak zdefiniowane konstruktory w klasie Para, możemy łatwo tworzyć obiekty-pary o zadanych wartościach np.
Para p1 = new Para(10,11); // para 10, 11
albo
Para p2 = new Para(2); // para 2, 2
Konstruktory zawsze są wywoływane za pomocą wyrażenia new.
Szczególnym rodzajem konstruktora jest konstruktor bezparametrowy. Jest on automatycznie dodawany do definicji klasy, gdy nie zdefiniowano żadnego konstruktora (przy czym jego ciało jest puste). Zatem jeśli nie dostarczymy w klasie żadnego konstruktora, to przy tworzeniu obiektu zostanie wywołany automatycznie dodany konstruktor bezparametrowy (który nic nie robi).
Uwaga. Konstruktor bezparametrowy nie jest dodawany, gdy w klasie zdefiniowano jakikolwiek konstruktor.
3.5. Przykład definiowania klasy
Jako podsumowanie powyższych rozważań przeanalizujemy pełny przykład definicji klasy. Zobaczymy, że definiowanie klas jest bardzo łatwe, czasem nawet trochę nudnawe, choć może być też i zabawne. Wyobraźmy sobie, że prowadzimy księgarnię. Księgarnia zajmuje się sprzedażą publikacji (książek, czasopism, płyt CD itp.). Zatem głównym obiektem naszego zainteresowania będą publikacje. Zauważmy, że budując klasę publikacji, staramy się znaleźć wspólne atrybuty wszystkich publikacji. Zatem np. właściwość "autor" będzie pominięta, bo nie wszystkie publikacje (np. czasopisma) mają autorów. O każdej publikacji powinniśmy wiedzieć:
jaki jest jej tytuł, kto ją wydał, rok wydania, jaki jest jej numer identyfikacyjny (ISBN, ISSN, jakiś inny), jaka jest cena, ile egzemplarzy jest w księgarni.
Te atrybuty będą stanowić pola klasy (kod 3.1).
public class Publication {
private String title;
private String publisher;
private int year;
private String ident;
private double price;
private int quantity;
...
}
Kod 3.1. Definicja pól klasy Publication
Każda publikacja może pojawić się w programie jako obiekt, gdy użyjemy wyrażenia new. Obiekt ten powinien być zainicjowany, dlatego musimy dostarczyć odpowiedni konstruktor, który będzie inicjował podanymi argumentami elementy obiektu (kod 3.2).
public class Publication {
private String title;
private String publisher;
private int year;
private String ident;
private double price;
private int quantity;
public Publication (String t, String pb, int y,
(String i, double pr, int q)
ROZDZIAŁ 1 Wprowadzenie
1.1. O programowaniu i językach programowania
Wszystkie programy komputerowe komunikują się z procesorem za pomocą specjalnego języka. Słowa tego języka to liczby. Niektóre z tych liczb mają określone znaczenie. Są kodami instrukcji do wykonania (bardzo prostymi instrukcjami, złożone programy składają się z bardzo dużej liczby takich prostych instrukcji). Inne liczby oznaczają dodatkową informację, która jest potrzebna do wykonania instrukcji. Ta dodatkowa informacja stanowi zwykle dane, na których są przeprowadzane jakieś operacje (np. dodawanie liczb). Cyfrowa reprezentacja instrukcji, zrozumiała przez procesor, nazywa się kodem maszynowym lub językiem maszynowym. Przykładowa sekwencja instrukcji maszynowych hipotetycznego komputera może wyglądać tak:
10001010 10001000 00000001 11001010 .....
Cóż to za dziwne liczby składające się z samych zer i jedynek? Jest to zapis informacji (w tym przypadku kodu programu) w języku maszynowym jako ciągu liczb w systemie binarnym (czyli systemie liczbowym, w którym do definiowania liczb używa się cyfr 1 i 0). Informacja rozumiana przez komputer musi być tak właśnie zapisywana, gdyż powszechnie stosowane urządzenia cyfrowe mają określone zbiory elementarnych stanów, z których każdy może być charakteryzowany jako włączony (1) lub wyłączony (0). Z tego wynika, że najmniejsza ilość informacji, którą może operować komputer, to cyfra 1 lub 0 w binarnej reprezentacji liczby. Wielkość ta jest nazywana bitem.
Ważną rolę w informatyce odgrywa bajt, który we współczesnych systemach jest równy 8 bitom, dlatego że procesory operują na jednostkach zwanych słowami maszynowymi, składających się z całkowitej liczby bajtów.
Bajt jest najmniejszą częścią słowa maszynowego, dostępną bezpośrednio dla procesora. Kody instrukcji procesorów były i są jedno- lub kilkubajtowe. Znaki (litery) były i są kodowanejako liczby jedno- (np. kody ASCII, EBCDIC) lub wielobajtowe (np. DCSB lub Unicode). A ponieważ pół bajta można przedstawić jako cyfrę szesnastkową, to heksadecymalny system liczbowy (w którym oprócz cyfr 0-9 używa się liter A, B, C, D, E, F, przy czym np. A ma wartość 10, F - 15, a liczba 10 oznacza dziesietne 16) jest wygodniejszą (od binarnego, bo jest bardziej czytelny; ale i od dziesiętnego, bo widzimy w nim wyraźny podział na bajty) formą przedstawiania zapisu maszynowego kodu programu.
Oczywiście, można napisać program w języku maszynowym, ale domyślamy się, że jest to zadanie niezwykle pracochłonne i nieefektywne. A jednak początkowo tak właśnie pisano programy.
Na pomoc biednym programistom pionierskiej epoki komputeryzacji przyszło stworzenie asemblerów - języków symbolicznego zapisu instrukcji maszynowych danego procesora. Od tego momentu program, który wcześniej trzeba było zapisywać np. tak:
5830 D252 5A30 D256 5030 D260
można było zapisać dużo prościej i bardziej zrozumiale, np.
Instrukcja asemblera
Wyjaśnienie
L 3, X
Do rejestru 3 załaduj liczbę znajdującą się w pamięci pod adresem oznaczonym symbolicznie przez X.
A 3, Y
Dodaj do zawartości rejestru 3 liczbę znajdującą się w pamięci pod adresem oznaczonym symbolicznie przez Y.
ST 3, Z
Zapisz nową zawartość rejestru 3 do pamięci pod adresem oznaczonym symbolicznie przez Z.
Taki zapis - bardziej zrozumiały dla nas - jest jednak niezrozumiały dla procesora. Programy zapisane w asemblerze, muszą być przetłumaczone na język maszynowy (ciąg cyfr binarnych); tym zajmują się translatory (zwane potocznie również asemblerami).
Wszakże programowanie w języku asemblera jest wciąż uciążliwe. Co gorsza, obarcza ono programistę obowiązkiem pamiętania różnych technicznych szczegółów (rejestry, ich numery, adresowanie pamięci), wymaga zapisywania bardzo elementarnych operacji i nie pozwala dostatecznie skupić się na dziedzinie i logice rozwiązywanego (przez program) problemu. W powyższym programiku chodzi o dodanie do siebie dwóch liczb (X i Y) i zapisanie wyniku jako Z (X, Y, Z nazywamy zmiennymi programu). Dlaczego by nie napisać po prostu
Z = X + Y ?
Stąd pojawiły się języki programowania (ściślej - i dla odróżnienia od języków asemblera - języki programowania wysokiego poziomu), które w swoich instrukcjach, składni, semantyce łączą wiele prostych instrukcji asemblerowych, ukrywają ich techniczne szczegóły i - w porównaniu z asemblerami - są składniowo niezależne od procesora. W tych językach naprawdę możemy napisać: Z = X + Y, nie martwiąc się rejestrami, względnym adresowaniem pamięci i zestawem rozkazów konkretnego procesora.
Użycie języków wysokiego poziomu wymaga jednak bardziej zaawansowanych środków tłumaczenia tekstu programu na instrukcje zrozumiałe dla procesora: kompilatorów i/lub interpreterów.
Oczywiście, najpierw program trzeba napisać. To nie jest przypadkowa czynność. Programy piszemy po to, aby rozwiązywać jakieś problemy. Zatem na początku trzeba zaprojektować algorytm rozwiązania problemu oraz dobrać odpowiednie struktury, za pomocą których dane problemu bedą odzwierciedlane w programie. Tekst programu zapisujemy w wybranym języku programowania. Każdy język programowania ma swój alfabet, czyli zbiór znaków (liter i cyfr), z których mogą być konstruowane symbole języka (ciągi znaków). Reguły składniowe definiują dopuszczalne sposoby tworzenia symboli oraz dopuszczalne porządki ich występowania w programie, semantyka języka zaś określa znaczenie wybranych symboli.
W jakimś języku programowania możemy się posługiwać alfabetem składającym się z liter, cyfr, znaków specjalnych (alfabet języka); z liter i cyfr możemy tworzyć nazwy zmiennych (czyli symboliczne oznaczenia konkretnych danych). Niektóre ciągi znaków (np. if) mogą być zarezerwowane i oznaczają instrukcje języka, sposób łączenia ze sobą symboli jest określony (np. napis if (a == b) a = 0; będzie poprawny składniowo, a napis if a =b a =0 będzie niepoprawny); znaczenie ciągów symboli jest określone, np. a = 3oznacza przypisanie zmiennej a wartości 3).
Istnieje wiele (dziesiątki tysięcy) języków programowania. Można je klasyfikować według różnych kryteriów. Niewątpliwie najważniejszym jest logiczna struktura języka i sposób tworzenia w nim programów. Jedną z możliwych klasyfikacji pokazano na rys. 1.1.
Rys. 1.1. Klasyfikacja języków programowania
Języki imperatywne wymagają od programisty wyspecyfikowania konkretnej sekwencji kroków realizacji zadania, natomiast języki deklaratywne - opisują relacje pomiędzy danymi w kategoriach funkcji (języki funkcyjne) lub reguł (języki relacyjne, języki programowania logicznego), a wynik działania programu jest uzyskiwany przez zastosowanie wobec opisanych relacji określonych, gotowych, wbudowanych w język algorytmów.
Podejście obiektowe polega przede wszystkim na łącznym rozpatrywaniu danych i możliwych operacji na nich, dając możliwość tworzenia i używania w programie nowych typów danych (reprezentowanych przez tzw. klasy), odzwierciedlających dziedzinę problemu. Programowanie proceduralne (czasami kojarzone z imperatywnym) rozdziela dane i funkcje i nie dostarcza sposobów prostego adekwatnego odzwierciedlenia dziedziny rozwiązywanego problemu w strukturach danych używanych w programie.
Przykładami języków proceduralnych są: ALGOL, FORTRAN, PL/I, C. Języki obiektowe to np. SmallTalk, Java, C++, C#. Najbardziej znanym językiem funkcyjnym jest Haskell, zaś językiem programowania logicznego - Prolog. Języki takie jak Python, Ruby, Groovy czy Scala łączą podejście obiektowe z elementami programowania funkcyjnego. Elementy programowania funkcyjnego pojawiły się również w Javie, poczynając od wersji 8.
Inny podział dotyczy sposobu, w jaki tekst programu jest przekształcany na instrukcje dla procesora; stąd podział na języki kompilowane i interpretowane.
Rys. 1.2. Proces programowania
Kompilator tłumaczy program źródłowy na instrukcje, które mogą być wykonane przez procesor i jednocześnie sprawdza składniową poprawność programu, sygnalizując wszelkie błędy. Proces kompilacji jest więc nie tylko procesem tłumaczenia, ale również weryfikacji składniowej poprawności programu.
W językach kompilowanych tekst programu (program źródłowy) jest tłumaczony jest na kod binarny (pośredni) przez program nazywany kompilatorem. Zazwyczaj inny program, zwany linkerem, generuje z kodu pośredniego gotowy do działania binarny kod wykonywalny i zapisuje go na dysku w postaci pliku typu wykonywalnego (np. z rozszerzeniem EXE lub z nadanym atrybutem "zdolny do wykonywania"). W ten sposób działają takie języki jak C czy C++. Czasem kompilator produkuje kod symboliczny, który jest wykonywany za pomocą interpretacji przez program zwany interpreterem. Tak właśnie dzieje się w języku Java.
Interpreter wykonuje bezpośrednio tekst programu. Zatem składniowa poprawność jest sprawdzana zazwyczaj dopiero w trakcie działania programu, aczkolwiek niektóre języki interpretowane udostępniają fazę symbolicznej kompilacji do kodu pośredniego, podczas której sprawdzana jest poprawność źródła. Niektóre interpretery wewnętrznie kompilują fragmenty kodu do postaci binarnej, aby przyspieszyć wykonanie.
W językach interpretowanych kod programu (źródłowy lub pośredni) jest odczytywany przez program zwany interpreterem, który na bieżąco - w zależności od przeczytanych fragmentów programu - przesyła odpowiednie polecenia procesorowi i w ten sposób wykonuje program. Przykładami języków interpretowanych są: REXX, ObjectREXX, Perl, PHP. Proces programowania przedstawia rys. 1.2.
1.2. Czym jest Java?
1.2.1. Nowoczesny język programowania
Java jest uniwersalnym językiem programowania. Składniowe podobieństwo do C/C++ czyni ten język łatwy do opanowania przez programistów znających C/C++. Jednocześnie Java ma narzędzia umożliwiające udoskonalanie swoich wzorców. Programista w zasadzie nie musi martwić się zarządzaniem pamięcią (w Javie funkcjonuje automatyczne odśmiecanie - garbage collection, polegające na automatycznym usuwaniu przydzielonych wcześniej a nieużywanych obszarów pamięci). W języku Java nie jest stosowana arytmetyka wskaźnikowa, która pozwala na odwoływanie się do dowolnych obszarów pamięci i jest częstą przyczyną błędów.
Ścisła kontrola zgodności typów na etapie kompilacji zapobiega prostym błędom. Co więcej, to tzw. statyczne typowanie umożliwia zintegrowanym środowiskom programowania (IDE) przebogate wspieranie programisty przy pisaniu i testowaniu programu (automatyczne dopisywanie kodu i poprawianie błędów). Konwersje (rzutowanie) typów przeprowadzane w fazie wykonania są bezpieczne, bowiem nigdy nie może powstać sytuacja przekształcenia danych do niewłaściwego dla nich typu.
Wymuszana przez kompilator obsługa niektórych wyjątków (błędów) czyni programowanie w Javie jeszcze bardziej bezpiecznym i niezawodnym, a wbudowane w język podstawowe elementy współbieżności umożliwiają tworzenie i synchronizowanie równolegle działających wątków (czyli równolegle wykonywanych fragmentów tego samego programu).
Wymienione pojęcia będziemy szczegółowo omawiać w kolejnych rozdziałach.
Niewątpliwie jednak najważniejszą cechą Javy jako "czystego języka" jest jej obiektowość. Oznacza to, iż programy pisze się w Javie łatwiej, bardziej uniwersalnie i niezawodnie niż w językach nieobiektowych. W wersji 8 Javy pojawiły się elementy programowania funkcyjnego (m.in. lambda-wyrażenia), które pozwalają na ograniczenie ilości kodu, potrzebnego do wykonania pewnych rutynowych działań, co dodatkowo ułatwia tworzenie programów.
Zalety Javy jako czystego języka programowania mogą być dyskusyjne. Ale nie dlatego warto się jej uczyć, że jest to język idealny (czy w ogóle są takie?). Dużo ważniejsza jest jej uniwersalność we wszelkich zastosowaniach informatycznych. Uniwersalność, zapewniana przez wieloplatformowość oraz wynikającą stąd (zrealizowaną) możliwość stworzenia przebogatych standardowych bibliotek na tyle zintegrowanych z samą Javą, że praktycznie będących jej synonimem.
1.2.2. Wieloplatformowość i uniwersalność Javy
Java jest językiem interpretowanym, co umożliwia wykonywanie binarnych kodów Javy bez rekompilacji praktycznie na wszystkich platformach systemowych. Kod źródłowy (pliki z rozszerzeniem .java) jest kompilowany przez kompilator Javy (program javac) do kodu bajtowego (B-kodu, pliki z rozszerzeniem .class). Kod bajtowy zaś jest interpretowany przez tzw. wirtualną maszynę Javy - JVM (jest to program java wraz z odpowiednimi dynamicznymi bibliotekami), zainstalowaną na danej platformie systemowej (rys. 1.3).
Rys. 1.3. Wieloplatformowość Javy
Oznacza to, teoretycznie, że raz napisany i skompilowany program będzie działał tak samo na wszystkich platformach systemowych. Idea wręcz doskonała. Wiemy bowiem, jak wiele wysiłku i kosztów pochłania przenoszenie programów z jednej platformy na drugą. Sama wieloplatformowość języka interpretowanego nie jest czymś nadzwyczajnym. Ale twórcy Javy wyciągnęli z tej jej cechy bardzo ważne wnioski. Stworzyli mianowicie bogaty zestaw bibliotek standardowych i narzędziowych interfejsów programistycznych (API), które umożliwiają w jednolity, niezależny od platformy sposób programować
graficzne interfejsy użytkownika (GUI), dostęp do baz danych, działania w sieci, aplikacje rozproszone, aplikacje WEB, oprogramowanie pośredniczące (middleware), zaawansowaną grafikę, gry i multimedia, aplikacje na telefony komórkowe i inne małe urządzenia.
Zestaw standardowych bibliotek - wraz z kompilatorem, debugerem, narzędziami tworzenia dokumentacji i innymi narzędziami pomocniczymi - nazywa się JDK (Java Development Kit). Oprócz tego wprowadzono prosty mechanizm rozszerzeń, który umożliwia rozszerzanie standardu bazowego o nowe (też standardowe) biblioteki. Podstawowy zestaw bibliotek jest uzupełniany - w zależności od zastosowań - przez dodatkowe technologie. W całości środki te tworzą platformę Javy, podzieloną - ze względu na zastosowania i powiązane z nimi technologie - na edycje
standardową - JavaStandard Edition (Java SE) - przeznaczoną głównie do standardowych zastosowań dla komputerów personalnych i serwerów, również połączonych w sieci; biznesową - JavaEnterprise Edition (Java EE) - do tworzenia rozbudowanych i zaawansowanych aplikacji biznesowych, przede wszystkim dla dużych firm; mikro - JavaMicro Edition (Java ME) - do programowania urządzeń elektronicznych, takich jak telefony komórkowe, telewizja, procesory w samochodach czy urządzeniach gospodarstwa domowego.
Czy warto uczyć się Javy? Jeśli nawet uznamy sam czysty język za nieco niekonsekwentny czy uciążliwy, to zachętą do przezwyciężenia wszelkich obiekcji jest ogromna uniwersalność Javy. Jak wspomniałem, jest to jedyny język programowania zawierający standardowe i uniwersalne środki realizacji niemal wszelkich zadań informatycznych. To wielkie bogactwo możliwości niewątpliwie skłania do jego poznania.
1.3. Kilka słów o obiektowości
Java jest językiem obiektowym, warto więc już na wstępie wprowadzić kilka pojęć związanych z obiektowością. Języki obiektowe posługują się pojęciem obiektu i klasy. Definicje tych pojęć poznamy później. Teraz potrzebne będzie tylko intuicyjne wyobrażenie, które powinno dopomóc w lekturze.
Cóż to jest "obiekt"? Intuicyjnie czujemy, że to coś w rodzaju "przedmiotu", czegoś co można wyodrębnić, nazwać, określić jego właściwości. Obiektami będą np.: rower, samochód, pies, człowiek. Każdy z tych obiektów ma inne właściwości. Człowiek ma imię, jest w określonym wieku. Samochód ma kolor, określoną moc silnika czy liczbę drzwi. Dwa samochody mają ten sam zestaw właściwości (atrybutów), np. markę, kolor i moc silnika. I choć marki i kolory mogą być różne oraz różna może być moc silników, to w pewnym sensie samochody są podobne (opisujemy je za pomocą takich samych cech). Powiemy, że obiekty-samochody są obiektami tej samej klasy. Klasa stanowi opis wspólnych cech grupy podobnych obiektów.
Zauważmy dalej, że obiekty mogą wykonywać jakieś czynności. Powiemy: udostępniają jakieś usługi. Inne obiekty mogą "poprosić" je o wykonanie tych usług. Obiekt-kierowca może "zlecić" obiektowi-samochodowi, by ten ruszył lub zatrzymał się (włączenie silnika, naciśnięcie na pedał gazu lub wciśnięcie hamulca). Powiemy, że do obiektów posyłane są komunikaty, żądające wykonania określonych usług. Obiekty nie mogą wykonywać dowolnych czynności (świadczyć dowolnych usług). Samochód może ruszyć lub stanąć, ale nie będzie latać. Można powiedzieć, że usługi udostępniane przez obiekty, komunikaty, które do nich posyłamy, prosząc je o wykonanie jakichś czynności, są również jakąś ich cechą. Zatem klasa będzie opisywać nie tylko wspólne cechy grupy podobnych obiektów, jak kolor, wiek czy waga, ale również zestawy usług, które obiekty tej klasy mogą świadczyć. A więc i komunikaty, które do tych obiektów można posłać.
Tak jest w rzeczywistości. I tak samo możemy to zapisać (kod 1.1) w wyimaginowanym języku obiektowym, w którym za pomocą definicji klasy opiszemy atrybuty urządzeń elektrycznych oraz zestaw usług, przez nie udostępnianych, mający odzwierciedlenie w komunikatach, które można posłać do tych obiektów. Ten zestaw usług - w wielu językach obiektowych - jest nazywany zestawem metod klasy.
class ElDev {
width, height; <-- atrybuty: szerokość, wysokość, stan
isOn;
================= Interfejs komunikatów
method on()
isOn = true; <--- usługa on (włącz), inaczej: metoda o nazwie on
method off()
isOn = false; <--- usługa off (wyłacz),inaczej: metoda o nazwie on
}
Kod 1.1. Schematyczna definicja klasy w językach obiektowych; powyższy zapis jest symboliczny, nie jest to zapis definicji klasy w Javie czy jakimkolwiek innym języku programowania.
Gdy mamy dwa obiekty, egzemplarze klasy urządzeń elektrycznych, oznaczane a i b, to możemy w programie symulować sekwencję działań: włączenie urządzenia a, właczenie urządzenia b, wyłączenie urządzenia a, za pomocą komunikatów posyłanych do obiektów (inaczej, wywołania metod na rzecz obiektów), np. w Javie (czy C++):
a.on(); // komunikat: obiekcie a włącz się
b.on(); // obiekcie b włącz się
a.off(); // obiekcie a wyłącz się
1.4. Pierwszy program i kilka elementów składni
Najpierw należy pobrać z Internetu aktualną wersję JDK oraz dokumentację. Program instalacyjny JDK poprowadzi nas za rękę. Po instalacji JDK powinniśmy do katalogu instalacyjnego JDK rozpakować pobrane archiwum z dokumentacją. Gotowe. Można przystępować do pracy. Pisząc programy, możemy korzystać ze zintegrowanych środowisk programowania (IDE) - takich jak Eclipse, NetBeans, IntelliJ - które łączą edycję, kompilację i uruchamianie programów, a także służą pomocą w pisaniu tekstu programu (podpowiedzi, autouzupełnianie) i wykrywaniu błędów. Można też po prostu używać wybranego edytora tekstowego i w sesjach znakowych (terminalach, oknach DOS) uruchamiać kompilator i maszynę wirtualną Javy. Jeśli nie korzystamy z IDE, to
powinniśmy zapewnić, by katalog, w którym program instalacyjny umieścił kompilator Javy (w systemie Windows javac.exe) oraz interpreter - maszynę wirtualną Javy (w Windows java.exe), znajdował się na ścieżce dostępu (PATH); program źródłowy Javy zapisujemy w pliku źródłowym za pomocą dowolnego edytora (np. w pliku Test.java); plik źródłowy, np. Test.java, kompilujemy za pomocą polecenia javac (np. javac Test.java); w rezultacie otrzymamy plik(i) klasowe (z rozszerzeniem .class) (np. Test.class) wykonanie programu uruchamiamy za pomocą polecenia java (np. java Test).
Uwagi
1. Program źródłowy może być zapisany w wielu plikach z rozszerzeniem .java; w każdym pliku musi występować pełna definicja jednej lub kilku klas.
2. Nazwa pliku powinna być taka sama (z uwzględnieniem wielkich i małych liter) jak nazwa publicznej klasy zdefiniowanej w tym pliku (czyli klasy ze specyfikatorem public, rozdz. 3). W pliku źródłowym może wystąpić tylko jedna klasa publiczna.
3. Uruchamiając maszynę wirtualną (polecenie java), podajemy jako argument nazwę klasy, w której jest zdefiniowana metoda main. Nie podajemy rozszerzenia pliku (.class), czyli java Test a nie java Test.class.
Program może wyglądać następująco:
public class Test {
public static void main(String[] args) {
System.out.println("Witaj Javo!");
}
}
Kod 1.2. Pierwszy program
Komentarze
1. Słowa kluczowe języka (public, class, static, void) zostały pogrubione. Mają one specjalne znaczenie i w Javie są zarezerwowane, co oznacza, że nie można ich używać w innych kontekstach, np. do nazywania zmiennych klas czy metod).
2. Znaczenie słów kluczowych poznamy w następnych rozdziałach.
3. Program w Javie zawsze składa się z definicji klas. Tu mamy zdefiniowaną jedną klasę o nazwie Test. Do definiowania klas służy słowo kluczowe class. Po nim podajemy nazwę klasy (tutaj Test, ale można nazwać ją inaczej). Samą definicję klasy podajemy w następujących potem nawiasach klamrowych.
4. W klasie Test zdefiniowano metodę o nazwie main (czyli coś bardzo podobnego do pojęcia funkcji lub procedury, znanego w innych językach).
5. Kod metody zapisujemy w nawiasach klamrowych.
6. Wykonanie programu zacznie się właśnie od podanej metody main.
7. Jak widać, metoda main ma parametr o nazwie args. Oznacza on tablicę łańcuchów znakowych (napisów, reprezentowanych przez obiekty typu String), która zawiera argumenty przekazane przy uruchomieniu programu (np. podane w wierszu poleceń sesji znakowej). W naszym programie nie korzystamy z tych argumentów.
8. W metodzie main wywołujemy metodę println (już dla nas przygotowaną i znajdującą się w bibliotece standardowych klas języka; zatem użyjemy tej właśnie nazwy metody, a nie jakiejś dowolnej). Metoda println wyprowadza przekazany jej argument na konsolę. Wywołanie metody stanowi wyrażenie. Kończąc je średnikiem, przekształciliśmy je w instrukcję programu do wykonania.
9. Wkrótce dowiemy się, dlaczego trzeba pisać System.out.println.
10. Argument podany w wywołaniu metody println, to literał łańcuchowy (napis podany literalnie).
11. Zapisany program musimy skompilować za pomocą javac Test.java (lub środkami IDE).
12. W rezultacie otrzymamy plik Test.class.
13. Uruchamiamy program (w IDE lub pisząc: java Test). Program wyprowadzi na konsolę napis "Witaj Javo!"
Trzeba jednak pamiętać, że obowiązuje nas przejrzysty styl programowania. Elementy stylu będziemy poznawać sukcesywnie przy omawianiu języka. W omawianym przykładzie zastosowaliśmy wcięcia i odpowiednie rozmieszczenie nawiasów klamrowych.
Przy kompilacji programu znaki spacji, tabulacji, końca wiersza (tzw. whitespaces) występujące pomiędzy konstrukcjami składniowymi języka (takimi jak nazwy zmiennych, metod, słowa kluczowe, literały) są pomijane. Zatem możemy dowolnie "formatować" kod programu (dzielić na wiersze, umieszczać dodatkowe spacje, np. po nawiasie otwierającym listę argumentów wywołania metody). Przy kompilacji pomijane są również komentarze. W Javie mamy trzy rodzaje komentarzy (kod 1.3):
teksty umieszczone w jednym wierszu programu po znakach // (komentarze jednowierszowe; cały tekst po znakach // do końca wiersza traktowany jest jako komentarz); teksty umieszczone pomiędzy znakami (teksty te mogą obejmować kilka wierszy); teksty umieszczone pomiędzy znakami (komentarze dokumentacyjne, które są przetwarzane przez oprogramowanie tworzące dokumentację, np. javadoc; mogą obejmować kilka wierszy).
/**
Klasa TestComm pokazuje
użycie komentarzy
(to jest komentarz dokumentacyjny)
*/
public class TestComm {
/*
to jest komentarz
wielowierszowy
*/
/* to też jest
komentarz */
// a to komentarz jednowierszowy
public static void main( String[] args ) {
// i tu też komentarz
System.out.println( "Zob. komentarze" ); // i tu też
}
}
Kod 1.3. Przykład zastosowania komentarzy
Oczywiście, komentarze nie mogą rozdzielać jednolitych konstrukcji składniowych (np. znajdować się wewnątrz nazwy zmiennej czy metody). Nazwy zmiennych, metod, klas nie są całkiem dowolne. Mogą zawierać litery (duże lub małe), cyfry oraz znaki _ i $. Przy czym nie mogą zaczynać się od cyfry.
Najlepiej przyjąć, że nazwy klas, metod, zmiennych zaczynamy od litery, a także
nie używać w nazwach znaków _ oraz $, ograniczać użycie cyfr, stosować sensowne, znaczące nazwy.
W trakcie kompilacji programu sprawdzana jest jego składniowa poprawność. Kompilacja może więc zakończyć się niepowodzeniem (wtedy nie dostaniemy pliku .class), a kompilator powiadomi nas o tym, gdzie i jakie błędy wystąpiły. Gdybyśmy w naszym programie testowym zapomnieli zamknąć nawias okrągły w wywołaniu metody println:
System.out.println("Dzień dobry!";
to kompilator wyprowadziłby następujący komunikat:
Test.java:4: ')' expected
System.out.println( "Dzień dobry!";
^
1 error
Mamy tu wyraźnie powiedziane, że błąd wystąpił w 4. wierszu pliku Test.java, że oczekiwany był okrągły nawias zamykający (a zabrakło go), przy czym miejsce, w którym wystąpił problem, wskazane jest znakiem ^. Nie zawsze komunikaty z kompilacji będą tak klarowne. Czasami błąd nie będzie dokładnie zlokalizowany (informacja będzie wskazywać na inny wiersz niż ten, w którym wystąpił błąd). Musimy wtedy głębiej zastanowić się nad przyczyną błędu i przeanalizować poprawność wcześniejszychwierszy programu.
Program, który przeszedł etap kompilacji, jest poprawny składniowo, ale jego wykonanie niekoniecznie musi być poprawne. Mogą w nim bowiem wystąpić tzw. błędy fazy wykonania. Następujący program skompiluje się bezbłędnie:
public class TestRTE {
static String napis;
public static void main( String[] args ) {
System.out.println( napis.length() );
}
ale przy jego wykonaniu wystąpi błąd:
Exception in thread "main" java.lang.NullPointerException
at TestRTE.main(TestRTE.java:7)
bowiem próbujemy pobrać długość łańcucha znakowego (napisu), który nie istnieje. Zresztą przyczyna nie jest tu istotna, teraz ważne jest tylko, byśmy wiedzieli, że błąd wykonania może wystąpić mimo pomyślnej kompilacji. Zwróćmy uwagę, że Java nazywa takie błędy wyjątkami (exception) i że JVM podaje, jaki rodzaj wyjątku wystąpił, w jakiej klasie, w jakiej metodzie i który wiersz w programie go spowodował. Warto jeszcze raz podkreślić, że nawet jeśli program nie ma błędów składniowych (kompilacja pomyślna) i nie występują błędy fazy wykonania, to jeszcze nie znaczy, że jest on poprawny. Na przykład następujący program przejdzie pomyślnie kompilację i wykona się bez błędów (zgłaszanych przez JVM):
public class TestBad {
public static void main( String[] args ) {
System.out.println("2 + 2 = " + (2 - 2) );
}
}
2 + 2 = 0
ale wyprowadzi (raczej) niepoprawny wynik, bo wygląda na to, że programista pomylił się i zamiast operatora + użył operatora -. Takie błędy są najtrudniejsze do wykrycia, a ich unikanie (czy walka z nimi) wymaga
tworzenia poprawnych algorytmów, starannego ich zapisu w języku programowania, pieczołowitego testowania gotowego programu przy różnych danych wejściowych i oceny, czy wyniki podawane przez program są poprawne.
ROZDZIAŁ 2 Typy i operacje
2.1. Literały i zmienne
Działanie każdego programu polega na przetwarzaniu danych. Dane w programie przedstawiamy za pomocą literałów, zmiennych oraz stałych.
Literał to napis w programie reprezentujący w sposób bezpośredni wartość danej.
Na przykład napis:
111
reprezentuje liczbę 111 i jest traktowany jako liczba 111.
Literały nie wystarczą, aby zapisać w programie algorytm. Spróbujmy napisać fragment programu, który przedstawia następującą sytuację: na koncie bankowym jest 1000 zł, wpłacamy 100 zł, wypłacamy 50 i następnie znowu wpłacamy 200. Za każdym razem należy wyprowadzić stan konta. Mając do dyspozycji tylko literały, musielibyśmy pisać tak:
System.out.println(1000 + 100);
System.out.println(1000 + 100 - 50);
System.out.println(1000 + 100 - 50 + 200);
Jest to oczywiście bez sensu. Musimy jakoś zapamiętywać bieżący stan konta. Do przechowywania danych w programie i ponownego ich użycia w kolejnych operacjach służą zmienne.
Zmienna jest symbolem w programie, oznaczającym obszar w pamięci komputera, w którym mogą być zapisywane różne dane. Czyli:
zmienna ma nazwę (użyty w programie symbol określa się nazwą lub identyfikatorem zmiennej); przez tę nazwę odwołujemy się do konkretnego obszaru pamięci, w którym chcemy przechowywać wartości jakiejś danej; zawartość tego obszaru (wartość zmiennej) możemy zmieniać w trakcie wykonywania programu.
Używając zmiennej skonta (przechowującej stan konta), poprzedni przykład możemy zapisać tak:
// ...
skonta = 1000; // zmienna skonta reprezentuje aktualny stan konta; // poczatkowo 1000
skonta = skonta + 100; // po wpłacie stan konta ulega zmianie; teraz zmienna // skonta ma wartość 1100
// ...
skonta = skonta - 50; // a teraz 1050 (po wypłacie)
// ...
skonta = skonta + 200; // po ostatniej wpłacie stan konta wynosi 1250
// ...
System.out.println(konto); // możemy go wypisać na konsoli, wyprowadzając // wartość zmiennej skonta
Istnieje również taki rodzaj zmiennych, których wartość (po początkowym ustaleniu) nie może się zmieniać. Nazywane są one stałymi.
2.2. Pojęcie typu. Typy proste
Działanie każdego programu polega na przetwarzaniu danych. Dane są zapisywane w pamięci komputera. Aby kompilator mógł stworzyć odpowiedni kod binarny, który m.in. specyfikuje, w jaki sposób dane są zapisywane w pamięci, ile zajmują miejsca, w jaki sposób są wykonywane operacje na danych, każdej jednostce danych trzeba przypisać typ.
Typ danej to zbiór jej możliwych wartości i zestaw operacji, które można na nich wykonywać. Jednocześnie określa on rozmiar pamięci, potrzebny do przechowania danej oraz sposób zapisu danej w pamięci komputera.
Na przykład, dane typu całkowitoliczbowego mogą przybierać wartości całkowitoliczbowe z jakiegoś przedziału, który zależy od tego, ile miejsca w pamięci zajmują dane tego typu oraz jak są w pamięci zapisywane. Określone są też operacje, które możemy wykonywać na tych danych: np. dodawanie, mnożenie, dzielenie, reszta z dzielenia oraz sposób, w jaki te operacje są wykonywane. Szczególnym sposobem wyrażenia operacji na danych są operatory języka.
Operator to specjalny symbol języka służący do przeprowadzania operacji na danych.
Skoro zmienne i literały reprezentują dane w programie, to typy dotyczą wartości zmiennych, stałych i literałów. Mówimy: zmienna typu ..., stała typu ..., literał typu ..., ale nie tylko. Ze zmiennych, stałych, literałów i operacji na nich (definiowanych m.in. przez operatory) budowane są wyrażenia. Każde wyrażenie jest obliczane (opracowywane) i ma jakiś wynik. Wyniki wyrażeń (powiemy krótko: wyrażenia) też mają określony typ. Typy mają swoje nazwy. W języku istnieje wiele typów prostych, których nazwy stanowią zarezerwowane słowa kluczowe języka.
Słowa kluczowe języka to słowa, które mają specjalne znaczenie (np. oznaczają instrukcje sterujące lub nazwy typów prostych). Mogą być one zarezerwowane i wtedy nie mogą być używane w innych kontekstach poza znaczeniem opisanym przez składnię języka.
Typy proste przedstawia tablica 2.1.
Tablica 2.1. Typy proste
Nazwa typu
Liczba bajtów
Dopuszczalne wartości
Znaczenie
byte
1
od -128 do 127
liczby całkowite
short
2
od -32768 do 32767
int
4
od -2147483648
do 2147483647
long
8
od -9223372036854775808
do 9223372036854775807
float
4
od -3.4028234663852886E38
do -1.401298464324817E-45
i
od 1.401298464324817E-45
do 3.4028234663852886E38
liczby rzeczywiste
double
8
od -1.7976931348623157E308
do -4.9E-324
i
od 4.9E-324
do 1.7976931348623157E308
char
2
od 0 do 65535
znaki Unicodu
boolean
1
true, false
wartości logiczne: prawda, fałsz
Typy byte, short, int, long, float, double, char są nazywane typami liczbowymi lub numerycznymi, ponieważ dane tych typów reprezentują liczby. Dane typu char reprezentują liczbowe kody znaków (zatem są nieujemnymi liczbami całkowitymi).
Istnieją różne systemy kodowania znaków, np. kod ASCII zawiera 256 kodów znaków (liczby od 0 do 255). W Javie jest używany Unicode. Gdy powstał język Java, Unicode zakładał definicję 65 536 znaków, dlatego w Javie wybrano ich dwubajtową reprezentację. Okazało się, że ta liczba znaków jest niewystarczająca do reprezentacji wszystkich języków (współczesnych i historycznych) oraz innych symboli (np. technicznych czy używanych w grach). Unicode poszerzono tak, by mógł reprezentować ponad milion znaków (dokładnie 1 114 111, o heksadecymalnych kodach od 0 do 10FFFF). Oczywiscie, nie mieści się to już na dwóch bajtach. Dla zachowania kompatybilności z poprzednimi wersjami języka w Javie pozostawiono dwubajtowy typ char (za jego pomocą można działać na znakach Unicodu z tzw. Basic Multi Plane), a do reprezentacji dodatkowych znaków (o heksadecymalnych kodach z zakresu od 100000 do 10FFFF) są stosowane pary danych typu char.
Przy okazji przedstawiania typów powiemy kilka słów o pewnych operacjach, w których mogą być używane dane tych typów. Wartości typów liczbowych są używane w operacjach arytmetycznych, a wartości typu char dodatkowo stanowią elementy łańcuchów znakowych (napisów). Operacje arytmetyczne, zapisywane za pomocą operatorów, to:
dodawanie (operator +), mnożenie (operator *), odejmowanie (operator -), dzielenie (/), reszta z dzielenia (operator %), zwiększanie o 1 (operator ++), oraz zmniejszanie o 1 (--).
W przypadku liczb całkowitych operacja dzielenie jest dzieleniem całkowitoliczbowym (a więc zwraca wynik dzielenia po odrzuceniu części ułamkowej, np. 1/3 tak naprawdę równe jest jedna trzecia, ale wynikiem tego wyrażenia będzie 0, gdyż biorą w nim udział dwie liczby całkowite i mamy tu dzielenie całkowitoliczbowe). Na wartościach typów numerycznych możemy również wykonywać operacje relacyjne (porównania: <, >, <=, >=, == (czy równe?), != (czy nie równe?) i bitowe.
Typ boolean (nazywany typem logicznym, orzecznikowym, boolowskim) służy do przedstawiania wartości logicznej o znaczeniu prawda lub fałsz. Wartości tego typu są używane w wyrażeniach logicznych, są też ich wynikiem, jak również są wynikiem wyrażeń relacyjnych. Mogą być też wykorzystywane bezpośrednio w instrukcjach sterujących (jako warunki). Wyrażenie a > b będzie miało wartość typu boolean stwierdzającą, czy wynik porównania jest prawdziwy czy fałszywy. Wyrażenie logiczne "x i y", zapisywane w Javie jako x && y będzie składniowo poprawne tylko wtedy, gdy x oraz y będą typu boolean, a jego wartość będzie wartością typu boolean, stwierdzającą, czy predykat "x i y" jest prawdziwy czy nie.
Wszystkie inne dane (poza danymi typów prostych) reprezentują w Javie obiekty, których atrybuty (a zatem możliwe "wartości" danych) oraz funkcjonalność (możliwe operacje na danych) definiują określone klasy. Zatem nazwę klasy można w tym przypadku traktować jako nazwę typu danego obiektu. W standardowym zestawie klas Javy występuje np. klasa String. Definiuje ona wspólne właściwości i funkcjonalność obiektów, będących łańcuchami znakowymi (napisami). Każdy łańcuch znakowy ma długość, niepuste łańcuchy znakowe mają znaki, obiekty klasy String są obowiązane świadczyć usługi polegające np. na odpowiedzi na nasze pytania: jaka jest długość łańcucha, czy łańcuch zawiera podany ciąg znaków itp., co możemy traktować jako operacje na łańcuchach znakowych. Typy takie ogólnie nazywają się typami referencyjnymi. Szczególnym rodzajem typów referencyjnych są typy wyliczeniowe, wprowadzane za pomocą słowa kluczowego enum. Będzie o nich mowa w dalszej części tekstu.
2.3. Typy i użycie literałów
Mamy do dyspozycji literały liczbowe, literały znakowe i literały łańcuchowe.
Literał liczbowy to bezpośredni zapis konkretnej liczby.
Uwaga. Zapisując liczby rzeczywiste w programach, stosujemy jako separator miejsc dziesiętnych kropkę (a nie przecinek), np.
3
101.3
Literał znakowy określa jeden bezpośrednio zapisany w programie znak. Do zapisania znaku będziemy stosować apostrofy, np.
'a', 'b', 'z', '5'
Literały łańcuchowe lub napisowe to bezpośrednio zapisane w programie ciągi znaków (napisy, inaczej zwane też łańcuchami znakowymi), które chcemy traktować jako teksty, a nie elementy języka. Ciągi takie ujmujemy w cudzysłów, np.
"ala ma kota"
"if"
Dlaczego musimy zapisywać znaki i ciągi znaków w apostrofach/cudzysłowie? Otóż, nieujęte w cudzysłów napisy i nieujęte w apostrof znaki alfabetyczne są traktowane jako nazwy zmiennych lub słowa kluczowe (oznaczające instrukcje lub inne elementy języka), np.
a = b;
będzie traktowane jako przypisanie zmienej a wartości zmiennej b. Jeśli chcemy przypisać zmiennej a znak b, musimy napisać:
a = 'b';
a zapis:
x = if;
będzie potraktowany jako błąd składniowy, gdyż if jest w Javie słowem zarezerwowanym.
O typy literałów troszczy się sam kompilator, stosując przy tym pewne reguły.
Każda liczba całkowita zapisana literalnie (np. 100) jest traktowana jako liczba typu int. Każda liczba rzeczywista zapisana literalnie (z kropką dziesiętną, np. 1.2, albo w notacji naukowej np. 1e+9) jest traktowana jako liczba typu double. Każdy literał znakowy (znak w apostrofach) jest typu char. Każdy napis true lub false jest literałem logicznym (typ boolean); słowa true i false są słowami zarezerwowanymi języka.
W niektórych przypadkach programista może zmienić interpretację literałów liczbowych, stosując specjalne modyfikatory:
użycie litery L lub l jako przyrostka liczby całkowitej sprawia, że liczba ta będzie traktowana jako typu long (np. 3L będzie literałem typu long), użycie f lub F spowoduje, że liczba rzeczywista będzie traktowana jako typu float (np. 0.3f).
Liczby całkowite mogą być zapisywane
w systemie dziesiętnym - w naturalny sposób (np. 3 lub 121), w systemie binarnym - przez poprzedzenie liczby znakami 0b lub 0B (np. 0b1110101), w systemie ósemkowym - przez poprzedzenie liczby znakiem 0 (np. 03), w systemie szesnastkowym - przez poprzedzenie liczby znakami 0x lub 0X (np. 0x1a, 0X11, 0xFF - zwróćmy uwagę, że cyfry szesnastkowe powyżej 9 mogą być zapisywane dużymi lub małymi literami).
Liczby rzeczywisty mogą być zapisywane w notacji naukowej (inżynierskiej) z wykorzystaniem litery e lub E, np. 2e+9 oznacza 2 pomnożone przez 10 do potęgi 9, 3e-11 oznacza 3 pomnożone przez 10 do potęgi -11. Przy zapisie liczb można pomiędzy cyframi umieszczać znak podkreślenia. W ten sposób zapis dużych liczb staje się bardziej czytelny, np. zamiast 1000000000 można pisać 1_000_000_000.
A po co są te wszystkie dodatkowe napisy (przyrostki, modyfikatory)? Sprawa jest oczywista w przypadku zmiany podstawy systemu liczbowego. Literał 10 jest traktowany jako zapis w sytemie dziesiętnym (i ma wartość 10). Jeżeli chcemy zaznaczyć, że chodzi nam o liczbę 10 zapisaną w systemie szesnastkowym (a jej wartość dziesiętnie jest już zupełnie inna - 16), to musimy użyć jakiejś konwencji (w przypadku Javy przedrostka 0x lub 0X).
Ale modyfikatory L czy F - po co? Otóż, niestety kompilator Javy, przypisując typ literałom liczbowym, nie kieruje się wartością zapisanych literalnie liczb. Jak powiedziano, wszystkie literały całkowitoliczbowe traktuje jako typu int, a rzeczywiste (czyli z kropką dziesiętną lub zapisane w notacji naukowej) - jako double. Zwróćmy jednak uwagę, że dane typu int zajmują tylko 4 bajty w pamięci i największa liczba, którą można zapisać w tych czterech bajtach, to 2 147 483 647. Jeśli zatem zapiszemy w programie literał 2147483649, to będzie on potraktowany jako liczba typu int, tylko niestety nie zmieści się ona w obszarze przeznaczonym na przechowywanie takich liczb. Kompilator rozpozna tę sytuację i zgłosi błąd. Powinniśmy zatem zapisać literał jako 2147483649L.
Gorszy przypadek. Jeśli zapiszemy operację dodawania dwóch literałów 2147483647 + 10, to obie liczby będą typu int (i faktycznie zmieszczą się w obszarach 4-bajtowych). Kompilator nie potrafi przeprowadzić sumowania, wie tylko, że dodawane są dwie liczby typu int i zakłada, że wynik będzie też typu int. Zatem wydziela na wynik tego wyrażenia obszar 4-bajtowy. A w 4. bajtach wynik się nie mieści i dostaniemy nieprawidłowy, bardzo dziwny, rezultat (bez żadnego błędu w kompilacji i w fazie wykonania). Aby tego uniknąć znowu musimy podać przyrostek L.
A co zrobić z wyrażeniem 10/3? Obie liczby są traktowano jako typu int, zatem przeprowadzone będzie dzielenie całkowitoliczbowe, które da w wyniku 3 (a nie 3.33333...). Jeśli chodzi nam o operację na liczbach rzeczywistych, to musimy albo użyć przyrostka d (wskazując, że chodzi o literał typu double) albo zastosować kropkę dziesiętną w zapisie którejś z liczb, np. 10. lub 10.0. Omówione przypadki pokazuje testowy program (kod 2.1), a wynik - listing 2.1.
public class LitLicz {
public static void main(String[] args) {
System.out.println(10 + 0x10);
System.out.println(10 / 3);
System.out.println(10. / 3);
System.out.println(10d / 3);
System.out.println(2147483648L);
System.out.println(2147483647 + 1);
System.out.println(2147483647L + 1);
}
}
Kod 2.1. Użycie literałów liczbowych
2633.33333333333333353.33333333333333352147483648-21474836482147483648
Listing 2.1. Wynik działania kodu 2.1
Zanotujmy (do pełniejszego wyjaśnienia w przyszłości) dwie obserwacje:
Operacje na liczbach rzeczywistych są niedokładne. W wyrażeniach podanych w programie wystarczyło zmodyfikować tylko jeden z literałów biorących udział w operacji, by otrzymać pożądany wynik (nie pisaliśmy 10d/3d, wystarczyło napisać 10d/3). Intuicyjnie możemy to rozumieć w ten sposób, że typ wyniku operacji jest określany przez "większy" (określający większe możliwe wartości) z typów biorących w niej udział argumentów (w tym przypadku literałów).
Literały znakowe (typ char) zapisujemy jako pojedyncze znaki w apostrofach np. 'a', '+' itp., a literały łańcuchowe (napisy) - jako ciagi znaków w cudzysłowie (np. "ala ma kota").
Literały łańcuchowe oznaczają obiekty klasy String.
Użycie lewego ukośnika (backslash) - nazywanego też symbolem ucieczki (escape character) - pozwala na zapisywanie w literałach znakowych i łańcuchowych znaków specjalnych (tablica 2.2).
Tablica 2.2. Znaki specjalne
Znaki specjalne
Zapis
Przejście do nowego wiersza (line feed - LF)
\n
Tabulacja (Tab)
\t
Backspace (BS)
\b
Powrót karetki (carriage return - CR)
\r
Nowa strona (form feed - FF)
\f
Apostrof
\'
Cudzysłów
\"
Lewy ukośnik (backslash)
\\
Dowolny znak o kodzie NNNN (gdzie N - cyfra szesnastkowa) w Unicodzie
\uNNNN
Uwagi. Ze względu na wczesne opracowywanie literałów niedopuszczalne jest stosowanie bezpośrednich kodów Unicode dla znaków LF (\u000a) i CR (\u000d), gdyż spowoduje to podział kodu programu, a nie wytworzenie literałów, odpowiadających znakom LF i CR. Zamiast tego należy stosować znaki \n i \r.
Należy zawsze pamiętać, że dwu- lub 6-znakowa (w tekście programu) sekwencja zapisana w apostrofach (lub wewnątrz zestawu znaków ujętych w cudzysłowy) i zaczynająca się od ukośnika tak naprawdę (czyli tak jak ją widzi kompilator) jest jednym znakiem.
Literał znakowy - w apostrofach - to zawsze jeden znak. Literał łańcuchowy - w cudzysłowie - to zero lub dowolna liczba znaków; jeśli w cudzysłowie nie podamy żadnego znaku, to mamy pusty łańcuch znakowy.
Niektóre znaki specjalne są przydatne przy wyprowadzaniu informacji, a ich dziwnie brzmiące niekiedy nazwy pochodzą z czasów, gdy jedynym urządzeniem wyjściowym była drukarka (np. powrót karetki to przesunięcie głowicy drukarki na pierwszą pozycję w wierszu; jeśli urządzeniem wyjściowym jest konsola, to wyprowadzenie tego znaku powoduje przejście kursora do pierwszej pozycji linii ekranu, w której aktualnie się znajduje).
A skąd na tej liście cudzysłów i apostrof poprzedzone ukośnikiem? Odpowiedź jest prosta. Skoro znaki ujmujemy w apostrofy, a łańcuchy znakowe w cudzysłowy, to jak zapisać znak apostrofu lub cudzysłów? Do tego właśnie służy ukośnik. Piszemy więc "\" i "\". Jeśli będziemy chcieli podać literalnie znak \, to trzeba go wtedy wpisać podwójnie.
W programie testującym (kod 2.2) pokazujemy kilka przykładów użycia literałów znakowych i łańcuchowych. Przy okazji poznamy też inną metodę wyprowadzania danych na konsolę. System.out.println(...) wyprowadza podane jako argument dane w bieżącym wierszu konsoli z następującym potem przejściem do nowego wiersza, a System.out.print(...) robi to samo, ale bez przejścia do nowego wiersza.
public class LitZn {
public static void main(String[] args) {
System.out.print("\u03b1\u03b2\u03b3 ");
System.out.print('\\');
System.out.print('\");
System.out.print(" alfa beta gamma '");
System.out.println("\nalfa\nbeta\ngamma");
System.out.println("c:\\util\\bak");
}
}
Kod 2.2. Użycie literałów znakowych
Wynik działania programu przedstawa listing 2.2.
??? \' alfa beta gamma '
alfa
beta
gamma
c:\util\bak
Listing 2.2. Wynik działania kodu 2.2
Uwaga. Aby uzyskać w tym przykładzie na konsoli greckie znaki, konsola musi mieć ustawione kodowanie na UTF-8.
Zwróćmy uwagę, że
'\u03b1' oznacza (heksadecymalnie) kod znaku a (alfa), inne kody w tym wierszu programu odpowiadają literom beta i gamma; znak apostrofu i lewy ukośnik w literale znakowym oznaczamy \' i \\; znaki specjalne zupełnie naturalnie wstawiamy do łańcuchów znakowych, w napisie "\nalfa\nbeta\ngamma" \n oznacza znak nowego wiersza, wobec tego napisy zostaną wyprowadzone w kolejnych wierszach; aby wyprowadzić napis zawierający ukośniki, musieliśmy je w tym napisie powtarzać .
2.4. Typy zmiennych. Deklaracje
W przeciwieństwie do literałów, typy zmiennych i stałych programista musi podać sam w deklaracjach.
Deklaracja zmiennej (stałej) polega na określeniu nazwy zmiennej (stałej), jej typu oraz ew. pewnych innych właściwości, które są istotne wtedy, gdy zmienna jest polem klasy (reprezentuje atrybut obiektów tej klasy).
Składnię deklaracji zmiennej w uproszczeniu można przedstawić w następujący sposób:
nazwa_typu nazwa_zmiennej;
Na przykład
int a; // deklaracja zmiennej całkowitoliczbowej a
char c; // deklaracja zmiennej c, która może zawierać znaki Unikodu
double price; // deklaracja zmiennej typu double o nazwie price
Podczas deklarowania zmiennych można ustalać ich wartości, co nazywa się inicjacją.
Inicjacja zmiennej to ustalenie jej wartości podczas deklaracji.
Deklarację z inicjacją zapisujemy w formie:
nazwa_typu nazwa_zmiennej = wyrażenie;
Na przyklad
int a = 3;
co jest skróconą formą od
int a;
a = 3;
W tym przypadku wyrażeniem inicjującym był literał, ale może być to dowolne wyrażenie, np.
int a = 3;
int b = a + 1; // deklaracja zmiennej b i ustalenie wartości na 4
Zauważmy, że użycie zmiennej a w wyrażeniu inicjującym wartość zmiennej b było możliwe tylko dlatego, że deklaracja zmiennej a wystąpiła przed użyciem jej w tym wyrażeniu.
Do deklarowania stałych używamy słowa kluczowego final.
Wartość stałej można ustalić tylko raz i poźniej ta wartość nie może być już zmieniona.
Piszemy:
final nazwa_typu nazwa_stałej [ = wyrażenie ]
przy czym inicjacja (podana w nawiasach kwadratowych) jest tu opcjonalna, bowiem istotne jest tylko to, by wartość stałej została ustalona tylko raz. Najczęściej jednak wartość tę będziemy ustalać w momencie deklaracji, np.
final int NUM = 55;
final double PI_SMALL = 3.14;
final char C;
...
C = 'c';
Deklaracje zmiennych i stałych, które oznaczają obiekty, zapisujemy w analogiczny sposób. Jeżeli int ma być typem zmiennej x (która oznacza liczbę całkowitą), to piszemy:
int
Podobnie jeżeli s jest nazwą zmiennej, która oznacza obiekt klasy String:
String s;
Tak samo postępujemy ze zmiennymi, które oznaczają obiekty każdej innej klasy (np. z zestawu klas standardowych, dostarczanych wraz ze środowiskiem JDK lub klas, które sami zdefiniujemy):
JButton b; // zmienna b oznacza obiekt klasy JButton
JFrame frame; // zmienna frame oznacza obiekt klasy JFrame
Zmienne, oznaczające obiekty, również można inicjować w czasie deklaracji. Omówimy to na przykładach dalej (bowiem wymaga to dodatkowej informacji o tworzeniu obiektów), ale już teraz możemy podać prostą postać inicjacji napisów:
String s = "Ala ma kota"; // zmienna s będzie oznaczać napis "Ala ma kota"
Reguły dobrego stylu programowania nakazują umieszczanie deklaracji każdej zmiennej w odrębnym wierszu programu wraz z dodatkowym komentarzem opisującym przeznaczenie zmiennej, gdy jej nazwa nie mówi wyraźnie, co dana zmienna oznacza.
Zauważmy jeszcze, że dopuszczalne jest deklarowanie kilku zmiennych tego samego typu w jednej deklaracji przez wymienienie ich (wraz z ew. inicjatorami) na liście rozdzielonej przecinkami, np.
int num = 1, count, ff = 10;
JButton b1, b2, b3;
Trzeba koniecznie pamiętać, że wszystkie zmienne w programie muszą być zadeklarowane! Deklaracje obowiązkowo muszą poprzedzać użycie zmiennych i stałych w innych instrukcjach programu (nie dotyczy to deklaracji pól klasy). Nie znaczy to jednak, że deklaracje muszą poprzedzać wszystkie instrukcje programu. Wręcz przeciwnie: zmienne i stałe deklarujemy zwykle możliwie najbliżej miejsca ich użycia, co zwiększa czytelność kodu.
Prześledźmy przykładowy program (kod 2.3), obliczający cenę komputera na podstawie cen jego składowych.
public class CompPrice {
public static void main(String[] args) {
int cProc = 700; // cena procesora
int cPly = 500; // ... płyty
int cPam = 300; // innych składników ...
int cDysk = 400;
int cInn = 500;
final double VAT = 1.22; // narzut podatku VAT
// Liczymy cenę komputera bez monitora
// wyrażenie sumujące składniki, zapisując
// jako wyrażenie inicjujące zmienną cKomp
double cKomp = (cProc + cPly + cPam + cDysk + cInn) * VAT;
System.out.println("Cena komputera bez monitora wynosi :");
System.out.println(cKomp);
int cMon = 1100; // cena monitora netto
cKomp = cKomp + cMon * VAT; // i nowa cena komputera z monitorem
System.out.println("Cena komputera z monitorem wynosi :");
System.out.println(cKomp);
}
}
Kod 2.3. Przykład deklaracji zmiennych w programie
Zauważmy, że gdybyśmy zapomnieli dodać słówko int przed cMon, zmienna cMon byłaby niezadeklarowana.
cMon = 1100; // cena monitora netto
cKomp = cKomp + cMon * VAT; // i nowa cena komoutera z monitorem
Kompilator wykryje tę sytuację i poinformuje nas o błędzie:
CompPrice.java:22: error: cannot find symbol cMon = 1100; // cena monitora netto ^ symbol: variable cMon location: class CompPrice
CompPrice.java:23: error: cannot find symbol cKomp = cKomp + cMon * VAT; // i nowa cena komputera z monitorem ^ symbol: variable cMon location: class CompPrice2 errors
Uwaga. W niektórych środowiskach uruchomieniowych kompilacja jest inkremetalna (w trakcie pisania programu) i wiersze, zawierające błędy, są od razu oznaczane jako błędne.
Z komunikatem cannot find symbol będziemy się spotykać dość często. Oznacza on (zwykle), że używamy nazwy, której kompilator nie może zidentyfikować (np. niezadeklarowanej zmiennej). Java rozróżnia wielkie i małe litery w nazwach zmiennych, stałych, metod, klas. Jednym z typowych źródeł błędów są błędy w pisowni, a przede wszystkim mylenie wielkich i małych liter w nazwach.
Identyfikatory. Nazwy zmiennych, stałych, metod, klas - nazywają się identyfikatorami. Identyfikatory muszą zaczynać się od litery lub podkreślenia (znaku _) i mogą składać się z dowolnego ciągu znaków alfanumerycznych (liter i cyfr) i/lub znaków podkreśleń. Jako identyfikatorów nie wolno używać zarezerwowanych słów języka.
Konwencje nazewnicze
Nazwy zmiennych i metod piszemy małą literą, każdy składnik wyróżniamy, rozpoczynając go dużą literą (np. x, price, numOfAllOccurs, liczbaDzieciMlodszych, increment(), getBackground()). Nazwy stałych piszemy dużymi literami, składniki nazwy rozróżniamy za pomocą znaku podkreślenia (np. NUM, EXIT_ON_CLOSE). Nazwy klas piszemy dużą literą, poszczególne składniki nazwy wyróżniamy też dużą literą (np. String, ArrayList).
Uwaga. Konwencja wyróżniania składników nazwy poprzez rozpoczynanie ich dużą literą nazywa się notacją węgierską.
Na zakończenie warto jeszcze raz zastanowić się, jaki jest sens pojęcia typu.Na poziomie instrukcji maszynowych każda jednostka danych rzeczywiście musi mieć typ. Wynika to ze znanego już faktu, że każda informacja (instrukcje maszynowe, dane) jest przedstawiana w pamięci komputera za pomocą ciągów bitów o wartościach 1 lub 0. Nazwa zmiennej oznacza zaś obszar pamięci, w którym znajduje się oznaczana przez tę zmienną wartość. Jeśli np. mamy zmienne x i y, to nazwy te odsyłają do początków obszarów, w których zapisane są jakieś ciągi bitowe:
x 1 1 0 0 1 0 1 1 ...y 1 ... 0 1 1 0 0 1 0 0 1 ...
Jak w takiej sytuacji komputer ma zinterpretować wyrażenie x + y? Gdzie kończą się wartości x i y? Co oznacza operacja +? Odpowiedzi na te pytania wynikają właśnie z typów zmiennych x i y, dlatego typy te muszą być określone. Jeśli np. zmienne x i y są typu int, to do przechowywania wartości tych zmiennych zostaną wydzielone obszary pamięci o określonej długości (4 bajty), a instrukcja dodawania będzie skutkować wygenerowaniem odpowiednich instrukcji maszynowych, które w sumie umożliwią dodanie do siebie dwóch wartości z tych obszarów przy zastosowaniu arytmetyki stałopozycyjnej. Gdyby zmienne x i y były typu float, to obszary zajmowanej przez nie pamięci byłyby inne (8 bajtów), a dodawanie byłoby realizowane w arytmetyce zmiennopozycyjnej.
Dlaczego jednak w programie pisanym w Javie, która przecież jest językiem wysokiego poziomu (abstrahującym - choć, jak widzimy, nie do końca - od szczegółów technicznych) programista zawczasu musi deklarować zmienne, określając przy tym ich typy? Są języki, które tego nie wymagają. Programuje się w nich nieco łatwiej, bo programista nie zaprząta sobie głowy szczegółami technicznymi i ma mniej pisania. Ale są argumenty, które przemawiają za określaniem typów zmiennych przez programistę. Warto przede wszystkim zauważyć, że dotyczy to zwłaszcza typów numerycznych. Dane różnych typów numerycznych (jak widzieliśmy) zajmują różną objętość pamięci. Mając do wyboru kilka typów liczbowych, programista może więc lepiej sterować zajętością pamięci i efektywnością programu. Jeśli np. musi przechowywać w pamięci 10 tys. liczb i wie, że wszystkie mogą być tylko całkowite, a ich wielkości mieszczą się na 4 bajtach, to używając typu int zamiast typu double, oszczędza 40 tys. bajtów oraz znacznie zwiększa efektywność programu, gdyż działania na liczbach całkowitych wykonują się zazwyczaj wielokrotnie szybciej niż na liczbach rzeczywistych. Powiedzmy jednak szczerze, ten kontekst jest bardzo ważny w zastosowaniach naukowych, przy dużych obliczeniach itd., natomiast na skutek rozwoju technologicznego oszczędności rzędu 1 MB pamięci czy kilka milionów operacji w programie dla codziennych powszechnych zastosowań oprogramowania nie są już tak istotne.
Dochodzimy więc chyba do najbardziej właściwej odpowiedzi na pytanie o sens deklarowania zmiennych i określania ich typów. Otóż określanie typów danych (nawet tych najbardziej elementarnych) w deklaracjach umożliwia kompilatorowi kontrolę poprawności programu, np. stwierdzenie, czy zmienne określonych typów stosujemy we właściwych kontekstach albo czy przekazujemy funkcjom (metodom) argumenty właściwych typów. Nazywa się to statyczną kontrolą zgodności typów (przez kompilator) i często pomaga wykrywać błędy w programie już w fazie kompilacji.
Java jest językiem ze statyczną i ścisłą kontrolą zgodności typów i temu głównie służy wybrany przez twórców Javy sposób kompilowania programów, odzwierciedlony w składni języka.
Statyczna ścisła kontrola zgodności typów oznacza, że typ wyniku każdego wyrażenia znany jest już w fazie kompilacji.
Przeświadczenie o tym, że statyczna ścisła kontrola zgodności typów chroni przed błędami, żywione przez twórców Javy, chyba najbardziej zaważyło na tym, iż w Javie trzeba deklarować zmienne i w deklaracjach określać ich typy, a stosując literały, trzeba pamiętać o konwencjach traktowania przez kompilator literalnie podanych danych.
2.5. Operatory i wyrażenia
2.5.1. Przegląd
Ze zmiennych, stałych, literałów oraz wywołań metod (funkcji), posługując się operatorami języka i nawiasami, możemy konstruować wyrażenia. Jeśli a i b są nazwami zmiennych typu numerycznego, to wyrażeniami będą np.:
a1b + 1a * (b + 10)'a'"ala ma kota"
Wyrażenia są opracowywane (wyliczane), a ich wyniki mogą być w różny sposób wykorzystane (np. w przypisaniu, jako argumenty innych operatorów, w instrukcjach sterujących wykonaniem programu, w wywołaniach metod). Jeśli np. zmienna a ma wartość 1, zmienna b - wartość 2, to
Wyrażenie
Wynik
a
wartość zmiennej a ( =1 )
1
wartość literału 1 ( = 1)
a * (b + 10)
12
'a'
znak a (dziesiętnie liczba 97, która jest kodem znaku a)
Kolejność wyliczeń zależy od priorytetów i wiązań operatorów użytych w wyrażeniach.
Priorytety określają, w jakiej kolejności będą wykonywane różne operacje zawarte w tym samym wyrażeniu (np. czy w wyrażeniu a + b * c najpierw będzie wykonywane mnożenie czy dodawanie).
Wiązania określają kolejność wykonywania operacji o tym samym priorytecie: czy są wykonywane od lewej strony wyrażenia czy od prawej (np. czy wyrażenie a + b - c będzie traktowane jako (a+b)-c czy jako (b-c)+a, co nie zawsze - chociaż nie w tym przypadku - jest kwestią obojętną.
Operatory mają argumenty, zwane też operandami. W Javie są operatory jedno- i dwuargumentowe oraz jeden operator trzyargumentowy. Argumentami operatorów zawsze są wyrażenia, np. w wyrażeniu a + (b - 1) argumentami dwuargumentowego operatora + są wyrażenia a i (b-1), natomiast w wyrażeniu b-1 argumentami dwuargumentowego operatora - (minus) są wyrażenia b i 1. W Javie zdefiniowano następujące operatory (tablica 2.3).
Tablica 2.3. Operatory
Liczba arg
Priorytet,wiązanie
Operator
Nazwa
1
1, lewe
[]
indeksowanie tablicy
()
wywołanie metody
.
selektor (wybór obiektu)
2, prawe
!
negacja logiczna
~
bitowe uzupełnienie do 1
+
jednoargumentowy + (liczba dodatnia)
-
jednoargumentowy - (liczba ujemna)
++
operator zwiększania
--
operator zmniejszania
(typ)
konwersja (rzutowanie)
2
3, lewe
*
mnożenie
/
dzielenie
%
reszta z dzielenia
4, lewe
+
dodawanie
-
odejmowanie
5. lewe
<<
przesunięcie bitowe w lewo
>>
przesunęcie bitowe w prawo
>>>
przesunęcie bitowe w prawo bez znaku
6, lewe
<
operatory relacyjne
<=
>=
>
instanceof
stwierdzenie typu
7, lewe
==
operatory równości (nierówności)
!=
2
8. lewe
&
bezwarunkowa koniunkcja logiczna; bitowa koniunkcja
9. lewe
^
bezwarunkowe wyłączające LUB; bitowe wyłączające LUB
10. lewe
|
bezwarunkowa bitowa alternatywa; bitowa alternatywa
11. lewe
&&
warunkowa logiczna koniunkcja
12. lewe
||
warunkowa logiczna alternatywa
3
13, prawe
?:
operator warunku (trójargumentowy)
2
14, prawe
=*=/=%=+=-=&=^ =|=<< =>>=
operatory przypisania
Uwaga. Mniejsza liczba w kolumnie priorytetu oznacza wyższy priorytet.
Nie będziemy teraz omawiać wszystkich operatorów, o niektórych z nich już wspominaliśmy, o innych będziemy się dowiadywać sukcesywnie. Kilka uwag warto jednak sformułować już teraz.
2.5.2. Operatory przypisania
Zauważmy najpierw, że operatory przypisania jako jedyne wsród operatorów dwuargumentowych mają wiązanie prawostronne. Oznacza to, że wyrażenie
x = a + b
jest opracowywane od prawej do lewej. W tym wyrażeniu nie ma to istotnego znaczenia, ale jeśli uwzględnimy to, że samo przypisanie jest wyrażeniem o wartości równej jego lewej stronie (która zawsze musi być zmienną) po wykonaniu przypisania, to np. w sekwencji
int x, a = 3, b = 4;int c = 1;x = a = b = c + 7
po pierwsze, priorytet operatora + jest wyższy od priorytetu operatora =, wobec tego najpierw zostanie wyliczone wyrażenie c + 7, jego wartość zostanie podstawiona na zmienną b, wartością wyrażenia b = c + 7 będzie teraz ta właśnie wartość (8). Następnie, przesuwając się do lewej strony całego wyrażenia, będzie ona podstawiona na zmienne a i x. W rezultacie zmienne x, a, b będą miały taką samą wartość równą 8.
W tablicy operatorów występuje cała grupa złożonych operatorów przypisania w postaci
op=
gdzie op - to jeden z operatorów
* / % + - << >> >>> & ^ |
Złożone operatory przypisania stosowane w następujący sposób:
x op= wyrażenie
są wygodną formą skrócenia zapisu
x = x op ( wyrażenie )
gdzie: x - dowolna zmienna, wyrażenie - dowolne wyrażenie, op - symbol operatora. Na przykład zamiast numOfChildren = numOfChildren + 2, możemy napisać numOfChildren += 2.
2.5.3. Zwiększanie i zmniejszanie
Wśród operatorów arytmetycznych szczególną rolę odgrywają jednoargumentowe operatory zwiększania (++) i zmniejszania (--). Oba występują w dwóch postaciach:
przyrostkowej (operator po argumencie-zmiennej), przedrostkowej (operator przed argumentem-zmienną).
Przy czym
++ zwiększa, a -- zmniejsza o jeden wartość argumentu (zmiennej); przyrostkowa forma operatorów (znak operatora po argumencie) powoduje, że jeśli operator jest używany w jakimś innym wyrażeniu (np. x = n++), to w wyrażeniu tym będzie wykorzystana wartość argumentu sprzed zwiększenia (zmniejszenia); przedrostkowa (znak operatora przed argumentem) powoduje, że w wyrażeniu będzie użyta zmieniona wartość argumentu operatora.
Na przykład zapis
int n, i = 1;
n = i++; // przyrostkowa forma operatora ++
jest interpretowany w następujący sposób: zmienna i zostanie zwiększona o 1, ale zmiennej n zostanie nadana wartość zmiennej i sprzed zwiększenia, czyli po wykonaniu obu instrukcji n będzie równe 1, a i będzie równe 2. Natomiast
int n, i = 1;
n = ++i; // przedrostkowa forma operatora ++
jest interpretowany w następujący sposób: zmienna i zostanie zwiększona o 1, ta nowawartość zostanie przypisana zmiennej n, czyli po wykonaniu obu instrukcji n = 2, i = 2. Oczywiście, jeśli jedynym wyrażeniem w instrukcji jest wyrażenie zwiększania (lub zmniejszania), to rozróżnienie przyrostkowej i przedrostkowej formy tych operatorów nie ma znaczenia, np. i++; znaczy to samo, co ++i; oraz i = i + 1.
Gdy nie jesteśmy absolutnie pewni kolejności opracowania wyrażeń, należy unikać stosowania operatorów ++ oraz -- w wyrażeniach, w których występują inne wyrażenia.
Aby lepiej zrozumieć przedstawione uwagi przeanalizujmy działanie kodu 2.4 i jego wyniki (listing 2.3).
public class Express1 {
public static void main(String[] args) {
int a = 1, b = 2, c = 3;
a = b = c * 1 + 2;
System.out.println("a=" + a + " b=" + b + " c=" + c);
a = b = c * (1 + 2);
System.out.println("a=" + a + " b=" + b + " c=" + c);
a = b++;
System.out.println("a=" + a + " b=" + b + " c=" + c);
c = --b;
System.out.println("a=" + a + " b=" + b + " c=" + c);
a++;
b++;
c++;
System.out.println("a=" + a + " b=" + b + " c=" + c);
a = b++ * ++c;
System.out.println("a=" + a + " b=" + b + " c=" + c);
int DlugaNazwaZmiennej = 20;
DlugaNazwaZmiennej = DlugaNazwaZmiennej * 10;
DlugaNazwaZmiennej *= 10;
System.out.println(DlugaNazwaZmiennej);
}
}
Kod 2.4. Różnica w działaniu przedrostków i przyrostków operatorów
a=5 b=5 c=3a=9 b=9 c=3a=9 b=10 c=3a=9 b=9 c=9a=10 b=10 c=10a=110 b=11 c=112000
Listing 2.3. Wynik działania kodu 2.4
W programie tym stosujemy operator + nie tylko do dodawania liczb. Służy on także konkatenacji (łączeniu łańcuchów znakowych). Wyrażenie "a = " + a oznacza dołączenie do napisu "a = " napisowej reprezentacji wartości zmiennej a. Jeśli a = 5, to w wyniku otrzymamy napis "a = 5". O szczegółach związanych z łączeniem napisów będzie mowa w rozdz. 4.8.
2.5.4. Dzielenie całkowite i reszta
Inne operatory arytmetyczne były już po części omówione. Jeszcze raz wartojednak podkreślić, że w dzieleniu liczb całkowitych jest odrzucana ułamkowa część wyniku, np.
int a,b,c;b = 1; c = 10; a = b/c; // a = 0, bo "prawdziwy" wynik dzielenia jest 0 i 1/10a = 19/10; // a = 1, bo "prawdziwy" wynik dzielenia jest 1 i 9/10
Ogólnie więc, gdy b i c są zmiennymi typu int nie jest spełniona intuicyjna zależność matematyczna:
b = b*c/b = b*(c/b) = (b*c)/b
Na przykład dla
b = 30; c = 20; b = c*b/c; // b = 30
ale dla
b = 30; c = 20; b = c*(b/c); // b = 20 !
Tutaj widzimy, jak nawiasy zmieniają kolejność obliczania wartości wyrażenia. Wiązanieoperatorów * i / jest lewostronne i w pierwszym przypadku cała operacja wykonuje sięw następujący sposób: c*b daje 600, co dzielone przez c = 20 daje 30. W drugim przypadku najpierw będzie wykonane dzielenie w nawiasach (30/20 da 1), a wynik pomnożony przez wartość c (20) da 20. Wbrew pozorom takie dzielenie całkowitoliczbowe może być użyteczne, zwłaszcza wraz z zastosowaniem operatora reszty z dzielenia. Rozważmy przykład.
Zadanie. Mamy pojemniki. Do pojemników wkładamy klocki. Kiedy zabraknie miejsca w pojemniku, bierzemy następny. W każdym pojemniku mieści się s klocków. Napisać program, który oblicza, ile potrzeba pojemników, aby przechować n klocków, i ile klocków jest w ostatnim użytym pojemniku.
Uwaga. Warto to zadanie rozwiązać samodzielnie, a dopiero później prześledzić podane rozwiązanie.
Rozwiązanie. Zadanie to można rozwiązać na wiele sposobów. Najbardziej zwartym i eleganckim jest wykorzystanie właściwości operatora dzielenia całkowitoliczbowego oraz operatora reszty z dzielenia.
Gdybyśmy rozwiązywali to zadanie za pomocą kartki i ołówka, to liczbę potrzebnych pojemników uzyskamy przez podzielenie liczby klocków przez pojemność jednego pojemnika. Jeżeli klocków jest 10, a w każdym pojemniku mieści się co najwyżej 5 klocków, to liczba potrzebnych pojemników jest równa 10/5 = 2. Jednak gdy klocków będzie 11, to uzyskamy 11/5 = 2 i 2/10. Ponieważ operujemy tylko całymi pojemnikami (nie ma czegoś takiego jak "kawałek" pojemnika), potrzebny jest trzeci pojemnik do przechowywania jedenastego klocka (który nie zmieścił się w dwóch już wypełnionych pojemnikach).
To samo możemy zapisać w Javie. Ze względu na właściwości operatora dzielenia dla liczb całkowitych, wynik dzielenia całkowitoliczbowego pokaże, ile jest pełnych pojemników. Musimy się jeszcze dowiedzieć, czy przypadkiem nie jest potrzebny pojemnik dodatkowy. O tym powie nam reszta z dzielenia liczby klocków przez pojemność pojemnika. Jeśli ta reszta jest równa 0, mamy tylko pełne pojemniki. W przeciwnym razie potrzebny jest dodatkowy pojemnik, w którym będą umieszczone ostatnie klocki. Ten dodatkowy pojemnik nie będzie całkowicie wypełniony. Powinniśmy wiedzieć, ile jest w nim klocków. Oczywiście, liczba klocków jest równa reszcie z dzielenia (kod 2.5 oraz listing 2.4).
public class Klocki {
public static void main(String[] args) {
int n = 41; // liczba klocków
int s = 7; // ile klocków mieści się w pojemniku
// jeżeli wszystkie pojemniki są pełne pokazuje
// również, ile jest klocków w ostatnim z nich
// wykorzystamy tę zmienną do pokazania,
// ile jest klocków w ostatnim pojemniku
int poj = n/s; // liczba pojemników; początkowo - pełnych
int rest = n%s; // reszta z dzielenia pokazuje,
// czy mamy dodatkowy pojemnik
// i ile jest w nim klocków
if (rest != 0) { // jeżeli jest dodatkowy pojemnik
poj++; // liczba pojemników zwiekszona o dodatkowy
s = rest; // zmieniamy wartość s - ostatni pojemnik niepełny!
}
System.out.println("Liczba pojemników : " + poj);
System.out.println("Liczba klocków w ostatnim: " + s);
}
}
Kod 2.5. Wykorzystanie dzielenia całkowitoliczbowego i reszty z dzielenia
Liczba pojemników : 6Liczba klocków w ostatnim: 6
Listing 2.4. Wynik działania kodu 2.5
W programie pojawia się kilka nowych elementów języka. Będą one szczegółowo omawiane dalej, teraz dla zrozumienia działanie programu wystarczy kilka komentarzy:
instrukcja if służy do sprawdzania warunków, jeśli warunek w nawiasach okrągłych jest prawdziwy, to jest wykonywana instrukcja zapisana po zamykającym nawiasie okrągłym; instrukcja grupująca { ... } służy do grupowania instrukcji, ujęte w nawiasy klamrowe są traktowane jako jedna instrukcja, co wykorzystujemy w if; operator czy nie równe zapisujemy w Javie jako !=; operator czy równe zapisujemy jako ==; zauważmy, że priorytet operatora % jest wyższy niż operatorów == i !=, dlatego w if nie musieliśmy stosować dodatkowych nawiasów.
2.6. Konwersje arytmetyczne
Ogólnie, jeżeli zmienna jest typu A, to nie można jej przypisać wartości typu B. Zapis
int a = "Ala ma kota";
spowoduje błąd w kompilacji, gdyż a jest zmienną typu int, a wyrażenie "Ala ma kota" jest literałem łańcuchowym i jest typu String. Byłoby jednak nierozumnie stosować tę zasadę w całej rozciągłości wobec typów arytmetycznych. Przecież to są liczby i często chcielibyśmy jakąś wartość liczbową przypisać zmiennej typu arytmetycznego, nie bacząc na typy.
Zmiana typu wyrażenia nazywa się konwersją lub rzutowaniem (cast).
Konwersje arytmetyczne, które nie mogą doprowadzić do utraty informacji reprezentowanej przez wartość wyrażenia, są dokonywane w sposób niejawny, niejako automatyczny. Możemy np. bez przeszkód przypisać zmiennej typu double wartość zmiennej typu int:
int a = 10;double d = a;
bowiem każda wartość zmiennej typu int zmieści się w zmiennej typu double. Takie konwersje nazywają się rozszerzającymi, bo przekształcane są "węższego" typu do "szerszych" (mogących reprezentować więcej wartości). Gdybyśmy jednak chcieli wykonać operację odwrotną:
double d = 10.1;...int a = d;
to kompilator będzie sygnalizował błąd. Taki zapis jest niedopuszczalny, ponieważ podczas takiego przypisania możemy stracić informację zawartą w zmiennej szerszego typu. Konwersja w tym przypadku nazywa się konwersją zawężającą i jest możliwa do wykonania tylko pod warunkiem użycia operatora konwersji. Mówimy, że konwersje zawężające muszą być wykonywane jawnie, za pomocą operatora konwersji. Użycie operatora konwersji oznacza, że programista świadomie decyduje się na przekształcenie typów, przy którym może nastąpić strata informacji i że nie jest to przypadkowy błąd w programie.
Operator konwersji (rzutowania) ma postać:
(nazwa_typu) wyrażenie
gdzie nazwa_typu oznacza typ do jakiego jest przekształcany aktualny typ wyrażenia.
W poprzednim przykładzie powinniśmy więc napisać
double d = 10.9;...int a = (int) d;
Teraz kompilator umożliwi pozostawienie takiego zapisu w programie. Warto zwrócić uwagę, że przy tej okazji może faktycznie nastąpić utrata informacji. W przykładzie zmienna a będzie miała wartość 10 (straciliśmy całą część ułamkową liczby). Podkreślmy: to nie jest zaokrąglenie, część ułamkowa jest po prostu odrzucana. Oczywiście nie zawsze informacja będzie tracona (gdy d = 10.0, to a = 10 i mamy praktycznie to samo).
Bardzo często przy operowaniu na znakach (typu char) i ich kodach liczbowych jest potrzebne wykonywanie konwersji. Obrazuje to kod 2.6 a wyniki zawiera listing 2.5.
public class Char {
public static void main(String[] args) {
char c = 'a';
int kod = c;
System.out.println("Kod znaku " + c + " = " + kod);
kod = 77;
c = (char) kod;
System.out.println("Kod znaku " + c + " = " + kod);
// Czasem trzeba użyć jawnej konwersji rozszerzającej
System.out.println("Kod znaku " + '*' + " = " + (int) '*');
// Oczywiście - zawężające zawsze trzeba podawać jawnie
System.out.println("Kod znaku " + (char) 66 + " = " + 66);
// No i skoro dane typu char mogą być traktowane jako liczby, to:
char b = 'a' + 1;
System.out.println(b + " - " + (int) b);
System.out.println(++b + " - " + (int) b);
}
}
Kod 2.6. Znaki i ich kody
Kod znaku a = 97Kod znaku M = 77Kod znaku * = 42Kod znaku B = 66b - 98c - 99
Listing 2.5. Wynik działania kodu 2.6
W trakcie wyliczania wartości wyrażeń arytmetycznych są dokonywane automatyczne konwersje, nazywane promocjami numerycznymi.
Promocja numeryczna polega na przekształceniu typów argumentów operatorów arytmetycznych do typu wspólnego dla ich argumentów.
Odpowiedź na pytanie: jaki jest ten wspólny typ, daje następująca reguła.
Promocje argumentów operatorów dwuargumentowych są wykonywane w następujący sposób:
jeżeli jeden z argumentów jest typu double, drugi jest przekształcany do typu double; w przeciwnym razie jeżeli jeden z argumentów jest typu float, drugi jest przekształcany do typu float; w przeciwnym razie jeżeli jeden z argumentów jest typu long, drugi jest przekształcany do typu long; w przeciwnym razie oba argumenty są przekształcane do typu int.