Vorlesungsskript 20

Reguläre Ausdrücke

In vielen computerlinguistischen Anwendungen spielen Strings eine zentrale Rolle. Sie haben bereits einige Operationen gelernt, mit denen Sie Strings analysieren und weiterverarbeiten können, z.B. string.startswith(), string.endswith() oder string.replace(old, new). Diese Operationen helfen Ihnen jedoch nur, wenn Sie bereits vorher genau wissen, wie die Strings aufgebaut sind und was sie enthalten. Heute werden Sie lernen, wie sie darüber hinaus Strings nach komplexen Kriterien verarbeiten können.

Das Modul re

Reguläre Ausdrücke (engl. regular expressions), sind ein Mechanismus, mit dem Strings nicht nur als Abfolgen von Zeichen, sondern auch als komplexe Muster beschrieben und zerlegt werden können. Reguläre Ausdrücke ähneln den Strings, die Sie bereits kennen, folgen aber einer fest definierten Syntax, mit der Sie darstellen können, nach welchem Muster Sie in einer beliebigen Zeichenkette suchen wollen. Die Muster können entweder spezifischen Zeichenabfolgen entsprechen, wie im folgenden Beispiel, oder komplexer aufgebaut sein.

import re
muster = re.compile('elfe')
wort = 'Nagelfeile'
if re.search(muster, wort):
    print('Wort wurde gefunden.')

Um mit regulären Ausdrücken zu arbeiten, müssen Sie das Modul re zu Beginn Ihres Codes importieren. In Zeile 2 des Beispiels wird ein regulärer Ausdruck erzeugt (kompiliert), der in der Variable muster gespeichert wird. Zeile 4 prüft, ob das definierte Muster — hier einfach die Abfolge der Zeichen e, l, f und e — im Wort Nagelfeile gefunden wurde.

In diesem Beispiel war die Verwendung von re noch nicht unbedingt nötig. Es gibt aber Fälle, in denen reguläre Ausdrücke zu wesentlich kompakterem Code führen können. Um beispielsweise sämtliche Vorkommen von Zahlen in einem Text zu zensieren, indem Sie sie durch ein X ersetzen, müssen Sie unter Verwendung der bisher bekannten Operationen viele fast identische Zeilen Code schreiben:

>>> secret = 'Sozialversicherungsnr.: 968127490567'
>>> secret = secret.replace('0', 'X')
>>> secret = secret.replace('1', 'X')
>>> secret = secret.replace('2', 'X')
>>> secret = secret.replace('3', 'X')
>>> secret = secret.replace('4', 'X')
>>> secret = secret.replace('5', 'X')
>>> secret = secret.replace('6', 'X')
>>> secret = secret.replace('7', 'X')
>>> secret = secret.replace('8', 'X')
>>> secret = secret.replace('9', 'X')
>>> print(secret)
Sozialversicherungsnr.: XXXXXXXXXXXX

Mit regulären Ausdrücken können Sie stattdessen eine Menge von Zeichen definieren, die alle durch ein X ersetzt werden sollen. Die Funktion re.sub(pattern, replacement, string) sucht im übergebenen string nach dem übergebenen pattern und ersetzt jede Stelle im String, die dem Muster entspricht, durch das gewünschte replacement. Der Rückgabewert ist vom Typ String.

>>> secret = 'Sozialversicherungsnr.: 968127490567'
>>> zahlen = re.compile('(0|1|2|3|4|5|6|7|8|9)')
>>> print(re.sub(zahlen, 'X', secret))
Sozialversicherungsnr.: XXXXXXXXXXXX

Der reguläre Ausdruck zahlen ist als Gruppe definiert, erkennbar an den runden Klammern an Anfang und Ende des Ausdrucks. Die einzelnen Elemente in der Gruppe sind durch das Zeichen | separiert, das in diesem Kontext für das logische "oder" steht. Sobald im String secret eins der Elemente aus der Gruppe gefunden wird, wird die entsprechende Stelle im String durch ein X ersetzt. Wenn wir über das Wiederfinden von Mustern in Strings sprechen, bezeichnen wir die Stelle im String, die dem Muster entspricht, als Match. Das hier verwendete Muster zahlen matcht auf jede Stelle im String secret, die der Zahl 0 oder der Zahl 1 oder der Zahl 2 oder... usw. entspricht.

Und es geht sogar noch kompakter:

>>> secret = 'Sozialversicherungsnr.: 968127490567'
>>> zahlen = re.compile('[0-9]')
>>> print(re.sub(zahlen, 'X', secret))
Sozialversicherungsnr.: XXXXXXXXXXXX

Hier wird der reguläre Ausdruck als Menge von Symbolen definiert, erkennbar an den eckigen Klammern. Das Programm verhält sich immer noch so wie vorher, ist aber durch die abgekürzte Schreibweise [0-9] noch übersichtlicher geworden.

Vordefinierte Mengen

Einige Arten von Zeichen, wie z.B. die Zahlen von 0 bis 9, bilden eine eigene Klasse von Symbolen. Für sie gibt es vordefinierte Sonderzeichen, mit denen reguläre Ausdrücke sich kompakter schreiben lassen.

