Validierung durch Ableitung regulärer Ausdrücke



[ Informatik-Seminar 2002 ] ... [ Thema XML-Validierung mit Haskell ] ... [ Validierung mit Parser Kombinatoren ] ...

Übersicht:


Beschreibung des Algorithmus

Der Algorithmus wurde zuerst beschrieben von Janusz A. Brzozowski; Derivatives of Regular Expressions; Journal of the ACM, Volume 11, Issue 4, 1964.
Informell beschrieben geht es bei diesem Algorithmus darum, einen regulären Ausdruck auf eine Zeichenkette anzuwenden und den verbliebenen regulären Ausdruck zu prüfen, ob dieser auf die leere Zeichenkette ableitbar ist. Kann der verbliebene reguläre Ausdruck auf eine leere Zeichenkette abgeleitet werden, passt der reguläre Ausdruck auf die Zeichenkette, andernfalls nicht.

Beispiele:
Der reguläre Ausdruck foobar soll bezüglich der Zeichenkette "foo" abgeleitet werden. Nach Anwendung des regulären Ausdrucks auf diese Zeichenkette bleibt der reguläre Ausdruck bar übrig. Dieser kann nicht auf eine leere Zeichenkette abgeleitet werden, da dieser Ausdruck noch die Zeichen 'b', 'a' und 'r' erwartet. Der reguläre Ausdruck passt also nicht auf die Zeichenkette. (Kurzschreibweise: foo\foobar = bar)

Der reguläre Ausdruck a* soll bezüglich der Zeichenkette "aa" abgeleitet werden. Nach der Ableitung bleibt der reguläre Ausdruck a* übrig. Dieser Ausdruck kann auf die leere Zeichenkette abgeleitet werden, da der *-Operator anzeigt, dass kein oder beliebig viele 'a'-Zeichen erwartet werden. Der reguläre Ausdruck passt somit auf die Zeichenkette. (Kurzschreibweise: aa\a* = a*)

Formale Beschreibung:
Begriffe:

Die Ableitung von e bezüglich s ergibt den regulären Ausdruck, der von e übrig bleibt, wenn e auf s angewendet wurde.
Ist der erhaltene reguläre Ausdruck auf einen leeren String ableitbar, passt e auf s.


Der Algorithmus in Haskell

1. Datentyp für reguläre Ausdrücke

Definition eines Datentyps für reguläre Ausdrücke

data RE a =

Regulärer Ausdruck für die leere Menge (symbolisiert Fehlerfall)

	RE_ZERO                 -- L(0)   = {}

Regulärer Ausdruck für die leere Zeichenfolge

	| RE_UNIT               -- L(1)   = { [] }

Regulärer Ausdruck für ein Symbol

	| RE_SYM a              -- L(x)   = { [x] }

Regulärer Ausdruck zur optionalen Wiederholung (*) eines anderen regulären Ausdrucks e

	| RE_REP (RE a)         -- L(e*)  = { [] } `union` L(e+)

Regulärer Ausdruck zur Wiederholung (+) eines anderen regulären Ausdrucks e

	| RE_PLUS (RE a)        -- L(e+)  = { x ++ y | x <- L(e), y <- L(e*) }

Regulärer Ausdruck e ist eine Option (?)

	| RE_OPT (RE a)         -- L(e?)  = L(e) `union` { [] }

Sequenz (,) zweier regulärer Ausdrücke e und f

	| RE_SEQ (RE a) (RE a)  -- L(e,f) = { x ++ y | x <- L(e), y <- L(f) }

Alternative (|) zweier regulärer Ausdrücke e und f

	| RE_ALT (RE a) (RE a)  -- L(e|f) = L(e) `union` L(f)
	deriving (Show, Eq)



2. Konstruktor-Funktionen für die Datentypen

Mit Hilfe der Konstruktor-Funktionen können die regulären Ausdrücke bei ihrer Erzeugung gleich minimiert werden. So ist z.B. die optionale Wiederholung (*) einer leeren Zeichenfolge einfach nur die leere Zeichenfolge. Analog ergibt die Sequenz (,) einer leeren Zeichenfolge und eines regulären Ausdrucks e nur den regulären Ausdruck e.

re_unit, re_zero        :: RE a
re_sym                  :: a -> RE a
re_rep, re_plus, re_opt :: RE a -> RE a
re_seq, re_alt          :: RE a -> RE a -> RE a

re_unit            = RE_UNIT
re_zero            = RE_ZERO
re_sym x           = RE_SYM x

re_rep RE_UNIT     = RE_UNIT
re_rep RE_ZERO     = RE_UNIT
re_rep e           = RE_REP e

