String processing #2

Search, parse, and replace strings by using regular expressions, using expression patterns for matching limited to: . (dot), * (star), + (plus), ?, \d, \D, \s, \S, \w, \W, \b. \B, [], ().

W pierwszym artykule z tego rozdziału przedstawiłem wiele metod, które w swoich argumentach przyjmowały wyrażenia regularne. W tym wpisie zapoznamy się dokładnie z możliwościami tego mechanizmu i nauczymy się tworzyć wyrażenia regularne na poziomie egzaminu 1Z0-804.


Na skróty:

Pattern: [Opis] [API]
Matcher: [Opis] [API]

Repozytorium z przykładami [GitHub]


Co to właściwie jest to wyrażenie regularne?

Najprościej mówiąc jest to wzór. Wzór, dla którego chcemy znaleźć pasujący ciąg znaków. W rzeczywistym świece takim wzorem może być oferta pracy dla programisty.”Programujesz w Javie, C# lub C++? Aplikuj śmiało!” – wystarczy, że znasz tylko jeden z wymienionych języków i pasujesz do wymaganego wzoru.

W ten sam sposób działają wyrażenia regularne (ang. regular expressions, w skrócie nazywane regex), operują one na wzorcu i na ciągu znaków, w którym te wzorce są poszukiwane.

REGEX to język

Tak, ponieważ regex ma swoją składnię, technicznie rzecz biorąc jest językiem. Na szczęście na poziomie egzaminu na OCPJSE7 nie musisz znać wielu jego „słów”.

Potrzebne Ci będą:

  • . oznacza jeden dowolny znak.
  • * kwantyfikator, oznacza zero lub więcej powtórzeń.
  • + kwantyfikator, oznacza jedno lub więcej powtórzeń.
  • ? kwantyfikator, oznacza zero lub jedno powtórzenie.
  • \d dowolna cyfra.
  • \D dowolny znak poza cyfrą.
  • \s dowolny biały znak, w tym spacja, \t (tabulator), \n (nowa linia), \r (powrót karetki) oraz znacznik końca linii \x0B.
  • \S dowolny znak nie będący białym znakiem.
  • \w dowolna litera alfabetu lub cyfra.
  • \W dowolny znak nie będący ani literą alfabetu, ani cyfrą.
  • \b początek słowa (ang. word boundary).
  • \B dowolne miejsce, tylko nie początek słowa (ang. nonword boundary).
  • ^ początek linii, ale też znak negacji, zależy od kontekstu.
  • $ koniec linii.
  • [] w nawiasie prostokątnym grupujemy kilka wzorców, z których tylko jeden musi być spełniony.
  • () w nawiasie okrągłym grupujemy kilka wzorców, które muszą być spełnione jednocześnie i w podanej kolejności.
  • {} W nawiasie klamrowym podajemy ilość powtórzeń.

Pamiętaj, że backslash ‚\’ jest w javie znakiem specjalnym, więc chcąc go wpisać w stringu reprezentującym regex musimy go poprzedzić drugim backslash’em. Przykładowo regex oznaczający dowolną cyfrę będzie taki: „\\d”.

Trochę praktyki

Ok, znamy już podstawowe słowa, spróbujemy teraz złożyć kilka prostych zdań.

"\\d"

Jeden z najprostszych przykładów, nasz wzorzec składa się z jednego znaku i jest to dowolna cyfra. Oznacza to, że jeżeli spróbujemy dopasować wzorzec do ciągu znaków „12A34”, to znajdziemy cztery dopasowania: „1”, „2”, „3”, „4”.

"\\d+"

Weźmy teraz ten sam wzorzec i rozbudujmy go o jedno „słowo”, którym jest znak + (plus). Znak ten postawiony zaraz za „\\d” oznacza, że do naszego wzorca pasuje co najmniej jedna cyfra, ale im więcej tym lepiej. Dlatego dla naszego ciągu znaków „12A34” tym razem zostaną znalezione dwa dopasowania do wzorca: „12” oraz „34”.

