Niuanse metody String.prototype.split z użyciem wyrażeń regularnych

Metoda split dzieli ciąg na podciągi (części) na podstawie określonego separatora, zwracając tablicę podciągów. Problem jednak pojawia się, gdy nie znamy do końca dokładnego ciągu stanowiącego separator. Zobaczmy więc jak wygląda stosowanie metody String.prototype.split z użyciem wyrażeń regularnych.

Metoda split pobiera co najmniej jeden element, którym jest separator, względem którego należy podzielić ciąg na tablicę kolejnych podciągów. Teoretycznie parametr ten jest opcjonalny, ale w przypadku jego pominięcia zwrócona zostanie tablica jednoelementowa, zwierająca cały analizowany ciąg znakowy. Można więc w ten sposób zamienić ciąg znakowy na tablicę jednoelementową. Zalecam jednak ostrożność w takim stosowaniu metody split, aby zamiana taka była czytelna i widoczny był jej sens (co ułatwi pracę nie tylko innym programistom ale i nam, gdy otworzymy kod po dłuższej przerwie).

Metoda przyjmuje również drugi, opcjonalny argument, który jest liczbą całkowitą i określa maksymalną liczbę elementów zawartych w zwrotnej tablicy. Jest to wartość maksymalna, a w przypadku mniejszej liczby elementów w zasadzie nie ma ona żadnego znaczenia:

let str = '111-222-333'
str.split();      //['111-222-333']
str.split('-');   //['111','222','333']
str.split('-',2); //['111','222']
str.split('-',5); //['111','222','333']

Widać zatem,  że gdy określimy maksymalną wartość na 5 to w tablicy i tak będą tylko 3 elementy, gdyż więcej nie da się „wyodrębnić” z ciągu str (metoda nie tworzy więc pustych elementów ani wartości undefined).

Dzielenie ciągu znakowego z użyciem innego ciągu String

Najczęściej metodę split wykorzystuje się do podzielenia danego ciągu znakowego na podstawie z góry określonego separatora, jak w powyższym przykładzie, gdzie separatorem jest znak  myślnika „-„. W roli separatora można podawać dowolny ciąg znakowy, również w zapisie szesnastkowym, Unicode czy w formie zmiennej, która przechowuje wartość typu String.

Tutaj należy się małe wyjaśnienie. Otóż teoretycznie zmienna, której wartość określa rodzaj separatora może być wartością inną niż String, lecz w takiej sytuacji nastąpi niejawne przekonwertowanie jej do postaci znakowej tak, jakby została wywołana na niej metoda toString(). Osobiście uważam jednak za złą praktykę poleganie na tego typu niejawnych konwersjach i zalecam jawne przekazywanie wartości określonego typu.

Zobaczmy więc kilka prostych przykładów zastosowania metody split:

let str = 'abc,def,ghi';
str.split(',');     //['abc','def','ghi']
str.split('def');   //['abc,',',ghi'] patrz na przecinki!
str.split(',def,'); //['abc','ghi'] patrz na przecinki!

let separator = ',';
str.split(separator); //['abc','def','ghi']

let str2 = 'aaa1bbb1ccc';
str2.split('1'); //['aaa','bbb','ccc']
str2.split(1);   //['aaa','bbb','ccc']

let tel = '111-222-333';
tel.split('-');      //['111','222','333']
tel.split('\x2d');   //['111','222','333']
tel.split('\u002d'); //['111','222','333']

Metodę split można również ciekawie wykorzystać np. w celu odwrócenia kolejności liter w słowie lub cyfr w liczbie:

let str = 'abcd';
str.split('').reverse().join(''); //'dcba'

let n = 12345;
+(n.toString().split('').reverse().join('')); //54321

//To samo co wyżej ale w nieco inny sposób :)
+(`${n}`).split('').reduceRight((a,b) => a+b); //54321

Ostatni przykład pozostawiam dla dociekliwych, wspomnę tylko, że użyłem tu składni Template strings i rzadko używanej w typowych zastosowaniach metody Array.prototype.reduceRight.