Zeichen Matcht auf…
\d …alle Zahlen von 0 bis 9.
\D …sämtliche Zeichen außer den Zahlen von 0 bis 9.
\s …sämtliche Arten von Whitespace, unter anderem Leerzeichen, Tabs und Zeilenumbrüche.
\S …sämtliche Zeichen außer Whitespace-Symbole.
\w …alle alphanumerischen Zeichen; gleichbedeutend mit der Menge [A-Za-z0-9_].
\W …sämtliche Zeichen außer alphanumerischen Zeichen.
^ …den Anfang des Strings. Wird verwendet, wenn der reguläre Ausdruck nur am Anfang der Zeichenkette gematcht werden soll.
$ …das Ende des Strings. Wird verwendet, wenn der reguläre Ausdruck nur am Ende der Zeichenkette gematcht werden soll.
. …genau ein beliebiges Zeichen. Achtung, kein Punkt!
\. …den Punkt (.)

Mit einem dieser Sonderzeichen wird unser Code von oben sogar noch kürzer:

>>> secret = 'Sozialversicherungsnr.: 968127490567'
>>> zahlen = re.compile('\d')
>>> print(re.sub(zahlen, 'X', secret))
Sozialversicherungsnr.: XXXXXXXXXXXX

Verarbeitung von Matches

Wenn ein regulärer Ausdruck ein komplexes Muster beschreibt, ist es oft hilfreich, die Teil-Matches aus dem analysierten String zu extrahieren und z.B. in Variablen zu speichern. Um dies zu tun, arbeiten wir mit Gruppen. Mit der Funktion group() können wir auf den Teil des Strings, der auf die Gruppe gematcht hat, zugreifen:

>>> drink = 'warm tea'
>>> description = re.compile('(hot|warm|cold)\s(milk|coffee|water|tea)')
>>> m = re.search(description, drink)
>>> m.group(0)
'warm tea'
>>> m.group(1)
'warm'
>>> m.group(2)
'tea'
>>> m.group(3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: no such group

group(0) entspricht dem gesamten Match, also dem Teil des Strings, der die im regulären Ausdruck definierten Bedingungen vollständig erfüllt. Alle folgenden ganzzahligen Werte entsprechen den Gruppen, die im Ausdruck definiert sind, von links nach rechts hochgezählt. Der Zugriff auf eine nicht existierende Gruppe führt zu einem IndexError. Falls gar kein Match gefunden wurde, kann group() nicht verwendet werden.

Gruppen und Mengen

Die Syntax von regulären Ausdrücken belegt runde und eckige Klammern mit einer Spezialfunktion, nämlich dem Erzeugen von Gruppen und Mengen. Die folgende Tabelle zeigt, wie diese Funktionen einzusetzen sind und wie ein regulärer Ausdruck die Klammersymbole selbst als Schriftzeichen matchen kann.

Ausdruck Funktion
A Findet alle Vorkommen des Zeichens A im String.
(A|B) Findet alle Stellen im String, die dem Zeichen A oder dem Zeichen B entsprechen.
(...) Definiert eine Gruppe. Innerhalb von Gruppen werden Zeichenabfolgen in der vorgegebenen Reihenfolge gematcht. Die runden Klammern sind Teil der Syntax von regulären Ausdrücken und finden sich nicht im String wieder.
\(, \) Matcht auf öffnende bzw. schließende runde Klammern im String.
[...] Definiert eine Menge. Die Reihenfolge der Zeichen spielt keine Rolle. Jedes Vorkommen von einem der Zeichen in der Menge führt zu einem Match.
[^...] Definiert eine auszuschließende Menge. Ein Match findet an jeder Stelle des Strings statt, an der keins der Zeichen in der Menge steht.
\[, \] Matcht auf öffnende bzw. schließende eckige Klammern im String.

Quantifizierer für Teilausdrücke

Soll ein Teil eines regulären Ausdrucks optional sein oder mehrfach nacheinander vorkommen können, werden die folgenden Zeichen verwendet. Sie beziehen sich immer auf den direkt vor ihnen stehenden Teilausdruck, also auf das vorangegangene Zeichen, die vorangegangene Gruppe oder die vorangegangene Menge.

Ausdruck Funktion
+ Voriges Element kommt entweder einmal oder mehrmals direkt nacheinander im String vor.
* Voriges Element kommt entweder nullmal oder mehrmals direkt nacheinander im String vor.
? Voriges Element kommt entweder nullmal oder einmal im String vor, ist also optional.
{3} Voriges Element kommt genau 3 mal nacheinander im String vor.
{3,5} Voriges Element kommt zwischen 3 und 5 mal nacheinander im String vor.
{3,} Voriges Element kommt mindestens 3 mal nacheinander String vor.
{,5} Voriges Element kommt höchstens 5 mal nacheinander im String vor.

Verwendungsbeispiel für reguläre Ausdrücke

Sie können alle oben gelisteten Elemente nach Belieben in einem einzigen regulären Ausdruck kombinieren. Das so definierte Muster wird dann beim Aufruf von re.sub() oder re.search() von links nach rechts mit dem String abgeglichen. Falls der String alle Bedingungen erfüllt, ist das Ergebnis von re.search(muster, string) ein Match. Andernfalls gibt die Funktion None zurück.

Im folgenden Beispiel sollen lateinische Substantivformen aus der Datei amicus.txt gelesen und dann als Dictionary abgespeichert werden. Hier das Format, in dem die Wörter vorliegen:

Nominativ: amicus
Genitiv: amici
Dativ: amico
Akkusativ: amicum
Ablativ: amico

Mit regulären Ausdrücken kann die Aufgabe elegant gelöst werden:

import re
infile = open('amicus.txt', 'r')
case_and_form = re.compile('(Nominativ|Genitiv|Dativ|Akkusativ|Ablativ):\s(.*)')
forms = {}
for line in infile:
    m = re.search(case_and_form, line)
    if m:
        case = m.group(1)
        form = m.group(2)
        forms[case] = form
infile.close()

Im Ausdruck case_and_form werden genau zwei Gruppen definiert: Die eine enthält alle erwarteten Kasusnamen, die andere enthält alle Zeichen, die nach dem Doppelpunkt und dem Whitespace noch in der Zeile folgen. Die erste Gruppe ist also sehr viel einschränkender als die Zweite. Doppelpunkt und Whitespace stehen außerhalb der Gruppen, weil sie nicht weiter verarbeitet werden sollen. Nach dem Ausführen des Programms erhalten wir folgende Werte in forms:

{'Ablativ': 'amico',
 'Akkusativ': 'amicum',
 'Dativ': 'amico',
 'Genitiv': 'amici',
 'Nominativ': 'amicus'}

Die Funktion re.split()

Sie können reguläre Ausdrücke auch verwenden, um Strings zu zerlegen. Sie kennen bereits die Funktion string.split(sep), bei der der gegebene String an allen Vorkommen des Substrings sep aufgeteilt wird. Das Ergebnis von split() ist eine Liste (vgl. Vorlesungsskript 12).

Fast analog dazu funktioniert die Funktion re.split(pattern, string). Das übergebene pattern beschreibt in der Syntax regulärer Ausdrücke den Separator, der jetzt nicht mehr exakt bekannt sein muss, sondern mithilfe von Bedingungen beschrieben werden kann.

Im folgenden Code wird eine logische Formel überall dort zerteilt, wo logische Operatoren vorkommen. Das Ziel ist es, eine Liste aller Teilformeln zu erhalten. (In diesem vereinfachten Beispiel gehen wir davon aus, dass alle großgeschriebenen Elemente im String logische Operatoren sind.)

>>> query = 'casus=nom AND (genus=f OR genus=m) AND NOT numerus=sg'
>>> operator = re.compile('[A-Z\(\)\s]+')
>>> print(re.split(operator, query))
['casus=nom', 'genus=f', 'genus=m', 'numerus=sg']

Bei re.split() gibt es eine Besonderheit, die zu Verwirrung führen kann: Wenn im regulären Ausdruck Gruppen definiert sind, enthält das Ergebnis der Operation nicht nur die Teile des Strings, die zwischen den Vorkommen von pattern gefunden wurden, sondern auch diejenigen Teile, die dem pattern entsprechen (also die Separatoren):

>>> query = 'casus=nom AND (genus=f OR genus=m) AND NOT numerus=sg'
>>> operator2 = re.compile('([A-Z\(\)\s]+)')
>>> print(re.split(operator2, query))
['casus=nom', ' AND (', 'genus=f', ' OR ', 'genus=m', ') AND NOT ', 'numerus=sg']

Wählen zwischen regulären Ausdrücken und Stringoperationen

Reguläre Ausdrücke sind im Vergleich zu den Ihnen bereits bekannten Stringoperationen sehr mächtig. Ob Sie mit Stringoperationen oder mit „Regexes“ arbeiten, hängt hauptsächlich von der Art der Aufgabe ab, die Sie lösen möchten. Was würden Sie beispielsweise in den folgenden Situationen tun?

  1. Einlesen einer Visitenkarte, deren Inhalte in einem Dictionary gespeichert werden sollen: {'Vorname': 'Petunia', 'Nachname': 'Dursley', 'Hausnummer': '4', 'Straße': 'Privet Drive'}
  2. Ermitteln, ob eine IP-Adresse wohlgeformt ist (vier Zahlenblöcke, getrennt durch je einen Punkt, jeder Block entspricht einer Zahl zwischen 0 und 255)
  3. Ersetzen aller E-Mail-Adressen in einem Text durch Platzhalterstrings
  4. Alle HTML-Tags aus dem Code einer Webseite entfernen (z.B. <h1>)
  5. Aus einem kompletten Dateipfad nur den Namen der Datei extrahieren (alle Zeichen nach dem letzten /, z.B. notizen.txt aus dem Pfad D:/Dateien/Python/notizen.txt)
  6. Ermitteln, ob ein String ein Palindrom ist

Links