Przeszukiwanie ciągów tekstowych

W dzisiejszym wpisie zajmiemy się problemem przeszukiwania ciągów znakowych, co jest częstym zadaniem w wielu aplikacjach. Przyjrzymy się bliżej kilku metodom obiektu globalnego String.

Przeszukiwanie ciągów znakowych (typu string) polega na sprawdzeniu, czy tzw. podciąg „s” znajduje się w ciągu głównym „str”. JavaScript udostępnia kilka metod ułatwiających przeszukiwanie ciągów typu string, do których należą trzy podstawowe: indexOf, lastIndexOf, search, oraz trzy nowe, wprowadzone w standardzie ECMAScript 6, tj. startsWith, endsWith oraz includes. W artykule nie będziemy zajmowali się metodą search, gdyż dotyka ona zagadnienia wyrażeń regularnych, które nie będą tutaj omawiane.

Ciągi znakowe i dostęp do pojedynczych znaków

Pierwsze trzy metody w przypadku znalezienia w ciągu „str” określonego podciągu „s” zwracają indeks pierwszego wystąpienia szukanego tekstu.

Należy tutaj przypomnieć, że w języku JavaScript ciągi znakowe (string) umożliwiają dostęp do poszczególnych znaków poprzez indeksy, zaczynające się od zera (podobnie jak wygląda dostęp do elementów tablicy):

var str = 'To jest przykładowy tekst';
str[0]; //'T'
str[5]; //'s'
str[str.length-1]; //'t'
str[str.length]; //undefined

Zacznijmy od metody indexOf

Jest to chyba najczęściej spotykana metoda do przeszukiwania ciągów tekstowych (w tym artykule nie zajmujemy się tablicami, więc nie będziemy omawiać wyszukiwania wartości w obiektach Array).

Metoda indexOf pobiera jeden lub dwa argumenty. Pierwszy określa podciąg, czyli tekst którego szukamy w ciągu przeszukiwanym. Drugi, opcjonalny argument oznacza miejsce, czyli indeks znaku, od którego należy rozpocząć przeszukiwanie.

Zwrotnie otrzymamy indeks pierwszego wystąpienia szukanego tekstu lub wartość -1 jeśli tekstu nie znaleziono.

Na początek przeanalizujmy poniższy kod, który za chwilę omówimy krok po kroku:

var str = 'To jest przykładowy tekst';

str.indexOf('jest'); // 3
str.indexOf('o'); //1
str.indexOf('To'); //0

str.indexOf('t'); //6
str.indexOf('t', 6); //6
str.indexOf('t', 7); //20

str.indexOf('pies'); //-1

Na początku zadeklarowaliśmy ciąg do przeszukiwania w zmiennej „str”. Należy pamiętać, że metoda indexOf to metoda obiektu globalnego String, więc musi zostać wywołana na takim właśnie obiekcie, czyli u nas na zmiennej str (typeof str === „string”).

Pierwsze wyszukanie dotyczy ciągu „jest”, który w zmiennej str znajduje się na pozycji 3. Wyszukiwanie działa tutaj na zasadzie zwrócenia indeksu pierwszego znaku ciągu „jest” w momencie znalezienia go w całości w szukanym tekście.

Kolejne dwa wyszukania również kończą się sukcesem i otrzymujemy zwrotnie indeks wystąpienia ciągu „o” oraz „To”.

Przyjrzyjmy się dokładniej następnym wywołaniom metody indexOf. Najpierw szukamy litery „t”, którą znajdujemy na pozycji 6. Metodę indexOf interesuje wyłącznie pierwsze napotkane wystąpienie szukanej wartości.

Następnie określiliśmy również drugi argument, czyli indeks, od którego ma się rozpocząć przeszukiwanie. W praktyce oznacza to, że metoda indexOf zainteresowała się wyłącznie fragmentem ciągu str, czyli „t przykładowy tekst”, nie analizując wcześniejszych znaków. Litera „t” znajduje się na pozycji 6, czyli od indeksu który wskazaliśmy jako punkt startowy. Zwrotnie otrzymujemy jednak nadal wartość 6, czyli miejsce znalezienia litery z szukanego fragmentu ciągu, ale z zachowaniem oryginalnych indeksów.

Przedostatnie wywołanie metody indexOf otrzymało indeks startowy równy 7, a więc nasza litera „t” z pozycji szóstej nie zostanie już wzięta pod uwagę w czasie wyszukiwania. Kolejny raz znak ten pojawia się dopiero na pozycji 20 i taki indeks zostanie zwrócony.

Ostatnie wywołanie próbuje znaleźć słowo „pies”, którego nie ma w naszym tekście, więc zwrotnie otrzymujemy wartość -1.

To teraz przejdźmy do metody lastIndexOf

