Wyrażenia regularne – część 2 (znaki alfanumeryczne)

W drugiej części poradnika o wyrażeniach regularnych zajmiemy się problemem przeszukiwania ciągów znakowych z wykorzystaniem zakresów oraz grup znaków w celu dopasowania znaków alfanumerycznych.

W artykule zajmiemy się dwoma problemami, które mogą wystąpić w kodzie produkcyjnym:

  • sprawdzenie poprawności zapisu wartości koloru w systemie szesnastkowym,
  • sprawdzenie, czy ciąg zawiera wyłącznie znaki alfanumeryczne.

Zadanie 1 – czy kolor jest poprawnym zapisem hex?

Załóżmy, że potrzebujemy stworzyć funkcję, która pobiera jako argument kolor w zapisie szesnastkowym i zwraca go w zapisie rgb lub innym. Naszym zadaniem jest sprawdzenie, czy przekazany argument to faktycznie poprawnie zapisany kolor w systemie szesnastkowym.

Zakładamy, że szesnastkowy kod koloru przekazujemy jako wartość typu string, poprzedzoną symbolem „#”, po którym może znajdować się grupa trzech albo sześciu znaków. Znakami tymi mogą być cyfry od 0-9 lub litery od a do f (zarówno małe jak i wielkie litery).

Sprawdzenie można by wykonać np. poniższym wyrażeniem regularnym (w dalszej części spróbujemy je uprościć):

function hex(str) {
    return /^#[\da-f]{3}$|^#[\da-f]{6}$/i.test(str);
}
hex('#12ff32'); //true
hex('#1fc'); //true
hex('3345ff'); //false - brak '#'
hex('#ffcd'); //false - tylko 3 lub 6 znaków i '#'
hex('#3385FF'); //true
hex ('3279cj'); //false - znaki 0-9 lub a-f

Ok, omówmy zatem kolejne elementy naszego wyrażenia regularnego. Na początku odszukaj w wyrażeniu symbolu alternatywy OR („|”), który w pewnym sensie dzieli nasze wyrażenie na dwa oddzielne. Jeśli pierwsze wyrażenie (z lewej strony OR) zwróci false, to sprawdzane jest drugie i dopiero gdy oba zakończą się niepowodzeniem to metoda test() zwróci wynikowo wartość false.

Zauważ, że w naszym wyrażeniu dwa razy użyłem symboli oznaczających początek i koniec przeszukiwanego tekstu („^” i „$”). Zostały one omówione w pierwszej części cyklu artykułów o RegExp. Ponieważ sprawdzamy zarówno początek jak i koniec ciągu to nie ma sensu stosowanie flagi przeszukiwania globalnego „g”.

W każdej z grup sprawdzamy, czy wyrażenie składa się ze znaku „#”, który znajduje się na pierwszej pozycji w analizowanym ciągu. Po nim występują trzy lub sześć znaków z podanego zakresu, czyli „\d” lub „a-f”.

Symbol „\d” jest skróconym zapisem oznaczającym to samo co „0-9”, czyli dowolną cyfrę.  Zapis „a-f” oznacza literę a,b,c,d,e lub f, a w połączeniu z flagą „i” pozwoli również dopasować wielkie litery A,B,C,D,E,F. Tworząc wyrażenia regularne zawsze najlepiej zacząć pracę od stworzenia testów i dokładnej analizy przypadków zarówno pozytywnych jak i negatywnych. Bez testów nie da się stworzyć bardziej rozbudowanych wyrażeń, szczególnie gdy mają one badać dane wprowadzane przez użytkowników (np. w formularzu html).

Zapis „{3}” również był już omawiany w pierwszej części więc nie będziemy tutaj powielać informacji. Oznacza on, że nasza grupa znaków ujęta w nawiasy kwadratowe musi wystąpić dokładnie trzy lub sześć razy.

Zadanie 1 – Wariant drugi z użyciem grupowania

Wcześniejsze wyrażenie można nieco uprościć, stosując dodatkowo tzw. grupy:

function hex(str) {
    return /^#(?:[\da-f]{3}){1,2}$/i.test(str);
}

Testy z poprzedniego przykładu zakończą się takim samym rezultatem. W wielu sytuacjach wyrażenia regularne można zapisać na co najmniej kilka sposobów, dlatego nie należy uczyć się ich na pamięć, lecz starać się zrozumieć zasadę działania.

W tym wypadku nie mamy już alternatywy OR. Zamiast niej założyliśmy, że wyrażenie powinno dopasować grupę trzech znaków, która może pojawić się w całym ciągu jeden lub dwa razy, co de facto oznacza trzy lub sześć znaków hex. Dodatkowo na początku musi znajdować się symbol „#”.

