Perličky: pokročilé regulární výrazy

20. 3. 2008
Doba čtení: 10 minut

Sdílet

Ilustrační obrázek
Autor: Depositphotos – stori
Ilustrační obrázek
Regulární výrazy jsou jedním ze základních stavebních prvků jazyka Perl. Téměř jakákoliv manipulace s řetězci je realizována pomocí operátorů pracujících s regulárními výrazy. V dnešním díle překročíme pomyslný můstek mezi extended regulárními výrazy z POSIXu a rozšířeními jedinečnými pro Perl.

Práce s regulárními výrazy

Kdysi jsem podstoupil diskusi s příznivci nejmenovaného graficky orientovaného OS (pro upřesnění, se špatnou příkazovou řádkou) na téma vizualizace a zkoušení regexpů. Tito příznivci vychvalovali jakýsi na GUI postavený program, který barevně zobrazoval části v řetězci, které odpovídaly zadanému regulárnímu výrazu. Naproti tomu můj názor na tento problém byl, že člověk zručný v unixovém programování si takovou věc napíše do dvou minut sám. Bez ohledu na to, jak diskuse dopadla, a jelikož dnes budeme experimentovat s regulárními výrazy, bude vhodné mít takový program po ruce.

Prvním problémem je získání regulárního výrazu (dále jen regexpu) od uživatele a následná kontrola tohoto regexpu. V nejjednodušším případě můžeme využít interpolaci proměnných uvnitř operátorů pracujících s regexpy. Takto by vypadala jakási miniaturní obdoba známého programu grep (nazveme jej třeba Mandarinka):

my $re = shift;
while (<>) {
    print if m/$re/;
}

Přičemž v případě chybného regexpu dostaneme vynadáno na řádku s operátorem m//. Efektivnějším a prozíravějším řešením je použití operátoru qr//. Tento operátor nahlíží na svůj vnitřek jako na regexp a vrátí referenci na objekt typu regexp. S modifikátorem /o můžeme nechat regexp okamžitě přeložit a tak ušetřit procesorový čas při dalších použitích tohoto regexpu. Případné chyby při kompilaci můžeme odchytit pomocí bloku eval.

my $input = shift;
my $regexp = eval { qr/$input/o };
die "Pattern error: $@\n" if $@;

Jak bylo řečeno, proměnná regexp se chová jako reference na regexp (lze ověřit pomocí print ref $regexp). Lze s ní tedy zacházet jako s každou jinou referencí, zejména předávat do funkcí a uchovávat v datových strukturách (což není totéž jako uchovávání řetězce obsahujícího regexp). Jedinou výjimkou je, že dereferencování provádí Perl sám, tudíž pro použití $regexp píšeme pouze $regexp bez dalších znaků.

while (<>) {
    print if m/$regexp/;
}

Modernější odrůdy programu grep umí barevně zvýraznit nalezený řetězec v rámci vypsaného řádku. V Perlu můžeme za tímto účelem použít proměnné $` (obrácený apostrof), $& a $' (obyčejný apostrof). Proměnná $& obsahuje nalezený řetězec, zbylé dvě část před, respektive za ním. Pomůcka pro zapamatování je psaní uvozovek v anglickém jazyce.

use Term::ANSIColor qw/:constants/;
while (my $line = <>) {
        print $`, BOLD, RED, $&, RESET, $' if ($line =~ m/$regexp/);
}

Pokud umíme takto pěkně zobrazit nalezený řetězec, nabízí se otázka, zda takto lze zobrazit i zachycené podřetězce v závorkách. Pro normální použití jsou tyto podřetězce v proměnných $1, $2, atd. Pro náš výpis však narážíme na dva problémy. Jednak neumíme říci (alespoň bez těžkého vrtání se v Perlovské tabulce symbolů), kolik takových proměnných bylo zachyceno. Druhý problém, a ten je horší, je, že máme k dispozici pouze nalezené řetězce a nikoliv obsah původního řetězce před a za nimi.

Oba problémy lze vyřešit použitím polí @-

@+. Tato pole obsahují pro každý nalezený podřetězec
pozici jeho začátku a konce (přesněji, znaku za koncem)
v původním řetězci. To, co bylo nalezeno v první závorce, je
v původním řetězci na pozicích $-[1]$-[1] - 1, podobně pro druhou a další závorku. Pro naše pohodlí jsou
$-[0]$+[0] pozice pro řetězec

