« poprzedni punkt  następny punkt »

2. Warunkowe pętle iteracyjne: instrukcje while i do..while
Instrukcja while ma następującą postać:

        while (wyr) ins

gdzie:
  • wyr - wyrażenie, dające w wyniku wartość typu boolean
  • ins - dowolna instrukcja (w tym grupująca)

Działanie pętli while jest następujące. Wyrażenie wyr reprezentuje podobny "warunek" jak w instrukcji if : jego wartość jest wyliczana i jeśli jest równa true to wykonywana jest instrukcja ins, w przeciwnym razie sterowanie przekazywane jest do pierwszej instrukcji po pętli while.
Po wykonaniu instrukcji ins ponownie "sprawdzany jest warunek" zawarty w wyrażeniu wyr i jeśli jest prawdziwy całą operacja znów jest powtarzana. Ta petla kończy działanie gdy wyrażenie wyr równe jest false lub gdy w instrukcji ins zawarto instrukcję break, lub return, które wyprowadzają sterowanie poza pętlę.

Przypomnienie: znaki można traktować jak liczby, bo są w komputerze reprezentowane za pomocą kodów liczbowych

Na przykład poniższy fragment wyprowadza wszystkie małe litery alfabetu angielskiego (będzie on działał prawidłowo dla wszystkich tablic kodowych, w których małe litery alfabetu angielskiego zajmują ciągłą przestrzeń tzn. po a następuje b, po b c - itd; "następowanie po" oznacza, że kod następnego znaku jest o 1 większy od kodu znaku poprzedniego).


        char c = 'a';              
        while (c <= 'z') System.out.print(c++);

Proszę sprawdzić działanie tego fragmentu we własnym programie.

Jak widać dla prawidłowego zakończenia pętli ważne jest aby w jej wnętrzu następowały zmiany (modyfikacje wartości zmiennych) wpływające na "warunek" reprezentowany przez wyrażenie w nawiasach while.
Warunki mogą być oczywiście złożone. Aby wyprowadzić "od końca" m ostatnich liter możemy napisać

        int n = 0;
        char c = 'z';
        while(c >= 'a' && n < m)  {
          System.out.println(c--);
          n++;
        }

Proszę sprawdzić działanie tego fragmentu we własnym programie (dla wybranych wartości m).

Dajemy tu podwójny warunek, nie wiadomo bowiem czy m nie przekroczy liczby małych liter w tablicy kodowej.
Jeszcze inaczej to samo można napisać w następujący sposób:

int m = ...;
char c = 'z';
while(m-- > 0) {
    if (c  <  'a') break;
    System.out.println(c--);
}

Przed niedopuszczalnymi wartościami m zabezpieczamy się wewnątrz pętli; gdy kolejny kod znaku przekroczy zakres kodów dla małych liter (będzie mniejszy od kodu litery a) instrukcja break spowoduje przerwanie pętli.

Baczny Czytelnik zauważył zapewne tu dwa ciekawe zjawiska.
Po pierwsze w pętli while możemy stosować licznik iteracji i wykonywać jakąś zadaną ich liczbę (tę rolę pełniły w powyższych przykładach najpiewr zmienna n, a później m).
Po drugie, pętla może nie wykonać "pełnej liczby" iteracji.  Gdy m ma wartość większą od liczby małych liter w alfabecie angielskim ostatnia iteracja wykonywana jest "w połowie": z dwóch instrukcji zapisanych w pętli wykonana zostanie tylko pierwsza (if...) i na skutek break sterowanie opuści pętlę.
Pętla i pół" (po angielsku "loop and half") jest typowym przypadkiem przy wprowadzaniu jakichś danych i wykonywaniu na nich operacji w pętli,

Np. w poniższym programie w pętli wprowadzamy i sumujemy liczby całkowite dopóki ich suma nie osiagnie lub nie przekroczy podanego limitu. Dodatkowo w każdej chwili użytkownik może zakończyć sumowanie (przed osiagnięciem limitu), jeśli tylko zrezygnuje z wprowadzenia danych w dialogu przez wybór Cancel.

import javax.swing.*;

public class Sumowanie {

  public static void main(String[] args) {

    final int LIMIT = 200;
    int sum = 0;

    while(sum < LIMIT)  {
      String data = JOptionPane.showInputDialog("Podaj liczbę całkowitą:");
      if (data == null) break;
      sum += Integer.parseInt(data);
    }
    System.out.println("Suma: " + sum);
    System.exit(0);
  }
 }