Nawiasy okrągłe „( … )” oznaczają grupę i cała dopasowana grupa musi wystąpić dokładnie 1 lub 2 razy. Na początku grupy zastosowałem symbol „?:”, który oznacza tzw. grupę nieprzechwytującą. Temat ten będzie omówiony w części trzeciej. W naszym przykładzie możesz równie dobrze pominąć „?:”, a wyrażenie i tak będzie działać poprawnie, ale zalecam już od początku nauki RegExp wyrobienie sobie nawyków świadomego przechwytywania lub ignorowania grup znaków.

Zadanie 2 – Sprawdzenie czy ciąg zawiera tylko znaki alfanumeryczne

Znaki alfanumeryczne oznaczają wyłącznie cyfry lub litery alfabetu. Teoretycznie mogłoby się wydawać, że zadanie to jest dość proste. Spójrzmy jednak na poniższy kod, spotykany w niektórych poradnikach online czy nawet książkach:

function alfanum(str) {
    return /^[a-z]+$/i.test(str);
}

alfanum('abcd'); //true
alfanum('123abc'); //true
alfanum('34ABcd'); //true
alfanum('Ćwiczenie'); //false ups...
alfanum('-45'); //false ups...

Pierwszy trzy testy zwracają oczekiwaną wartość true. Co jednak stało się w czwartym wywołaniu naszej funkcji? Otóż należy pamiętać, że zakres „a-z” odnosi się nie tyle do samych liter co do ich kodów znakowych w tabeli ASCII (tak naprawdę analizujemy tablicę znaków Unicode, w której zawarta jest również tablica ASCII ale do tego wrócimy w kolejnych artykułach).

Zakres „a-z” oznacza zatem znaki od kodu 97 (lub 61 w hex) do kodu 122 (lub 7a w hex). Jest to alfabet amerykański, w którym nie występują polskie znaki takie jak ą,ś,ć,ę,ć,ó,ł,ź,ż. Tworząc wyrażenia sprawdzające ciągi zawierające litery zawsze należy wiedzieć, na jakim alfabecie pracujemy. Poprawmy więc nasze wyrażenie:

function alfanum(str) {
    return /^[a-ząęćśółźż]+$/i.test(str);
}

alfanum('abcd'); //true
alfanum('123abc'); //true
alfanum('34ABcd'); //true
alfanum('Ćwiczenie'); //true - teraz jest ok!
alfanum('-45'); //false ups...

Przeoczyliśmy również inny problem, który wystąpił w ostatnim wywołaniu funkcji alfanum. Na początku założyliśmy, że ciąg może zawierać cyfry, litery lub cyfry i litery. Co jednak z liczbami ujemnymi, czy je również dopuszczamy jako spełniające test?

Jeśli tak, to mamy kolejny problem. Czy dopuszczamy wtedy występowanie również liter, czy wyłącznie samych cyfr? W pierwszej chwili niektórzy mogą sobie pomyśleć, że przecież ujemne mogą być tylko liczby a nie ciągi zawierające litery alfabetu. No dobrze, a co z możliwością zapisu ujemnej liczby w systemie szesnastkowym, czyli składającym się zarówno z cyfr jak również liter od „a” do „f”?

W naszym przykładzie założymy jednak, że nie dopuszczamy wartości ujemnych gdyż musielibyśmy zbyt mocno rozbudować wyrażenie, co nie do końca jest przedmiotem tego artykułu (może w kolejnych wpisach powrócimy do tego problemu). W związku z tym zakładamy, że ostatnie wywołanie zwraca oczekiwaną przez nas wartość false.

Zakresy małych i wielkich liter alfabetu

Często zdarza się, że musimy sprawdzać w wyrażeniach regularnych występowanie w ciągu znakowym dużych i małych liter alfabetu. Najlepszym i najbezpieczniejszym rozwiązaniem jest stosowanie w tym celu zapisu „a-z” lub „A-Z” z zastosowaniem flagi „i”.

Wbrew niektórym opiniom z poradników i książek flaga „i” nie oznacza ignorowania wielkości liter. Tak naprawdę niejawnie rozszerza ona zakres podany w wyrażeniu regularnym, czyli nasz zapis „a-z” przy użyciu flagi „i” będzie niejawnie zamieniony na „A-Za-z”.

Kilka razy zdarzyło mi się jednak widzieć również zapis „A-z”, który był opisywany jako skrócona forma „A-Za-z”. Nigdy nie stosuj takiej formy zapisu zakresu znaków alfabetu! Przeanalizujmy poniższy kod:

/^[A-z]+$/.test('abcd'); //true
/^[A-z]+$/.test('abCD'); //true
/^[A-z]+$/.test('abCD[xx]'); //true ups...

Dwa pierwsze testy zwracają oczekiwaną wartość true. Spójrzmy jednak dokładnie na trzeci przykład, gdzie w analizowanym ciągu znakowym zastosowano nie tylko litery alfabetu lecz również symbole nawiasów kwadratowych.

Dlaczego jednak otrzymaliśmy wartość true, skoro nawiasy nie należą przecież do liter alfabetu?

