Wyrażenia regularne – Część 3 (grupy i odwołania wsteczne)

Omówiliśmy podstawowe zagadnienia związane z budowaniem wyrażeń regularnych, więc przyszedł czas aby zająć się tematyką nieco bardziej zaawansowaną, a mianowicie obsługą grupowania wyrażeń.

Podobnie jak robiliśmy to w poprzednich wpisach tutaj również od razu przejdziemy do przykładowych problemów i postaramy się krok po kroku opisać proponowane wyrażenie regularne.

W pierwszym przykładzie załóżmy, że chcemy sprawdzić, czy ciąg znakowy składa się wyłącznie z takich samych znaków, przy czym mogą to być dowolne znaki. Nie będziemy się tutaj wdawać w problematykę weryfikacji konkretnych zakresów znaków co pozwoli nam skupić się na kwestii najważniejszej – grupy i odwołania wsteczne.

Czy ciąg zawiera wyłącznie identyczne znaki?

Przeanalizujmy poniższy kod:

var reg = /^(.)\1*$/
reg.test('xxx'); //true
reg.test('xyz'); //false

Na początku próbujemy dopasować dowolny znak, co w RegExp określa symbol kropki. Problem rozwiążemy w następujący sposób: dopasujemy jeden raz dowolny znak, a następnie sprawdzimy, czy kolejne znaki w ciągu to ten ten sam znak. Dopuszczamy jednocześnie sytuację, w której ciąg zawiera wyłącznie jeden znak i zwracamy wtedy true. W przypadku pustego ciągu zwracamy wartość false.

Aby sprawdzić nasz warunek pierwszy znak znajdujący się w ciągu (czyli dowolny znak Unicode) musimy umieścić w tzw. grupie, określanej nawiasami okrągłymi. Jak się okaże w dalszej części artykułu grupa nie musi zawierać konkretnego znaku, lecz może także mieścić w sobie inne wyrażenie regularne.

Po co jednak te nawiasy i grupowanie jednego znaku? Otóż dlatego, że grupy mają kilka charakterystycznych dla nich cech, do których należy m.in. przechwytywanie. Oznacza to, że zapis „(.)” spowoduje dopasowanie jeden raz dowolnego znaku i zapamiętanie go w celu późniejszego wykorzystania. Do grupy tej możemy się w naszym wyrażeniu regularnym odnosić poprzez zapis „\n” gdzie n oznacza kolejną (licząc od lewej) przechwyconą grupę. W naszym przypadku istnieje tylko jedna grupa, więc odwołujemy się do niej zapisem „\1”.

Następnie stosujemy ogólny kwantyfikator „*” (gwiazdka) oznaczający, że dany element (u nas „\1”) może wystąpić zero lub więcej razy i jednocześnie musi to być ostatni element ciągu (patrz: symbol granicy ciągu „$”).

W pierwszym teście na początku dopasowaliśmy pierwszą literę „x”, po czym spełniliśmy warunek dopasowania przechwyconej grupy – druga litera „x”, i jednocześnie drugi „x” występuje co najmniej zero razy (u nas 2 razy). Za ostatnim „x” nie znajduje się już żaden inny ciąg, a więc cały String zostaje dopasowany do wyrażenia regularnego.

W drugim wypadku przechwytujemy znak „x” z początku ciągu lecz kolejny znak nie pełnia warunku „\1” czyli nie jest identyczny z pierwszym dopasowaniem. W tym miejscu kończy się sprawdzanie wyrażenia regularnego i metoda test() zwraca false.

Tak na marginesie warto wspomnieć, że w ECMAScript 6 istnieje wbudowana metoda obiektu String pozwalająca wykonać nasz test:

var s = 'xxx';
s === s[0].repeat(s.length); //true

Nie będziemy tutaj omawiać niuansów związanych z metodą repeat()  ale generalnie tworzy ona ciąg, w którym pierwszy znak s[0] zostanie powtórzony określoną ilość razy. Chcę tylko zwrócić uwagę na fakt, że czasami istnieją również inne metody sprawdzenia ciągów znakowych niż RegExp. W tym wypadku jednak metoda z użyciem repeat() ma jedną wadę, a mianowicie wywołana na pustym ciągu zwróci błąd TypeError, gdy poprzednie wyrażenie regularne zwróci po prostu false. Wynika to z faktu, że w pustym ciągu nie istnieje wartość s[0], a metoda repeat musi zostać wywołana na obiekcie String, a nie na undefined (a taka wartość zostanie zwrócona dla s[0]).

Wiele grup z odwołaniami wstecznymi

Zalecam tworzenie wyrażeń regularnych z maksymalnie 3-4 grupami przechwytującymi jeśli stosujemy później odwołania wsteczne do tych grup (czy to w samym RegExp czy np. metodzie String.prototype.replace).

Do kolejnych grup możemy odwoływać się poprzez kolejne numery, przy czym możemy dowolnie wybierać grupy, które chcemy wykorzystać:

var reg = /(abc)(.*)(xyz)/g,
    str = 'Dowolny tekst zawierający abc oraz xyz.';

str.replace(reg, '$3$2$1'); //Dowolny tekst zawierający xyz oraz abc.

W powyższym przykładzie chcieliśmy wyszukać ciągi „abc” oraz „xyz” zamieniając je kolejnością, ale pozostawiając nie ruszone pozostałe znaki w ciągu str.

W tym celu przechwyciliśmy trzy grupy, przy czym druga grupa stanowi dowolny ciąg znajdujący się pomiędzy abc i xyz (występujący dowolną ilość razy, w tym zero razy).  Wewnątrz wyrażeń regularnych odwołujemy się do grup poprzez zapis „\n”, natomiast w metodzie replace stosujemy zapis „$n”.