re_plus RE_UNIT    = RE_UNIT
re_plus RE_ZERO    = RE_ZERO
re_plus e          = RE_PLUS e

re_opt RE_UNIT     = RE_UNIT
re_opt RE_ZERO     = RE_UNIT
re_opt e           = RE_OPT e

re_seq RE_ZERO f   = RE_ZERO
re_seq RE_UNIT f   = f
re_seq e RE_ZERO   = RE_ZERO
re_seq e RE_UNIT   = e
re_seq e f         = RE_SEQ e f

re_alt RE_ZERO f   = f
re_alt e RE_ZERO   = e
re_alt e f         = RE_ALT e f



3. Ableitung des regulären Ausdrucks auf leere Zeichenfolge

Die Berechnung, ob sich regulärer Ausdruck auf eine leere Zeichenfolge ableiten lässt geschieht durch die Funktion nullable.

nullable :: RE sym -> Bool

Die leere Menge ist nicht auf leeren Zeichenfolge ableitbar

nullable RE_ZERO      = False

Die leere Zeichenfolge ist auf eine leere Zeichenfolge ableitbar

nullable RE_UNIT      = True

Ein Zeichen ist nicht auf eine leere Zeichenfolge ableitbar

nullable (RE_SYM s)   = False

Die optionale Wiederholung (*) eines regulären Ausdrucks e ist auf eine leere Zeichenfolge ableitbar

nullable (RE_REP e)   = True

Die Wiederholung (+) eines regulären Ausdrucks e ist auf eine leere Zeichenfolge ableitbar, wenn der wiederholte reguläre Ausdruck e auf eine leere Zeichenfolge ableitbar ist

nullable (RE_PLUS e)  = nullable e  --

Die Option (?) eines regulären Ausdrucks e ist auf eine leere Zeichenfolge ableitbar

nullable (RE_OPT e)   = True

Die Sequenz (,) zweier regulärer Ausdrücke e und f ist auf eine leere Zeichenfolge ableitbar, wenn e und f auf eine leere Zeichenfolge ableitbar sind.

nullable (RE_SEQ e f) = nullable e && nullable f

Die Alternative (|) zweier regulärer Ausdrücke e und f ist auf eine leere Zeichenfolge ableitbar, wenn e oder f auf eine leere Zeichenfolge ableitbar ist.

nullable (RE_ALT e f) = nullable e || nullable f



4. Reduktion eines regulären Ausdrucks bezüglich eines Zeichens

Die Ableitung des regulären Ausdrucks bezüglich eines Zeichens geschieht durch die Funktion delta. Die Funktion erhält als Parameter einen regulären Ausdruck und ein Zeichen. Sie reduziert den regulären Ausdruck um dieses Zeichen. Rückgabewert ist der reduzierte reguläre Ausdruck.

delta :: (Eq a) => RE a -> a -> RE a
delta re x = case re of

Die leere Menge kann nicht weiter reduziert werden.

	RE_ZERO          -> re_zero

Die leere Zeichenfolge kann nicht um ein Zeichen reduziert werden.

	RE_UNIT          -> re_zero

Ein Symbol kann erfolgreich reduziert werden, wenn es dem Zeichen entspricht.

	RE_SYM sym
		| x == sym   -> re_unit
		| otherwise  -> re_zero

Die optionale Wiederholung (*) wird in eine Sequenz aus dem reduzierten regulären Ausdruck e und dem ursprünglichen regulären Ausdruck e überführt. Der ursprüngliche reguläre Ausdruck e wird optional wiederholt.

	RE_REP e         -> re_seq (delta e x) (re_rep e)

Die Wiederholung (+) wird in eine Sequenz aus dem reduzierten regulären Ausdruck e und dem ursprünglichen regulären Ausdruck e überführt. Der ursprüngliche reguläre Ausdruck e wird optional wiederholt.

	RE_PLUS e        -> re_seq (delta e x) (re_rep e)

Bei der Option (?) wird der enthaltene reguläre Ausdruck e auf das Zeichen angewendet.

	RE_OPT e         -> delta e x

Bei der Sequenz (,) wird geprüft, ob der erste enthaltene reguläre Ausdruck e auf eine leere Zeichenfolge ableitbar ist. Ist dies der Fall, müssen die beiden regulären Ausdrücke e und f der Sequenz auf das Zeichen angewendet werden. Ansonsten genügt es, der ersten regulären Ausdrück e bezüglich des Zeichens abzuleiten.

	RE_SEQ e f
		| nullable e -> re_alt (re_seq (delta e x) f) (delta f x)
		| otherwise  -> re_seq (delta e x) f

Bei der Alternative (|) wird eine Alternative mit den Ableitungen von beiden enthaltenen regulären Ausdrücken e und f erzeugt.

	RE_ALT e f       -> re_alt (delta e x) (delta f x)