Kwantyfikatory + oraz * są tak zwanymi kwantyfikatorami zachłannymi (ang. greedy). Oznacza to, że będą chciały dopasować do wzorca jak najdłuższy ciąg znaków. Gdyby naszym ciągiem testowym było „1234” to dla wyrażenia „\\d+” zostałoby zwrócone jedno dopasowanie- cały ciąg „1234”, tylko litera ‚A’ po środku przełamuje nasz wzorzec, dzieląc ostateczny wynik na dwa mniejsze dopasowania.

Kolejnym prostym przykładem będzie wzorzec sprawdzający, czy użytkownik podał poprawny kod pocztowy. Zrobimy to na kilka sposobów, aby pokazać, że w regexie, jak w każdym języku, to samo można powiedzieć różnymi słowami.

"\\d\\d-\\d\\d\\d"

Mało czytelnie, prawda? Można nawet nie zauważyć, że gdzieś tam jest myślnik. Wyrażenie to możemy przetłumaczyć jako: dwie cyfry, myślnik, trzy cyfry. Niby działa, ale czytelność taka sobie.

"\\d{2}-\\d{3}"

Sposób drugi. Już widać trochę więcej. Pojawiają nam się nawiasy klamrowe, w których podajemy ile dokładnie elementów się spodziewamy. Końcowe znaczenie pozostaje bez zmian, do wzorca będzie pasował ciąg: dwie cyfry, myślnik, trzy cyfry.

"[0-9]{2}-[0-9]{3}"

Sposób trzeci. Inaczej. Czy czytelniej? Kwestia dyskusyjna. Oznaczenie „\\d” zamienieliśmy na „[0-9]” czyli jeden znak z zakresu 0-9.

Każdy z tych trzech sposobów znajdzie nam w ciągu znaków fragment odpowiadający kodowi pocztowemu. Niestety wszystkie te sposoby mają też wspólny mankament. Znajdą dopasowanie wzorca nawet w takim ciągu znaków: „Roman123456-789Kowalski”. Tym dopasowaniem będzie oczywiście „56-789”. Co jeśli chcielibyśmy być pewni, że w podanym ciągu znaków jest tylko kod pocztowy i nic więcej?

Na początek, bazując na sposobie drugim, sprawdźmy taki zapis:

"\\b\\d{2}-\\d{3}\\b"

Do naszego wyrażenia dodałem znacznik „\\b”, który pojawił się na początku i na końcu. Jest to znacznik granicy słowa, czyli dopasowanie do wzorca znajdzie się tylko wtedy, kiedy przed i po naszym kodzie pocztowym będzie jakiś biały znak (np. spacja). Lepiej… Ale wciąż źle. Wystarczy delikatnie zmodyfikować nasz ciąg wejściowy na „Roman1234 56-789 Kowalski” i znów jesteśmy w punkcie wyjścia.

"^\\d{2}-\\d{3}$"

W ostatecznym rozwiązaniu, z pomocą przychodzą znaki końca i początku linii. Uzupełniając nasze poprzednie wyrażenie o znak „^” na początku, oraz „$” na końcu, zaznaczamy, że wzorzec będzie spełniony tylko wtedy, kiedy pierwsze dwie cyfry będą początkiem ciągu znaków i ostatnie trzy cyfry będą końcem ciągu znaków.

Rozgrzewkę mamy za sobą. Przeanalizujmy bardziej rozbudowany przypadek. Załóżmy, że dostaliśmy plik tekstowy z danymi klienta. Chcemy sprawdzić, czy plik jest zgodny z ustalonym formatem danych, to znaczy: w każdej linii mamy kolejno:

  • Ośmiu-znakowy, numeryczny identyfikator klienta.
  • Imię.
  • Nazwisko.
  • Mail.
  • Numer komórkowy w formacie (+48) 9 cyfr.
  • znak # jako umówiony znacznik końca linii.

Dodatkowo wszystkie te pola oddzielone są znakiem „|” (ang. pipe).

Przejdźmy przez to etapami:

Po pierwsze chcemy mieć pewność, że jedna linijka będzie zawierała tylko jeden taki zestaw danych, dlatego od razu do naszego wyrażenia wstawiamy oznaczenia początku i końca linii:

