Wyrażenia regularne – zaawansowane dopasowania wyprzedzające

Temat dopasowań wyprzedzających poruszałem już przy okazji omawiania walidacji hasła proponowanego przez użytkownika w czasie rejestracji. Dzisiaj zajmiemy się podobnym zagadnieniem choć nieco bardziej rozbudowanym i omówimy tzw. dopasowania wyprzedzające.

Podstawowe informacje na temat wyrażeń regularnych i dopasowań wyprzedzających (pozytywnych i negatywnych) znajdziesz we wcześniejszych moich wpisach, dlatego dzisiaj skupię się nie tyle na dokładnym omawianiu całej składni lecz na logice wyrażenia. Zobaczymy jak dopasowania wyprzedzające mogą ułatwić przeszukiwanie ciągów o nie do końca znanej budowie.

Zacznijmy więc od zdefiniowania dzisiejszego problemu. Otóż zakładamy, że sprawdzamy ciąg znakowy, który powinien zawierać informacje takie jak name,age oraz skill. Dla uproszczenia załóżmy, że w umiejętnościach każdej osoby (skill) podamy tylko jeden język programowania. Nasze końcowe wyrażenie można w prawdzie zmodyfikować tak, aby uwzględniało również podanie kilku języków (np. PHP, JavaScript) ale problem ten nie jest ściśle związany z wyprzedzeniami regexp więc pozostawiam to zadanie czytelnikowi (w razie czego gdyby ktoś był zainteresowany dalszym „rozwojem” problemu to chętnie pomogę). Zakładamy również formę zapisu „name:xxx,age:111,skill:xxx” bez spacji czy innych białych znaków aby nadmiernie nie komplikować i nie wydłużać naszego wyrażenia i skupić się na sprawach najważniejszych.

Umożliwiamy natomiast podawanie „name/age/skill” w dowolnej kolejności z jednym warunkiem: w ciągu znakowym muszą znaleźć się wszystkie trzy informacje i muszą one posiadać prawidłowe wartości. Jak się za chwilę okaże, ten punkt stanowi jeden z poważniejszych problemów…

Dodatkowo zakładamy, że zadanie to musimy rozwiązać z użyciem wyłącznie jednego wyrażenia regularnego, bez pisania bardziej rozbudowanych funkcji.

Na początek testy…

Na samym początku proponuję stworzenie dokładnych testów dla naszego problemu. W kodzie produkcyjnym kwestię testów jednostkowych wykonuje się w nieco innej formie, lecz tutaj zdecydowałem się na stworzenie dwóch tablic, zawierających poprawne i niepoprawne ciągi znakowe.

var arrPositive = [
        'name:Tomek,age:31,skill:JavaScript',
        'age:50,name:Adam,skill:JavaScript',
        'age:30,skill:Perl,name:Konrad',
        'skill:JavaScript,age:36,name:Pawel',
        'name:Daria,skill:Perl,age:42',
        'skill:PHP,name:Jakub,age:56'],
    arrNegative = [
        'name:,age:20,skill:',
        'name:Adam,age:dd,skill:',
        'age:15,name:5555,skill:123',
        'jakis przypadkowy tekst',
        'name,age,skill',
        'name:Adam,age:60',
        'skill:PHP,name:Michal',
        'name:Tomek,age:30,skill:PHP,',
        ',name:Tomek,age:30,skill:PHP'];

W przypadku ciągów nieprawidłowych sprawdzam kilka możliwości jak brak wartości dla któregoś z elementów (name/age/skill) lub w ogóle brak wszystkich trzech elementów. Ponad to w dwóch ostatnich pozycjach (tablica arrNegative) jako błąd traktujemy ciąg, który zaczyna się lub kończy znakiem przecinka (co jak się okaże może stworzyć pewne problemy przy konstruowaniu wzorca regexp).

Stworzymy sobie jeszcze prostą funkcję, której zadaniem będzie wyświetlenie w konsoli wyniku testu (TRUE/FALSE) oraz ciągu, jaki był analizowany. Do realizacji testów de facto lepsza jest metoda console.assert() ale większość początkujących programistów (a dla takich przeznaczony jest ten blog) posługuje się głównie console.log() dlatego pozostaniemy przy tej metodzie (artykuł nie traktuje o metodach realizacji testów lecz skupimy się na konkretnym problemie).

function test (array,regex) {
    array.forEach(v => {
        console.log(`${regex.test(v)}: ${v}`);
    });
}

No to zaczynamy tworzenie wzorca RegExp

Część osób w pierwszym odruchu może próbować napisać wyrażenie podobne do poniższego:

var reg = /^(name:[a-z]+,)(age:\d+,)(skill:[a-z]+)$/i;

test(arrPositive, reg);
//true: name:Tomek,age:31,skill:JavaScript
//true: name:Michal,age:45,skill:Java
//true: name:Aneta,age:25,skill:PHP
//false: age:50,name:Adam,skill:JavaScript
//false: age:30,skill:Perl,name:Konrad
//false: skill:JavaScript,age:36,name:Pawel