Otóż musimy wrócić do wcześniejszej części tego artykułu, gdzie mówiłem, że zakresy podane w nawiasach kwadratowych wyrażenia regularnego oznaczają tak naprawdę zakresy kodowe znaków, czyli u nas zakres znaków ASCII od kodu oznaczającego dużą literę „A” do kodu małej litery „z”.

W tym miejscu polecam abyś znalazł w internecie tablicę znaków ASCII i dokładnie przeanalizował kody między znakami „A” oraz „z”. Jak zobaczysz, na pozycjach kodowych od 91 do 96 znajdują się znaki nie będące literami, w tym właśnie nasze nawiasy kwadratowe.

Z tego względu, jak już wspominałem, bardzo złą praktyką jest stosowanie zapisów „A-z” gdyż umożliwiają poprawną walidację danych, które najprawdopodobniej powinny zostać odrzucone. Zamiast tego zawsze stosuj zapis [a-z] lub [A-Z] wraz z flagą „i”.

To jeszcze nie koniec problemów związanych ze znakami alfanumerycznymi

Jak widać tworzenie poprawnych wyrażeń regularnych wymaga bardzo dobrego rozpoznania środowiska w jakim pracujemy (tutaj odnosi się to do stosowanego przez użytkownika alfabetu) jak również odpowiedniego zestawu testów.

W naszych przykładach pominęliśmy jeszcze pewien drobny szczegół. Otóż chcemy dowiedzieć się, czy wpisany przez użytkownika ciąg zawiera wyłącznie znaki alfanumeryczne. Czy jednak na pewno dobrze sprawdziliśmy wszystkie przypadki?

/^[\da-z]+$/i.test('abcd'); //true
/^[\da-z]+$/i.test('abCD'); //true
/^[\da-z]+$/i.test('ab CD'); //false ups...
/^[\da-z ]+$/i.test('ab CD'); //true
/^[\da-z ]+$/i.test('ab 123'); //true

Jak widać w naszym wyrażeniu zapomnieliśmy, że ciąg może zawierać również spację. Czy jednak na pewno jest to błąd? Otóż w tym miejscu należy zastanowić się jakiego typu informacje testujemy. Jeśli jest to sprawdzenie hasła przy zakładaniu konta to raczej mało prawdopodobne abyśmy faktycznie dopuszczali zastosowanie znaku spacji. Jeśli jednak jest to wartość, będąca treścią wiadomości, którą za chwilę chcemy przesłać e-mailem to jak najbardziej spacja może i na 99% wystąpi w analizowanym tekście. Ponownie więc powiem – nigdy nie ucz się RegExp na pamięć i pisz testy. W wielu przypadkach znacznie dłużej będziesz analizować wszystkie możliwe przypadki true/false niż pisać samo wyrażenie regularne.

No dobrze, został więc jeszcze jeden mały szczegół. W wielu poradnikach można znaleźć informacje, że zalecane jest korzystanie z form skróconych jak „\d” zamiast „0-9”. W odniesieniu do liter alfabetu amerykańskiego również istnieje taka forma, która jednocześnie łączy w sobie wielkie i małe litery oraz cyfry, a więc szukane przez nas znaki alfanumeryczne – symbol „\w”.

Można by więc przypuszczać, że powyższe wyrażenie warto skrócić do postaci:

/^[\w]+$/.test('abcd'); //true
/^[\w]+$/.test('abCD'); //true
/^[\w]+$/.test('ab CD'); //false - znowu spacja!
/^[\w ]+$/.test('ab CD'); //true
/^[\w ]+$/.test('ab 123'); //true

/^[\w ]+$/.test('ab_cd'); //true ups...

Zwróć uwagę na dwie kwestie. Po pierwsze możemy zrezygnować ze stosowania flagi „i” gdyż symbol „\w” zawiera w sobie zapis „A-Za-z”, a dodatkowo cyfry, czyli zapis „0-9”. Należy jednak pamiętać, że w symbolu „\w” zawarty jest również (z bliżej nieokreślonych powodów) znak dolnego podkreślenia „_”.

Zanim zastosujesz skróconą formę „\w” zastanów się więc czy dopuszczasz stosowanie podkreślenia. Z pewnością będzie to poprawny symbol w przypadku sprawdzania adresów e-mail ale już nie zawsze jest on dopuszczalny przy wyborze nazwy użytkownika czy w hasłach podczas próby rejestracji.

Pamiętaj jeszcze o jednej kwestii – o granicach przeszukiwanego ciągu. W naszych przykładach założyliśmy, że cały ciąg musi składać się ze znaków alfanumerycznych dlatego obowiązkowo należy zastosować granice „^” i „$”. Nie są one konieczne gdybyśmy chcieli uzyskać odpowiedź na pytanie „Czy w ciągu znajdują się m.in. znaki alfanumeryczne?”.