Zaawansowane wyrażenia regularne. Wszystko o regexach
Wyrażenia regularne, znane też jako „regexy”, to potężne narzędzie w rękach każdego programisty, obecne w znakomitej większości współczesnych języków programowania. Dzięki nim możemy bardzo szybko sprawdzić, czy przetwarzany tekst pasuje do założonego wzorca, przechwycić jego interesujący fragment lub zastąpić go innym, oraz wykonać wiele innych operacji. W tym artykule zaprezentuję kilka mniej znanych możliwości wyrażeń regularnych dostępnych w bibliotece PCRE.
Tomasz Kowalczyk. Architekt oprogramowania w GOG.com z ponad 10 latami doświadczenia w branży aplikacji webowych, prelegent na wielu konferencjach technicznych, entuzjasta programowania funkcyjnego, autor kilku bibliotek open source. Uwielbia usuwać kod i tworzyć minimalne oraz łatwe w utrzymaniu rozwiązania. Znajdziesz go na Twitterze: twitter.com/tmmx oraz stronie domowej: kowalczyk.cc .
Ten silnik wyrażeń regularnych jest bezpośrednio używany m.in. w PHP, HHVMie, Perlu, i Nginxie, ale niektóre z opisanych funkcji są dostępne także w innych środowiskach — m.in. w Rubym, Pythonie, JavaScripcie lub Javie. Czasem używają też innej składni. Jeśli korzystasz z innego środowiska, jest szansa, że ono także wspiera opisane niżej możliwości. Sprawdź w dokumentacji lub napisz do mnie, chętnie podpowiem, jak rozwiązać Twój problem — dane kontaktowe znajdują się na końcu artykułu.
Spis treści
Procedury
Bardzo przydatną techniką w programowaniu jest podejście „dziel i zwyciężaj”, w którym dzielimy problem na mniejsze części, dzięki czemu możemy zmierzyć się z każdą oddzielnie, a następnie skonstruować z nich pełne rozwiązanie. Wyrażenia regularne bardzo często są pisane jako jedna linia tekstu bezpośrednio w wywołaniu odpowiedniej funkcji w wybranym języku programowania. W swojej karierze często widywałem w projektach fragmenty kodu (w tym przypadku języka PHP) bardzo podobne do:
if(preg_match('~[super](?complex).regex(?=string)$~')) { // ... }
Mimo że taki kod (zazwyczaj) działa poprawnie, może powodować problemy z czytelnością i zrozumieniem co dokładnie jest sprawdzane w tym warunku. Na szczęście jest na to sposób — możemy zastosować tzw. „procedury” (ang. subroutines), dzięki którym podzielimy wyrażenie na mniejsze części, a następnie wykonamy je, konstruując z nich całe wyrażenie. Procedury tworzymy używając bloku DEFINE, który pozwala na zadeklarowanie dowolnej ilości nazwanych grup, które następnie można wykorzystać w dalszej części wyrażenia.
Zdefiniujmy zatem dwie grupy: letters, która będzie przechwytywała litery alfabetu, oraz digits, która analogicznie przechwyci cyfry. Modyfikator x powoduje, że białe znaki są ignorowane, dzięki czemu możemy zapisać wyrażenie regularne w bardziej czytelny sposób:
~(?(DEFINE) (?<letters>[a-zA-Z]+) (?<digits>[0-9]+) )~x
Mając definicje grup, możemy ich użyć poprzez specjalną grupę z symbolem &, np. (&digits). Jeśli chcielibyśmy sprawdzić, czy tekst JustJoin2018 spełnia wymagania naszego wyrażenia regularnego, wystarczy, że za grupą DEFINE umieścimy właściwe wyrażenie odwołujące się do uprzednio zdefiniowanych grup:
~ (?(DEFINE) (?<letters>[a-zA-Z]+) (?<digits>[0-9]+) ) ^(&letters)(&digits)$ ~x
Zgodnie z powyższym opisem białe znaki nie mają znaczenia, grupa DEFINE dostarcza jedynie definicje, natomiast kolejne instrukcje (od znaku ^) kolejno sprawdzają pozycję na początku tekstu, znaki alfabetu, cyfry, oraz pozycję na końcu tekstu.
Sekwencja ucieczki
Zdarza się, że podczas przetwarzania danych trafiamy na fragmenty tekstu, które wykorzystują symbole obecne w składni wyrażeń regularnych — nawiasy, znak plusa, gwiazdki, oraz inne tego typu. W takim przypadku możemy zastosować tzw. znak ucieczki (ang. escape character), poprzedzając nim wszystkie problematyczne symbole, przez co będą one interpretowane jak zwykłe znaki. Tym znakiem w większości środowisk jest backslash – jeśli chcemy być pewni, że znak nawiasu otwierającego grupę jest interpretowany dosłownie, wystarczy, że zamiast ( w wyrażeniu regularnym zapiszemy (.
Czasem jednak tych znaków jest więcej — poprzedzanie każdego z nich jest uciążliwe oraz nierzadko generuje błędy w miejscach, które przeoczyliśmy. Na to też istnieje rozwiązanie — jest to tzw. „sekwencja ucieczki” (ang. escape sequence), która składa się ze specjalnego znacznika otwierającego Q oraz zamykającego E. Wszystko, co znajdzie się między nimi jest traktowane jako zwykły znak. Jeśli chcielibyśmy sprawdzić, czy tekst ([0-9]+)* zawiera sam siebie, wystarczy nam następujące wyrażenie regularne:
~^Q([0-9]+)*E$~
Wewnętrzna część wyrażenia regularnego została potraktowana jako zwykły tekst i dopasowana do wzorca. Ta funkcja PCRE bardzo przydała mi się podczas podczas pracy z projektem parsera do formatu danych, który składał się w sporej części właśnie ze znaków specjalnych.
Warunki
Jedną z najbardziej podstawowych instrukcji języków programowania jest warunek (ang. conditional), który jest zazwyczaj zapisywany przez konstrukcję:
if(condition) { true; } else { false; }
W tym przykładzie condition oznacza sprawdzany warunek, a true oraz false oznaczają warianty kodu, który wykona się odpowiednio jeśli warunek jest prawdziwy lub fałszywy. Wyrażenia regularne także oferują tego typu możliwości poprzez specjalną grupę warunkową, której składnia wygląda następująco:
~(?(condition)true|false)~
Nazwy użyte w przykładzie odpowiadają tym z poprzedniego przykładu — w tym kontekście jednak condition, true, oraz false oznaczają odpowiednio wyrażenie, które musi zostać dopasowane oraz warianty wyrażeń, które wykonają się w zależności od jego wyniku. Jeśli chcielibyśmy dopasować wartość „JustJoin”, jeśli aktualna pozycja w tekście rozpoczyna się od słowa „Just”, a w przeciwnym przypadku dopasować jedynie ciąg cyfr, wystarczy, że użyjemy wyrażenia:
~(?(?=Just)JustJoin|d+)~
Wykorzystując tekst JustJoin2018 z poprzednich przykładów, możemy sprawdzić, czy cały tekst podlega opisanej wyżej regule przez wyrażenie:
~^(?(?=Just)JustJoin|d+)+$~
Odwołania
Pomimo swoich szerokich możliwości, wyrażenia regularne nie pozwalają na definiowanie zmiennych — nie możemy zapisać dowolnej wartości pod wybranym identyfikatorem i później nią manipulować. Możemy jednak odwoływać się do istniejących nazwanych grup i wykorzystywać przechwycone wartości do dalszego przetwarzania. Tego typu odwołania nazywane są „odwołaniami wstecznymi” (ang. backreferences), głównie dlatego, że pozwalają na wykorzystanie już przetworzonych części wyrażenia w aktualnym kontekście.
W silniku PCRE możemy ich użyć na kilka sposobów:
- wykorzystując nazwę docelowej grupy: (?&foo) oznacza odwołanie do wcześniej zdefiniowanej grupy foo,
- wykorzystując bezwzględną pozycję docelowej grupy w całym wyrażeniu: (?3) oznacza trzecią grupę licząc od początku całego wyrażenia,
- wykorzystując relatywną pozycję względem odwołania: g{-2} oznacza drugą grupę poprzedzającą odwołanie, analogicznie g{3} oznacza trzecią grupę następującą po odwołaniu.
Jednym z podstawowych zastosowań jest sprawdzanie, czy w kilku miejscach pojawia się ta sama wartość. Jeśli w tekście:
Just2018
Join2018
It2018
Yeah2018
chcielibyśmy sprawdzić, czy każda linia kończy się tą samą liczbą, możemy to osiągnąć w następujący sposób:
~^Just(?<num>2018)nJoin(?&num)nIt(?1)nYeahg{-1}$~
Za każdym razem sprawdzamy konkretny fragment tekstu, tj. słowo oczekiwane w danej linii, ale w pierwszej z nich przechwytujemy oczekiwaną liczbę, aby w kolejnych jedynie odwołać się do niej — kolejno po nazwie num, bezwzględnej pozycji 1, oraz pozycji względnej -1. Jeśli chcielibyśmy po prostu sprawdzić, czy każda z linii kończy się liczbą, wystarczy, że zmienimy 2018 na d+.
Rekurencja
Kolejną często wykorzystywaną funkcją obecną w wielu językach programowania jest rekurencja. Jest ona także obecna w wyrażeniach regularnych jako tzw. grupa rekurencyjna (?R). Możliwości rekurencji w tym przypadku są ograniczone i ich działanie sprowadza się do ponownego przetworzenia całego wyrażenia.
Popularnym przykładem wykorzystania rekurencji jest tzw. balansowanie struktur (ang. balancing constructs), tj. sprawdzanie, czy po obu stronach znajduje się tyle samo elementów wymaganych typów. Jeśli chcielibyśmy sprawdzić, czy nasz tekst {{{}}} zawiera tyle samo nawiasów otwierających i zamykających, możemy wykorzystać następujące wyrażenie:
~{(?R)?}~
W ten sposób wyrażenie najpierw sprawdza, czy tekst zaczyna się od nawiasu otwierającego, po czym grupa rekurencyjna „wchodzi” ponownie do całego wyrażenia, aż trafi na pierwszy nawias zamykający. W tym momencie dalsze przetwarzanie nie uda się, ale zauważamy, że grupa rekurencyjna jest „opcjonalna” (kończy się znakiem zapytania), przez co możliwe jest dalsze przetwarzanie.
Wszystkie znaki zamykające są kolejno weryfikowane, a w momencie, gdy ostatni z nich jest sprawdzany na „najwyższym poziomie” silnik widzi, że wyrażenie się kończy, a on sam może poinformować o sukcesie. Jeśli przetwarzanie skończyłoby się wcześniej (za mało znaków zamykających) lub zbyt późno (analogicznie – za dużo), silnik poinformowałby o tym, że konstrukcja nie jest zbalansowana.
Branch reset
Nierzadko wyrażenia regularne są wykorzystywane do przetwarzania tekstu, który zawiera różne informacje w zależności od kontekstu, w którym się znajdują. Do ich przechwycenia można wykorzystać wyżej opisaną konstrukcję warunku lub zwykłą alternatywę, warto jednak być świadomym istnienia funkcji branch reset, której nazwę ciężko przetłumaczyć na język polski nie kalecząc ani nazwy, ani języka polskiego.
Branch reset to specjalna grupa, w której możemy umieścić kilka wariantów. Warianty te zostaną kolejno sprawdzone przez silnik rozpoczynając od dokładnie tej samej pozycji w tekście. Składnia tej konstrukcji wygląda następująco (zwróćmy uwagę na znak | następujący po znaku zapytania):
~(?|[a-zA-Z]+|d+|s+)~
W tym przypadku zostanie dopasowany jeden z wariantów: ciąg liter, ciąg cyfr, albo ciąg białych znaków. Weźmy pod uwagę nasz testowy tekst JustJoin2018 oraz wyrażenie:
(?<foo>(?|[a-zA-Z]+|d+|s+))(?<bar>(?|[a-zA-Z]+|d+|s+))
Najpierw zostaną kolejno przetestowane wyżej opisane warianty na pozycji początkowej tekstu, a pod nazwą foo pojawi się ciąg znaków JustJoin. Następnie cała operacja zostanie powtórzona na pozycji znaku 2, a pod nazwą bar pojawi się ciąg cyfr 2018. Można taką konstrukcję wykorzystać w każdym miejscu, w którym znamy możliwe warianty dalszej zawartości tekstu wejściowego i potrzebujemy jedynie przechwycić je do dalszego przetwarzania zamiast dokładnie sprawdzić który z nich pojawił się w tekście.
Podsumowanie
Mam nadzieję, że artykuł zachęcił Cię do dalszego zgłębiania wiedzy o wyrażeniach regularnych. Mówi się, że jeśli masz problem i użyjesz ich do rozwiązania go, to… masz dwa problemy — dlatego warto znać wykorzystywane narzędzia i ich tajniki na tyle dobrze, żeby móc podjąć odpowiednią decyzję. Ja użyłem „regexów” wiele razy w swojej karierze, nierzadko redukując dziesiątki, a nawet setki linii kodu do pojedynczych wyrażeń, ale znam też przypadki, w których ich wykorzystanie nie było najlepszą decyzją. Wszystko zależy od doświadczenia i świadomości programisty, którą nabywa się poprzez lata pracy nad różnego rodzaju projektami.
Do dalszej lektury polecam stronę internetową regular-expressions.info, która zawiera opisy praktycznie wszystkich funkcji łącznie z informacjami o środowiskach, w których są wspierane. Polecam także stronę regex101.com, na której możesz szybko przetestować nowo nabytą wiedzę.
Jeśli chciał(a)byś zadać mi dodatkowe pytania lub potrzebujesz jakiejkolwiek pomocy (konsultacje, szkolenia, warsztaty) związanej z tworzeniem oprogramowania, zachęcam do odwiedzenia mojej strony internetowej kowalczyk.cc, na której znajdziesz wszystkie potrzebne informacje kontaktowe. Do usłyszenia!
Zdjęcie główne artykułu pochodzi z stocksnap.io