"^$"

Pierwszym polem, które sprawdzamy jest osiem cyfr identyfikatora:

"^[\\d]{8}\\|$"

Zapisu „\\d]{8}” już raczej nie muszę wyjaśniać. Na końcu umieściliśmy nasz znak pipe, jednak jest on poprzedzony dwoma backslash’ami „\\|”. Dlaczego? Ponieważ „|” też ma w regexie swoje znaczenie i podobnie jak w samej Javie, oznacza logiczne „lub”. Dodając backslash’e powiedzieliśmy, że interesuje nas dosłownie ten znak, a nie jego funkcjonalność w języku.

Pora na imię i nazwisko. To nic innego jak dwie grupy składające się z co najmniej jednej litery każda:

"^[\\d]{8}\\|[a-zA-Z]+\\|[a-zA-Z]+\\|$"

Zapis „[a-zA-Z]” oznacza, że spodziewamy się znaku z zakresu małych liter od a do z lub dużych od A do Z. Oczywiście dodajemy też rozdzielenie znakami pipe;

Czas na email. Będzie to dość uproszczona walidacja. Zakładamy, że będzie to dowolny ciąg liter lub cyfr „[\\w]+” , między którymi może wystąpić myślnik lub „podłoga”: „[\\w]+[-_]?[\\w]+” , możemy mieć wiele myślników w różnych miejscach, więc cały wzór mnożymy dodając kwantyfikator „+” : „([\\w]+[-_]?[\\w]+)+„. Później koniecznie mysi być małpa: „([\\w]+[-_]?[\\w]+)+@„, a po niej alfanumeryczna nazwa strony i po kropce alfanumeryczna domena: „([\\w]+[-_]?[\\w]+)+[\\w]+.[\\w]+„. Dodając na koniec znak pipe, otrzymujemy:

"^[\\d]{8}\\|[a-zA-Z]+\\|[a-zA-Z]+\\|([\\w]+[-_]?[\\w]*)+@[\\w]+.[\\w]+\\|$"

Została nam banalna walidacja numeru telefonu. Stały ciąg (+48) musimy zapisać z użyciem backslash’y, ponieważ nawiasy oraz plus są częścią składni regex, a chcemy użyć ich dosłownego znaczenia: „\\(\\+48\\)” , a później dopisujemy już dziewięć cyfr: „\\(\\+48\\)[\\d]{9}” .

Po dopisaniu na końcu znaku „#”, otrzymujemy finalne wyrażenie do walidacji naszego pliku tekstowego:

"^[\\d]{8}\\|[a-zA-Z]+\\|[a-zA-Z]+\\|([\\w]+[-_]?[\\w]*)+@[\\w]+.[\\w]+\\|\\(\\+48\\)[\\d]{9}#$"

Oczywiście nie jest to ani jedyne możliwe rozwiązanie, ani rozwiązanie optymalne. Pokazuje jednak szereg możliwości wyrażeń regularnych. Gdybyśmy chcieli wykonać taką walidację bez korzystania z wyrażeń regularnych, potrzebne byłoby o wiele więcej kodu, z wykorzystaniem między innymi metod klasy String.


Klasy Pattern i Matcher

No dobra. Umiemy już pisać wyrażenia regularne, wiemy, że można je stosować np. w metodach klasy String. Ale co, gdy nie chcemy wykorzystać regexa do podmiany jakiegoś fragmentu tekstu, lub podzielenia go? Jeśli potrzebujemy tylko sprawdzić czy dany ciąg znaków pasuje do wzorca, albo wyciągnąć z dużego tekstu wszystkie dopasowania, z pomocą przychodzą dwie ściśle współpracujące ze sobą klasy: Pattern i Matcher.

Obie klasy reprezentują tzw. wzorzec „Fabryka”. Wzorzec ten jest przedmiotem oddzielnego wymagania i będzie omówiony w innym artykule. Na ten moment istotne jest tylko to, że takiego obiektu, będącego fabryką, nie możemy sami utworzyć poprzez słowo kluczowe „new”. Musimy poprosić, za pomocą statycznej metody tej klasy, aby utworzyła i przekazała nam swoją instancję.