Metoda lastIndexOf działa w zasadzie podobnie jak indexOf z tą różnicą, że przeszukuje ciąg znakowy od końca, czyli od prawej do lewej. Spójrzmy na poniższy przykład:

var str = 'To jest przykładowy tekst';

str.indexOf('s'); //5
str.lastIndexOf('s'); //23

str.lastIndexOf('s', 23); //23
str.lastIndexOf('s', 22); //5

str.lastIndexOf('s', 4); //-1

Pierwsze wywołanie to znana już metoda indexOf, która znalazła literę „s” na pozycji 5. Kolejne wywołanie to zastosowanie metody lastIndexOf, które zwraca pozycję 23. Stało się tak dlatego, że metoda ta przeszukuje ciąg od końca czyli od indeksu 24 do 0.

Jeśli jako drugi argument wskazaliśmy indeks startowy równy 22, to znaczy, że z przeszukiwania wyłączyliśmy końcówkę tekstu, czyli de facto szukanie dotyczyło „To jest przykładowy tek”. Z tego powodu pierwsze wystąpienie litery „s” (szukając od prawej do lewej) wystąpiło na pozycji 5.

Jeśli wycięliśmy z przeszukiwania ciąg po znaku 4, czyli pozostawiliśmy tylko „To je” to próba znalezienia litery „s” się nie powiedzie, w związku z czym metoda lastIndexOf zwróci wartość -1 gdyż przeszukiwaliśmy ciąg „To je”.

Uwaga na Unicode i wielkość znaków

Przy pracy z metodami indexOf i lastIndexOf należy pamiętać o dwóch ważnych kwestiach: wyszukiwanie znaków w zapisie unicode oraz na wielość liter.

var str = 'To jest przykładowy tekst';

str.indexOf('To'); //0
str.indexOf('to'); //-1

Dlaczego w drugim wywołaniu metody indexOf nie znaleziono tekstu „to”? Otóż dlatego, że tak na prawdę nie wyszukujemy tekstu „to” lecz znaków o konkretnych kodach w systemie Unicode. Nie należy więc zmiennych string traktować jako tekstów, lecz jako ciągów znakowych, w których każdy ze znaków (litera, cyfra, znak) ma określony kod. Z tego względu litery „t” i „T” określone są przez dwa różne kody w zapisie szesnastkowym (odpowiednio \x74 i \x54) więc drugie wywołanie indexOf nie mogło znaleźć w zmiennej str szukanych znaków. Problem ten można rozwiązać np. stosując metodę search, która oczekuje podania szukanego podciągu zapisanego w formie wyrażenia regularnego ale temat ten będzie poruszony oddzielnie, przy okazji omawiania RegExp.

Przeanalizujmy również drugi przykład:

var str = 'To jest nutka: \u266b';

str; //'To jest nutka: ♫'

str.length; //24
str[23]; // '♫'
str.indexOf('♫'); //23
str.indexOf('\u266b'); //23

str.indexOf('266b'); //-1

Podczas ustawiania wartości zmiennej str użyliśmy zapisu „\u266b” aby zapisać znak z systemu Unicode reprezentujący symbol nuty muzycznej. Odwołując się do ostatniego znaku ciągu, czyli indeksu 23 otrzymujemy właśnie ten symbol.

Następnie wyszukujemy w ciągu str nutki poprzez jej zapis jako symbol „♫” oraz jako kod Unicode „\u266b”. W obu przypadkach otrzymujemy zwrotnie indeks 23.

W ostatnim wierszu próbujemy wyszukać ciąg znakowy „266b”. Nie znajdujemy go jednak, gdyż takich znaków nie ma w zmiennej str! Zapis „\u266b” odnosi się do jednego znaku Unicode, dlatego nie ma możliwości wyszukania metodą indexOf fragmentu tego kodu znakowego.

Pamiętajmy również, że białe znaki jak spacje, tabulatory itp. również traktowane są jako oddzielne znaki więc podlegają indeksacji.

Testowanie wystąpienia ciągu w instrukcjach warunkowych

Często spotykaną praktyką jest uzależnianie wykonania jakiś operacji od wystąpienia określonych znaków w analizowanym ciągu, np. przy użyciu instrukcji warunkowej if.

Większość poradników zaleca stosowanie zapisów podobnych do poniższego:

var str = 'kowalski@domena.pl";

if (str.indexOf('@') !== -1) {
    //operacje gdy znaleziono "@"
}
else {
    //operacje gdy nie znaleziono "@"
}

Jak już wspomniano, metody indexOf oraz lastIndexOf w przypadku nieznalezienia szukanego ciągu zwracają wartość -1. W związku z tym chcąc zweryfikować istnienie symbolu „@” należy porównać wynik zwracany metodą indexOf z wartością -1. Oczywiście jest to tylko przykładowy kod i w żadnym wypadku nie jest związany z tzw. walidacją adresów e-mail!

