16. Wyrażenia regularne

 

Wyrażenia regularne wykorzystywane w praktyce do przetwarzania tekstów (tak w Pythonie, jak i w innych językach programowania i programach użytkowych) opierają się na koncepcji wyrażeń regularnych znanej z teorii języków formalnych. Praktyczne wyrażenia regularne rozszerzają jednak tę koncepcję o szereg nowych rozwiązań, użytecznych w przetwarzaniu tekstu, w związku z czym, nie są to pojęcia tożsame.

Wyrażenie regularne (ang. regular expression) stanowi wzorzec napisu. W oparciu o nie, możemy automatycznie odnaleźć w tekście każdy napis pasujący do wzorca (ang. matching string, lub krócej match).

Funkcje obsługujące wyrażenia regularne w Pythonie znajdują się w module re.

 

>>> import re

 

Przygotujmy tekst, który wykorzystamy w ćwiczeniach:

 

okocie="""Wlazł kot na płot i nie mruga.

Smutna to blablalaaaa, niedługa?"""

>>> print okocie

Wlazł kot na płot i nie mruga.

Smutna to blablalaaaa, niedługa?

 

Podstawową funkcją modułu re jest search, która wyszukuje w napisie podany wzorzec i zwraca obiekt klasy MatchObject:

 

>>> kot=search("kot",okocie)

 

W zmiennej kot mamy teraz zapamiętany wynik wyszukiwania wzorca "kot" w napisie okocie w postaci obiektu klasy MatchObject, który posiada m.in. metody start i end zwracające początek i koniec pasującego do wzorca fragmentu napisu, który z kolei zwracany jest przez metodę group wywołaną z parametrem 0 (kolejne liczby naturalne zwracają kolejne grupy wyrażenia regularnego – patrz dalej).

 

>>> print kot.start(),kot.end(),kot.group(0)

6 9 kot

 

Funkcja findall pozwala znaleźć wszystkie wystąpienia wzorca w tekście i zwraca ich listę.

 

>>> print findall("nie",okocie)

['nie', 'nie']

 

Jak widać, udało nam się znaleźć aż dwa takie przypadki (pierwszy to słowo „nie” przed „mruga”, drugie to fragment wyrazu „niedługa”).

Funkcja split zwraca listę fragmentów napisu, pomiędzy którymi występuje wzorzec.

 

>>> split(" - ","30 - 543 - 124")