5. Regulären Ausdruck auf eine Zeichenfolge anwenden

Die Funktion matches dient zum Prüfen, ob eine Zeichenfolge durch einen regulären Ausdruck definiert wird. Sie nimmt als Parameter einen regulären Ausdruck und eine Zeichenfolge entgegen. Als Ergebnis liefert sie True zurück, wenn der reguläre Ausdruck auf die Zeichenfolge passt, andernfalls False.

matches :: (Eq a) => RE a -> [a] -> Bool
matches e = nullable . foldl delta e


Exkurs higher-order function foldl
Die Funktion matches ist sehr kompakt geschrieben. Kern der Funktion ist die higher-order function foldl, welche als Parameter die Funktion delta, den regulären Ausdruck und die Zeichenfolge entgegennimmt.

Die Funktionen foldl nimmt als Parameter eine Funktion, einen Startwert und eine Liste von Werten entgegen. Sie ist in der Haskell Standard-Prelude wie folgt definiert:

foldl :: (a -> b -> a) -> a -> [b] -> a
foldl f st []     = st
foldl f st (x:xs) = foldl f (f st x) xs

Bei einer leeren Liste wird der Startwert zurückgegeben, ansonsten ruft sich die Funktion rekursiv auf, wobei als neuer Startwert die übergebene Funktion f auf den ursprünglichen Startwert und den Kopf der Liste angewendet wird. Als neue Liste wird der Rest der Liste übergeben.

Beispiel - Addition einer Liste von Zahlen mit foldl:

foldl (+) 0 [1..4]

1. foldl (+) (0 + 1) [2..4]
2. foldl (+) ((0 + 1) + 2) [3..4]
3. foldl (+) (((0 + 1) + 2) + 3) [4]
4. foldl (+) ((((0 + 1) + 2) + 3) + 4) []
5. ((((0 + 1) + 2) + 3) + 4)
6. 10



Der Algorithmus im Beispiel

Im folgenden soll der beschriebene Algorithmus am Beispiel des regulären Ausdrucks (a , b) | (a , c) demonstriert werden.

Der reguläre Ausdruck wird zunächst mit Hilfe der beschriebenen Konstruktor-Funktionen erzeugt:

(a , b) | (a , c) = re_alt (re_seq (re_sym "a") (re_sym "b")) (re_seq (re_sym "a") (re_sym "c"))


Dieser reguläre Ausdruck wird nun zusammen mit der Zeichenfolge "ab" der Funktion matches übergeben:

matches re_alt(...) ["a","b"]

Im Folgenden wird nun der Ablauf innerhalb von matches demonstriert.
Vorsicht! Zur Veranschaulichung wird so getan, als ob Haskell strikt auswertet. In Wahrheit wird natürlich durch die Lazy Evaluation nur das Notwendigste berechnet!

Die bereits beschriebene higher-order function foldl wird mit der delta Funktion, dem regulären Ausdruck und der Zeichenfolge aufgerufen. Dabei wird die delta Funktion für jedes Zeichen der Zeichenfolge aufgerufen, siehe Additions-Beispiel.

foldl delta re_alt(...) ["a","b"]

1. foldl delta (delta re_alt(...) "a") ["b"]
2. foldl delta (delta (delta re_alt(...) "a") "b") []

Ist die Liste der Zeichenfolge leer, fangen die ineinander geschachtelten delta Funktionen an zu arbeiten. Zunächst wird der eingegebene reguläre Ausdruck (a , b) | (a , c) auf das Zeichen 'a' angewendet. Da es sich um eine Alternative (|) handelt, werden die beiden enthaltenen regulären Ausdrücke der Alternative (|) um das Zeichen 'a' reduziert.

delta re_alt(...) "a"
1. re_alt (delta re_seq(...) "a") (delta re_seq(...) "a")

Die enthaltenen regulären Ausdrücke der Alternative sind Sequenzen (,). Da der erste reguläre Ausdruck der Sequenzen nicht auf eine leere Zeichenfolge ableitbar ist, werden die ersten regulären Ausdrücke der Sequenzen nach dem Zeichen 'a' abgeleitet.

delta re_seq(...) "a"
2.1  re_seq (delta re_sym "a" "a") re_sym "b"
2.2  re_seq (delta re_sym "a" "a") re_sym "c"

Beide reguläre Ausdrücke der Sequenzen akzeptieren das Zeichen 'a' und liefern als neuen regulären Ausdruck die leere Zeichenfolge.

delta (re_sym "a") "a" => re_unit