Niby wszystko w metodzie split wydaje się jasne i klarowne, ale do czasu… aż wpadnie nam pomysł użycia w roli separatora wyrażenia regularnego!

Metoda split i wyrażenia regularne

Jestem zwolennikiem stosowania RegExp tam, gdzie może to istotnie uprościć kod (np. przy wielu metodach walidujących dane) ale szczerze mówiąc staram się unikać RegExp w połączeniu z metodą split. Jeśli nawet muszę to zrobić to zawsze opracowuję odpowiednio dokładne testy aby mieć pewność co do sposobu działania mojego rozwiązania.

Dość jednak teoretyzowania, przejdźmy do przykładów, a przekonasz się, że niestety metoda splitregular expressions nie do końca się lubią…

let str = '123-456-789';

str.split('-'); //['123','456','789']
str.split(/-/); //['123','456','789']

//otaczamy znak "-" w grupę przechwytującą:
str.split(/(-)/); ['123','-','456','-','789'] ups...

//a teraz otoczymy "-" grupą nieprzechwytującą:
str.split(/(?:-)/); //['123','456','789']

W wyrażeniu regularnym nie trzeba stosować dodatkowej flagi „g” (global) gdyż i tak metoda split analizuje cały ciąg znakowy. Wszystko było dobrze gdy jako separator przekazaliśmy dosłownie symbol myślnika „-” oraz gdy użyliśmy regexp z grupą nieprzechwytującą. Problem pojawił się, gdy zastosowaliśmy grupowanie z przechwytem (czyli zapamiętaniem dopasowania).

W powyższym przykładzie najlepszym rozwiązaniem jest po prostu użycie separatora w roli bezpośredniego ciągu znakowego: „-„. Niestety nie zawsze jest to skuteczne i możliwe do zastosowania rozwiązanie…

Kiedy konieczne jest użycie RegExp w metodzie split?

Załóżmy, że nie znamy dokładnie rodzaju separatora, a jedynie znaki, które są dozwolone jako separator. Zmodyfikujmy więc nieco nasz przykład i zapiszmy numer z użyciem dwóch różnych separatorów między kolejnymi liczbami:

let num = '123 - 456-789'; //dozwolone spacje i myślniki

num.split(/[- ]/);      //['123','','','456','789'] ups...
num.split(/[- ]+/);     //['123','456','789'] teraz OK
num.split(/([- ]+)/);   //['123',' - ','456','-','789'] ups...
num.split(/(?:[- ]+)/); //['123','456','789'] teraz OK

W powyższym przykładzie widać wyraźnie, że nie można było przekazać bezpośredniego ciągu znakowego, gdyż musimy uwzględnić kilka wariantów separatora (dopuszczamy stosowanie spacji i myślników). Konieczne jest więc wyrażenie regularne.

W pierwszym wywołaniu metody split zapomnieliśmy jednak o ustawieniu minimalnej liczby wystąpień dopasowania (znak "=" jest równoważny zapisowi "{1,}" oznaczającemu co najmniej jedno dopasowanie).  W zwrotnej tablicy mamy co prawda oczekiwane trzy liczby, ale są również dwa dodatkowe puste ciągi znakowe (za chwilę pokażemy jak bezpiecznie je usunąć).

Myślniki w tablicy wynikowej dla trzeciego wywołania są zawarte dlatego, że metoda split umieszcza w niej elementy rozdzielane separatorem oraz wszystkie grupy przechwytujące użyte w wyrażeniu RegExp (wbrew pozorom wcale nie jest to błąd, co za chwilę udowodnimy).

Stosowanie grupowania w wyrażeniach regularnych dla metody split

Analizując trzecie i czwarte wywołanie metody split można by wysnuć wniosek, że nie warto stosować tutaj wyrażeń regularnych z grupowaniem gdyż rodzi to tylko same problemy. W tym konkretnym przypadku owszem… ale zobaczmy nieco inny przykład.