$&.
while (my $line = <>) {
        chomp $line;
        if ($line =~ m/$regexp/) {
                for my $i (0 .. $#+) {
                        my ($start, $stop) = ($-[$i], $+[$i]);
                        print q/$/
                              , $i ? $i : q/&/
                              , q/: /
                              , substr($line, 0, $start)
                              , BOLD, RED
                              , substr($line, $start, $stop - $start)
                              , RESET
                              , substr($line, $stop)
                              , qq/\n/;
                }
        }
}

Pokud máte rádi use English, lze samozřejmě všechny zmiňované proměnné najít pod anglickými názvy (dohledání necháme na čtenáři).

Důvodem, proč pozice v poli @+ ukazují za a nikoliv na poslední znak, je, že některé nalezené podřetězce mohou mít nulovou délku. Například $1 v regexpu (^|a)hoj bude mít délku jednoho znaku pro řetězec ahoj! a nulovou délku pro hoj!. Ve vizualizačním programu můžeme tuto situaci ošetřit například tak, že na pozici shody s nulovou délkou zobrazíme nějaký oddělovací znak a navíc jinou barvou.

… while / if / for …
                        print q/$/
                              , $i ? $i : q/&/
                              , q/: /
                              , substr($line, 0, $start)
                              , BOLD,
                              , ($stop - $start > 0) ?
                                        (RED, substr($line, $start
                                                     , $stop - $start))
                                        : (BLUE, q/|/)
                              , RESET
                              , substr($line, $stop)
                              , qq/\n/;

Tímto máme program pro experimentování s regulárními výrazy hotový. Pokud se chceme na vlastní oči přesvědčit, co se v nějaké části regexpu (ne)zachytává, stačí příslušný kus ozávorkovat. Doplňkem k tomu je použití modulu YAPE::Regex::Explain, který převede regexp do řetězce s vysvětlivkami.

perl -e 'use YAPE::Regex::Explain; print YAPE::Regex::Explain->new(qr/(?i:^|a)hoj(\s+)(?#\p?$)(?!de(?:ti|cka))(?>.*?\1!+)/)->explain;'

# výstup
The regular expression:

(?-imsx:(?i:^|a)hoj(\s+)(?#\p?$)(?!de(?:ti|cka))(?>.*?\1!+))

matches as follows:

NODE                     EXPLANATION
----------------------------------------------------------------------
(?-imsx:                 group, but do not capture (case-sensitive)
                         (with ^ and $ matching normally) (with . not
                         matching \n) (matching whitespace and #
                         normally):
----------------------------------------------------------------------
  (?i:                     group, but do not capture (case-
                           insensitive) (with ^ and $ matching
                           normally) (with . not matching \n)
                           (matching whitespace and # normally):
----------------------------------------------------------------------
    ^                        the beginning of the string
----------------------------------------------------------------------
   |                        OR
----------------------------------------------------------------------
    a                        'a'
----------------------------------------------------------------------
  )                        end of grouping
----------------------------------------------------------------------
  hoj                      'hoj'
----------------------------------------------------------------------
  (                        group and capture to \1:
----------------------------------------------------------------------
    \s+                      whitespace (\n, \r, \t, \f, and " ") (1
                             or more times (matching the most amount
                             possible))
----------------------------------------------------------------------
  )                        end of \1
----------------------------------------------------------------------
  (?!                      look ahead to see if there is not:
----------------------------------------------------------------------
    de                       'de'
----------------------------------------------------------------------
    (?:                      group, but do not capture:
----------------------------------------------------------------------
      ti                       'ti'
----------------------------------------------------------------------
     |                        OR
----------------------------------------------------------------------
      cka                      'cka'
----------------------------------------------------------------------
    )                        end of grouping
----------------------------------------------------------------------
  )                        end of look-ahead
----------------------------------------------------------------------
  (?>                      match (and do not backtrack afterwards):
----------------------------------------------------------------------
    .*?                      any character except \n (0 or more times
                             (matching the least amount possible))
----------------------------------------------------------------------
    \1                       what was matched by capture \1
----------------------------------------------------------------------
    !+                       '!' (1 or more times (matching the most
                             amount possible))
----------------------------------------------------------------------
  )                        end of look-ahead
----------------------------------------------------------------------
)                        end of grouping
----------------------------------------------------------------------

Modifikátory regexpových operátorů

Zřejmě nejpopulárnějším užívaným modifikátorem je /g (global match – nezastavuj se po první shodě), a to v kombinaci s operátorem s///. V kombinaci s  m// můžeme zachytit více výskytů hledaného řetězce.

# primitivní překladač cest win-to-unix
$cesta =~ s{\\}{/}g;

# vypište z článku …
@ditini = m/\b\w*[dtn]i\w*\b/g;

Modifikátor /e umožní v operátoru s/// použít na pravé straně perlovský výraz namísto nahrazujícího výrazu. Toto je velmi užitečná funkce, zvláště když si uvědomíme, že uvnitř výrazu může být normální kód, v kterém můžeme použít opět regexpové operátory (a cokoliv dalšího). Kupříkladu uvažme následující problém. Na vstupu máme text, v kterém se může vyskytovat prostrkávané slovo ve formátu text text _S_L_O_V_O_ text a chceme v něm toto slovo nahradit normálním řetězcem SLOVO. V levé části operátoru s/// budeme hledat prostrkávaný text, v pravé části ho pomocí dalšího operátoru s/// jednoduše zbavíme prostrkávacích znaků.

perl -pe 's/\b_([A-Z]_)+\b/do { my $a = $&; $a =~ s,_,,g; $a; }/ge'

Modifikátor /o jsme již zmínili. Jeho použití přeloží regexp okamžitě, čímž se výrazně urychlí jeho následné používání. Nevýhodou je, že takto přeložený regexp pak nebude interpolovat proměnné a další dynamický obsah (respektive stane se tak pouze jednou, a to při prvotním překladu).

Dále je tu svatá čtveřice /imsx. Při použití složitějších regexpů (někteří doporučují dokonce vždy) je vhodné regexp strukturovaně formátovat, podobně jako bychom to učinili v případě normálního zdrojového textu programu. K tomu slouží modifikátor /x. Při jeho použití bude Perl ignorovat prázdné znaky a vše za znakem # do konce řádku (tj. komentáře). Chceme-li v regexpu uvést takový znak, stále jej můžeme uvodit pomocí zpětného lomítka, nebo použít skupinu pro prázdné znaky, např. \s.

m/'(?:\\[\\']|[^\\'])*'/;

# čitelnější zápis

m/
    '       # úvodní apostrof

    (?:
        \\[\\'] # escapovaný apostrof nebo zp. lomítko
    |       # NEBO
        [^\\']  # neescapovatelný znak
    )*      # to celé libovolně-krát

    '       # ukončující apostrof
/x;

Při takovémto komentování je potřeba dávat pozor, abychom do komentáře nenapsali ukončující znak operátoru, jako například takto:

m/
    '       # úvodní apostrof

    (?:
        \\[\\'] # escapovaný apostrof / zp. lomítko
…

Perl totiž v rozporu s očekáváním nejprve najde hranice operátoru m// a až potom se zabývá tím, co je uvnitř. V tomto případě bychom dostali přes čumák za neuzavřenou závorku uvnitř regexpu. Pokud by náhodou byl regexp v pořádku, Perlu bude zřejmě připadat divné řetězcové spojení neexistujících funkcí zp() . lomítko(), a tak dále. Do jisté míry lze tomuto problému předejít pomocí používání závorek na ohraničení regexpu. Perl ctí vnoření závorek, takže následující zápis je v pořádku.

m{
    \{          # začátek bloku: {

        [0-9]+      # první posloupnost cifer
        (?:,        # další posloupnosti, oddělené čárkami
            [0-9]+
        )*

    \}          # konec bloku: }
}x;

Nicméně pokud bychom porušili symetrii závorek, nastane stejný problém. V tom případě nezbývá nic jiného než escapovat.

Modifikátory /m/s ovlivňují chování regexpu vůči více řádkům. Pomocí /m dovolíme symbolům

^$ nalézt začátek a konec řádku
(řádky jsou rozděleny pomocí  \n) namísto začátku
a konce celého řetězce. Začátek a konec řetězce pak lze stále
najít pomocí \A\z (malé zet,

\Z je něco mírně jiného). Modifikátor /s
dovolí symbolu . nalézt také znak \n, což
normálně nemůže. Obvyklé použití je dohromady jako /ms,
čímž pracujeme s regexpem jako s víceřádkovým a navíc
můžeme pomocí tečky „sežrat“ i konce řádků.

# umaže rozloučení a vše pod ním
$dopis =~ s/^S pozdravem.*//ms;

Konečně, modifikátor /i slouží pro case-insensitive hledání, tj. bez rozdílu mezi malými a velkými písmeny.

Závorky bez ukládání

Pokud se divíte, co znamená sekvence ?: na začátku závorek, vězte, že je to neukládací závorka. Z hlediska seskupování funguje úplně stejně jako normální závorka, ale neukládá podřetězec. Její zpracování je díky tomu poněkud rychlejší než u obyčejné závorky. Navíc zbytečně neplníme proměnné řetězci, které nás nezajímají (například pokud výsledek regexpového operátoru m// přira­zujeme do pole).

Mezi znaky ?: lze vložit modifikátory ze svaté čtveřice imsx. Můžeme tak například v rámci jednoho regexpu hledat s ohledem i bez ohledu na velikost písmen ( (?i:P)erl namísto [pP]erl). Pokud chceme uvnitř závorky danou vlastnost zrušit, můžeme to udělat pomocí znaku minus. Odtud pochází i  -imsx na začátku regexpu z příkladu na  YAPE::Regex::Explain.

Look-arounds

Jak již bylo řečeno, některé symboly nalézají řetězce nulové délky. Takovýmto symbolům se obecně říká assertions (předpoklady). Například ^ standardně nachází hranici mezi začátkem řetězce a jeho prvním znakem. Symbol \b nachází hranici mezi slovem a ne-slovem. V Perlu je možné i z nenulové množiny znaků vyrobit shodu o délce nula znaků. Jedná se o tzv. look-arounds. Existují čtyři druhy look-around symbolů:

  1. Pozitivní dopředný look-around (?=)
  2. Negativní dopředný look-around (?!)
  3. Pozitivní zpětný look-around (?<=)
  4. Negativní zpětný look-around (?<!)

Pomocí těchto předpokladů můžeme učinit předpoklad o textu před nebo za aktuální pozicí. Přitom ale nesežereme tento text z řetězce, look-around má nulovou délku. Předpokládejme, že chceme nalézt jméno Douglas nebo James, ale pouze pokud je za ním jméno Adams. Klasické řešení by bylo:

/(Douglas|James)\sAdams/

Přičemž nalezený řetězec by byl buď prázdný (nic nenalezeno), nebo celé jméno včetně příjmení. Křestní jméno bychom pak extrahovali z proměnné $1. S použitím dopředného look-around se tomuto můžeme vyhnout:

/(?:Douglas|James)(?=\sAdams)/

V tomto případě bude nalezeným řetězcem pouze křestní jméno, ale jen v případě, že za ním následuje správné příjmení. Pokud bychom chtěli naopak příjmení, můžeme použít zpětný look-around.

/(?<=James\s)(?:Adams|Bond)/

Poznamenejme, že zpětný look-around s proměnnou délkou nelze použít (?<=Douglas|James), neboť toto zatím Perl neumí. Mezi dopředným a zpětným look-around tedy existuje jistá asymetrie.

Abychom úplně pochopili, co se děje v případě look-around, rozebereme si ještě několik případů. U všech použijeme jako vstup řetězec Douglas Adams.

/(?=.*\sAdams)Douglas/      # najde shodu
/(?=\sAdams)Douglas/        # nenajde shodu

V prvním případě požadujeme, aby na začátku nalezeného výrazu bylo slovo Douglas, ale jenom pokud za tímto začátkem nalezeného výrazu následoval libovolný počet libovolných znaků (vyjma  \n), mezera a slovo Adams. Toto je v případě našeho vstupu splněno.

Ve druhém případě požadujeme, aby na začátku nalezeného výrazu bylo slovo Douglas, ale jenom pokud za tímto začátkem nalezeného výrazu je mezera a slovo Adams. To znamená, že začátek nalezeného výrazu musí být před mezerou ve vstupním řetězci:

Douglas| Adams

Za tímto začátkem však není splněno, že následuje slovo Douglas, shoda tedy není nalezena. Look-around má nulovou délku, a proto druhým regexpem neprojde ani vstup Douglas AdamsDouglas. Začátek nalezeného výrazu bude ukotven před mezerou před slovem Adams a look-around s ním díky nulové délce nepohne.

Prakticky to znamená, že pokud chceme za znaky, které nalezl dopředný pozitivní look-around, nalézt ještě něco dalšího, musíme nejprve tyto znaky extra sežrat. Pro negativní look-around toto samozřejmě neplatí, neboť negativní look-around hledá řetězec, který ve vstupu není.

hacking_tip

/James\s(?!Bond)(\w+)/      # najde shodu pro 'James Adams'
                # nebo 'James Brown'
                # ale nikoliv pro 'James Bond'

Závěr

V dnešním průvodci regulárními výrazy jsme si ukázali některé Perlovské speciality pro práci s nimi. Nebyl by to ale Perl, kdyby již neobsahoval na CPANu (moduly pod Regexp::Common) spousty předdefinovaných a parametrizo­vatelných regexpů na všemožné použití. Takže ve svém kódu se soustřeďte především na vynalézání regexpů specifických pro váš problém a nikoliv na vynalézání kola (zejména pokusy typu regexp na e-mailové adresy končí velkým překvapením pro naivní a zděšením pro toho, kdo se odváží otevřít příslušné RFC).

Pro další experimentování s regexpy je možno použít (nebo rozšířit) již zmíněný program Mandarinka.

Autor článku