następny punkt »

1. Napisy

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 Java SDK. Jest ona w postaci HTML: klasy podzielone są według pakietów a także dostępna jest alfabetyczna lista wszystkich klas.

Proszę koniecznie otworzyć dokumentację i oswoić się z metodami nawigacji pośród różnych pakietów, klas oraz ich metod.


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

  • kolejne znaki napisów występują na pozycjach, które są indeksowane poczynając od 0: np. napis "Ala" ma trzy znaki na pozycjach 1, 2, 3; pierwsza pozycja ma indeks 0, druga - 1, trzecia 2. Możemy też powiedzicć, że pierwszy znak ma indeks 0, a ostatni - indeks o 1 mniejszy od długości napisu,
  • części napisów (łańcuchów znakowych) określa się terminem "podłańcuch" (substring),
  • większość z omawianych dalej metod (wszystkie metody niestatyczne) używana jest "na rzecz" obiektow klasy String; o obiekcie na rzecz którego wywołano metodę mówimy ten napis,
  • przedstawiono tu nie wszystkie metody klasy String, a jedynie te najbardziej użyteczne; o niektórych innych użytecznych metodach dowiemy się przy okazji omawiania wyrażeń regularnych w przyszłym semestrze.

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.
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.

r Problem: 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.


Przed lekturą dalszego tekstu proszę to zadanie rozwiązać samodzielnie

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 javax.swing.*;

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 = JOptionPane.showInputDialog(msg)) != null) {
      int i = res.charAt(0) - 'A';
      if (i < 0 || i > dest.length -1) continue;
      JOptionPane.showMessageDialog(null, dest[i] + " - cena: " + price[i]);
    }
   System.exit(0);

  }

}

Dodatkowe komentarze:

  • w oknie dialogowym wprowadzania danych tekst komunikatu (msg) składa się z kilku wierszy; tekst dzielimy na wiersze za pomocą znaku '\n',
  • program napisano w taki sposób, że przy zmianie liczby wycieczek należy zmienić tylko inicjacje tablic dest i price; inne fragmenty kodu nie ulegną zmianie!,
  • przy tworzeniu komunikatu (msg) znowu wykorzystano możliwość traktowania znaków jako liczb (kody znaków); jednak operacje arytmetyczne na znakach dają w wyniku wartości typu int, a ponieważ chcemy pokazać znak , a nie jego kod (int) - to musimy jawnie przekształcić te wartości do typu char - stąd konieczność użycia operatora konwersji (char)
  • poprawność pierwszego znaku wprowadzonego napisu (czy mieści się w przedziale A-E) sprawdzamy za pomocą if; innym sposobem obsługi błędu byłaby obsługa wyjątku (ArrayIndexOutOfBoundsException).

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 wypisanie wszystkich takich nagłówków.

Uwaga. W tej chwili zajmujemy się najbardziej prymitywnym rozwiązaniem problemu, w oparciu o "tradycyjne" metody klasy String. Głównie po to, by przećwiczyć stosowanie tych metod. Inne rozwązania mogą wykorzystywac parsery HTML lub wyrażenia regularne

Na razie jeszcze nie umiemy wczytywac plików, ale możemy już przygotować i przetestować klasę, która pozwoli "wyłuskiwać" z dokumentu nagłówki drugiego poziomu.


Niech ta klasa (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ć listę nagłówków drugiego pioziomu, rozdzielonych separatorami nowego wiersza. Listę tę możemy następnie wypisać na konsoli lub skierować standardowy strumień wyjściowy 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ę.


Przed lekturą dalszego tekstu proszę to zadanie rozwiązać samodzielnie


Klasa Toc może wyglądać tak.

import javax.swing.*;

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() {

     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) return Emsg("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;
  }

  private String Emsg(String txt) {  // komunikat o błędzie
    JOptionPane.showMessageDialog(null, txt);
    return toc;
  }
  

Uwaga: powyższa wersja programu 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 0d , 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łal bez rekompilacji na każdej platformie.

[ więcej o właściwościach systemowych ]

Klasę Toc możemy na razie przetestować na "sztywno" zadanym dokumencie. Za chwilę, gdy nauczymy się wczytywać pliki - zobaczymy jak działa na konkretnych plikach html.

class TocTest {

  public static void main(String[] args) {
    String doc = "<h2>1.1. Punkt 1</h2>" +
                 "ble ble ble ble ble  " +
                 "ble ble ble ble ble  " +
                 "<h2>1.2. Punkt 2</h2>" +
                 "ble ble ble ble ble  " +
                 "ble ble ble ble ble  " +
                 "<h2>1.3. Punkt 3</h2>" +
                 "ble ble ble ble ble  " +
                 "ble ble ble ble ble  " +
                 "ble ble ble ble ble  " +
                 "ble ble ble ble ble  " +
                 "<h2>1.4. Punkt 4</h2>" +
                 "ble ble ble ble ble  " +
                 "ble ble ble ble ble  " +
                 "<h2>1.5. Punkt 5</h2>" +
                 "ble ble ble ble ble  " +
                 "ble ble ble ble ble  " ;

    System.out.println(new Toc(doc).getToc());
  }
}

Wydruk programu (możemy go przekierować do pliku np. tak: java TocTest > out):

1.1. Punkt 1
1.2. Punkt 2
1.3. Punkt 3
1.4. Punkt 4
1.5. Punkt 5

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ńucha 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.

Czasami jednak zmiany obiektów napisów są potrzebne.

Modyfikowalne obiekty-łańcuchy znakowe definiuje klasa StringBuffer.

Obiekty klasy StringBuffer 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 wupelniać go zawartośćią dopisując do niego jakieś kolejne napisy:

sb.append("jakiś napis 1");
sb.append("jakiś napis 2");
...

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

String s = sb.toString();

Proszę zapoznać się z dokumentacją klasy StringBuffer.

Wykorzystanie klasy StringBuffer zamiast String jest wskazane przy dużej liczbie "kumulatywnych" operacji konkatenacji.
Jak już wspomniano, każda operacja konkatenacji obiektów klasy String zapisana za pomocą operatora + powoduje stworzenie nowego obiektu klasy String i skopiowanie do "jego wnętrza" znaków łączonych łańcuchów. Jest to dosyć czasochłonne.

Różnicę pokazuje poniższy program (przygotowany w oparciu o materiały JDC TechTips - biuletynu Java Developer Connection).
Do mierzenia czasu operacji korzystamy w nim z klasy QTimer, w której zdefiniowaliśmy metodę getElapsed() zwracającą upływ czasu od mometu 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() {
     start = System.currentTimeMillis();
   }

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

public class Test {


  public static void main(String args[]) {

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


    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");


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

Wynik:


String operator +; Czas: 3900 ms
StringBuffer append. Czas: 60 ms
Wykonano 9000 operacji.
Relacja A/B = 65.0

Jak widać, "kumulatywna" konkatenacja za pomocą klasy StringBuffer jest (przy 9 tys. operacji konkatenacji) 65-krotnie szybsza niż za pomocą operatora +.


 następny punkt »