Załóżmy, że nasz ciąg znakowy składa się z samych cyfr, a my chcemy podzielić go na tablicę, zawierającą elementy po trzy cyfry. Oczekujemy więc, że dla ciągu '111222333' otrzymamy tablicę ['111','222','333']. Nie możemy użyć tu żadnego dosłownego separatora gdyż taki po prostu nie występuje – interesują nas bowiem wszystkie znaki z analizowanego ciągu. Ponownie pozostaje więc tylko wyrażenie regularne i właśnie w tej chwili przyda nam się wcześniejsza informacja związana z grupami przechwytującymi:

let num = '111222333';

num.split(/\d{3}/); //["","","",""]
num.split(/(?:\d{3})/); //["","","",""]
num.split(/(\d{3})/); //["","111","","222","","333",""]

Najbliższy oczekiwanego wyniku jest rezultat ostatniego wywołania metody split, gdzie wyrażenie regularne \d{3} otoczono grupą przechwytującą. Każda z przechwyconych grup została dodana do zwrotnej tablicy. Grupa ta pełni jednak de facto rolę separatora dla metody split, a ponieważ poza cyframi nie mamy w naszym ciągu innych znaków to tablica zawiera także elementy puste (a dokładniej puste ciągi znakowe).

Istnieje pewna sztuczka na pozbycie się tych pustych, niechcianych elementów z użyciem metody Array.prototype.filter:

let num = '111222333';
num.split(/(\d{3})/); //["","111","","222","","333",""]
num.split(/(\d{3})/).filter(Boolean);
//['111','222','333']

num.split(/(\d{3})/).map(Boolean);
//[false,true,false,true,false,true,false]

Użycie metody filter powoduje zwrócenie tablicy, na której dla każdego elementu zostaje wywołana funkcja zwrotna. Jeśli dla danej wartości funkcja callback zwróci false, to element ten nie zostanie ujęty w tablicy zwrotnej. W naszym wypadku wywołanie filter(Boolean) dokonuje jawnej kowersji każdej wartości na wartość true/false (co pokazuje dokładnie drugi przykład z metodą Array.prototype.map). Zwrotnie dostajemy więc oczekiwaną tablicę zawierającą trzy elementy.

Podsumowanie

W dzisiejszym wpisie nie chciałem tworzyć wielu bardziej lub mniej praktycznych przykładów użycia metody split. Moim celem było jedynie zwrócenie uwagi na problem stosowania w tej metodzie separatora w formie wyrażenia regularnego.

Jak niektórzy pewnie wiedzą jestem pasjonatem tematyki RegExp’ów, lecz jak to w życiu bywa od każdej reguły są wyjątki… podobnie i tutaj. Zalecam więc dużą ostrożność w stosowaniu wyrażenia regularnego w roli separatora, szczególnie jeśli miałoby to być bardziej skomplikowane wyrażenie (w dzisiejszym wpisie ograniczyłem się do bardzo prostych RegExp).

Chciałem pokazać także, że niestety nie zawsze da się ich uniknąć i czasami trzeba spróbować „polubić” metodę splitRegExp. W takich przypadkach, zanim zaczniesz operować dalej na zwrotnej tablicy dokładnie sprawdź co ona tak na prawdę zawiera i opracuj kilka testów dla różnych wariantów ciągu znakowego. Ponad to czasami nie warto na siłę rozbudowywać wyrażenia RegExp lecz stworzyć prostszą formułę i „wspomóc” się dodatkową filtracją z użyciem metody Array.prototype.filter.

Jeśli konieczny byłby podział ciągu według bardziej skomplikowanego RegExp to osobiście wolałbym w pierwszej kolejności ciąg przetworzyć np. metodą String.prototype.replace (w której nie ma omawianych dziś problemów i stosowanie RegExp jest jasne i klarowne) zamieniając dopasowane separatory na jakiś unikalny, pojedynczy symbol (np. coś z bardziej abstrakcyjnych znaków Unicode dla uniknięcia ewentualnej kolizji z innymi znakami ciągu) i dopiero taki ciąg podzieliłbym metodą split (tym razem z użyciem prostego, dosłownego ciągu pełniącego rolę separatora).