Ενότητα 2-2

Regular expressions

Regular expressions (‘κανονικές εκφράσεις’) είναι ένα ισχυρό εργαλείο που προέρχεται από τη θεωρία των τυπικών γλωσσών (formal languages), το οποίο επιτρέπει την ευέλικτη αναζήτηση και ‘ταίριασμα’ (matching) κειμένου σύμφωνα με μια προδιαγραφή (matching pattern).

Μπορούμε να φανταστούμε την προδιαγραφή αυτή ως μία μίνι-γλώσσα, η οποία χρησιμοποιείται για τη συγκρότηση μιας μηχανής ταιριάσματος (matching engine). Η μηχανή επεξεργάζεται το κείμενο εισόδου σύμφωνα με τις οδηγίες της προδιαγραφής και επιστρέφει

  • αν υπάρχει ταίριασμα (αληθές/ψευδές)
  • σε ποια σημεία (θέση στο κείμενο)
  • ποιο κομμάτι του κειμένου ταίριαξε με την προδιαγραφή
  • επίσης, η μηχανή μπορεί να αντικαταστήσει τα κομμάτια που ταίριαξαν, με άλλο κείμενο, αν αυτό ζητηθεί

Μια προδιαγραφή regular expression μπορεί να αποτελείται από άλλες μικρότερες ή από ένα σύνολο εναλλακτικών υπο-προδιαγραφών. Οι προδιαγραφές απαρτίζονται από χαρακτήρες: είτε απλοί χαρακτήρες όπως το a που ταιριάζει μόνο το γράμμα a, είτε ειδικοί χαρακτήρες ελέγχου, όπως η τελεία . που ταιριάζει (σχεδόν) οποιοδήποτε γράμμα.

Η προδιαγραφή περνά από μια διαδικασία ‘μεταγλώττισης’ (compilation) και κατασκευάζεται ένα σύνολο οδηγιών για τη μηχανή ταιριάσματος. Η βασική μηχανή είναι γραμμένη συνήθως σε C και πολύ γρήγορη σε εκτέλεση.

Υπάρχουν δύο τύποι μηχανών:

  • DFA, όπου η απόδοση εξαρτάται μόνο από το μέγεθος του κειμένου εισόδου (κι όχι από την πολυπλοκότητα της προδιαγραφής της regular expression). Πολύ γρήγορη μηχανή που για κάθε χαρακτήρα εισόδου παρακολουθεί ταυτόχρονα όλες τις πιθανές θέσεις ταιριάσματος. Δεν διαθέτει όμως πρόσθετα χαρακτηριστικά (θα δούμε αργότερα ποια), τα οποία είναι χρήσιμα στον προγραμματισμό.
  • NFA, αργότερη μηχανή ταιριάσματος, δοκιμάζει διαδοχικά τις εναλλακτικές προδιαγραφές της regular expression αρχίζοντας από την αρχή του κειμένου εισόδου μέχρι να βρει ένα επιτυχές ταίριασμα. Παρέχει ενδιαφέροντα πρόσθετα εργαλεία, αλλά απαιτείται προσοχή στον τρόπο κατασκευής της προδιαγραφής: η απόδοση εξαρτάται από το πώς είναι γραμμένη. Σχεδόν όλες οι γλώσσες προγραμματισμού υλοποιούν NFA μηχανές για regular expressions.

Note

Η χρήση των regular expressions είναι δύσκολη για τους μη πεπειραμένους και πρέπει να γίνεται με προσοχή. Εξετάστε αν μπορείτε να λύσετε το πρόβλημά σας με πιο απλόν τρόπο πριν καταφύγετε στη λύση των regular expressions (RE).

Some people, when confronted with a problem, think 'I know, I'll use
regular expressions.' Now they have two problems.
         -- Jamie Zawinski, alt.religion.emacs (08/12/1997)

Note

Πολλά από τα παραδείγματα που ακολουθούν βασίζοντια σε υλικό από το βιβλίο “Mastering Regular Expressions”, 2nd ed., Jeffrey E.F. Friedl, O’Reilly Media, July 2002.

Python και regular expressions