Byłoby ono poprawne, gdybyśmy nie umożliwili dowolnej kolejności podawania poszczególnych danych name/age/skill. istnieje więc sześć różnych kombinacji (prawidłowych, czyli bez pomijania którejś z wartości).

Teoretycznie można by rozbudować powyższe wyrażenie w formie alternatywy OR (znak „|”) ale musielibyśmy stworzyć aż sześć wersji. A co jeśli musielibyśmy analizować nie trzy lecz cztery informacje, np. name/age/city/skill? W tym momencie mamy już nie sześć lecz aż 24 możliwe kombinacje!

Spójrzmy jednak na sposób analizy poszczególnych elementów. Dla „name:” i „skill:” poszukujemy ciągu znaków [a-z], przy czym wymagamy co najmniej jednego takiego znaku (kwantyfikator „+”).

Dalej analizujemy wiek (age) gdzie poszukujemy wyłącznie cyfr (co najmniej jednej). Należy w tym wypadku zaznaczyć, że „\d” dopasuje cyfry w zakresie [0-9] więc teoretycznie jako poprawny traktujemy wiek „0” (zero). Pozostawimy jednak taką formę aby niepotrzebnie nie zaciemniać naszych wyrażeń. Pamiętaj jednak o tym zawsze wtedy, gdy będziesz chciał wyrażeniem regularnym analizować wiek (warto wtedy zastanowić się nad sensowną granicą dolną i ewentualnie również górną).

No to drugie podejście do naszego wzorca regexp…

No to spróbujmy nieco zmodyfikować nasze wyrażenie. W tym wypadku nie określamy dokładnego położenia dopasowania name/age/skill lecz narzucamy warunek, że dopasowania te muszą znaleźć się w ciągu. Robimy to poprzez zapis „^(?=…..)(?=…..)(?=…..)”. Oznacza to, że po znalezieniu początku ciągu znakowego „^” musi wystąpić każde z wymienionych dopasowań (lecz w dowolnej kolejności).

Ważne jednak, aby przed każdym z nich zastosować dodatkowo zapis „.*” czyli dopasowanie dowolnych znaków. Właśnie ten zapis określa nam dowolną kolejność dopasowań. Dla „age” dowolne znaki znajdujące wcześniej to również np. dopasowanie name czy skill. Jeśli pominiemy zapis „.*” to de facto nadal będziemy wymuszali dokładnie określoną kolejność wystąpień.


var reg = /^(?=.*name:[a-z]+,?)(?=.*age:\d+,?)(?=.*skill:[a-z]+,?).+$/i;
//Testy pozytywne: oczekiwane TRUE
test(arrPositive,reg);
//true: name:Tomek,age:31,skill:JavaScript
//true: name:Michal,age:45,skill:Java
//true: name:Aneta,age:25,skill:PHP
//true: age:50,name:Adam,skill:JavaScript
//true: age:30,skill:Perl,name:Konrad
//true: skill:JavaScript,age:36,name:Pawel

//Testy negatywne: oczekiwane FALSE
test(arrNegative,reg);
//false: name:,age:20,skill:
//false: name:Adam,age:dd,skill:
//false: age:15,name:5555,skill:123
//false: jakis przypadkowy tekst
//false: name,age,skill
//false: name:Adam,age:60
//false: skill:PHP,name:Michal
//true: name:Tomek,age:30,skill:PHP,  UPS...
//true: ,name:Tomek,age:30,skill:PHP  UPS...

Hmm, wszystko ok za wyjątkiem ostatnich dwóch testów negatywnych gdzie na końcu lub na początku ciągu znajduje się przecinek, gdy w poprawnym ciągu powinny być tylko dwa przecinki wewnątrz, bez takiego znaku na końcu lub początku.

No to kolejna modyfikacja wyrażenia regularnego…

Pierwszym odruchem może być próba zamienienia końcowego zapisu „.+$” na zapis „[a-z\d]+$”, ale od razu uprzedzę, że jest to bardzo zły pomysł. Pierwsze trzy grupy to tzw. wyprzedzenia pozytywne, i dopiero po nich znajduje się element faktycznie dopasowywany, czyli u nas „.+” (dowolny znak co najmniej raz). Jeśli zamienimy to na „[a-z\d]” to wyrażenie regularne zakończy się w momencie natrafienia na pierwszy dwukropek, czyli żaden z testów dla arrPositive nie da wartości true.

Musimy więc nieco zmodyfikować końcowy fragment naszego wyrażenia:

var reg = /^(?=.*name:[a-z]+,?)(?=.*age:\d+,?)(?=.*skill:[a-z]+,?)[a-z][a-z\d,:]+[a-z\d]$/i;

