Przetwarzanie danych: napisy i liczby


1. Napisy modyfikowalne

Pewną zaskakującą może właściwością klasy String jest to, że jej obiekty są niemodyfikowalne - to znaczy utworzonych za pomocą klasy String napisów nie możemy zmieniać (np. do napisu dodać inny).
Jak to ? Przecież wielokrotnie zajmowaliśmy się konkatenacją łańcuchów.
No tak, ale wynikowy napis, powstający z dodania do łańcucha znakowego innego napisu, jest nowym obiektem i np. w takiej sekwencji:

String s = "Ala";
s = s + " ma kota";

tworzony jest nowy obiekt, zawierający napis "Ala ma kota" i referencja do niego przypisywana jest zmiennej s, która poprzednio zawierała referencję do napisu "Ala".

Zwróćmy też uwagę, że w klasie String nie ma żadnych metod pozwalających modyfikować istniejący obiekt-napis.

Niemodyfikowalność klas jest zaletą: zwiększa niezawodność programowania.
Czasami jednak zmiany obiektów napisów są potrzebne.

Modyfikowalne obiekty-łańcuchy znakowe definiują klasy StringBuffer i StringBuilder.


Obiekty klas StringBuffer (i StringBuilder) to "bufory", które dynamicznie możemy wypełniać napisami. W szczególności możemy utworzyć pusty - na razie - bufor:

StringBuffer sb = new StringBuffer();

po czym wypelniać go zawartością dopisując do niego jakieś kolejne napisy, znaki lub liczby (znakowe reprezentacje liczb), lun napisowe reprezentacje obiektów:

sb.append("jakiś napis 1");
sb.append("jakiś napis 2");
sb.append(10);
sb.append('\n');
sb.append (new Para(1,2));
...

Metoda append zwraca StringBuffer wobec tego możemy  to samo zapisać jako:

sb.append("jakiś napis 1").append("jakiś napis 2").sb.append(10).sb.append('\n').append(new Para(1,2));

Inne metody klasy StringBuffer pozwalają m.in.  na pobieranie i usuwanie fragmentów napisu, wstawianie napisów, wyszukiwanie itp.

Obiekt klasy StringBuffer łatwo można przekształcić w obiekt klasy String za pomocą metod toString():

String s = sb.toString();

Klasa StringBuilder ma takie same metody jak StringBuffer.

Wykorzystanie klasy StringBuffer lub StringBuilder zamiast String jest wskazane przy dużej liczbie "kumulatywnych" operacji konkatenacji, bo dzięki temu uzyskujemy wielokrotnie wyższą efektywność działania programu (przy naprawdę dużej - powiedzmy ponad 10 tys.-  operacji np. w pętli, użycie klasy String i operatora + jest wręcz niedopuszczalne).

Uwaga: różnica między StringBufferem i StringBuilderem polega tylko na tym, że ten pierwszy zapewnia synchronizację dostępu z równolegle wykonujących się fragmentów kodu, a wobec tego jest nieco mniej efektywny; jednak ta właściwość jest niezbędna w programowaniu współbieżnym.

Różnicę pomiędzy użyciem String i StringBuffera pokazuje poniższy program.
Do mierzenia czasu operacji korzystamy w nim z klasy QTimer, w której zdefiniowaliśmy metodę getElapsed() zwracającą upływ czasu od momentu utworzenia obiektu tej klasy. Czas mierzymy za pomocą statycznej metody klasy System currentTimeMillis(), która zwraca bieżący czas w milisekundach.
import javax.swing.*;

class QTimer {

   private final long start;

   public QTimer() {
     System.gc();
     start = System.currentTimeMillis();
   }

   public long getElapsed() {
      return System.currentTimeMillis() - start;
   }
}

public class Test {


  public static void main(String args[]) throws InterruptedException {

    int n = Integer.parseInt(JOptionPane.showInputDialog("Liczba operacji"));

    // String
    QTimer t = new QTimer();
    String strA = "";
    for (int i = 1; i <= n; i++)  strA += "A";
    long etA = t.getElapsed();
    System.out.println("String operator +;   Czas: " + etA  + " ms");

    // StringBuffer
    t = new QTimer();
    StringBuffer sb = new StringBuffer();
    for (int i = 1; i <= n; i++) sb.append("B");
    sb.toString();  
    long etB = t.getElapsed();
    System.out.println("StringBuffer append. Czas: " +  etB + " ms");  
 
    System.out.println("Wykonano " + n + " operacji.");
    System.out.println("Relacja String/StringBuffer  = " + (double) etA/etB); 
  }
}


Możliwy wynik:
String operator +;   Czas: 58875 ms
StringBuffer append. Czas: 15 ms
Wykonano 90000 operacji.
Relacja String/StringBuffer  = 3925.0


2. Pliki tekstowe

Potrafimy już odczytać informację z pliku tekstowego (zob. punkty nt. czytania informacji z pliku za pomocą skanera w poprzednim materiale). A jak ją zapisać do pliku? I czy skaner to jedyny możliwy sposób?

Bardziej ogólnym i uniwersalnym sposobem jest zastosowanie tzw. klas strumieniowych z pakietu java.io.
O koncepcji tej i jej szczegółach będzie mowa w przyszłym semestrze. Teraz zajmiemy się wycinkiem tej problematyki, skrótowo i niejako czysto "instruktażowo".

Ogólnie, pliki jako ciągi bajtów (powiemy: pliki bajtowe) są w Javie reprezentowane przez obiekty klas strumieniowych FileInputStream (pliki wejściowe - z których wczytujemy dane) i FileOutputStream (pliki wyjściowe - do których zapisujemy dane).

Przygotowanie pliku do przetwarzania przez program nazywa się otwarciem pliku.
W Javie pliki są otwierane automatycznie przy tworzeniu plikowych obiektów strumieniowych (czyli obiektów oznaczających pliki, w tym obiektów wspomnianych wyżej klas FileInputStream i FileOutputStream).
Obiekty-pliki bajtowe możemy tworzyć za pomocą konstruktorów klas FileInputStream i FileOutputStream, podając jako argument nazwę pliku.

Np.
FileInputStream in = new FileInputStream("Program1.java");       // gdy nie podano ściezki,
FileOutputStream out = new FileOutputStream("Program2.java"); // pliki w katalogu roboczym aplikacji
lub

FileInputStream in = new FileInputStream("C:/Test/Program1.java");   // z podaną ściezką
FileOutputStream out = new FileOutputStream("C:\\Test\\Program2.java"); // uwaga na symbol "escape"! 

Z plików bajtowych możemy czytać bajty za pomocą metody int read() i możemy do nich zapisywać bajty za pomocą metody write(int).
Zwróćmy uwagę - bajty doskonale mieszczą się w zmiennej typu byte, ale read() zwraca wartość typu int, gdyż  przy próbie czytania bajtów spoza końca pliku  musi jakoś poinformować o końcu pliku. Umownie zwraca wtedy wartość -1 (typu int), co oczywiście jest zupełnie inną wartością niż wszelkie możliwe wartości bajtów.

Po wykonaniu operacji na pliku powinniśmy plik zamknąć, co np. powoduje ostateczny, fizyczny zapis informacji, być może do tego momentu będącej jeszcze w buforach systemowych oraz inne działania porządkowe na poziomie systemu operacyjnego (niekiedy np. związane z możliwością udostępnienia pliku innym programom, lub z umożliwieniem otwarcia przez nasz program innych plików).

Do zamykania plików służy metoda close().

Możemy teraz napisać program, który - bajt po bajcie - kopiuje dowolny plik wejściowy do dowolnego pliku wyjściowego. Nazwy plików podajemy jako argumenty wywołania programu.

import java.io.*;
import static javax.swing.JOptionPane.*;

public class CopyFile {

  public static void main(String[] args) {

    FileInputStream in = null;   // plik wejściowy
    FileOutputStream out = null; // plik wyjściowy

    try {
      in = new FileInputStream("in1");
      out = new FileOutputStream("out1");
      int c;
      while ((c = in.read()) != -1)  out.write(c);    // kopiowanie
    } catch (ArrayIndexOutOfBoundsException exc) {    // brak argumentu
        System.out.println("Syntax: CopyFile in out");
        System.exit(1);
    } catch (FileNotFoundException exc) {             // nieznany plik
        System.out.println("Plik wejściowy nie istnieje.");
        System.exit(2);
    } catch (IOException exc) {                       // inny błąd wejścia- wyjścia
        System.out.println(exc.toString());
        System.exit(3);
    } finally {                     // zawsze zamykamy pliki
        try {                       // niestety close może zgłosić wyjatek kontrolowany - trzeba użyć try
          if (in != null) in.close();
          if (out != null) out.close();
      } catch (IOException exc) {
          System.out.println(exc.toString());
      }
    }
  }

}

Każdy plik jest sekwencją bajtów. Ale znaczenie bajtów może być bardzo różne. Mogą to być np. binarne reprezentacje jakichś liczb, albo mogą to być znaki (wtedy będziemy mówić o plikach tekstowych).

Zwróćmy jednak uwagę, że w Javie znaki są przedstawiane w Unicodzie (czyli jako wartości dwubajtowe). Jeżeli tekst w pliku zapisany jest w ten właśnie sposób - to nie ma problemu. Ale często pliki tekstowe zapisywane są w różnych systemach kodowania, niekoniecznie w Unicodzie. Sposób kodowania znaków tekstu nazywa się stroną kodową. Np. wiele polskich dokumentów HTML zapisanych jest z wykorzystaniem strony  kodowej ISO8859-2, inne - z wykorzystaniem strony Cp1250 (inaczej zwanej Windows 1250). To oczywiście nie jest Unicode - znaki zajmują 1 bajt.