Στην Python η υλοποίηση των regular expressions είναι αντικειμενοστρεφής.

  • Η υποστήριξη των regular expressions υπάρχει ενσωματωμένη στη γλώσσα, στο module re:

    import re
    
  • Ξεκινάμε με την προδιαγραφή εκφρασμένη σε ένα string:

    restr = r'([-+]?[0-9]+(\.[0-9]*)?)\s*([CF])'
    

    Note

    Το string restr του παραδείγματος περιγράφει μια regular expression που ταιριάζει βαθμούς θερμοκρασίας, με προαιρετικό πρόσημο, ακέραιο και προαιρετικό δεκαδικό μέρος, πιθανά κενά και μετά τα γράμματα C ή F. Μην ανησυχείτε αν δεν την καταλαβαίνετε ακόμα!

    Παρατηρήστε πώς γράφεται το regular expression: η σταθερά string δίνεται ως r' .... ', κάτι που συμβολίζει ένα raw string. Δεν σχετίζεται ειδικά με τα regular expressions, απλά βολεύει στην περίπτωσή μας:

    • Η Python σε σταθερές string αντικαθιστά όπως και η C τα \n, \t κλπ με τον αντίστοιχο χαρακτήρα newline, tab, κ.ο.κ.
    • Σε raw σταθερά string όμως, η αντικατάσταση αυτή δε γίνεται: το r'\n' είναι πάντα 2 χαρακτήρες, το \ και το n.
    • Οι regular expressions χρησιμοποιούν πολύ τον μηχανισμό \χαρακτήρα, έτσι πρέπει να πούμε στην Python να μην προχωρήσει σε μετατροπές.
  • Στη συνέχεια κατασκευάζουμε το αντικείμενο regular expression, το οποίο μπορούμε να θεωρήσουμε ως τη μηχανή ταιριάσματος. Είναι το αντικείμενο που μας παρέχει τις μεθόδους αναζήτησης και αντικατάστασης σύμφωνα με την προδιαγραφή του RE.

    rexp = re.compile(restr)
    

    Note

    Το rexp του παραδείγματος συμβολίζει το αντικείμενο-RE και συνήθως κατασκευάζεται άπαξ, στην αρχή του προγράμματος. Στη συνέχεια, μπορούμε να το χρησιμοποιήσουμε όσες φορές θέλουμε για να ταιριάξουμε κείμενο.

Στις επόμενες παραγράφους αναφέρονται μερικά μόνο από τα εργαλεία της Python για regular expressions. Για την πλήρη περιγραφή του module re, δείτε στο http://docs.python.org/library/re.html.

Συνήθεις προδιαγραφές για regular expressions

που αναγνωρίζονται από την Python είναι:

abc ταιριάζει ακριβώς το γράμμα a, ακολουθούμενο από το b και μετά το c
a|b είτε το a, είτε το b
[abc] character class: ένα από τα a, b ή c (ΠΡΟΣΟΧΗ: ΕΝΑΣ ΧΑΡΑΚΤΗΡΑΣ ΜΟΝΟ)
[a-zA-Z] όπως προηγουμένως, αλλά σε ένα πεδίο τιμών, από a έως και z και από A έως και Z
[^ab] ένας οποιοσδήποτε χαρακτήρας, εκτός από a ή b
$ ταιριάζει στο τέλος του string (ρυθμίζεται να ταιριάζει και στο τέλος κάθε γραμμής του string)
^ ταιριάζει στην αρχή του string (ρυθμίζεται να ταιριάζει και στην αρχή κάθε γραμμής του string)
. ένας οποιοσδήποτε χαρακτήρας εκτός από newline (ρυθμίζεται να ταιριάζει και το newline)
* 0 ή περισσότερες φορές ο χαρακτήρας που προηγείται
+ 1 ή περισσότερες φορές ο χαρακτήρας που προηγείται
? 0 ή 1 φορά ο χαρακτήρας που προηγείται
{n} {m,n} n φορές / από n έως m φορές ο χαρακτήρας που προηγείται
\b ταιριάζει στην αρχή και στο τέλος μιας λέξης
\w \W οποιοσδήποτε αλφαριθμητικός / μη αλφαριθμητικός χαρακτήρας
\s \S οποιοσδήποτε whitespace / μη whitespace χαρακτήρας
*? +? ?? μη άπληστες (non-greedy) μορφές των *, + και ? (μόνο σε NFA, βλ. πιο κάτω)
() group ομαδοποίησης τμημάτων μιας RE, συγκρατούν και το κείμενο που ταιριάζει στο τμήμα (NFA μόνο)
\1 \2 κλπ μέσα στη RE αντικαθίστανται από το τι έχει ταιριάξει ως τώρα στο αντίστοιχο group (NFA μόνο)

Με βάση τα πιο πάνω μπορούμε να συντάξουμε σύνθετες προδιαγραφές, όπως εκείνη του παραδείγματος με τη θερμοκρασία.

Note

Η Python μπορεί να χρησιμοποιήσει την ίδια προδιαγραφή regular expression τόσο σε strings όσο σε unicode strings. ΠΡΟΣΟΧΗ!! Αν η προδιαγραφή περιέχει μη ASCII χαρακτήρες, πρέπει να χρησιμοποιήσετε raw unicode string (ur'....') για αυτήν, αλλιώς δεν θα λάβετε τα αποτελέσματα που περιμένετε!

Μέθοδοι του αντικειμένου regular expression