Jest to dobry sposób na odpowiednią modyfikację wyświetlanych danych. Na przykład w formularzu kontaktowym dopuszczamy podanie numeru telefonu z wykorzystaniem myślników i spacji ale wynikowo chcemy zapisać sam numer:

var reg = /^(\d{3})[- ]?(\d{3})[- ]?(\d{3})$/,
    str = '607-784-441';

str.replace(reg, '$1$2$3'); //'607784441'

Taką wartość możemy na przykład przekazać do zapisania w bazie danych czy obrobić w dowolny inny sposób i wyświetlić użytkownikowi.

Grupy nieprzechwytujące

Do tej pory omawialiśmy wyłącznie grupy, który były przechwytywane, czyli w pewnym uproszczeniu zapisywane w pamięci „podręcznej” i wykorzystywane w późniejszym etapie.

Czasami chcemy korzystać z grupowania lecz nie potrzebujemy tych grup użyć później jako odwołań wstecznych. Załóżmy, że chcemy sprawdzić, czy ciąg znakowy zawiera wyłącznie unikalne cyfry, czyli żadna z cyfr nie może się powtórzyć. Wykorzystamy do tego wyrażenie regularne i nieco bardziej zaawansowane grupowanie:

var reg = /^(?:(\d)(?!\d*\1))+$/;

reg.test('1234'); //true
reg.test('2322'); //false

Z początku wyrażenie może wydawać się nieco zagmatwane, spróbujmy więc przeanalizować jego poszczególne elementy.

Na początku definiujemy grupę główną:

^(?: ........ )+$

w której symbol „?:” oznacza, że dopasowania do tej grupy nie mają być przechowywane w pamięci. Generalnie wyrażenie wewnątrz tej grupy weryfikuje (co za chwilę omówimy) czy dopasowano dowolną cyfrę, a następnie sprawdza czy cyfra ta występuje w ciągu więcej niż jeden raz. W naszym wypadku każda z kolejnych cyfr ciągu stanowi oddzielną grupę główną.

W jej wnętrzu znajduje się znany już zapis „(\d)”, który dopasowuje dowolną cyfrę i zapamiętuje ją – zauważ, że tutaj nie ma już symbolu „?:”. Z tego powodu w dalszej części wyrażenia możemy sprawdzić, czy ponownie występuje dopasowanie do przechwyconej grupy poprzez „\1”.

Omówmy jeszcze wyrażenie „(?! ……… )”. Mimo zastosowania tutaj nawiasów okrągłych to wyrażenie nie stanowi grupy. Jest to tzw. dopasowanie wyprzedzające, czyli znajdujące się po wcześniejszym dopasowaniu. Wyróżniamy wyprzedzenia pozytywne i negatywne.

Wyprzedzenie pozytywne zapisujemy jako „(?= …… )”, a negatywne jako „(?! ….. )”. W naszym wypadku mamy do czynienia z wyprzedzeniem negatywnym, co oznacza, że wcześniejsze dopasowanie (czyli \d) zostanie spełnione tylko wtedy, gdy po nim NIE ZNAJDZIE się dopasowanie zawarte wewnątrz „(?! … „). Wyrażenie wyprzedzające sprawdza, czy po wcześniej przechwyconej cyfrze znajduje się dowolna cyfra dowolną ilość razy (kwantyfikator „*”), w tym również cyfra dopasowana wcześniej (symbol „\1”).

Oznacza to, że całe dopasowanie zostanie spełnione tylko wtedy, gdy dowolna cyfra znajduje się w ciągu tylko jeden raz. Każde kolejne wystąpienie tej samej cyfry natychmiast kończy analizowanie wyrażenia regularnego i zwraca wartość false.

Istnieją jeszcze wyprzedzenia pozytywne, co oznacza, że wyrażenie jest spełnione, jeśli po wcześniejszym elemencie ZNAJDUJE się określone dopasowanie.

Powtórzę jednak jeszcze raz – wyprzedzenia pozytywne i negatywne nie są grupami, a więc nie ma możliwości odniesienia się do nich jako do odwołań wstecznych. Co więcej, nie zostaną one również zwrócone w wynikach dopasowania, np. w metodzie match.

Nasze wyrażenie moglibyśmy jednak zapisać także w nieco innej formie:

var reg = /^(?!\d*(\d)\d*\1)+$/;

reg.test('1234'); //true
reg.test('2322'); //false

Powyższe wyrażenie od razu zaczyna się wyprzedzeniem, otoczonym znakami początku i końca ciągu. Oznacza to, że zostanie ono spełnione jeśli w całym ciągu nie wystąpi ani razu dopasowanie zawarte wewnątrz „(?! … )”. Ponownie wyrażenie zostaje spełnione tylko dla ciągu zawierającego unikalne cyfry 0-9.

Wyprzedzenia i grupy przechwytujące to potężne narzędzie przy analizie skomplikowanych ciągów gdy konieczne jest stworzenie rozbudowanych RegExp. Niestety w JavaScript na chwilę obecną nie ma konstrukcji umożliwiających sprawdzanie dopasowań wstecznych. Są pewne sztuczki na poradzenie sobie z tym problemem ale omówimy je w oddzielnym artykule.

To nie wszystkie informacje związane z analizą wyrażeń grupowanych w JavaScript. Do tematu powrócimy jeszcze przy okazji omawiania metody String.prototype.replace, która daje bardzo duże możliwości współpracy z wyrażeniami regularnymi i analizą dopasowań wzorców.