Durch die Konstruktor-Funktionen verbleibt folgender regulärer Ausdruck nach Ableitung des ursprünglichen regulären Ausdrucks (a, b) | (a,c) bezüglich 'a':

(b | c) = re_alt (re_sym "b") (re_sym "c")

Der verbliebene reguläre Ausdruck wird nun auf das Zeichen 'b' angewendet. Da es sich bei dem regulären Ausdruck (b | c) um eine Alternative (|) handelt, werden die beiden enthaltenen regulären Ausdrücke auf 'b' angewendet.

delta (re_alt (re_sym "b") (re_sym "c")) "b"

1. re_alt (delta (re_sym "b") "b") (delta (re_sym "c") "b")

2.1 delta (re_sym "b") "b" => re_unit
2.2 delta (re_sym "c") "b" => re_zero

Nur der reguläre Ausdruck für das Zeichen 'b' kann auf 'b' angewendet werden. Durch die Konstruktor-Funktionen wird der reguläre Ausdruck (b | c) auf den regulären Ausdruck für die leere Zeichenfolge reduziert. Da dieser reguläre Ausdruck auf die leere Zeichenfolge ableitbar ist, passt der reguläre Ausdruck (a, b) | (a, c) auf die Zeichenfolge "ab".

re_alt (re_unit) (re_zero) => re_unit

nullable re_unit => True


Verwendung des Algorithmus zur Validierung von XML-Dokumenten

Eine Übertragung dieses Algorithmus auf XML-Dokumente ist recht einfach. Zunächst gilt es, das Inhaltsmodell auf die regulären Ausdrücke abzubilden.

Abbildung des Inhaltsmodells auf reguläre Ausdrücke
#PCDATA re_opt (re_sym "#PCDATA")
EMPTY re_unit
ANY (PCDATA | elem_1 | ... | elem_n)*
children /
mixed content
regulärer Ausdruck der DTD


Liegt das XML-Dokument als Baum-Struktur vor, ist die Validierung des Dokuments mit diesem Algorithmus besonders einfach. Statt den regulären Ausdruck auf Zeichen anzuwenden, wird er auf Element-Knoten angewendet. Um öffnende und schießende Tags muss man sich an dieser Stelle nicht mehr kümmern.
Es sind zwei Arten der Verarbeitung denkbar. Bei ersten Ansatz wird der reguläre Ausdruck des Inhaltsmodells eines Elements auf die Liste der Kinder dieses Elements angewendet. Anschließend sind die Kind-Elemente der Kinder zu prüfen. Beim zweiten Ansatz wird der vorhandene reguläre Ausdruck bei jedem erkannten Element um den regulären Ausdruck für dessen Inhaltsmodell erweitert und anschießend die Kinder dieses Elements gegen den neuen regulären Ausdruck getestet. Dieses Vorgehen entspricht dem Bilden eines regulären Ausdrucks über das gesamte Dokument, wobei der reguläre Ausdruck dynamisch erweitert und durch die Konstruktor-Funktionen reduziert wird.

Implementierung des zweiten Ansatzes

Bei der Implementierung des zweiten Ansatzes müsste die vorhandene Baumstruktur in eine Liste umgewandelt werden. Da für jedes erkannte Element sofort dessen Kinder verarbeitet werden, ist die Liste entsprechend einer Preorder-Traversierung des Baumes aufzubauen.
Beim Erkennen eines Elements in der delta Funktion würde nun nicht mehr der reguläre Ausdruck für eine leere Zeichenfolge, bzw. Elementfolge, zurückgegeben werden, sondern der reguläre Ausdruck für dessen Inhaltsmodell.

delta re x = case re of
    ...
    RE_SYM sym
        | expectedNode x sym -> getContentModel (...)
        | otherwise          -> re_zero
    ...


Bewertung

Der beschriebene Algorithmus stellt einen sehr eleganten Ansatz zur XML-Validierung dar. Statt das Inhaltsmodell in Endliche Automaten zu transformieren, kann mit dessen regulären Ausdrücken gearbeitet werden.

Es muss jedoch ein Weg gefunden werden, um aussagekräftige Fehlermeldungen zu erzeugen, die dem User informieren an welcher Stelle im XML-Dokument ein Fehler aufgetreten ist. Die dargestellte Implementierung dieses Algorithmus unterstützt aussagekräftige Fehlermeldungen in keinster Weise, sondern meldet lediglich nach der Abarbeitung aller Zeichen, bzw. Elemente, ob eine Zeichenkette, bzw. ein XML-Dokument, einen regulären Ausdruck entspricht.


[ Informatik-Seminar 2002 ] ... [ Thema XML-Validierung mit Haskell ] ... [ Ableitung regulärer Ausdrücke ] ... [ Validierung mit Parser Kombinatoren ] ...