Klasa Pattern udostępnia nam dwie metody, z pomocą których możemy otrzymać instancję tej klasy. Na egzaminie spotkamy się tylko z tą pierwszą metodą, dlatego omówię tylko ją.

static Pattern 	compile(String regex)
static Pattern 	compile(String regex, int flags)

Do metody compile(), przekazujemy w argumencie utworzone przez nas wyrażenie regularne. Metoda zwraca nam obiekt klasy Pattern, który reprezentuje nasz wyrażenie regularne.

Metoda compile może zwrócić wyjątek PatternSyntaxException, jeśli w wyrażeniu regularnym będą błędy składniowe.

Posiadając obiekt klasy Pattern, wołamy na nim metodę matcher(), podając w jej argumencie ciąg znaków, który ma być przeszukany pod kątem naszego wzorca. Metoda ta zwraca nam obiekt klasy Matcher, utworzony przez fabrykę konkretnie dla naszego wyrażenia regularnego i testowanego nim ciągu znaków.

String text= "kod pocztowy: 00-111";
String regex = "[\\d]{2}-[\\d]{3}";
    
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(text);

W powyższym przykładzie utworzyliśmy obiekt klasy Matcher dla ciągu tekstowego „kod pocztowy: 00-111” i znanego nam już wyrażenia regularnego do wyszukiwania kodu pocztowego.

Mając Matcher, korzystamy z jego podstawowych metod do odnalezienia dopasowań do wzorca:

boolean find()
String group()
int start()
int end()
int groupCount()

Metoda find() zwraca wartość true, jeśli istnieją dopasowania do zwrócenia, możemy się nią posługiwać jako warunkiem w pętli iterującej się po wszystkich dopasowaniach do wzorca:

while(matcher.find()){
  String foundValue = matcher.group(); //pobierz kolejne dopasowanie
  // zrób coś ze znalezionym dopasowaniem
}

Metoda group() zwraca String zawierający całe znalezione dopasowanie. Każde wywołanie metody zwraca jedno następne dopasowanie, w kolejności ich wystąpienia w przeszukiwanym ciągu znaków.

Metody start() oraz end() zwracają pozycje, na jakich w przeszukiwanym stringu znajdują się odpowiednio pierwszy i ostatni znak ciągu, który byłby zwrócony przez następne wywołanie metody group(). Możemy również policzyć wszystkie znalezione dopasowania, posługując sie metodą groupCount().

Pamiętasz zasadą jaką przytoczyłem w poprzednim artykule? Że prawie zawsze kiedy Java pracuje na zakresie min-max, w rzeczywistości jest to zakres min- (max-1)? Tak samo jest tutaj. Jeżeli metoda group() zwróci ciąg „Kot”, metoda start() zwróci 0 (zero), to jaką pozycję zwróci metoda end()? Zwróci 3, mimo, że litera ‚K’ ma pozycję 0, litera ‚o’ ma pozycję 1, a litera ‚t’ ma pozycję 2.

Kilka przykładów
//Zliczanie zdań w teksie, zakładając, że każde zdanie oddzielone jest kropką.
String text = "To jest pierwsze zdanie. A to jest drugie zdanie. Jest jeszcze trzecie zdanie.";
String regex = "[\\w\\s]*\\.";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(text);
System.out.println(m.groupCount());

 

//Wskazanie pozycji każdego numeru telefonu zakładając, że telefonem jest każde 9 cyfr w formacie:
//3 cyfry spacja 3 cyfry spacja 3 cyfry
String text = "Agata:800 900 700, Stefan:789 852 123, Zenon:745 563 215 ";
String regex = "[\\d]{3}\\s[\\d]{3}\\s[\\d]{3}";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(text);		
while(m.find()){
  System.out.println(m.start());
}

 

//Sprawdzenie czy każdy wyraz w stingu zaczyna się dużą literą
String text = "Id Etre Nosten Toro Mine Atulis Manore";
String regex = "^([A-Z][a-z]+\\s*)*$";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(text);
if(m.find()){
  System.out.println(true);
}else{
  System.out.println(false);	
}