web-dev-qa-db-fra.com

Écrire un analyseur pour les expressions régulières

Même après des années de programmation, j'ai honte de dire que je n'ai jamais vraiment saisi les expressions régulières. En général, lorsqu'un problème appelle une expression régulière, je peux généralement (après avoir fait référence à la syntaxe) en trouver une appropriée, mais c'est une technique que je me retrouve à utiliser de plus en plus souvent.

Donc, pour m'enseigner et comprendre correctement les expressions régulières , j'ai décidé de faire ce que je fais toujours en essayant d'apprendre quelque chose; c'est-à-dire, essayez d'écrire quelque chose d'ambitieux que j'abandonnerai probablement dès que j'aurai suffisamment appris.

À cette fin, je veux écrire un analyseur d'expressions régulières en Python. Dans ce cas, "apprendre suffisamment" signifie que je veux implémenter un analyseur capable de comprendre complètement la syntaxe regex étendue de Perl. Cependant, il ne doit pas être l'analyseur le plus efficace ou même nécessairement utilisable dans le monde réel. Il doit simplement correspondre correctement ou ne pas correspondre à un modèle dans une chaîne.

La question est, par où commencer? Je ne sais presque rien sur la façon dont les expressions rationnelles sont analysées et interprétées, à part le fait qu'il s'agit en quelque sorte d'un automate à états finis. Toute suggestion sur la façon d'aborder ce problème plutôt intimidant serait très appréciée.

EDIT: Je dois préciser que pendant que je vais implémenter l'analyseur d'expressions régulières en Python, je ne suis pas trop agité sur le langage de programmation dans lequel les exemples ou les articles sont écrits. Tant qu'il ne sera pas dans Brainfuck, j'en comprendrai probablement suffisamment pour que cela en vaille la peine.

68
Chinmay Kanchi

L'écriture d'une implémentation d'un moteur d'expression régulière est en effet une tâche assez complexe.

Mais si vous êtes intéressé par la façon de le faire, même si vous ne comprenez pas suffisamment les détails pour le mettre en œuvre, je vous recommande au moins de lire cet article:

La correspondance d'expressions régulières peut être simple et rapide (mais est lente en Java, Perl, PHP, Python, Ruby, ...)

Il explique combien de langages de programmation populaires implémentent des expressions régulières d'une manière qui peut être très lente pour certaines expressions régulières, et explique une méthode légèrement différente qui est plus rapide. L'article comprend quelques détails sur le fonctionnement de l'implémentation proposée, y compris du code source en C.Cela peut être un peu lourd à lire si vous commencez tout juste à apprendre des expressions régulières, mais je pense qu'il vaut la peine de connaître la différence entre les deux approches.

37
Mark Byers

J'ai déjà donné un +1 à Mark Byers - mais pour autant que je m'en souvienne, l'article ne dit pas grand-chose sur le fonctionnement de la correspondance d'expressions régulières au-delà d'expliquer pourquoi un algorithme est mauvais et un autre beaucoup mieux. Peut-être quelque chose dans les liens?

Je vais me concentrer sur la bonne approche - créer des automates finis. Si vous vous limitez à des automates déterministes sans minimisation, ce n'est pas vraiment trop difficile.

Ce que je vais décrire (très rapidement), c'est l'approche adoptée dans Modern Compiler Design .

Imaginez que vous ayez l'expression régulière suivante ...

a (b c)* d

Les lettres représentent des caractères littéraux à faire correspondre. Le * est la correspondance habituelle de zéro ou plusieurs répétitions. L'idée de base est de dériver des états sur la base de règles en pointillés. État zéro, nous prendrons l'état où rien n'a encore été apparié, donc le point va à l'avant ...

0 : .a (b c)* d

La seule correspondance possible est 'a', donc le prochain état que nous dérivons est ...

1 : a.(b c)* d