Η μέθοδος match()

Η μέθοδος match() λειτουργεί ακριβώς όπως η search(), με τη διαφορά ότι προσπαθεί να βρεί ταίριασμα μόνο από την αρχή του string (κι όχι οπουδήποτε μέσα στο string όπως η search).

Η μέθοδος finditer()

Δέχεται ως όρισμα ένα string και επιστρέφει ένα αντικείμενο iterator, το οποίο δίνει διαδοχικά όλα τα match objects για όλα τα σημεία ταιριάσματος μέσα στο string.

  • Παράδειγμα: χρήση της τελείας . για να ταιριάξουμε οτιδήποτε (εκτός από newline!)

    >>> rexp = re.compile(r'a.c')
    >>> mi = rexp.finditer('ab abc acc axc afc acb a\nc avc')
    >>> for m in mi:
    ...     print m.group(0)
    ...
    abc
    acc
    axc
    afc
    avc
    
  • Παράδειγμα: color ή colour?

    >>> rexp = re.compile(r'colou?r')
    >>> mi = rexp.finditer('some write colour instead of color')
    >>> for m in mi:
    ...     print m.group(0)
    ...
    colour
    color
    
  • Παράδειγμα: Εύρεση λέξεων που αντιπροσωπεύουν “σωστή” ώρα σε 24ωρο format

    >>> rexp = re.compile(r'\b([01]?[0-9]|2[0-3]):[0-5][0-9]\b')
    >>> mi = rexp.finditer('4:24 3:56  25:02 12:45 9:62')
    >>> for m in mi:
    ...     print m.group(0)
    ...
    4:24
    3:56
    12:45
    

Η μέθοδος findall()

Επιστρέφει όλα τα ταιριάσματα, αλλά ΠΡΟΣΟΧΗ!! Η επιστρεφόμενη τιμή δεν μοιάζει με εκείνες των προηγούμενων μεθόδων:

  • Αν δεν υπάρχουν groups στην RE, επιστρέφει απλά μια λίστα με τα strings που ταίριαξαν.

  • Αν υπάρχουν groups, επιστρέφει μόνο αυτά κι όχι το πλήρες ταίριασμα (δηλ. δεν σας δίνει το group(0)).

    • Οταν τα groups είναι περισσότερα από ένα στην RE, κάθε στοιχείο της επιστρεφόμενης λίστας είναι ένα tuple με όλες τις τιμές των groups για το συγκεκριμένο ταίριασμα.
  • Παράδειγμα: Ταίριασμα ονομάτων μεταβλητών:

    >>> rexp = re.compile(r'\b[a-zA-z_][a-zA-Z0-9_]*\b')
    >>> l = rexp.findall('var1 1var _var')
    >>> l
    ['var1', '_var']
    
  • Παράδειγμα: Ταίριασμα κειμένου μέσα σε “..”

    >>> rexp = re.compile(r'"[^"]*"')
    >>> l = rexp.findall('"RE" is a "better" name for "regular expressions"')
    >>> l
    ['"RE"', '"better"', '"regular expressions"']
    
  • Παράδειγμα: Λέξεις 5 κεφαλαίων από Α ώς Ζ (αγγλικά)

    >>> rexp = re.compile(r'\b[A-Z]{5}\b')
    >>> l = rexp.findall('ABCDE aEFGHI AB ABCDEFGHIJ NNNNN')
    >>> l
    ['ABCDE', 'NNNNN']
    
  • Παράδειγμα: Βρείτε τις ετικέτες σε κώδικα HTML. Ένας λάθος (λόγω απληστίας του + που προσπαθεί να ταιριάξει όσο το δυνατόν περισσότερο κείμενο) και 2 σωστοί τρόποι (με κλάση χαρακτήρων και τη μη άπληστη μορφή του +?)

    >>> rexp = re.compile(r'<.+>')
    >>> l = rexp.findall('this <b>is</b> a <em>HTML</em> text')
    >>> l
    ['<b>is</b> a <em>HTML</em>']
    
    >>> rexp = re.compile(r'<[^>]+>')
    >>> l = rexp.findall('this <b>is</b> a <em>HTML</em> text')
    >>> l
    ['<b>', '</b>', '<em>', '</em>']
    
    >>> rexp = re.compile(r'<.+?>')
    >>> l = rexp.findall('this <b>is</b> a <em>HTML</em> text')
    >>> l
    ['<b>', '</b>', '<em>', '</em>']
    

Η μέθοδος sub()

