Trudno wyobrazić sobie program bez stosowania pętli… a jednak studenci i mnie uczą czegoś nowego co jakiś czas… np. zadanie: Napisz program, gdzie w zdefiniowanej tablicy znajduje się pięć imion – Alicja, Tomasz, Wojtek, Ewa, Zofia. Wypisz je, każde w osobnej linii z podaniem numeru tej linii… często ze zdumieniem widzę taki program jak poniżej:
public class Imiona {
public static void main(String[] args) {
imiona[0]="Alicja";
imiona[1]="Tomasz";
imiona[2]="Wojtek";
imiona[3]="Ewa";
imiona[4]="Zofia";
System.out.println("1) "+imiona[0]);
System.out.println("2) "+imiona[1]);
System.out.println("3) "+imiona[2]);
System.out.println("4) "+imiona[3]);
System.out.println("5) "+imiona[4]);
}
}
Co można o tym programie powiedzieć?! Działa?! Działa! Są w nim błędy?! Nie ma! Realizuje zadanie?! Realizuje… a co można powiedzieć o programiście?! Hmmm… powstrzymam się od zgadywania bowiem w takim kodzie jest kilka nieporozumień co najmniej znaczeniowych.
Po pierwsze: Dlaczego deklaracja i inicjalizacja tablicy jest rozpisana na atomowe czynności?! Czy dlatego, że student nie wie jak utworzyć zmienną tablicową i od razu nadać jej wartości?! Po drugie: Dlaczego nie użyto jakiejkolwiek pętli, aby zautomatyzować proces?! Czy dlatego, że student nie zna tego mechanizmu?!
A może napisać program tak:
public class Imiona {
public static void main(String[] args) {
String imiona[]={"Alicja", "Tomasz", "Wojtek", "Ewa", "Zofia"};
for (int licznik=0;licznik<imiona.length;licznik++) System.out.println(licznik+") "+imiona[licznik]);
}
}
Też, nie najlepiej, ale… i program krótszy, i widać, że student zna obiekty tablicowe oraz potrafi korzystać również z pętli. Przyjrzyjmy się zatem pętlom.
do … while ();
Pętla do … while (); jest pętlą realizującą kod pomiędzy do, a while dopóki spełniony jest warunek zawarty w (). Spróbujmy więc przerobić powyższy przykład opierając się na tej pętli…
public class Imiona {
public static void main(String[] args) {
String imiona[]={"Alicja", "Tomasz", "Wojtek", "Ewa", "Zofia"};
int licznik=0;
do { System.out.println(imiona[licznik]); } //jeśli mamy do czynienia TYLKO z jednym poleceniem w bloku, możemy pominąć wtedy { }
while (++licznik<imiona.length);
}
}
To co tu jest istotne, to miejsce i sposób inkrementacji licznika. Porównujemy go z rozmiarem tablicy, więc musimy zagwarantować, że maksymalna jego wartość nie przekroczy największego indeksu tablicy. Spróbuj zamienić pre-inkrementację (++licznik), na post-inkrementację (licznik++)… i co się dzieje?? Charakterystyczną cechą tej pętli jest miejsce sprawdzenia warunku umiejscowione na końcu pętli, co oznacza, że kod z wnętrza pętli wykona się chociaż jeden raz.
while () do …;
Kolejną pętlą jest while () do …; wnoszoną przez nią różnicą jest sprawdzanie warunku na początku pętli zanim wykona się kod wewnątrz pętli. Zatem, przeróbmy nasz program tak, aby był wykonany za pomocą tej oto pętli:
public class Imiona {
public static void main(String[] args) {
String imiona[]={"Alicja", "Tomasz", "Wojtek", "Ewa", "Zofia"};
int licznik=0;
while (licznik<imiona.length) { System.out.println(imiona[licznik++]); } // także i TUTAJ jeśli w bloku znajduje się tylko jeden rozkaz, nie trzeba używać {}
}
}
Znowuż i w tej pętli kluczowym jest miejsce inkrementacji licznika. Nie może być ono już w warunku1teoretycznie może, należy jednak zmodyfikować punkt wejścia i zakończenia iteracji, tak aby wartości odpowiadały dopuszczalnym wartościom indeksów tablicy, a została ona przeniesiona wgłąb pętli.
for () {…};
Trzecią tutaj, choć i użytą w pierwszym przykładzie jest pętla for () {…}; gdzie w () podajemy deklarację i inicjalizację licznika, warunek do sprawdzenia i sposób iteracji, a w bloku {…} wpisujemy kod wykonywany we wnętrzu pętli. Nasz przykładowy program został już przedstawiony jako drugi listing z obecnego tematu, ale został określony „nienajlepszym” ;-).
public class Imiona {
public static void main(String[] args) {
String imiona[]={"Alicja", "Tomasz", "Wojtek", "Ewa", "Zofia"};
for (int licznik=0;licznik<imiona.length;licznik++) System.out.println(licznik+") "+imiona[licznik]);
}
}
Co zatem zrobić aby zoptymalizować nasz kod?? Można użyć w tym celu tzw. pętli foreach (dla każdego…). Pętla taka dla każdego elementu będącego częścią przetwarzanej struktury (u nas tablicy) wykona operację będącą zdefiniowaną w bloku pętli. Zatem nasz przykład wyglądać będzie tak:
public class Imiona {
public static void main(String[] args) {
String imiona[]={"Alicja", "Tomasz", "Wojtek", "Ewa", "Zofia"};
int licznik=0;
for (String i:imiona) System.out.println(++licznik+") "+i);
}
}
Clue tej pętli jest to, że nie musimy sprawdzać ilości elementów – zrobi to za nas kompilator. My jedynie przypisujemy kolejne elementy pętli zmiennej umieszczonej w () pętli i wykorzystujemy te wartości we wnętrzu pętli.
W każdym powyższym przypadku uzyskamy wynik działania programu podobny do poniższego zdjęcia:
Powoli jednak pojawiają się nam zagrożenia. Rozpatrzmy inny przykład oparty na pętli foreach; Załóżmy, że mamy tablicę liczb i chcemy wypełnić ją liczbami od 10 do 100 ze skokiem co 10; Zróbmy to wpierw za pomocą klasycznej pętli for() {…};.
public class Imiona {
public static void main(String[] args) {
int liczbaTab[]=new int[10];
for (int x=1;x<liczbaTab.length;x++) System.out.println(liczbaTab[x]=x*10+10);
}
}
Osiągamy efekt nie do końca zamierzony, czyli:
A teraz spróbujmy bezrefleksyjnie przerobić to na pętlę foreach;
public class Imiona {
public static void main(String[] args) {
int liczbaTab[]=new int[10];
for (int x:liczbaTab) System.out.println(x=x++*10+10);
}
}
Osiągamy (rzekomo!!!) efekt poniższy:
Ale coś jest nie tak… nie te liczby miały być.
Sprawdźmy może w oddzielnej linii co jest w tablicy tak naprawdę zapisane:
public class Imiona {
public static void main(String[] args) {
int liczbaTab[]=new int[10];
for (int x:liczbaTab) System.out.println(x=x++*10+10);
for (int x:liczbaTab) System.out.println(x);
}
}
To mamy jeszcze większą zagwozdkę, bowiem mieliśmy mieć w każdym kolejnym elemencie tablicy wartości 10, 20, 30, … 100. W wyniku poprzedniego kodu wydawało nam się, że są tam umieszczone same 10. A przy osobnym sprawdzeniu zawartości tablicy wychodzi na to, że w niej są same 0… I gdzie jest problem?! Problemem jest fakt, że pętla foreach; jest jednokierunkowa, a jej działanie polega mniej więcej na tym, że do zmiennej zadeklarowanej jako pojedynczy element tablicy są wstępnie kopiowane sukcesywnie każdy po każdym elementy tablicy. KOPIOWANE, a nie są to elementy tablicy. Stąd przypisanie czegokolwiek do tej zmiennej nie zmienia zawartości komórki tablicy, a jedynie… wartość tej zmiennej. Trzeba o tym pamiętać!
Rekurencja
Zupełnie innym rodzajem pętli, ale możliwym do zrealizowania jest tzw. pętla rekurencyjna. Aby ją zrealizować musimy poznać konstrukcję metod (funkcje czy procedury). Ta pętla często jest przydatna przy bardziej zaawansowanych projektach, na tym etapie stanowi jednak jedynie ciekawostkę programistyczną. Nasz program z wypisywaniem imion z tablicy może przyjąć poniższą postać:
public class Imiona {
static String pisz(String tab[],int index) {
if ((--index>0) && (index<=tab.length)) System.out.println(pisz(tab,index)); // program zagnieżdża się w rekurencji.
return tab[index];
}
public static void main(String[] args) {
String imiona[]={"Alicja", "Tomasz", "Wojtek", "Ewa", "Zofia"};
System.out.println(pisz(imiona,imiona.length)); // wejście do rekurencji... i pomimo pierwszego System.out.println(); wypisanie... ostatniego imienia (Zofia).
}
}
Jak widać, w kodzie programu nie użyto ani jednej konstrukcji ze znanych pętli, a mimo to program działa doskonale. Wyjaśnieniem tego jest rekurencja, czyli wielokrotne wywoływanie podprogramu przez niego samego. Wygląda to następująco, że w programie głównym wywołujemy podprogram raz, a następnie on sam regulując to za pomocą odpowiednich warunków modyfikuje parametry i uruchamia następną instancję samego siebie, aż do wyczerpania warunku. Za każdym uruchomieniem razem coś zostaje zrobione. Następnie program kończąc działanie zwraca wynik do instancji „wyżej”, czyli do tej, która go wywołała. Odbywa się to aż do wyczerpania instancji i powrotu do programu głównego. Bądź (co jest częstsze z powodu błędów koncepcyjnych) do zagnieżdżania się instancji aż do wyczerpania pamięci programu (błąd StackOverflowError). Działanie programu jest dosyć „zagmatwane” bowiem zagnieżdża się on aż do ostatniego elementu tablicy, a następnie zaczyna zwracać wartości od ostatniego do pierwszego elementu. Jednakże zwracane wartości są obsługiwane w odwrotnej kolejności przez polecenie System.out.println();, bowiem zagnieżdżenia „odwijają się”, wobec czego wypisanie wyników jest zgodne z naszymi potrzebami. Ostatni wypis dokonywany jest przez System.out.println(); umieszczony w programie głównym (bo dopiero teraz dociera do niego zwrócona przez podprogram wartość ostatniego elementu, czyli „Zofia”). Program wypełnił się i kończy działanie.
Warunki pętli, a zmienne zmiennoprzecinkowe.
Kolejnym zagrożeniem w pętlach jest użycie ostrych warunków realizacji pętli i brak zrozumienia reprezentacji liczb zmiennoprzecinkowych.
Rozważmy program, który wypisuje wartości od 1 do 10:
public class Imiona {
public static void main(String[] args) {
for (int i=1;i!=11;i++) System.out.println(i);
}
}
W rezultacie zgodnie z oczekiwaniem wypisze nam poniższe wartości:
Ale teraz spróbujmy zmienić nasz typ wyliczeniowy na liczbę pseudo-rzeczywistą (typ zmiennoprzecinkowy):
public class Imiona {
public static void main(String[] args) {
for (float i=1;i!=11;i++) System.out.println(i);
}
}
Czy coś się zmieniło?! Wydaje się, że nie… bowiem ciągle używamy iteratora całkowitego, zmieńmy więc to i użyjmy iteratora będącego liczbą nie 1.0, a 0.1. Ile razy wykona się pętla?? 10x czy 100x? Wydaje się, że 100x, powiem iterator jest 10x mniejszy niż poprzednio, a pętla wtedy wykonywała się 10x. No to w drogę… mamy program:
public class Imiona {
public static void main(String[] args) {
for (float i=1;i!=11;i+=.1) System.out.println(i);
}
}
Ile razy wykonała się pętla? I dlaczego?
Jak widać używanie ostrego porównania w przypadku liczb zmiennoprzecinkowych jest ryzykowne i należałoby zastosować tutaj raczej porównania zakresów <, >, <=, >= itp. Absolutnie należy wystrzegać się warunków typu == lub !=.
Zadanie 1: Napisz program, który wypisze wszystkie litery alfabetu łacińskiego (bez polskich liter).
Zadanie 2: Napisz program, który wyświetli tabliczkę mnożenia w sposób jak najbardziej zbliżony do tego.
…
Zadanie 98: Na Podstawach Programowania zajmowaliśmy się schematami blokowymi – w tym i rekurencji. Spróbuj teraz zakodować wszystkie te schematy blokowe w postaci programów. Na koniec zmierz się z rekurencją.
Zadanie 99: Napisz program, który będzie w pętli „rzucał kostką do gry” i wyświetlał wylosowany wynik w postaci podobnej do poniższej:
co daje wylosowaną liczbę oczek „5” itd. Może być przydatna umiejętność korzystania z biblioteki Random, która opisana jest z przykładami w Dodatku.