Nous avons maintenant deux possibilités - faire correspondre le "b" (s'il y a au moins une répétition de "b c") ou faire correspondre le "d" sinon. Remarque - nous faisons essentiellement une recherche digraphique ici (soit la profondeur d'abord ou la largeur d'abord ou autre), mais nous découvrons le digraphe pendant que nous le recherchons. En supposant une stratégie élargie, nous devrons mettre en file d'attente l'un de nos cas pour un examen ultérieur, mais j'ignorerai ce problème à partir de maintenant. Quoi qu'il en soit, nous avons découvert deux nouveaux états ...

2 : a (b.c)* d
3 : a (b c)* d.

L'état 3 est un état final (il peut y en avoir plusieurs). Pour l'état 2, nous ne pouvons faire correspondre que le "c", mais nous devons être prudents avec la position du point par la suite. Nous obtenons "a. (B c) * d" - qui est le même que l'état 1, donc nous n'avons pas besoin d'un nouvel état.

IIRC, l'approche dans Modern Compiler Design est de traduire une règle lorsque vous frappez un opérateur, afin de simplifier la gestion du point. L'État 1 serait transformé en ...

1 : a.b c (b c)* d
    a.d

Autrement dit, votre prochaine option consiste soit à faire correspondre la première répétition, soit à ignorer la répétition. Les états suivants en sont équivalents aux états 2 et 3. Un avantage de cette approche est que vous pouvez supprimer tous vos matchs passés (tout avant le ".") Car vous ne vous souciez que des matchs futurs. Cela donne généralement un modèle d'état plus petit (mais pas nécessairement minimal).

[~ # ~] modifier [~ # ~] Si vous supprimez les détails déjà correspondants, la description de votre état est une représentation de l'ensemble de chaînes pouvant se produire à partir de ce moment.

En termes d'algèbre abstraite, il s'agit d'une sorte de fermeture d'ensemble. Une algèbre est fondamentalement un ensemble avec un (ou plusieurs) opérateurs. Notre ensemble est de descriptions d'état, et nos opérateurs sont nos transitions (correspondances de caractères). Un ensemble fermé est celui où l'application d'un opérateur à n'importe quel membre de l'ensemble produit toujours un autre membre qui se trouve dans l'ensemble. La fermeture d'un ensemble est l'ensemble le plus grand qui soit fermé. Donc, fondamentalement, en commençant par l'état de départ évident, nous construisons l'ensemble minimal d'états qui est fermé par rapport à notre ensemble d'opérateurs de transition - l'ensemble minimal d'états accessibles.

Minimal se réfère ici au processus de fermeture - il peut y avoir un automate équivalent plus petit qui est normalement appelé minimal.

Avec cette idée de base à l'esprit, il n'est pas trop difficile de dire "si j'ai deux machines à états représentant deux ensembles de chaînes, comment en dériver une troisième représentant l'union" (ou intersection, ou définition de différence ...). Au lieu de règles pointillées, vos représentations d'état auront un état actuel (ou un ensemble d'états actuels) de chaque automate d'entrée et peut-être des détails supplémentaires.

Si vos grammaires habituelles deviennent complexes, vous pouvez les minimiser. L'idée de base ici est relativement simple. Vous regroupez tous vos états dans une classe d'équivalence ou "bloc". Ensuite, vous testez à plusieurs reprises si vous devez fractionner des blocs (les états ne sont pas vraiment équivalents) par rapport à un type de transition particulier. Si tous les états d'un bloc particulier peuvent accepter une correspondance du même caractère et, ce faisant, atteindre le même bloc suivant, ils sont équivalents.

L'algorithme Hopcrofts est un moyen efficace de gérer cette idée de base.

Une chose particulièrement intéressante à propos de la minimisation est que chaque automate fini déterministe a précisément une forme minimale. De plus, l'algorithme Hopcrofts produira la même représentation de cette forme minimale, quelle que soit la représentation du cas plus grand à partir duquel il est parti. C'est-à-dire qu'il s'agit d'une représentation "canonique" qui peut être utilisée pour dériver un hachage ou pour des ordonnances arbitraires mais cohérentes. Cela signifie que vous pouvez utiliser un minimum d'automates comme clés dans des conteneurs.

Les définitions ci-dessus sont probablement un peu bâclées WRT, alors assurez-vous de rechercher vous-même les termes avant de les utiliser vous-même, mais avec un peu de chance, cela donne une introduction rapide et juste aux idées de base.

BTW - jetez un coup d'œil dans le reste de site Dick Grunes - il a un livre gratuit PDF sur les techniques d'analyse. La première édition de Modern Compiler Design est assez bonne IMO , mais comme vous le verrez, une deuxième édition est imminente.

21
Steve314

Cet article adopte une approche intéressante. L'implémentation est donnée en Haskell, mais elle a été réimplémentée en Python au moins une fois.

7
dhaffey

Il y a un chapitre intéressant (quoique légèrement court) dans Beautiful Code par Brian Kernighan, appelé à juste titre "A Regular Expression Matcher". Il y discute d'un simple matcher qui peut correspondre à des caractères littéraux, et le .^$* symboles.

6
Richard Fearn

Je suis d'accord que l'écriture d'un moteur regex améliorera la compréhension, mais avez-vous jeté un coup d'œil à ANTLR ??. Il génère automatiquement les analyseurs pour tout type de langue. Alors peut-être pouvez-vous essayer votre main en prenant l'une des grammaires linguistiques listées à exemples de grammaire et parcourez le AST et l'analyseur qu'il génère. Il génère un très compliqué mais vous aurez une bonne compréhension du fonctionnement d'un analyseur.

1
A_Var