ProblemKomentarze - przypomnienia:

  • stałe deklarujemy jako final, używamy ich zamiast "ważnych" literałów (takich jak np limit sumowania,  liczba powtórzeń, etc)
  • jeśli zrezygnowano z wprowadzania danych (np. poprzez wybranie Cancel w okienku dialogowym), to metoda showInputDialog zwraca null
  • instrukcja break służy do przerywania pętli
  • liczby całkowite wprowadzone w dialogu są napisami - trzeba je przekształcić na postać binarną i do tego służy statyczna metoda parseInt z klasy Integer
  • moglibyśmy napisać int a = Integer.parseInt(data); sum = sum + a; jednak krócej to samo zapisujemy jako: sum += Integer.parseInt(data);


Problem
W tym momencie warto poświęcić czas na rozwiązanie trochę większego zadania, którego opis pojawi się po kliknięciu ikonki "Problem do rozwiązania".



Nieco inne podejście do sterowania wykonaniem pętli while można zrealizować za pomocą tzw. zmiennej sterującej:

boolean again = true;
while(again) {
  .....
  if (/*warunek zakończenia pętli*/) again = false;
}

Ten sam przykład można zapisać za pomoca pętli nieskończonej i z użyciem break:

while (true) {
    ...
    if (/*warunek zakończenia petli*/) break;
}

Instrukcja do ... while jest bardzo podobna do while.
Ma ona postać:

                do ins while (wyr)

Znaczenie ins i wyr jest takie samo jak w przypadku instrukcji while. Jedyna (zaznaczana zresztą przez zapis) różnica w stosunku do instrukcji while polega na tym, że warunek określony przez wyrażenie wyr jest sprawdzany po każdym wykonaniu pętli (instrukcji ins), a nie przed (jak to jest w przypadku while).

Pętla while może więc nie wykonać się ani razu, natomiast pętla do ... while zawsze wykona się przynajmniej raz.

Podsumujmy. Pętle while lub do..while stosujemy zwykle wtedy, gdy kontynuacja działania pętli zależy od jakieś warunku, a liczba iteracji nie jest z góry znana lub łatwa do określenia.

Zobaczmy teraz bardziej praktyczny przykład zastosowania instrukcji while.
Będziemy symulować zmiany stanu konta bankowego.
Warunki są takie:

  • konto ma jakiś dany stan (ilość pieniędzy na koncie)
  • co miesiąc na konto wpływa zadana suma i wypłacana jest z niego inna zadana suma
  • suma na koncie jest oprocentowana w skali rocznej wg podanej stopy procentowej
  • odsetki są doliczane do tej sumy co miesiąc, na początku miesiąca, przed zaksięgowaniem miesięcznego wpływu i wydatku

Należy stworzyć klasę Konto (Account) o podanych charakterystykach i dostarczyć w niej metody np. o nazwie getMonthsToBalance, która (za pomocą symulacji miesięcznych zmian konta) pozwala odpowiedzieć po ilu miesiącach suma na koncie osiągnie podaną jako argument docelową wielkośc

Testowanie klasy Account może wyglądac tak:

// Tworzymy obiekt konto ze stanem 2000, wpłatami 2400, wypłatami 1800 i oprocentowaniem 10% w skali roku

Account ac = new Account(2000, 2400, 1800, 10);

// ile miesięcy zajmie uzyskanie na koncie  sumy co najmniej 10000

int lMies =   ac.getMonthsToBalance(10000);


Przed lekturą dalszego tekstu proszę spróbować samodzielnie rozwiązać to zadanie.


Jedno z możliwych rozwiązań jest następujące:

public class Account {

  private double balance;          // stan konta
  private double monthIncome;      // stałe miesięczne wpływy (dochód)
  private double monthExpend;      // stałe miesięczne wydatki
  private double interest;         // stopa oprocentowania (roczna)

  // Konstruktor

  public Account(double s, double wpl, double wypl, double p) {
    balance = s;
    monthIncome = wpl;
    monthExpend = wypl;
    interest = p;
  }

  // Metoda - zwraca aktualny stan konta

  public double getBalance() {
    return balance;
  }

  // Metoda - zwraca liczbę miesięcy potrzebnych
  // by stan konta osiągnął wartość targetBalance