Δέχεται ως πρώτο όρισμα ένα string αντικατάστασης και ως δεύτερο ένα string αναζήτησης. Επιστρέφει ένα νέο string, όπου όλα τα σημεία του string αναζήτησης που ταιριάζουν με την RE έχουν αντικατασταθεί από το string αντικατάστασης.

  • Παράδειγμα: απαλοιφή διπλών ίδιων συνεχόμενων λέξεων από ένα string (προσέξτε ότι το backreference \1 απαιτεί το 1ο όρισμα της sub να είναι raw string!)

    >>> rexp = re.compile(r'\b([a-z]+)\s+\1\b', re.IGNORECASE)
    >>> s = rexp.sub(r'\1','This this is a a known fact.')
    >>> s
    'This is a known fact.'
    

Bonus υλικό: Αφαίρεση ετικετών HTML

Ας δούμε ένα ολοκληρωμένο παράδειγμα που χρησιμοποιεί πολλά από όσα αναφέρθηκαν προηγουμένως.

Το ζητούμενο είναι η αφαίρεση των ετικετών από σελίδα HTML, έτσι ώστε να παραμένει το καθαρό κείμενο της σελίδας. Θα χρησιμοποιήσουμε τη μέθοδο sub() για να αντικαταστήσουμε ό,τι επιλέγουν οι RE με το κενό (space).

Η χρήση του <[^>]*> προσφέρει μια γρήγορη λύση, όπως έχουμε ήδη δει. Τι γίνεται όμως όταν συναντήσουμε (απολύτως νόμιμο) HTML κώδικα όπως <input type="text" name="tx1" value=">>>" />; Μια πιο σύνθετη λύση θα ήταν όσο βρισκόμαστε μεταξύ "') να αγνοούμε οτιδήποτε άλλο:

<("[^"]*"|'[^']*'|[^'">])*>

Στο προηγούμενο, ζητάμε να ταιριάξουμε μεταξύ < και >, 0 ή περισσότερες φορές

  • είτε " ακολουθούμενο από μηδέν ή περισσότερα μη " και μετά "
  • είτε ' ακολουθούμενο από μηδέν ή περισσότερα μη ' και μετά '
  • είτε οτιδήποτε δεν είναι ", ' ή >
    • η τελευταία επιλογή δεν χρησιμοποιεί το * γιατί καλύπτεται από το εξωτερικό *. Σε αντίθετη περίπτωση θα είχαμε παράθεση δύο *, με τις γνωστές δυσάρεστες συνέπειες!

Πριν αφαιρέσουμε όλα τα tags, σε ένα πρώτο πέρασμα αφαιρούμε όλα τα σχόλια και το περιεχόμενό τους. Αυτό γίνεται εύκολα με το μή άπληστο *:

<!--.*?-->

Επίσης, πριν διαγράψουμε όλες τις ετικέτες, θα πρέπει να εξουδετερώσουμε εκείνες που περιέχουν styles ή scripts (θεωρήστε ότι το περιεχόμενο αυτών των ετικετών δεν μας ενδιαφέρει!):

<(script|style).*?</\1>

Στο προγούμενο εκτός του μη άπληστου (non-greedy) *?, χρησιμοποιούμε και το backreference \1, ώστε η ετικέτα κλεισίματος να ταιριάζει με εκείνη της αρχής. Η μέθοδος δεν είναι 100% ασφαλής (π.χ. ένα script σε javascript μπορεί να περιέχει ένα string “</script>”, αλλά θεωρούμε ότι καλυπτόμαστε από την πιο πάνω RE).

Στο πρόγραμμα που ακολουθεί εφαρμόζουμε τις 3 RE που αναφέραμε παραπάνω στο κείμενο μιας ιστοσελίδας. Επειδή διαβάζουμε όλη την ιστοσελίδα σε ένα string, χρειάζεται η επιλογή re.DOTALL, έτσι ώστε η τελεία . να ταιριάζει και τα εσωτερικά newlines του string.

#!/usr/bin/python
# -*- coding: UTF-8 -*-

"""
Sample app to strip comments and entire tags from HTML page.
Uses re module
"""

import urllib
import re

page = urllib.urlopen("http://di.ionio.gr/msc/")
pagetext = page.read()	# page uses UTF-8, leave as is
page.close()

# prepare regex to remove comments and their content
rstr1 = r'<!--.*?-->'
rexp1 = re.compile(rstr1,re.DOTALL)	# dot matches \n too
# remove comments
pagetext = rexp1.sub(' ',pagetext)

# prepare regex to remove script/style tags and enclosed content
rstr2 = r'<(script|style).*?</\1>'
rexp2 = re.compile(rstr2,re.DOTALL)
# remove script/style tags and enclosed content
pagetext = rexp2.sub(' ',pagetext)

# prepare regex to remove all remaining tags
rstr3 = r'<("[^"]*"|\'[^\']*\'|[^\'">])*>'
rexp3 = re.compile(rstr3,re.DOTALL)
# remove remaining tags
pagetext = rexp3.sub(' ',pagetext)


print pagetext