['30', '543', '124'

 

Wzorcem był tu myślnik otoczony spacjami. Rezultatem są więc liczby, wcześniej połączone wzorcem.

Funkcja sub służy do podmiany ciągów pasujących do wzorca innym tekstem. Jej parametrami są wzorzec do wyszukania, napis, którym zostanie on zastąpiony, napis, w którym ma zostać wykonana zamiana oraz maksymalna liczba wykonywanych zamian (domyślnie – bez ograniczeń). Jako swój wynik zwraca podmieniony tekst, nie zmieniając przy tym tekstu oryginalnego.

 

>>> okocie=sub("Smut","Ład",okocie)

>>> print okocie

Wlazł kot na płot i nie mruga.

Ładna to blablalaaaa, niedługa?

 

Powyżej dokonano jednej zamiany. Teraz będą dwie:

 

>>> okocie=sub("ot","otek",okocie)

>>> print okocie

Wlazł kotek na płotek i nie mruga.

Ładna to blablalaaaa, niedługa?

 

Drugim parametrem funkcji sub, zamiast napisu do wstawienia, może być funkcja, która zwróci taki napis, przyjmując jako swój parametr znaleziony MatchObject.

 

>>> print sub("uga",lambda m: str.upper(m.group(0)),okocie)

Wlazł kotek na płotek i nie mrUGA.

Ładna to blablalaaaa, niedłUGA?

 

W powyższym przykładzie zmieniamy wielkość liter na duże wszystkim znalezionym sekwencjom ‘uga’.

Do tej pory szukaliśmy wzorców podanych znak po znaku – równie dobrze moglibyśmy więc użyć metod find i replace napisów. Cała potęga wyrażeń regularnych tkwi w możliwości posłużenia się znakami specjalnymi, dzięki którym wzorzec może obejmować wiele różnych napisów.

Najprostszym znakiem specjalnym jest zapewne kropka, która zastępuje dowolny znak, za wyjątkiem znaku końca wiersza (co jednak można zmienić – patrz dalej).

 

>>> findall("a.",okocie)

['az', 'a ', 'a.', 'ad', 'a ', 'ab', 'al', 'aa', 'aa', 'a?']

 

Znaleźliśmy oto wszystkie ciągi składające się z litery „a” i dowolnego innego znaku. Skoro kropka oznacza dowolny znak, jak znaleźć w tekście kropkę? Znaki specjalne w wyrażeniach regularnych (oprócz kropki są to: otwarty nawias kwadratowy, backslash ‘\’, circumflex ‘^’, znak dolara ‘$’, kreska pionowa ‘|’, znak zapytania ‘?’, gwiazdka ‘*’, plus ‘+’, oraz nawiasy okrągłe) można traktować jak normalne, poprzedzając je znakiem backslash. Jako że backslash jest jednocześnie znakiem specjalnym w samym Pythonie, takie wyrażenia najwygodniej przechowywać w napisach „surowych” (z literą ‘r’ z przodu).

 

>>> okocie=sub(r"\.",",",okocie)

>>> okocie=sub(r"\?","!",okocie)

>>> print okocie

Wlazł kot na płot i nie mruga,

Ładna to blablalaaaa, niedługa!

 

Znak zapytania służy do określenia znaku, który może, lecz nie musi, znaleźć się w wyszukiwanym ciągu:

 

>>> findall("a.a?",okocie)

['az', 'a ', 'a,', 'ad', 'a ', 'ab', 'ala', 'aaa', 'a!']

 

Znaleźliśmy oto wszystkie ciągi zaczynające się z litery ‘a’ i dowolnego innego znaku, które mogą, lecz nie muszą, kończyć się literą ‘a’.

Gwiazdka działa podobnie jak znak zapytania, z tą różnicą, że akceptuje dowolną liczbę powtórzeń (od zera do nieskończoności) znaku, który poprzedza:

 

>>> findall("a.a*",okocie)

['az', 'a ', 'a,', 'ad', 'a ', 'ab', 'alaaaa', 'a!']

 

Znaleźliśmy oto wszystkie ciągi zaczynające się z litery ‘a’ i dowolnego innego znaku, które mogą, lecz nie muszą, kończyć się ciągiem liter ‘a’.

Jeżeli chcemy wymóc, by akceptowano dowolną liczbę powtórzeń, ale większą od zera, zamiast gwiazdki używamy plusa:

 

>>> findall("a.a+",okocie)

['alaaaa']

 

Znaleźliśmy oto wszystkie ciągi zaczynające się z litery ‘a’ i dowolnego innego znaku, które kończą się co najmniej jedną literą ‘a’.

Przy użyciu nawiasów klamrowych możemy określić dokładną liczbę powtórzeń:

 

>>> findall("a.a{4}",okocie)

['alaaaa']

 

Znaleźliśmy oto wszystkie ciągi zaczynające się z litery ‘a’ i dowolnego innego znaku, które kończą się dokładnie czterema literami ‘a’.

Albo jej akceptowany zakres (domknięty):

 

>>> findall("a.a{3,5}",okocie)

['alaaaa']

 

Znaleźliśmy oto wszystkie ciągi zaczynające się z litery ‘a’ i dowolnego innego znaku, które kończą się miedzy trzema a pięcioma literami ‘a’.

Circumflex oznacza początek przeszukiwanego napisu. Aby otrzymać pierwsze 4 znaki, napiszemy:

 

>>> findall("^.{4}",okocie)

['Wlaz']

 

Dolar oznacza koniec napisu. Aby otrzymać końcowy fragment, od ostatniego wystąpienia litery ‘u’ włącznie, napiszemy:

 

>>> findall("u.*$",okocie)

['uga!']

 

Nawiasy kwadratowe pozwalają podać listę znaków, z których akceptowany jest jeden.

 

>>> findall("[rł]..a",okocie)

['ruga', '\xb3uga']

 

(\xb3 to oczywiście litera ‘ł’ zapisana w szesnastkowym kodzie ASCII).

Jeżeli lista jest długa, możemy podać ją jako zakres:

 

>>> findall("[a-s]..a",okocie)

['ruga', 'adna', 'abla', 'laaa']

 

Czasami prościej jest podać listę znaków, które mają nie zostać zaakceptowane:

 

>>> findall("[^tł]..a",okocie)

['ruga', 'adna', ' bla', 'lala']

 

Nawiasy okrągłe służą do grupowania elementów wzorca. Poszczególne grupy zwracane są osobno, jest to zatem przydatne narzędzie z punktu widzenia dalszego przetwarzania.

 

>>> findall(r"((bla)+(la*)?)",okocie)

[('blablalaaaa', 'bla', 'laaaa')]

 

Pierwsza grupa w powyższym przykładzie – ((bla)+(la*)) – akceptuje takie ciągi, które składają się z co najmniej jednej sekwencji ‘bla’ i, być może, litery ‘l’, po której może nastąpić dowolna liczba liter ‘a’. Druga grupa, (bla), zawierać będzie jedynie ciąg ‘bla’; trzecia (la*) – literę ‘l’, po której może nastąpić dowolna liczba liter ‘a’.

Do każdej grupy spoza niej można odwoływać się przez podanie jej numeru po znaku backslash. Przykładowo, to wyrażenie:

 

>>> findall(r"((bla)\2+l(a)\3+)",okocie)

[('blablalaaaa', 'bla', 'a')]

 

wyszuka napis składający się z co najmniej dwóch wystąpień ciągu ‘bla’ (który stanowi grupę numer 2), po której następuje litera ‘l’ i co najmniej dwóch wystąpień litery ‘a’ (która stanowi grupę numer 3).

            Jeżeli nie potrzebujemy odwoływać się do poszczególnych grup, ani w samym wyrażeniu regularnym, ani po zakończeniu wyszukiwania, a jedynie potrzebujemy nawiasów, by oznaczyć powtórzenie sekwencji znaków, używamy notacji znak zapytania – dwukropek:

 

>>> findall(r"(?:bla)+",okocie)

['blabla']

>>> findall(r"kot(?:ek)?","kot czy kotek")

['kot', 'kotek']

 

            Pierwsze z powyższych wyrażeń znajdzie ciągi składające się z pojedynczego lub większej liczby wystąpień ‘bla’. Drugie – znajdzie wszystkie ciągi ‘kot’, które mogą, lecz nie muszą być zakończone sufiksem ‘ek’.

Sekwencja ‘(?=...)’ pozwala znaleźć tylko takie ciągi, po których następuje podany ciąg. Sekwencja ‘(?<=...)’ pozwala znaleźć tylko takie ciągi, które poprzedza podany ciąg. Przykładowo, by znaleźć ciąg pomiędzy ‘lazł’ i ‘na’, napiszemy:

 

>>> findall(r"(?<=lazł).+(?=na)",okocie)

[' kotek ']

 

Widzimy, że wynik, prócz wyrazu ‘kotek’ zawiera spacje. Kiedy przedmiotem przetwarzania są wyrazy, wygodnie jest używać symbolu ‘\b’, oznaczającego granicę słowa (‘\B’ oznacza brak granicy słowa). Porównajmy:

 

>>> findall(r"na",okocie)

['na', 'na']

 

Znaleziono dwa ciągi: wyraz ‘na’ i fragment wyrazu ‘Ładna’.

 

>>> findall(r"\bna\b",okocie)

['na']

 

Znaleziono tylko jeden ciąg: wyraz ‘na’.

 

>>> findall(r"nie",okocie)

['nie', 'nie']

 

Znaleziono dwa ciągi: wyraz ‘nie’ i fragment wyrazu ‘niedługa’.

 

>>> findall(r"nie\B",okocie)

['nie']

 

Znaleziono tylko jeden ciąg: fragment wyrazu ‘niedługa’.

Sekwencja ‘\w’ oznacza znak w słowie (alfanumeryczny), ‘\W’ zaś znak poza słowem (niealfanumeryczny).

 

>>> findall(r"(?<=lazł\W)\w+(?=\Wna)",okocie)

['kotek']

 

Zauważmy jednak, że wynik poniższej operacji będzie inny od spodziewanego:

 

>>> findall(r"\w+ek",okocie)

['kotek', 'otek']

 

Nie znaleziono wyrazu ‘płotek’, gdyż zawiera on polską literę ‘ł’. Aby uniknąć tego rodzaju problemów, należy przekazać funkcji findall jako trzeci parametr opcję LOCALE oznaczającą, że przetwarzamy tekst napisany w innym niż łaciński alfabecie:

 

>>> findall(r"\w+ek",okocie,LOCALE)

['kotek', 'p\xb3otek']

 

Pozostałe opcje sterujące wyrażeniami regularnymi to:

·       IGNORECASE – ignoruje przy porównywaniu wielkość liter,

·       MULTILINE – sprawia, że znaki $ i ^ oznaczają nie tylko początek (koniec) napisu, ale też początek (koniec) wiersza,

·       DOTALL – sprawia, że kropka obejmuje też znaki końca wiersza,

·       UNICODE – pozwala na poprawne przetwarzanie napisów UNICODE przy użyciu ‘\w’ i ‘\b’,

·       VERBOSE – ignoruje białe znaki we wzorcu i pozwala umieszczać w nim komentarze (opcja stosowana wyłącznie dla poprawienia przejrzystości kodu źródłowego).

Kilka opcji łączymy ze sobą operatorem sumy bitowej |:

 

>>> findall(r"^\w+",okocie,LOCALE|MULTILINE)

['Wlaz\xb3', '\xa3adna']

 

Możemy teraz poprawić piosenkę:

 

>>> okocie=sub(r"bl\w+","piosenka",okocie)

>>> print okocie

Wlazł kotek na płotek i nie mruga,

Ładna to piosenka, niedługa!

 

Sekwencja ‘\s’ oznacza dowolny znak biały, a ‘\S’ dowolny znak nie będący białym. Aby znaleźć wszystkie wyrazy nie zawierające litery ‘ł’, napiszemy:

 

>>> findall(r"\b[^ł\s]+\b",okocie,LOCALE|IGNORECASE)

['kotek', 'na', 'i', 'nie', 'mruga', 'to', 'piosenka']

 

Kończymy poprawianie piosenki:

 

>>> okocie=sub(r"nie\s","",okocie)

>>> print okocie

Wlazł kotek na płotek i mruga,

Ładna to piosenka, niedługa!

 

Dobieranie tekstu do wzorca odbywa się normalnie w sposób zachłanny, w którym zwracane są możliwie długie dopasowania. Z tego powodu poniższe wyrażenie zwróci ciąg kończący się na drugim, a nie pierwszym nawiasie zamykającym:

 

>>> findall(r"\(.+\)","(1)(2)")

['(1)(2)']

 

Aby określić leniwy tryb dobierania tekstu do wzorca, w którym zwracane są możliwie krótkie dopasowania, używamy znaku zapytania:

 

>>> findall(r"\(.+?\)","(1)(2)")

['(1)', '(2)']

 

Kreska pionowa pozwala podać alternatywne wzorce do wyszukiwania. Poniżej dwa sposoby na znalezienie wszystkich wyrazów mających nie więcej niż dwa znaki długości lub zawierających ‘r’ lub ‘s’:

 

>>> findall(r"\b\w{1,2}\b|\b\w*[rs]\w*\b",okocie,LOCALE|IGNORECASE)

['na', 'i', 'mruga', 'to', 'piosenka']

>>> findall(r"\b(?:\w{1,2}|\w*[rs]\w*)\b",okocie,LOCALE|IGNORECASE)

['na', 'i', 'mruga', 'to', 'piosenka']

 

Wyrażenia regularne stosujemy często do ekstrakcji z dłuższego tekstu interesujących nas elementów. Przykładowo, możemy wybrać z tekstu wszystkie daty zgodne z formatem:

 

>>> findall(r"\b\d{4}-\d{2}-\d{2}\b","Zdarzyło się to 2006-08-12.")

['2006-08-12']

 

Liczby dziesiętne:

 

>>> findall(r"-?\b\d+(?:[,\.]\d*)?\b","7 ludzi ma 3 psy warte -112.30 zł")

['7', '3', '-112.30']

 

Po dwa pierwsze wyrazy z każdej linii:

 

>>> findall(r"(^\w+)\s(\w+)","""Pierwsza linia tu,

Druga linia tam,

Trzecia linia...""",MULTILINE)

[('Pierwsza', 'linia'), ('Druga', 'linia'), ('Trzecia', 'linia')]

 

Oczywiście, wyszukiwanie łatwo można połączyć z przetwarzaniem i podmianą:

 

>>> print sub(r"-?\b\d+(?:[,\.]\d*)?\b",

       lambda m: "%.f"%(float(m.group(0))*2),

       "7 ludzi ma 3 psy warte -112.30 zł")

14 ludzi ma 6 psy warte -225 zł

 

Jeżeli pewnego wzorca mamy zamiar używać wielokrotnie, wygodnie jest go skompilować. Funkcje modułu re dostępne są wtedy jako metody skompilowanego wyrażenia. Poprawia to także szybkość wyszukiwania.

 

>>> procenty=compile(r"\b\d+%")

>>> procenty.findall("Woda ma 0% cukru.")

['0%']

>>> procenty.findall("Śmietana ma 6% cukru.")

['6%']

>>> procenty.findall("Cukier ma 100% cukru.")

['100%']

>>> procenty.search("Cukier ma 100% cukru.").start()

10

>>> procenty.sub("90%","Cukier ma 100% cukru.")

'Cukier ma 90% cukru.'

>>> procenty.split("Cukier ma 100% cukru.")

['Cukier ma ', ' cukru.']

 

Ćwiczenie 1. Stwórz wyrażenie regularne, które pozwoli wyszukać w dowolnym tekście wszystkie zawarte w nim adresy e-mail. Pamiętaj o znakach, które muszą być w każdym poprawnym adresie e-mail, oraz o znakach, które mogą w nim wystąpić.

Ćwiczenie 2. Typowym błędem przy szybkim wpisywaniu tekstu jest pisanie drugiej litery wyrazu dużą literą, np. SZczecin (zamiast Szczecin) czy POlska (zamiast Polska). Napisz program, wykorzystujący funkcję sub i wyrażenia regularne, który poprawi wszystkie takie błędy w tekście wprowadzonym przez użytkownika. Wyrazy dłuższe niż dwie litery mają być poprawiane automatycznie, natomiast o podmianę wyrazu dwuliterowego (np. IT na It) program ma pytać użytkownika za każdym razem, gdy na taki natrafi.