  public int getMonthsToBalance(double targetBalance) {

    int n = 0;                                // miesiące
    double diff = targetBalance - balance;    // różnica między aktualnym
                                              // i docelowym stanem
    while (diff > 0) {         // dopóki jest TA różnica -
                               // symulujemy upływ miesięcy i zmiany konta
      n++;
      balance *= (1 + (interest/100)/12);     // doliczenie odsetek
      balance += monthIncome - monthExpend;   // dochody, wydatki
      double prevDiff = diff;                 // poprzednia różnica
      diff = targetBalance - balance;         // bieżąca różnica
      if (prevDiff <= diff) return -1;        // jeżeli różnica się
    }                                         // nie zmniejsza - nie ma szans
    return n;                                 // osiagnięcia docelowego stanu
  }


}

// Klasa testująca konto

class TestKonta {

  public static void main(String[] args) {

    Account ac = new Account(2000, 2400, 1800, 10);
    double cel = 10000;
    int m =  ac.getMonthsToBalance(cel);
    System.out.println("Miesiace do osiagniecia co najmniej " + cel + ":");
    System.out.println(m + " --- stan konta " + ac.getBalance());
  }


}

Wynik działania programu:

Miesiace do osiagniecia co najmniej 10000.0:
13 --- stan konta 10430.006715578125

Klasa jest suto komentowana, zwróćmy więc uwagę tylko na to, że staramy się tu zabezpieczyć przed nieosiągalnymi docelowymi stanami konta (np. kiedy wydatki są większe od dochodów, a - w którymś momencie - oprocentowanie nie pokryje tej różnicy, to docelowy stan, który jest większy od aktualnego nigdy nie zostanie osiagnięty). Innymi słowy staramy się zapewnić  zakończenie pętli while. W tym programie robimy to  sprawdzając w pętli - czy z każdą  iteracją różnica pomiędzy stanem docelowym i aktualnym zmniejsza się. Jeśli nie, to nie ma szansy na osiągnięcie stanu docelowego i metoda zwraca -1 jako liczbę miesięcy.

Zauważmy dalej, że unikając głębszego zastanawiania się nad kształtowaniem comiesięcznego stanu konta, rozbiliśmy wyliczenia na dwa kroki: naliczenie odsetek i dodanie "czystego" dochodu:

     balance *= (1 + (interest/100)/12);     // doliczenie odsetek
     balance += monthIncome - monthExpend;   // dochody, wydatki
 
Mamy tu dwa przypisania, a możemy miec jedno. Możemy zapisać:

balance  +=  ((interest/100)/12)*balance + monthIncome - monthExpend;

W istocie, dotknęliśmy tu problemu optymalizacji struktury programu pod względem zwiększenia szybkości jego wykonania. Niegdyś kwestia ta zajmowała wiele uwagi programistów. Dzisiaj - ze względu na dużą moc obliczeniową współczesnego sprzętu komputerowego zeszła jakby na plan dalszy.
Poza tym same kompilatory (w jakimś zakresie) starają się optymalizowac program.
Czy warto więc o tym myśleć?
Sądzę, że - do pewnego przynajmniej stopnia - tak.

Sprawa dotyczy szczególnie instrukcji zawartych w pętlach iteracyjnych. Faktycznie wykonują się one wielokrotnie, czasami miliony razy - zatem ich optymalizacja ma istotny sens.

Optymalizacji należy jednak dokonywać rozsądnie.

Oto staraliśmy się przyspieszyć działanie pętli poprzez usunięcie instrukcji przypisania ( podwójne przypisywanie wartości zmiennej balance), gdy tymczasem znacznie bardziej obciążające czasowo jest wyliczanie wspołczynnika naliczania odsetek:  (interest/100)/12.
Jest on wyliczany w każdej iteracji, a przecież można go policzyć tylko raz - przed pętlą. Istotnie, jego wartość jest dla każdej iteracji stała.
Powinniśmy więc raczej napisać:

double wspOds  = (interest/100)/12;
while (...) {
    ....
    balance += wspOds*balance + monthIncome - monthExpend;
    ....
}


Przed lekturą dalszego tekstu proszę samodzielnie od podstaw przygotować ostateczną, uwzględniającą uwagi o optymalizacji instrukcji wykonywanych w pętli, wersję metody monthsToBalance i przetestowac jej działanie w klasie Account


Ostatecznie więc metodę monthsToBalance moglibyśmy zapisać następująco.

  public int getMonthsToBalance(double targetBalance) {
    double wspOds = (interest/100)/12;
    double diff = targetBalance - balance;
    int n = 0;
    while (diff > 0) {
      n++;
      balance += wspOds*balance +  monthIncome - monthExpend;
      double prevDiff = diff;
      diff = targetBalance - balance;
      if (diff >= prevDiff) return -1;
    }
    return n;
  }


« poprzedni punkt  następny punkt »