W każdym systemie operacyjnym  możemy też ustawić tzw. domyślną stronę kodową, która będzie wykorzystywana np. przy wczytywaniu i zapisie plików przez systemowe edytory tekstu. Np. w systemie Windows taką domyślną stroną kodową najczęściej jest - w polskich warunkach - Cp1250.
Przy wczytywaniu Java musi dokonać przekodowania plików zapisanych w domyślnej stronie kodowej na Unicode, a przy zapisie wykonać operację odwrotną - przekodowania z Unicodu do domyślnej strony kodowej.
Metody klas FileInputStream i FileOutputStream - nie wykonują tego zadania (czytają i piszą bajt po bajcie, co w przypadku plików tekstowych może powodować utratę informacji.
Zobaczmy przykład.
Poniższy program.
import java.io.*;

public class ReadBytesAsChars {

 public static void main(String[] args) {
   StringBuffer cont = new StringBuffer();

   try {
     FileInputStream in = new FileInputStream(args[0]);
     int c;
     while ((c = in.read()) != -1) cont.append((char) c);
     in.close();
   } catch(Exception exc) {
       System.out.println(exc.toString());
       System.exit(1);
   }
   String s = cont.toString();
   System.out.println(s);
 }

}
czyta plik tekstowy i zapisuje jego zawartość w łańcuchu znakowym (String), po czym wypisuje na konsoli  ten łańcuch znakowy. Jeśli przeczytaliśmy z pliku zapisanego w Cp1250 następujący tekst:

Początek
pogłębienia
znajomości
Javy

to na konsoli uzyskamy:

Pocz?tek
pog??bienia
znajomo?ci
Javy

Takich strat informacji nie będzie, jeśli do czytania plików wykorzystamy obiekt klasy FileReader, a do zapisywania - FileWriter, bowiem klasy te zapewniają konwersje między domyślną stroną kodową systemu operacyjnego i  Unicodem

Do przetwarzania plików tekstowych należy wykorzystywać klasy FileReader i FileWriter

 
Poprzedni przykład możemy teraz zapisać tak:
import java.io.*;

public class ReadByReader {

 public static void main(String[] args) {
   StringBuffer cont = new StringBuffer();

   try {
     FileReader in = new FileReader(args[0]);
     int c;
     while ((c = in.read()) != -1) cont.append((char) c);
     in.close();
   } catch(Exception exc) {
     System.out.println(exc.toString());
     System.exit(1);
   }
   String s = cont.toString();
   System.out.println(s);
 }

}

Przy przetwarzaniu plików zetkniemy się także z kwestią efektywności.
Np. przy czytaniu dużych plików tekstowych należy unikać bezpośredniego czytania za pomocą klasy FileReader, bowiem każde odczytanie znaku może powodować fizyczne odwołanie do pliku (to samo dotyczy zapisu i klasy FileWriter).
Operacje fizycznych odwołań do pliku (dysku) są czasochłonne.
Aby je ograniczyć - stosujemy tzw. buforowanie.
W pamięci operacyjnej wydzielany jest duży obszar pamięci, który zapełniany jest przez jednorazowe fizyczne odwołanie do pliku. Instrukcje czytania pliku pobierają informacje z tego bufora. Gdy bufor jest pusty - następuje kolejne jego wypełnienie poprzez fizyczne odwołanie do pliku. W ten sposób liczba fizycznych odwołań do pliku (do dysku) jest mniejsza niż liczba zapisanych w programie instrukcji czytania danych.

W Javie do buforowania wejściowych plików tekstowych stosujemy klasę BufferedReader.
Ale klasa ta nie pozwala - przy tworzeniu obiektów - bezpośrednio, w konstruktorze , podawać źródła danych (np. nazwy pliku).
Źródło to podajemy przy tworzeniu obiektu typu FileReader, a po to, żeby uzyskać buforowanie, "opakowujemy" FileReader -  BufferedReaderem.

Wygląda to tak:
// tu powstaje związek z fizycznym źródłem
FileReader fr = new FileReader("plik.txt"); 

// tu dodajemy "opakowanie", umożliwiające buforowanie 
BufferedReader br = new BufferedReader(fr);

//... teraz wszelkie odwołania czytania itp. kierujemy do obiektu br
Dodatkowo w klasie BufferedReader zdefiniowano wygodną metodę czytania wierszy pliku:

        readLine()

która zwraca kolejny wiersz jako String lub null jeśli wystąpił koniec pliku

Zarys czytania:
 try {
   String line;
   FileReader fr = new FileReader(fname); // fname jest nazwą pliku
   BufferedReader br = new BufferedReader(fr);

   while ((line = br.readLine()) != null) { // kolejny wiersz pliku: metoda readLine
      ...
      // tu robimy coś z wierszami pliku
   }
   br.close(); // zamknięcie pliku
 } catch (IOException e) {
     e.printStackTrace();
 } 
i jego realizacja na przykładzie poprzedniego programu:
import java.io.*;

public class BuffRead {

  public static void main(String[] args) {
    
    try {
      BufferedReader in = new BufferedReader(
                              new FileReader("in1"));
      StringBuffer sb = new StringBuffer();
      String line;
      while ((line = in.readLine()) != null) {
        sb.append(line).append('\n');
      }
      in.close();
      System.out.println(sb);
    } catch (IOException exc) {
      exc.printStackTrace();
    }

  }

}
Naturalnie, do czytania plików tekstowych zapisanych w domyślnej stronie kodowej nieco wygodniejszy jest Scanner.
Przypomnijmy schemat:
import java.io.*;
import java.util.*;

public class ScanRead {

  public static void main(String[] args) {
    StringBuffer sb = new StringBuffer();
    try {
      Scanner scan = new Scanner(new File("in1"));
      while (scan.hasNextLine()) {
        sb.append(scan.nextLine()).append('\n');
      }
      scan.close();
      System.out.println(sb);
    } catch (FileNotFoundException exc) {
      exc.printStackTrace();
    }

  }

}
Ale do zapisywania plików musimy używać klas strumieniowych.
Buforowany zapis plików tekstowych uzyskać można przez opakowanie obiektu klasy FileWriter w obiekt klasy BufferedWriter. Do zapisu tekstu używamy metody write(String). Dodatkowo klasa BuffredWriter posiada wygodną metodę newLine(), która dopisuje właściwy dla danej platformy systemowej znak końca wiersza (0d0aX w Windows, 0aX - Unix).

Przykład: kopiowanie plików tekstowych z buforowaniem odczytu i zapisu.
public class BuffTextFileCopy {

  public static void main(String[] args) {
    BufferedReader in = null;
    BufferedWriter out = null;
    
    try {
      in = new BufferedReader(new FileReader("in1"));
      out = new BufferedWriter(new FileWriter("out1"));
      String line;
      while ((line = in.readLine()) != null) {
        out.write(line);
        out.newLine();
      }
    } catch (IOException exc) {
      exc.printStackTrace();
    } finally {
      try {
        if (in != null) in.close();
        if (out != null) out.close();
      } catch(IOException exc) {
        exc.printStackTrace();
      }
    }

  }

}
Przy zapisie buforowanych plików ważne jest, aby po zakończeniu zapisu zawsze je zamykać. W przeciwnym razie część (lub całość) informacji może pozostać w buforze i nie trafić do pliku. Dlatego zamknięcie plików umieściliśmy w klauzuli finally, która wykona się zawsze  bez względu na to czy powstaną jakieś wyjątki czy nie.

Na zakończenie tego krótkiego wprowadzenia do przetwarzania plików tekstowych warto zwrócić uwagę na następujące kwestie:
File file = new File("tekst.txt");

// załóżmy, że plik jest zapisany w stronie kodowej ISO8859-2
// normalnie skaner dokonuje dekodowania do domyślnej strony kodowej na danej platformie
// jeśli nie jest to ISO8859-2, to plik nie zostanie właściwie odczytany
// możemy jednak podać z jakiej strony kodowej ma być dekodowanie

Scanner scan = new Scanner(file, "ISO8859-2");

// i teraz zawartość pliku będzie włąsciwie przekształcona do Unicodu
   


3. Metody klasy String

W praktycznych programach bardzo często będziemy operować na łańcuchach znakowych (napisach). Wiemy doskonale, że są one reprezentowane przez obiekty klasy String. W klasie tej znajdziemy wiele użytecznych metod przeznaczonych do operowania na łańcuchach znakowych.

Dokumentację klas i ich metod standardowych pakietów Javy znajdziemy w podkatalogu docs katalogu instalacyjnego Javy. Jest ona w postaci HTML: klasy podzielone są według pakietów a także dostępna jest alfabetyczna lista wszystkich klas.

Dla wygody poniżej przedstawiono wybrane metody klasy String.
Zwróćmy uwagę, że:


Wybrane metody klasy String
charcharAt(int index)
Zwraca znak na pozycji, oznaczonej indeksem index. Pierwsza pozycja ma indeks 0.
intcompareTo(String anotherString)
Porównuje dwa napisy: ten (this) na rzecz którego użyto metody oraz przekazany jako argument.
Metoda zwraca 0, gdy napisy są takie same.
Jeżeli się różnią, to - gdy występują w nich różne znaki - zwracana jest wartość:
this.charAt(k) - anotherString.charAt(k),
gdzie k - indeks pierwszej pozycji, na której występuje różnica znaków. Jeżeli długość napisów jest różna (a znaki napisów są takie same w części określanej przez dlugośc krótszego napisu) - zwracana jest różnica dlugości:
this.length() - anotherString.length().

Oznacza to, że wynik jest ujemny, gdy ten (this) łańcuch poprzedza leksykograficznie (alfabetycznie) argument (anothetString) oraz dodatni - gdy ten łańcuch jest leksykograficznie większy od argumentu.
intcompareToIgnoreCase(String str)
Porównuje leksykograficznie dwa napisy, bez rozróżnienia małych i wielkich liter.
booleanendsWith(String suffix)
Zwraca true, gdy napis kończy się łańcuchem znakowym podanym jako argument, false - w przeciwnym razie.
booleanequals(Object anObject)
Zwraca true gdy anObject jest takim samym co do zawartości napisem jak ten napis; w każdym innym przypadku - zwraca false.
booleanequalsIgnoreCase(String anotherString)
J.w. - ale bez rozróżniania małych i wielkich liter.
intindexOf(String str)
Zwraca indeks pozycji pierwszego wystąpienia w danym napisie napisu podanego jako argument str; jeżeli str nie występuje w tym napisie - zwraca -1
intindexOf(String str, int fromIndex)
Poszukuje pierwszego wystąpienia napisu str poczynając od pozycji oznaczonej przez indeks fromIndex; zwraca indeks pozycji na której zaczyna się str lub - 1 gdy str nie występuje w tym napisie.
Jeśli fromIndex jest ujemne lub zero - przeszukiwany jest cały napis; jeśli fromIndex jest większe od długości napisu - zwracane jest -1.
intlastIndexOf(String str)
Jak indexOf - ale zwracany jest indeks pozycji ostatniego wystąpienia.
intlastIndexOf(String str, int fromIndex)
J.w.
Uwaga: metody indexOf i lastIndexOf mają również swoje wersje dla argumentów - znaków (typu char).
intlength()
Zwraca długość napisu.
Stringreplace(char oldChar, char newChar)
Zwraca nowy obiekt klasy String, w którym zastąpiono wszystkie wystąpienia znaku oldChar na znak newChar.
Stringreplace(CharSequence target, CharSequence replacement)
Zwraca nowy obiekt klasy String, w którym zastąpiono wszystkie wystąpienia podnapisu target na napis replacement.
StringreplaceAll(String regex, String replacement)
Zwraca nowy obiekt klasy String, w którym zastąpiono wszystkie wystąpienia podnapisów pasujących do wzorca podanego przez wyrażenie regularne regex na napis target.
Stringsplit(String regex)
Rozkłada napis na jego podnapisy rozdzielone dowolnymi separatorami, pasującymi do wzorca regex.
booleanstartsWith(String prefix)
Zwraca true, gdy napis zaczyna się podanym jako argument łańcuchem znakowym; false - w przeciwnym razie.
booleanstartsWith(String prefix, int toffset)
Zwraca true, gdy podłańcuch tego łańcucha znakowego zaczynający się na pozycji o indeksie toffset zaczyna się napisem prefiks; zwraca false w przeciwnym razie, lub gdy toffset jest < 0 albo większy od dlugości napisu.
Stringsubstring(int beginIndex)
Zwraca podłańcuch tego łańcucha znakowego zaczynający się na pozycji o indeksie beginIndex (do końca łańcucha).
Stringsubstring(int beginIndex, int endIndex)
Zwraca podłańcuch tego łańcucha jako nowy obiekt klasy String. Podłańcuch zaczynay się na pozycji o indeksie beginIndex, a kończy (uwaga!) - na pozycji o indeksie endIndex-1. Długość podlańcucha równa jest endIndex - beginIndex.
char[]toCharArray()
Znaki łańcucha -> do tablicy znaków (typ char[]).
StringtoLowerCase()
Zamiana liter na małe.
StringtoUpperCase()
Zamiana liter na duże.
Stringtrim()
Usuwa znaki spacji, tabulacji, końca wiersza itp. tzw. biale znaki z obu końców łańcucha znakowego. Zwraca wynik jako nowy łańcuch.
static StringvalueOf(boolean b)
Zwraca wartość boolowską (boolean) jako napis (String).
static StringvalueOf(char c)
Zwraca wartość typu char jako napis.
static StringvalueOf(char[] data)
Zwraca napis złożony ze znakow tablicy.
static StringvalueOf(double d)
Zwraca znakową treprezentację liczby typu double.
static StringvalueOf(float f)
Zwraca znakową treprezentację liczby typu float.
static StringvalueOf(int i)
Zwraca znakową treprezentację liczby typu int.
static StringvalueOf(long l)
Zwraca znakową reprezentację liczby typu long.

W pierwszym przykładowym programie wykorzystamy metodę charAt(), zwracającą znak znajdujący się w napisie na podanej pozycji oraz emetody length() i equals().

rProblem: napisać program, który prosi użytkownika o wybranie jednej z możliwych wycieczek oznaczanych dużymi literami A, B, C ..., po czym podaje cenę tej wycieczki. Miejsca docelowe wycieczek oraz ich ceny mają być zapisane w tablicach, np.:

String[] dest = { "Bali", "Cypr", "Ibiza", "Kenia", "Kuba" };
double[] price = { 5000, 2500, 2800, 4500, 6000 };

a program winien dawać użytkownikowi możliwość wyboru za pomocą pokazanego obok okna dialogowego.

A zatem użytkownik wprowadza napis, składający się z jednej litery "A" lub "B" lub "C', ... itd.
Dalsze działanie programu zależy od tego jaką literę wprowadzil.
Jeśli res oznacza wprowadzony napis, to moglibyśmy np. napisać:

if (res.equals("A")) System.out.println(dest[0] + " - cena: " + price[0]);
else if (res.equals("B")) System.out.println(dest[1] + " - cena: " + price[1]);
else if (res.equals("C")) ..
else if (res.equals("D")) ...
else if (res.equals("E")) ..
else ...

Ale jest to dość uciążliwe i nieeleganckie. Narażone na błędy. Trudne do modyfikacji.

A przecież wprowadzona litera daje nam natychmiastowe odniesienie do odpowiednich elementów tablic dest i price. Litera to znak. Znak ma swój kod. Kod jest liczbą. Łatwo jest więc przekształcić znaki w odpowiednie indeksy tablic.
Znak A powinien dać indeks 0, znak B - indeks 1, znak C - indeks 2.
Zauważmy, że: 'A' - 'A' = 0 , 'B'- 'A' = 1, 'C' - 'A' = 2 ...
Zatem wyliczenie odpowiedniego indeksu można zapisac tak:
indeks = <wprowadzony_znak> - 'A'

No, ale musimy jeszcze sięgnąć po ten znak. Z dialogu dostajemy napis (łańcuch znakowy). To jest dana typu String, a nie char. Napis ten składa się z jednego znaku, znajdującego się na pierwszej pozycji łańcucha (czyli pod indeksem 0). Znak ten otrzymamy stosując metodę charAt z klasy String.

Jeśli res oznacza wprowadzony napis, to - zamiast poprzedniej "piętrowej" konstrukcji if-else możemy po prostu napisać:

int i = res.charAt(0) - 'A';
System.out.println(dest[i] + " - cena: " + price[i]);


Cały program pokazano poniżej.
import static javax.swing.JOptionPane.*;

public class Wycieczki {

  public static void main(String[] args) {

    String[] dest  = { "Bali", "Cypr", "Ibiza", "Kenia", "Kuba" };
    double[] price = { 5000, 2500, 2800, 4500, 6000 };

    String msg = "Wybierz kierunek - " +
                 " wpisując literę A-"+ (char) ('A'+dest.length-1)+ ":\n";

    for (int i=0; i < dest.length; i++)
      msg += (char) ('A' + i) + " - " + dest[i] + '\n';

    String res;
    while ((res = showInputDialog(msg)) != null) {
      if (res.length() == 1) { 
        int i = res.toUpperCase().charAt(0) - 'A';
        if (i < 0 || i > dest.length -1) continue;
        showMessageDialog(null, dest[i] + " - cena: " + price[i]);
      }
    }
  }

}
Zobacz demo programu.

Dodatkowe komentarze:

Spróbujmy teraz rozwiązać inne zadanie. Wyobraźmy sobie, że mamy dokument html o prostej strukturze, w którym kolejne tytuły punktów treści znajdują się między znacznikami <h2> ... </h2> (tekst zawarty pomiędzy otwierającym znacznikiem <h2> i zamykającym znacznikiem </h2> - traktowany jest jako nagłówek drugiego poziomu i odpowiednio do tego formatowany przy wyświetlaniu w przeglądarce).
Naszym zadaniem jest odnalezienie wszystkich takich nagłówków i stworzenie z nich tekstu, który zawiera każdy tytuł w nowej linii.
Niech klasa realizująca to zadanie (trochę na wyrost) nazywa się Toc. Przy tworzeniu obiektu tej klasy przekazujemy konstruktorowi cały dokument HTML w postaci łańcucha znakowego, a wywołanie metody String getToc() - ma zwrócić napis ze wszystkimi nagłówkami drugiego poziomu, rozdzielonymi separatorami nowego wiersza. Otrzymaną "listę"  możemy następnie wypisać na konsoli lub zapisać do pliku. 

Jak podejść do tego problemu?
W tekście dokumentu musimy kolejno znajdować początki nagłówków ("<h2>"), a następnie "wyłuskiwać" podłańcuchy, które są zawarte pomiędzy znacznikami "<h2>" i "</h2>". Do znajdowania napisów w napisie służy metoda indexOf, do "wyłuskiwania" podłacuchów metod substring. Uwaga: powinniśmy zastosować tę wersję metody indexOf, która zaczyna poszukiwanie od podanej pozycji łańcucha i wraz z postępem przeszukiwania odpowiednio zmieniać tę pozycję.

Klasa Toc może wyglądać tak.
package toc;

public class Toc {

   private String doc;           // przekazany dokument
   private String toc = "";      // wynikowy spis treści

   // separator końca wiersza; ponieważ jest zależny od systemu
   // pobieramy go jako wartość tzw. właściwości systemowej

   private final String ls = System.getProperty("line.separator");

   public Toc(String doc) { // Konstruktor
     this.doc = doc;
   }

  public String getToc() throws IllegalStateException {

     int p = 0; // pozycja od której zaczynamy szukanie "<h2>"

     while ((p = doc.indexOf("<h2>", p)) != -1) { // dopóki są "<h2>"

       // poszukajmy znacznika zamykającego
       // end jest indeksem pozycji na której on występuje

       int end = doc.indexOf("</h2>", p+4);

       // jeżeli go nie ma ...

       if (end == -1) throw new IllegalStateException("Invalid document structure");

       // w przeciwnym razie: wyłuskujemy nagłówek
       toc += doc.substring(p+4, end) + ls;  // ls - separator wierszy

       p = end + 5;  // i przesuwamy pozycję od której będziemy dalej szukać
     }

     return toc;
  }
}
Uwaga: w przypadku braku zamykającego znacznika </h2> zgłaszany jest wyjątek IllegalStateException. Jest on pochodny od klasy RuntimeExecption, więc wołający metodę getToc() może go obsługiwać bądź nie. Mimo  to w celach dokumentacyjnych wymieniono wyjątek w klauzuli throws. Ta wersja programu jest oczywiście niedoskonała -  nie obsługuje przypadku wadliwej struktury dokumentu, gdy znacznik <h2> występuje pomiędzy znacznikami <h2> i </h2>.

Przy okazji tego programu wykorzystaliśmy możliwość pobierania właściwości systemowych. Za pomocą odwołania:

System.getProperty("line.separator")

zapytaliśmy Javy jaki na danej platformie systemowej obowiązuje separator wierszy (w plikach), Zauważmy, że różne systemy stosują różne separatory (np. pod Unixem jest to 0a , a pod Windows 0d0a - szesnastkowo). Ponieważ nasz spis treści chcemy zapisywać do pliku to musimy użyć znaku separatora wierszy. A zgodnie z założeniami Javy (wieloplatformowość) powinniśmy przygotować program tak by działał bez rekompilacji na każdej platformie. Nb za pomocą metody getProperty z klasy System możemy pobierać różne inne właściwości systemowe (zob. ich spis w dokumentacji Javy).

Do testowania klasy Toc można wykorzystać następujący programik, który wczytuje dokument HTML z pliku, a spis treści zapisuje do innego pliku.
import java.io.*;
import java.util.*;
import static javax.swing.JOptionPane.*;

public class MainToc {

  public static void main(String[] args) throws Exception {
    Scanner fnameScan = new Scanner(showInputDialog("in out ?"));
    File in = new File(fnameScan.next());
    BufferedWriter out = new BufferedWriter(new FileWriter(fnameScan.next()));
    Scanner inScan = new Scanner(in);
    StringBuffer sb = new StringBuffer();
    try {
      while (inScan.hasNextLine())
        sb.append(inScan.nextLine());
      inScan.close();
      Toc toc = new Toc(sb.toString());
      out.write(toc.getToc());
    } finally {
      out.close();
    }
  }

}
Nazwy plików sa pobierane z dialogu wejściowego.  Skaner fnameScan wyróżnia je z tekstu wpisanego w dialogu, skaner inScan czyta wiersze pliku wejściowego i tworzy z nich jeden długi tekst w StringBufferze. Tekst ten jest przekazywany klasie Toc, a otrzymany wynikowy spis treści zapisywany do pliku wyjściowego. Warto zwrócić uwagę na obsługę wyjatków. Metoda main wymienia klasę Exception w klauzuli throws, co zwalnia  z ich obsługi w kodzie. Mimo to, zastosowano blok try, tym razem bez żadnego catch, ale za to z finally, po to by zapewnić zamknięcie pliku wyjściowego w każdej sytuacji (czy powstał jakiś wyjątek czy nie).


4. Rozbiór tekstów i StringTokenizer


Wyobraźmy sobie np., że łańcuch znakowy zawiera napisy reprezentujące liczbę całkowitą, dowolną (>1) liczbę spacji, znak operacji arytmetycznej, dowolną (>1) liczbę spacji i napis reprezentujący drugą liczbę całkowitą. Naszym zadaniem jest "wyłuskanie" z całego napisu napisów-liczb, przekształcenie ich do postaci binarnej i wykonanie na nich podanej operacji.
r
Napis taki może wyglądać następująco: "21 + 21"

Wyłuskiwane symbole są rozdzielone separatorami.
W przykładzie z liczbami separatorami będą spacje.
Dowolny ciąg znaków, który nie zawiera spacji - będzie symbolem.
Dowolna liczba spacji (>1)będzie oddzielać symbole od siebie.

Do wyłuskiwania symboli z łańcuchów znakowych można użyć klasy StringTokenizer z pakietu java.util.
Po to by dokonać rozbioru tekstu - tworzymy obiekt klasy StringTokenizer, podając jako argument konstruktora - tekst do rozbioru np.

String expr = "21 + 21";
StringTokenizer st = new StringTokenizer(expr);
Ta postać konstruktora zaklada domyślnie, że separatorami są znaki z następujacego zestawu " \t\n\r\f" (czyli znak spacji, tabulacji, przejścia do nowego wiersza, powrotu karetki, nowej strony - tzw. "Białe znaki"). W tym przypadku symbolami będą ciągi znaków, które nie zawierają żadnego z wymienionych separatorów.

Obiektu klasy StringTokenizer możemy teraz zapytać o to ile symboli zawiera przekazany konstruktorowi napis:
int n = st.countTokens(); // n = 3

Wyłuskiwanie symboli odbywa się sekwencyjnie poczynając od początku łańcucha. Służy temu metoda nextToken() , która zwraca kolejny symbol jako String. Pierwsze wywołanie tej metody zwróci pierwszy symbol, następne - będą zwracać kolejne symbole łańcucha.
String s1 = st.nextToken(); // napis "21"
String s2 = st.nextToken(); // napis "+"
String s3 = st.nextToken(); // napis "21"
Gdy nie ma już symboli "do zwrotu" - wywołanie nextToken() spowoduje powstanie wyjątku NoSuchElementException.

Zatem zawsze przeglądamy łańcuch od początku i "wyłuskując" symbole przesuwamy się do jego końca (uwaga: kolejne symbole są zwracane, ale oczywiście - nie są usuwane z łańcucha).

Do stwierdzenia, czy w łańcuchu znakowym są jeszcze symbole do zwrotu służy metoda hasMoreTokens().
Metoda hasMoreTokens() zwraca true, jeśli w łańcuchu znakowym są jeszcze nie "wyluskane" symbole i false w przeciwnym razie. Inaczej mówiąc: hasMoreTokens() zwraca true, jeśli następne odwołanie do nextToken() zwróci kolejny symbol i zwraca false, gdy ew. następne odwołanie do nextToken() spowoduje wyjątek NoSuchElementException.

Łatwo zapisać pętle, w której będziemy pobierać kolejne symbole:
while (st.hasMoreTokens()) {
  Sring s = st.nextToken();
  // ... tu coś robimy z s
}

Wiedząc to wszystko można teraz napisać program, który w oknie dialogowym prosi użytkownika o wprowadzeia liczby całkowitej, spacji, operatora, spacji i drugiej liczby i wykonuje żądaną operację arytmetyczną na tych dwóch liczbach. Jeśli użytkownik wprowadził wadliwe dane, to program prosi go o ich poprawienie. Pierwsza wersja tego programu może wyglądać tak:
import java.util.*;
import static javax.swing.JOptionPane.*;

public class Oper {

  public static void main(String[] args) {

    String normalQuest = "Liczba1 op Liczba2",     // normalny komunikat
           errorQuest = "Wadliwe dane. Popraw.\n", // komunikat w przypadku błędu
           quest = normalQuest;

    String expr = "";                              // wyrażenie do obliczenia 

    while ((expr = showInputDialog(quest, expr)) != null) {

      StringTokenizer st = new StringTokenizer(expr);

      if (st.countTokens() != 3) {  // jeżeli za mało lub za dużo symboli
          quest = errorQuest;
          continue;
      }

      String snum1 = st.nextToken(),   // pierwsza liczba (napisowo)
             sop  = st.nextToken(),    // symbol operatora
             snum2 = st.nextToken();   // druga liczba (napisowo)
      
      int num1 = 0, num2 = 0, res = 0; // liczbt do obliczeń i wynik 
      
      try {
        num1 = Integer.parseInt(snum1);
        num2 = Integer.parseInt(snum2);
      } catch (NumberFormatException exc) { // jeżeli napisy nie są liczbami całkowitymi
          quest = errorQuest;               // komunikat o błędzie 
          continue;
      }
      
      char op = sop.charAt(0);
      
      // jezeli napis oznaczający operator za długi (np. ktoś wprowadził +*)
      // lub gdy w ilorazie dzialnik jest zerem - błąd
      if (sop.length() != 1 || (op == '/' && num2 == 0)) {  
        quest = errorQuest;
        continue;
      }

      switch (op) {
        case '+' : res = num1 + num2; break;
        case '-' : res = num1 - num2; break;
        case '*' : res = num1 * num2; break;
        case '/' : res = num1 / num2; break;
        default: {                           // wadliwy operator
          quest = errorQuest;
          continue;
        }
      }
      showMessageDialog(null, "Wynik = " + res);
      quest = normalQuest;
      expr = "";            // w kolejnym dialogu inicjalny tekst ma być pusty
    }

  }

}
Uwaga: w programie zastosowano wersję metody showInputDialog, w której drugi argument oznacza inicjalny napis umieszczony w polu tekstowym dialogu. Normalnie będzie to pusty tekst, ale w przypadku błędu umieścimy w polu tekstowym błędny napis, tak aby od razu można go było poprawić.

Na marginiesie warto zauważyć, że ten program można napisać znacznie krócej, korzystając ze zgłaszania wyjątków i ich uogólnionej obsługi, co pokazuje poniższy kod.
import java.util.*;
import static javax.swing.JOptionPane.*;

public class Oper1 {

  public static void main(String[] args) {

    String normalQuest = "Liczba1 op Liczba2",
           errorQuest = "Wadliwe dane. Popraw.\n" + normalQuest,
           quest = normalQuest,
           expr = "";

    while ((expr = showInputDialog(quest, expr)) != null) {
      int res;
      try {
        StringTokenizer st = new StringTokenizer(expr);
        int num1 = Integer.parseInt(st.nextToken());
        String sop = st.nextToken();
        int num2 = Integer.parseInt(st.nextToken());
        
        if (sop.length() != 1 || st.hasMoreTokens()) 
          throw new IllegalArgumentException();
      
        
        switch (sop.charAt(0)) {
          case '+' : res = num1 + num2; break;
          case '-' : res = num1 - num2; break;
          case '*' : res = num1 * num2; break;
          case '/' : res = num1 / num2; break;
          default  : throw new IllegalArgumentException();
        }
      } catch (Exception exc) {
        quest = errorQuest;
        continue;
      }
      showMessageDialog(null, "Wynik = " + res);
      quest = normalQuest;
      expr = "";
    }
    
  }

}
Zobacz demo programu

Tutaj, w przypadku za małej liczby symboli powstaje wyjątek NoSuchElementException. Jeśli napisy-argumenty nie dają się zinterpretować jako liczby całkowite - NumberFormatExcepetion. Gdy mamy dzielenie przez zero - ArithmeticException. Inne błędy (za dużo symboli, za długi napis na oznaczenie operatora, nieznany operator) sygnalizujemy sami zgłaszając wyjątek IllegalArgumentException. Wszystkie te wyjątki (a zatem błędy) są obsługiwane w jednym miejscu i kod jest bardziej zwarty, logiczny.  

Inna postać konstruktora klasy StringTokenizer pozwala na określenie zbioru separatorów, które będą służyć do wyróżniania symboli.
Jeżeli napiszemy np.

StringTokenizer st = new StringTokenizer(s, "., " - to sepaartorami będą kropka, przecinek i spacja.

Zadanie:
Stworzyć klasę Words, której obiekty będą zawierać tablicę słów napisu przekazanego jako argument konstruktora. Za słowa uznajemy ciągi znaków rozdzielonych spacjami i znakami interpunkcji.
W klasie dostarczyć metod:
getWordsCount() - zwraca liczbę słów
getWord(int i) - zwraca i-te słowo napisu (i =1,2, ... n; gdzie n liczba słów w napisie)
getWords() - zwraca tablicę słów
getMaxLenWord() - zwraca najdłuższe słowo
getMinLenWord() - zwraca najkrótsze słowo

Możliwe rozwiązanie:

import java.util.*;

public class Words {

  private String[] words;      // tablica slów
  private String maxLenWord;   // słowo o max długości
  private String minLenWord;   // słowo o minimalnej długości

  // Konstruktor
  public Words(String txt) throws IllegalArgumentException {

    // Sprawdzamy czy przekazano właściwy argument
    if (txt == null) 
      throw new IllegalArgumentException("Wadliwy argument konstruktora Words: null"); 

    // Uwzględniamy bogaty zestaw separatorów słów
    StringTokenizer st = new StringTokenizer(txt, " \t\n\r\f.,:;()[]\"'?!-{}");
     
    int n = st.countTokens();      // ile słów?
    if (n == 0) 
      throw new IllegalArgumentException("Wadliwy argument konstruktora Words: napis nie zawiera słów");
    
    words = new String[n];         // utworzenie tablicy słów
    words[0] = st.nextToken();     // pierwsze słowo
    int maxL = words[0].length(),  // max i min długość (na razie = długości pierwszego słowa)
        minL = maxL;                          

    int i = 1;                     // kolejny indeks w tablicy

    while (st.hasMoreTokens()) {   // dopóki są slowa
      String s = st.nextToken();
      int len = s.length();
      if (len > maxL) {            // maksymalna długość ?
         maxL = len;
         maxLenWord = s;
      }
      if (len < minL) {            // minimalna długość ?
        minL = len;
        minLenWord = s;
      }
      words[i++] = s;              // slowo -> do tablicy; zwiększenie indeksu
    }
  }

  // Zwraca liczbę słów
  public int getWordsCount() {
    return words.length;
  }

  // Zwraca i-te słowo (liczymy od 1)
  // jeśli podano wadliwy indeks - zwraca null
  public String getWord(int i) {
    return (i < 1 || i > words.length) ? null : words[i-1];
  }

  // Zwraca tablicę slów
  public String[] getWords() {
    return words;
  }

  // Zwraca slowo o max długości
  public String getMaxLenWord() {
    return maxLenWord;
  }
  // Zwraca slowo o min długości
  public String getMinLenWord() {
    return minLenWord;
  }


}
I klasa testująca:
import static javax.swing.JOptionPane.*;

public class TestWords {

  public static void main(String[] args) {
    String txt;
    while ((txt = showInputDialog("Wpisz tekst")) != null) {
      Words w = new Words(txt);
      int n = w.getWordsCount();
      System.out.println("Liczba słów: " + n);
      System.out.println("Kolejne slowa: ");
      for (int i=1; i <= n; i++) System.out.println(w.getWord(i));
      int iw = Integer.parseInt(showInputDialog("Podaj numer słowa:"));
      System.out.println("Słowo o numerze " + iw + ": " + w.getWord(n+1));
      System.out.println("Kolejne slowa: ");
      String[] words = w.getWords();
      for (String wrd : words) System.out.println(wrd);
      System.out.println("Najdluższe słowo: " + w.getMaxLenWord());
      System.out.println("Najkrótsze słowo: " + w.getMinLenWord());
    }
  }

}
Zobacz demo działania programu.

Java udostępnia również szereg innych sposobów składniowej analizy tekstów. Klasa StreamTokenizer (podobna do StringTokenizer) dostarcza nieco bardziej zaawansowanych sposobów rozbioru tekstu, ale wymaga operowania na strumieniach.
Gdy mamy do czynienia z dokumentami HTML - możemy zastosować parsery HTML (ze standardu Javy lub zewnętrzne). Do przetwarzania dokumentów XML standardowa wersja Javy przeznacza  bogate i różnorodne środki.

Bardzo mocne i elastyczne możliwości analizy składniowej dają też wyrażenia regularne.


5. Wyrażenia regularne

W dokumentacji klasy StringTokenizer można przeczytać, że do rozbioru tekstów lepiej jest stosować metodę split z klasy String.
Wywołanie:

    txt.split(sep);

zwraca tablicę symboli napisu txt rozdzielonych separatorami pasującymi do wzorca podanego przez napis - wyrażenie regularne sep.

Trzeba więc pamiętać o tym, że w metodzie split podajemy jako argument wyrażenie regularne. Niezbędna jest zatem wiedza o składni i znaczeniu wyrażeń regularnych. Bez tego łatwo jest wpaść w pułapkę i otrzymać nieoczekiwane wyniki.
Przykładowe różnice w działaniu StringTokenizera i  metody split pokazuje poniższa tablica.

Tekst
txt
Separator
sep
StringTokenizer st =
     new StringTokenizer(txt, sep)
String[] s = txt.split(sep)
Liczba
symboli:
st.countTokens()

Wyróżnione
symbole:
st.nextToken()
 
Liczba
symboli:
s.length
Wyróżnione
symbole:
s[i]
1"ala ma kota i psa"" "50: Ala
1: ma
2: kota
3: i
4: psa
50: Ala
1: ma
2: kota
3: i
4: psa
2"ala ma kota  i  psa"" "50: Ala
1: ma
2: kota
3: i
4: psa
70: Ala
1: ma
2: kota
3:
4: i
5:
6: psa
3"Pierwszy.Drugi.Trzeci""."30: Pierwszy
1: Drugi
2: Trzeci
0


W przypadku (1) wyniki są identyczne, ale podobieństwo jest mylące. Przypadek (2) pokazuje, że split traktuje separator bardzo dosłownie: ma to być jedna spacja. W tekście wyszukiwane są podnapisy ograniczane separatorem (jedną spacją) lub końcem wiersza. Dlatego, jako wyróżnione  symbole pojawią się (na pozycji 3 i 5) puste podnapisy (np. podnapis "kota" na poz. 2 ograniczony jest spacją, za nią jest jeszcze jedna spacja - pomiędzy tymi dwoma spacjami znajdzie się więc element - pusty podnapis).  Po to, by uzyskać zamierzony (taki sam jak przy użyciu StringTokenizera) efekt trzeba w split podać wyrażenie regularne "jedna lub więcej spacji", co zapisujemy za pomocą tzw. kwantyfikatora  +: " +".
String[] s = txt.split(" +");
da w wyniku tablicę wszystkich podnapisów napisu txt rozdzielonych co najmniej jedną spacją.

W przypadku (3) StringTokenizer bardzo ładnie rozbił podany tekst na separatorze "." (kropka), natomiast metoda split nie wyróżniła żadnych symboli. Stało się tak dlatego, że kropka w składni wyrażeń regularnych ma specjalne znaczenie (dowolny znak), wobec tego w tekście nie ma żadnego podnapisu zakończonego separatorem (są tylko same separatory). Aby użyć w  wyrażeniu regularnym znaku, który ma specjalne znaczenie należy go poprzedzić odwrotnym ukośnikiem, przy czym ze względu na to, że odwrotny ukośnik ma w zapisie Stringów znaczenie "symbolu ucieczki" musimy zapisać go literalnie jako dwa odwrotne ukośniki.
Zatem dopiero poprzez:
String[] s = txt.split("\\."); 
uzyskamy tablicę podnapisów napisu txt rozdzielonych kropką.

W  prostych przypadkach użycie StringTokenizera może się wydać wygodniejsze, ale jest wiele sytuacji, w których za jego pomocą nie sposób osiągnąć wymaganego efektu i wtedy wyrażenia regularne mogą okazać się bardzo pomocne.
Na pewno więc warto się z nimi zaznajomić. Tutaj przedstawione zostanie tylko kilka ogólnych informacji wprowadzających w to zagadnienie, a także krótkie praktyczne przykłady. Więcej szczegółów można uzyskać z dokumentacji.


Regularne wyrażenie stanowi opis wspólnych cech (składni) zbioru łańcuchów znakowych


Możemy sobie wyobrażać, że regularne wyrażenie jest pewnym wzorcem, który opisuje jeden lub wiele napisów, pasujących do tego wzorca. Wzorzec taki zapisujemy za pomocą specjalnej składni wyrażeń regularnych.

Najprostszym wzorcem jest po prostu sekwencja znaków, które nie mają specjalnego znaczenia (sekwencja literałów).
Np. wyrażenie regularne abc stanowi wzorzec opisujący trzy występujące po sobie znaki: a, b, i c. Wzorzec ten opisuje jeden napis "abc".

We wzorcach możemy stosować znaki specjalne (tzw. metaznaki) oraz tworzone za ich pomocą konstrukcje składniowe. Do znaków specjalnych należą:

$^.*
+?[]
(
}
\
Uwagi:
  1. jeśli chcemy traktować znaki specjalne jako literały - poprzedzamy je odwrotnym ukośnikiem \.
  2. w niektórych konstrukcjach składniowych metaznaki tracą specjalne znaczenie i są traktowane literalnie.
Za pomocą znaków specjalnych i tworzonych za ich pomocą bardziej rozbudowanych konstrukcji składniowych opisujemy m.in.
Wyrażeń regularnych możemy użyć m.in. do:
W Javie do najogólniejszego posługiwania się wyrażeniami regularnymi służą klasy pakietu java.util.regex: Pattern i Matcher.

Przed zastosowaniem wyrażenia regularnego do składniowej analizy jakiegoś napisu musi ono być skompilowane. Obiekty klasy Pattern reprezentują skompilowane wyrażenia regularne, a obiekty te uzyskujemy za pomocą statycznych metod klasy Pattern - compile(...), mających za argument tekst wyrażenia regularnego.
Obiekty klasy Matcher wykonują operacje wyszukiwania  w tekście za pomocą interpretacji skompilowanego wyrażenia regularnego i dopasowywania go do tekstu lub jego części.
Obiekt-matcher jest zawsze związany z danym wzorcem. Zatem uzyskujemy go od obiektu-wzorca za pomocą metody matcher(...)  klasy Pattern, podając jako jej argument przeszukiwany tekst. Następnie możemy dokonywać różnych operacji przeszukiwania i zastępowania tekstu poprzez użycie różnych metod klasy Matcher.
W szczególności:
Do rozbioru tekstów służą natomiast metody split(...) z klasy Pattern.

Przykłady.

A. Metoda matches() stwierdza czy cały tekst pasuje do wzorca.
import java.util.regex.*;

public class Sample1 {

  public static void main(String[] args) {
    // Wzorzec: jedno lub więcej wystąpień dowolnej cyfry
    String regex = "[0-9]+";

    // Kompilacja wzorca
    Pattern pattern = Pattern.compile(regex);

    // Tekst wejściowy 
    String txt = "196570";
    
    // Uzyskanie matchera
    Matcher matcher = pattern.matcher(txt);

    // Czy tekst pasuje do wzorca?

    boolean match = matcher.matches();

    System.out.println("Tekst: " + txt + '\n' + 
                       (match ? " " : " NIE ") + "pasuje do wzorca: " + regex);

    // Nowy tekst wejściowy
    txt = "123 996";
    // reset matchera "zeruje" jego stany i pozwala też na podanie nowego tekstu
    matcher.reset(txt);
    match  = matcher.matches();
    System.out.println("Tekst: " + txt + '\n' + 
        (match ? " " : " NIE ") + "pasuje do wzorca: " + regex);
  }

}
wynik:
Tekst: 196570
 pasuje do wzorca: [0-9]+
Tekst: 123 996
 NIE pasuje do wzorca: [0-9]+

B. Metoda find() odnajduje w napisie kolejne podnapisy pasujące do wzorca.

iimport java.util.regex.*;

public class Sample2 {

  public static void main(String[] args) {
    // Wzorzec: jedno lub więcej wystąpień dowolnej cyfry
    String regex = "[0-9]+";

    // Tekst wejściowy 
    String txt = "123 996";
    
    System.out.println("Tekst: \n" + "'" + txt + "'" + 
                       "\nWzorzec: " + "'" + regex + "'");

    // Kompilacja wzorca
    Pattern pattern = Pattern.compile(regex);

    // Uzyskanie matchera
    Matcher matcher = pattern.matcher(txt);
    
    String result = "";  // do prezentacji wyników wyszukiwania
    
    // Zastosujemy metodę find()
    // Jej wywołanie zwraca true po znalezieniu pierwszego
    // pasującego do wzorca podłańcucha w tekście.
    // Kolejne wywołania pozwalają wyszukiwać kolejne pasujące podłańcuchy;
    // wynik false oznacza, że w tekście nie ma pasujących podłańcuchów
    
    while (matcher.find()) {
      result += "\nDopasowano podłańcuch '" + 
        matcher.group() + "'" +                // group() zwraca ostatni dopasowany tekst 
        "\nod pozycji " + matcher.start() +    // start() zwraca jego poczatkową pozycję
        "\ndo pozycji " + matcher.end();       // end() zwraca pozycję po ostatnim dopasowanym znaku
    }
    
 
    if (result.equals("")) result = "Nie znaleziono żadnego podnapisu " +
                                    "pasującego do wzorca";
    System.out.println(result);
  }

}
wynik:
Tekst:
'123 996'
Wzorzec: '[0-9]+'

Dopasowano podłańcuch '123'
od pozycji 0
do pozycji 3
Dopasowano podłańcuch '996'
od pozycji 4
do pozycji 7


C. Używając grup (ujmując odpowiednie fragmenty wyrażenia regularnego w  nawiasy okrągłe) możemy łatwo wyłuskiwać fragmenty dopasowanego tekstu.
import java.util.regex.*;

public class Sample3 {

  public static void main(String[] args) {
    // Wzorzec: 
    // jedno lub więcej wystąpień dowolnej cyfry (grupa, bo w nawiasach)
    // po czym jeden lub więcej białych znaków
    // po czym jedna lub więcej liter Unicode (grupa 2, w nawiasach)
    // po czym jeden lub więcej białych znaków
    // po czym dowolna liczba całkowita > 1 (grupa 3, w nawiasach)
    
    String regex = "([0-9]+)\\s+(\\p{L}+)\\s+([1-9][0-9]*)";

    // Tekst wejściowy 
    String txt = "1111 Odkurzacz 20";
    
    System.out.println("Tekst: " + "'" + txt + "'" + 
                       "\nWzorzec: " + "'" + regex + "'");

    // Kompilacja wzorca
    Pattern pattern = Pattern.compile(regex);

    // Uzyskanie matchera
    Matcher matcher = pattern.matcher(txt);
    
    // Dopasowanie tekstu
    boolean isMatching = matcher.matches();
    if (isMatching) {
      int n = matcher.groupCount();      // ile jest grup
      for (int i = 1; i <=n; i++) {
        String grupa = matcher.group(i); // pobranie zawartości i-ej grupy (numeracja od 1)
        System.out.println("Grupa " + i + 
                           " = '" + grupa + "'");
      }
    } else System.out.println("Tekst nie pasuje do wzorca");
    
  }

}
wynik:
Tekst: '1111 Odkurzacz 20'
Wzorzec: '([0-9]+)\s+(\p{L}+)\s+([1-9][0-9]*)'
Grupa 1 = '1111'
Grupa 2 = 'Odkurzacz'
Grupa 3 = '20'

D. Używając metody split() z klasy Pattern można dokonać rozbioru tekstu
public class Sample4 {

  public static void main(String[] args) {
    // ogólny wzorzec separatorów do wyróżniania słów:
    // separatorem jest 1 lub więcej "białych znaków" lub znaków interpunkcji
    String regex = "[\\s\\p{Punct}]+";

    // Tekst wejściowy 
    String txt = "Ala(11), kot,; pies-1 <kot2>[mrówka]";
    
    // Kompilacja wzorca
    Pattern pattern = Pattern.compile(regex);
    String[] words = pattern.split(txt);     // inaczej wołane niż  split() z klasy String
    
    System.out.println("Liczba wyróżnionych słów: " + words.length);
    for (String w : words) {
      System.out.println(w);
    }
  }

}
wynik:
Liczba wyróżnionych słów: 7
Ala
11
kot
pies
1
kot2
mrówka


E. Metoda replaceFirst  usuwa z napisu pierwsze wystąpienie podnapisu pasującego do wzorca
import java.io.*;
import java.util.*;
import java.util.regex.*;

public class Sample5 {

  public static void main(String[] args) throws Exception {
    
    // Usuniemy z tekstu z pliku wszystkie komentarze jednowierszowe 
    // (zaczynające się od dwóch ukosników - składnia jak w Javie)
    // wynik zapiszemy do innego pliku
    
    Scanner in = new Scanner(new File("test1.txt"));    // skaner dla pliku wejściowego
    BufferedWriter out = new BufferedWriter(
                          new FileWriter("test2.txt")); // plik wyjściowy

    // Wzorzec komentarzy:
    // 0 lub więcej białych znaków, potem dwa ukosniki po których występują bądź nie inne znaki
    String regex = "\\s*//.*";
    Pattern pattern = Pattern.compile(regex);
    Matcher matcher = pattern.matcher(""); 
    
    try {
      while (in.hasNextLine()) {
        String line = in.nextLine();
        matcher.reset(line);
        String nline = matcher.replaceFirst("");  // komentarz zastępujemy pustym napisem
        if (!nline.equals("")) {                  // wynikowy wiersz zapiszemy, jeśli nie jest pusty
          out.write(nline);
          out.newLine();
        }
      }
    } finally {
      in.close();
      out.close();
    }
  }

}
wynik:
Przykładowy plik wejściowy
tekst
// to jest komentarz
a tu jest tekst // i komentarz // cos tam
  // kom1
  // kom2
idzie dalej tekst

Wygenerowany plik wyjściowy
tekst
a tu jest tekst
idzie dalej tekst

F. W tekście zastępującym dopasowanie w metodach replaceFirst i replaceAll możemy odwolywać się do zawartości grup wyrażenia regularnego.
import java.util.regex.*;

public class Sample6 {

  public static void main(String[] args) throws Exception {
    
    // Zastąpimy w tekście wszystkie napisy:
    // (liczbaCałkowita1:liczbaCałkowita2)
    // na napisy:
    // [liczbaCałkowita2:liczbaCałkowita1]
    // czyli zmienimy nawiasy na kwadratowe i przestawimy miejscami liczby
   
    // Wzorzec:
    // nawias,liczba,dwukropek,liczba, nawias - uwaga nawias jest znakiem specjalnym - uzyjemy ukośnika
    // zastosujemy dwie grupy: dla liczby1 i liczby2 
    String regex = "\\((\\d):(\\d)\\)";
    Pattern pattern = Pattern.compile(regex);
    
    String txt = "tekst 1 (ale) (2) (1:2) wołanie f() (3:4) (8:9)(10:11)";  
    Matcher matcher = pattern.matcher(txt);
    
    // W wywołaniu metody replaceAll (i replaceFirst) podając tekst zastępujący
    // możemy  odwoływać się do zawartości grup wzorca.
    // Wtedy tekst zastępujący będzie zawierał zawartość grupy z dopasowania wyrażenia.
    // W tekście zastępującyn stosujemy znak $ z następującym po nim numerem grupy 
    // (a więc znak $ jest w tym kontekście zarezerwowany!)
    // W naszym przykładzie mamy dwie grupy: pierwszą liczbę i drugą liczbę
    // oznaczamy je $1 i $2
    
    // zamiana nawiasów i przestawienie liczb
    String newTxt = matcher.replaceAll("[$2:$1]");
    
    System.out.println("Tekst przed zamianą:");
    System.out.println(txt);
    System.out.println("Tekst po zamianie:");
    System.out.println(newTxt);
  }

}



6. Uproszczenia stosowania wyrażeń regularnych w klasach String i Scanner

Jako pewne uproszczenie, do zastosowań ad hoc, znajdziemy w klasie String metody, które odwzorowują niektóre z omówionych metod klas Matcher i Pattern.
Są one następujące.

 booleanmatches(String regex)
          Czy ten napis pasuje do wzorca regex?
 StringreplaceAll(String regex, String replacement)
          Zastępuje każdy pasujący do regex podłańcuch tego napisu podanym napisem replacement. Uwaga: znaki $ i \ mają specjalne znaczenie (zob. przykład F z poprzedniego punktu).
 StringreplaceFirst(String regex, String replacement)
          Zastępuje pierwszy pasujący do regex podłańcuch tego napisu podanym napisem replacement. Uwaga: j.w.
 String[]split(String regex)
          Rozklada ten napis wokół separatorów, które są podłancuchami pasującymi do wzorca
 String[]split(String regex, int limit)
          j.w., ale nie więcej niż limit-1 razy


Metody te są ścisłymi odpowiednikami odpowiednich metod klas Matcher i Pattern. Faktycznie, "wewnętrznie" wykorzystują one właśnie te klasy i ich metody. Np. jeśli txt jest typu String, to
txt.matches("[0-9]+");
jest tożsame z :
Pattern.compile("[0-9]+").matcher().matches();

Oznacza to, że tych "skróconych" metod klasy String powinniśmy używać wyłącznie wtedy, gdy dane wyrażenie regularne i związany z nim matcher używane są jednokrotnie. Przy wielokrotnym użyciu nalezy najpierw wyrażenie skompilować (raz), po czym dopiero wielokrotnie użyć, co oczywiście już wymaga wykorzystania klas Pattern i Matcher.

Klasa Scanner mocno korzysta z wyrażeń regularnych.

Użycie wyrażeń regularnych w klasie Skaner jest - podobnie jak w klasie String - oparte na odwołaniach do klas Pattern i Matcher. Ale - inaczej niż w String - to wykorzystanie jest efektywne, ponieważ stosowane wzorce są kompilowane jednokrotnie i przechowywane w mapach.

Przykłady.

A. Aby uzyskać taki sam efekt rozbioru jak w przypadku StringTokenizera należy zastosować wyrażenie regularne opisujące wystąpienie jeden lub więcej razy dowolnego znaku z podanego zbioru np. [\\s\\p{Punct}]+ - jeden lub więcej białych znaków lub znaków interpunkcji.

import java.util.*;
import static java.lang.System.out;

public class Skaner1 {

  // Metoda pomocnicza do okalania napisów apostrofami 
  public static String quote(String s) {  return "'" + s + "'" + " "; }

  public static void main(String[] args) {
    // Zadanie: wyróżnić wszystkie napisy rozdzielone spacjami lub znakiem / 
    String txt = "1/2 /3/ 4";
        
    // Separator?
    // intuicyjnie jedno z: spacja lub znak /
    String delim = "[ /]";
    
    // Przy tworzeniu skanera od razu można ustalić separator
    Scanner scan = new Scanner(txt).useDelimiter(delim);
    
    out.println("Tekst : " + quote(txt) + " Separator: " + quote(delim));
    
    // Ale wynik - podobnie jak w split() inny od oczekiwań
    // ze względu na sposób działania machiny wyrażeń regularnych
    
    // uzyskamy 5 symboli zamiast 4: '1' '2' '' '3' '' '4'
    while (scan.hasNext()) out.print( quote(scan.next()) + " ");
    
    // Aby uzyskać taki sam wynik jak w StringTokenizerze
    // nalezy zastosować wyrażenie regularne: 1 lub więcej spacji lub znaków /
    delim = "[ /]+";
    // Uwaga: do nowego skanowania zawsze trzeba tworzyć nowys skaner (nawet jeśli tekst jest ten sam)
    
    scan = new Scanner(txt).useDelimiter(delim);  
    
    out.println("\nTekst : " + quote(txt) + " Separator: " + quote(delim));
    
    // uzyskamy 4 symbole : '1' '2', '3' '' '4'
    while (scan.hasNext()) out.print( quote(scan.next()) + " ");

  }

}
Wynik:
Tekst : '1/2 /3/ 4'  Separator: '[ /]'
'1'  '2'  ''  '3'  ''  '4' 
Tekst : '1/2 /3/ 4'  Separator: '[ /]+'
'1'  '2'  '3'  '4' 

B. Separatorem może być dowolne wyrażenie regularne, np. jakieś fragmenty tekstów mogą być traktowane jako separatory.
import java.util.*;

public class Skaner2 {

  public static void main(String[] args) {
    
    // Zadanie: z napisu (np. jakiegoś dokumentu)
    // wyróżnić nazwiska, imiona i daty urodzenia
    // wstawione w odpowiednio opisane pola dokumentu
    
    String txt = "LNAME: Kowalski FNAME: Jan BORN: 1980-12-01\n" +
                 "LNAME:  Malinowski FNAME: Stefan BORN: 1950-01-15\n";
    
    // Separator:
    // dowolna z nazw pól LNAME: albo FNAME: albo BORN:
    String delim = "(LNAME:)|(FNAME:)|(BORN:)";
    
    Scanner scan = new Scanner(txt).useDelimiter(delim);
    while(scan.hasNext()) {
      String s = scan.next();
      // Ponieważ wyłuskane symbole mogą na końcach zawierać białe znaki
      // usuniemy je za pomocą metody trim() z klasy String
      s = s.trim();
      System.out.println( "'" + s + "'");
    }
  }

}

Wynik:
'Kowalski'
'Jan'
'1980-12-01'
'Malinowski'
'Stefan'
'1950-01-15'

C. Skaner łączy siłę wyrażeń regularnych z łatwym przekształcaniem napisów w liczby.

import java.util.*;

public class Skaner3 {

  public static void main(String[] args) {
    
    // Zadanie: zsumować wszystkie liczby całkowite występujące w tekście
    
    String txt = "Wydano najpierw 20, a później 35.\n" +
                 "W kolejnym dniu zakupiono coś jescze za 1000";
    
    // Separator:
    // 1 lub więcej wystapień nie-cyfry
    // można tu użyć klasy znaków \D, ale zapiszemy bardziej naocznie: 
    String delim = "[^0-9]+";
    
    Scanner scan = new Scanner(txt).useDelimiter(delim);
    int sum = 0;
    while(scan.hasNextInt()) {
      sum += scan.nextInt();
      
    } 
    System.out.println("Tekst:\n" + txt + "\n\nSuma liczb: " + sum);
  }

}

Wynik:
Tekst:
Wydano najpierw 20, a później 35.
W kolejnym dniu zakupiono coś jescze za 1000

Suma liczb: 1055


D. Skaner może działać bezpośrednio na różnych źródłach danych i w działaniu tym stosować zaawansowane konstrukcje ze świata wyrażeń regularnych (np. pobrać matcher i uzyskać od niego dodatkowe potrzebne informacje).

import java.util.*;

public class Skaner4 {
  
  public static void main(String[] args) throws Exception {
    // Znane nam już zadanie z wyróżnieniem tytułow rozdziałów 
    // (napisy w znacznikach <h2> dokumentu html)
    // w kilku linijkach kodu
     
    // Będziemy wczytywać podany plik
    Scanner fScan = new Scanner(new File("TypyOp.html"));

    // Wyrażenie reg. do wyłuskania tekstu w znacznikach;
    // - kwantyfikator jest wstrzęmiężliwy (znak ? po +) inaczej bylyby kłopoty
    // - zastosujemy grupę (nawiasy), aby od razu mieć tekst bez okalających znaczników
    String h2regex = "(?s)<h2>(.+?)</h2>";
    
    // Metoda findWithinHorizon wyszukuje kolejne wystąpienie 
    // tekstu pasującego do wzorca (drugi arg 0 = limit wyszukiwania nieograniczony)
    
    while(fScan.findWithinHorizon(h2regex, 0) != null) {
      
      // Skaner może uzyskać Matcher przez odwolanie match()
      // Od Matchera pobierzemy zawartość jedynej grupy  
      String title = fScan.match().group(1);
      System.out.println(title);
    }
    fScan.close();  // zamykamy skaner i plik
  }
}

Wynik:
1. Literały i zmienne
2. Pojęcie typu. Typy proste
3. Typy i użycie literałów
4. Typy zmiennych. Deklaracje
5. Operatory i wyrażenia
5.1. Przegląd
5.2. Operatory przypisania
5.3. Zwiększanie i zmniejszanie
5.4. Dzielenie całkowite i reszta
6. Konwersje arytmetyczne
7. Podsumowanie
8. Zadania i ćwiczenia

E. Skaner pozwala na ustalenie i zmiany w trakcie skanowania lokalizacji (ustawień regionalnych) użwanej przez niego przy interpretacji napisów, traktowanych jako liczby rzeczywiste z separatorem miejsc dziesiętnych.

import java.util.*;

public class Skaner5 {

  public static void main(String[] args) {
    
    // Zadanie: pobrać liczby rzeczywiste z tekstu 
    // uwzględniając format ich zapisu właściwy dla podanych lokalizacji
    // Tekst ma postać: symbol_języka zapis_liczby_zgodny_z_lokalizacją ...
    // zsumować wszystkie liczby
    
    String txt = "en 1.1 fr 2,2 pl 3,3";   
    
    Scanner scan = new Scanner(txt);
    
    // Załóżmy, że symbol języka składa się z dwóch dowolnych małych liter
    // Będziemy go w tekście wyszukiwać za pomocą poniższego wzorca
    String langSymRx = "[a-z][a-z]";

    double sum = 0;
    
    // Metoda findInLine znajduje w wierszu kolejny podnapis 
    // pasujący do wzorca, jeśli go nie ma - zwraca null
    
    String lang = scan.findInLine(langSymRx);
    while (lang != null) {
      scan.useLocale( new Locale(lang) );
      sum += scan.nextDouble();
      lang = scan.findInLine(langSymRx);
    }  
    
    System.out.println("Tekst:\n" + txt + "\n\nSuma liczb: " + sum);
  }

}


Wynik:
Tekst:
en 1.1 fr 2,2 pl 3,3

Suma liczb: 6.6


Na koniec opowieści o skanerze przyjrzyjmy się jeszcze tylko jednemu przykładowemu programikowi, przetwarzającemu raport o dochodach i wydatkach.
Plik raportu ma następującą przykładową postać:
1.dochody: 100,11 21,21 500,80 
2.wydatki: 200,10 11,31 756,21
3.wydatki:  10,61  2,11  25,00
4.dochody:   2,30 99,12 101,11
przy czym liczba i kolejność wierszy a także ilość liczb w poszczególnych wierszach  może być różna.
Trzeba zbudować klasę, która dostarcza  metody sumowania poszczególnych kategorii finansowych (wydatków, dochodów). Argumentem metody jest kategoria ("wydatki" lub "dochody"), a metoda ma zwracać odpowiednio sumę liczb z danej kategorii. Metoda sumowania może być wywoływana wielokrotnie, a to wymaga wielokrotnej interpretacji treści pliku. W takich sytuacjach najlepiej jest wczytać plik raz (jeśli nie jest olbrzymi) i następnie działać już na jego tekście umieszczonym w pamięci (jako String).

Do rozwiązania problemu zastosujemy skaner, bo za jego pomocą:
  1. łatwo odnaleźć początki sekwencji liczb w danych kategoriach (np. kategorie dochodów znajdzie nam następujące wyrażenie regularne  "\d\.dochody:"),
  2. łatwo pobierać z tekstu liczby,
  3. nie musimy się martwić formatem liczb (wystarczy lokalizacja domyślna; zapewne raport jest przygotowany przez księgową, która np. w Polsce stosuje przecinek jako separator miejsc dziesiętnych),
  4. do wczytania całego pliku wystarczy praktycznie jedna instrukcja (użycie jako separatora dla skanera  granicy \Z, oznaczającej koniec wejścia")
Program przedstawiono na wydruku.
import java.io.*;
import java.util.*;

public class Report {
  
  private String report;       
  
  public double sum(String what) {
    Scanner sc = new Scanner(report);
    String regex = "\\d\\." + what + ":";
    
    if (sc.findWithinHorizon(regex, 0) == null)
      throw new IllegalArgumentException("Brak wymaganej kategorii");

    double sum = 0;
    do {
      while (sc.hasNextDouble()) {
        sum += sc.nextDouble();
      }
    } while (sc.findWithinHorizon(regex, 0) != null);
            
    return sum;
  }
  
  public Report(String fname) throws FileNotFoundException {
    Scanner fs = new Scanner( new File(fname) );
    report = fs.useDelimiter("\\Z").next();
    fs.close();
  }
  
  public static void main(String[] args) throws FileNotFoundException {
    Report rep = new Report("RaportKsiegowej.txt");     
    
    double wydatki = rep.sum("wydatki"),
           dochody = rep.sum("dochody");
    
    System.out.println("Dochody: " + dochody + "\nWydatki: " + wydatki );

  }
}
Dla przykładowego pliku otrzymamy w wyniku:
Dochody: 824.65
Wydatki: 1005.34


Wyrażenia regularne i skaner to mocne narzędzia. Ale też trudne. Ich poprawne stosowanie wymaga wiedzy o mechanizmach działania. Tutaj informacja taka pojawiła się we fragmentach (warto sięgnąć po dodatkowe materiały). Na pewno warto już teraz, niejako od początku nauki, stosować wyrażenia regularne, ale  dopóki nie osiągniemy w tym swego rodzaju mistrzostwa - raczej ostrożnie i z zastosowaniem wielokrotnych testów.
 

7. Podsumowanie

Zapoznaliśmy się z praktycznymi sposobami przetwarzania tekstów m.in.:
Jest to wiedza niezbędna w praktycznym programowaniu, a opisane tu sposoby działania i podejścia są stosowane niemal na codzień.