//Testy pozytywne: oczekiwane TRUE
test(arrPositive,reg);
//true: name:Tomek,age:31,skill:JavaScript
//true: name:Michal,age:45,skill:Java
//true: name:Aneta,age:25,skill:PHP
//true: age:50,name:Adam,skill:JavaScript
//true: age:30,skill:Perl,name:Konrad
//true: skill:JavaScript,age:36,name:Pawel

//Testy negatywne: oczekiwane FALSE
test(arrNegative,reg);
//false: name:,age:20,skill:
//false: name:Adam,age:dd,skill:
//false: age:15,name:5555,skill:123
//false: jakis przypadkowy tekst
//false: name,age,skill
//false: name:Adam,age:60
//false: skill:PHP,name:Michal
//false: name:Tomek,age:30,skill:PHP,
//false: ,name:Tomek,age:30,skill:PHP

Zwróć uwagę, że de facto rodzaj znaków określa tutaj wyrażenie:

^[a-z][a-z\d,:]+[a-z\d]$

Dodatkowe dopasowania wyprzedzające umieszczone po znaku „^” są tylko pewnego rodzaju dodatkiem. Wymagamy więc, aby cały ciąg składał się z następujących elementów:

  • znak [A-Za-z] jako pierwszy znak ciągu,
  • kolejne znaki to: [A-Za-z0-9,:] co najmniej jeden raz
  • i na końcu ostatnim znakiem musi być [A-Za-z0-9]

Zakładamy więc, że nasz ciąg musi mieć co najmniej trzy znaki, lecz tak na prawdę ten element wyrażenia służy nie tyle do określenie ilości znaków lecz rodzaju, czyli dopuszczalnych zakresów znaków.

A na koniec rozwiązanie alternatywne…

Gdy już opisaliśmy czym są i jak działają wyprzedzenia pozytywne to spróbujemy nasze wyrażenie zapisać w nieco innej postaci, bez używania dopasowań wyprzedzających:

let reg = /^(?:(?:age:\d+|name:[a-z]+|skill:[a-z]+),){2}(?:age:\d+|name:[a-z]+|skill:[a-z]+)$/i;

Jest to wersja alternatywna. Działa ona w następujący sposób:

  • krok 1: szukamy age|name|skill z przecinkiem na końcu „,”,
  • krok 2: jak wyżej (kwantyfikator {2}),
  • krok 3: szukamy age|name|skill, tym razem bez przecinka na końcu.

W rozwiązaniu tym stosujemy alternatywę OR, wyrażaną symbolem „|”, co oznacza, że w każdym dopasowaniu ma się znaleźć albo „age”, albo „name” albo „skill”.

Rozwiązanie to pozytywnie przechodzi wszystkie testy. Czy jest ono lepsze od wcześniejszego, wykorzystującego dopasowania wyprzedzające? Nie ma jednej, dobrej odpowiedzi na to pytanie. Można by się zastanawiać który wariant jest szybszy ale w praktyce mówimy tu o mikrosekundach co w większości przypadków nie ma aż takiego znaczenia (nie jesteśmy tutaj programistami gier…). Nie przesadzajmy na siłę z wydajnością. Często słyszę o konieczności korzystania z tej czy innej metody gdyż jest o ułamek sekundy szybsza, gdy równocześnie te same osoby ładują na stronę zdjęcia zajmujące nawet 1mb (nie dawno miałem okazję analizować stronę, która po wczytaniu zajmowała aż 6,8mb…).

Osobiście wolę w takich wypadkach stosować rozwiązanie z dopasowaniami wyprzedzającymi, gdyż jednoznacznie rozdzielamy wtedy elementy jakie muszą się znaleźć w ciągu od określenia zakresu znaków dopuszczonych w całym ciągu. Jest to jednak kwestia gustu. Polecam jednak zgłębić wiedzę na temat dopasowań wyprzedzających zarówno pozytywnych (?=) jak i negatywnych (?!), gdyż w niektórych sytuacjach ich użycie pozwala zaoszczędzić wielu dodatkowych linii kodu proceduralnego.

Podsumowanie

W naszym przykładzie zakładamy również, że wszystkie dane podawane są w alfabecie łacińskim, czyli nie dopuszczamy stosowania polskich znaków diaktrycznych. Jeśli chcesz to jako ćwiczenie możesz w prosty sposób rozbudować regexp o dopuszczenie „polskich znaków” jak również może warto pomyśleć o ewentualnym zezwoleniu na rozdzielenie kolejnych pól name/age/skill nie tylko przecinkiem lecz również spacją, średnikiem itp.

Dopasowania wyprzedzające nie są czymś co będziemy często stosować w większości wyrażeń, ale warto o ich pamiętać w sytuacjach nieco bardziej skomplikowanych jak chociażby dopuszczenie różnej kolejności występowania dopasowań w ciągu. Oczywiście nasz problem można łatwo rozwiązać także z jednym prostszym wyrażeniem i kilkoma dodatkowymi funkcjami JavaScript, ale osobiście uważam, że warto dobrze znać zasadę działania regexp i umieć z nich świadomie korzystać.