Istnieje jednak również inny, krótszy zapis dla tego samego zadania:

var str = 'kowalski@domena.pl";

if (~str.indexOf('@')) {
    //operacje gdy znaleziono "@"
}
else {
    //operacje gdy nie znaleziono "@"
}

Jest to zapis wykorzystujący operator bitowy NOT (symbol pojedynczej tyldy „~”). Zapis indexOf(‚@’) zwraca nam pozycję wystąpienia „@”, a więc wartość 8. Operator NOT wykonuje z tym momencie następującą operację: -(n+1), gdzie n to właśnie nasz numer 8. Otrzymujemy więc -(8+1) = -9.

Jeśli nie stosujemy operatora porównania (np. ===, !===, >, <= itp.) to wartość zwrócona przez „~indexOf(‚@’)” zostanie niejawnie przekonwertowana na wartość typu boolean. W języku JavaScript ze wszystkich wartości liczbowych tylko zero (a dokładnie -0 i +0) zostanie przekonwertowane na fałsz (false), natomiast każda inna liczba będzie oznaczać prawdę (true).

Wracając do naszego przykładu otrzymaliśmy wartość -9, a więc różną od zera, co oznacza, że jest to wartość true i przystępujemy do wykonywania instrukcji dla przypadku znalezienia znaku „@”.

A co gdybyśmy wyszukiwali litery „k”, którą odnajdziemy na pozycji 0? Przeanalizujmy poniższy przykład:

var str = 'kowalski@domena.pl';

str.indexOf('k'); //0
!!str.indexOf('k'); //false - ups...

~indexOf('k'); //-1
!!~indexOf('k'); //true

Zawsze musimy pamiętać, że szukany ciąg może znajdować się na pozycji zerowej, dlatego nie wolno tworzyć zapisów typu:

if (str.indexOf('x')) { ... }

gdyż jeśli „x” znajduje się pod znakiem str[0] to nasza instrukcja nigdy się nie wykona. Należy zatem zawsze testować czy zwracany index jest równy (bądź nierówny) -1, albo czy zwracany indeks jest większy lub równy 0. Osobiście polecam stosowanie dwóch rozwiązań: wykorzystującego operator bitowy NOT albo wykorzystanie nowej metody ECMAScript 6 o nazwie includes, opisanej w dalszej części artykułu.

Wyszukiwanie ciągów metodami ECMAScript 6

W standardzie ES6 wprowadzono nowe metody do obiektu globalnego String, umożliwiające uzyskanie informacji, czy dany ciąg znajduje się w szukanym ciągu znakowym. Metody te zwracają jednak wyłacznie true/false, a nie indeksy znalezionego dopasowania jak opisywane wczesniej indexOf i lastIndexOf.

Metody startsWith i endsWith sprawdzają, czy szukane znaki znajdują się odpowiednio na początku i na końcu przeszukiwanego ciągu. Na szczególną uwagę zasługuje metoda String.prototype.includes sprawdzająca wystąpienie szukanego tekstu w całym ciągu. Jest to więc odpowiedź na często stosowane rozwiązania z porównywaniem wyników zwracanych przez metodę indexOf z wartością -1 lub wykorzystujące operator bitowy NOT.

Podobnie jak indexOf i lastIndexOf nowe metody ES6 również analizują kody znaków, a więc ważna jest wielkość liter w przeszukiwanym tekście.

Pobierają one także drugi, opcjonalny argument. W przypadku metody startsWith oraz includes jest to indeks, od którego metoda ma rozpocząć wyszukiwanie, natomiast dla endsWith jest to indeks, który metoda ma traktować jako koniec przeszukiwanego tekstu.

To tyle teorii, a teraz na koniec przeanalizujmy omawiane metody na przykładach:

var str = 'To jest przykładowy tekst';

str.startsWith('To'); //true
str.startsWith('TO'); //false - ważna wielkość liter!
str.startsWith('To', 1); //false str: 'o jest przy...'
str.indexOf('tekst'); //20
str.startsWith('tekst',20); //true str: 'tekst'

str.endsWith('tekst'); //true
str.endsWith('tekst',20); //false str: '... przykładowy '
str.endsWith('owy',20); //false - uwaga: spacja to też znak!
str.endsWith('owy ',20); //true

str.includes('jest'); //true
str.indexOf('jest'); //3
str.includes('jest', 4); //false str: 'est przykładowy tekst'
str.includes('est',4); //true str: 'est przykładowy tekst'

Jeśli projektujemy aplikacje dla starszych przeglądarek, nie wspierających ES6 to z ww. metod można korzystać po wczytaniu tzw. polyfills (np. es6-shim), które są napisane z wykorzystaniem metod wspieranych przez ECMAScript 3 i 5.