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

Στο προηγούμενο άρθρο της σειράς αναφερθήκαμε στα alternation (εναλλαγή) και eagerness (ενθουσιασμός;) που διέπουν τη λειτουργία ορισμένων μηχανών κανονικών εκφράσεων. Μελετήσαμε επίσης και τα σημεία αναφοράς (anchors), τα οποία δεν περιγράφουν τη σύνθεση ενός string, δηλαδή τους χαρακτήρες που το απαρτίζουν, αλλά μία θέση μέσα σ’ αυτό. Στο τέλος γνωρίσαμε και τις λεγόμενες αναφορές (backreferences), οι οποίες επιτρέπουν να αναφερόμαστε σε συγκεκριμένα τμήματα ενός string τα οποία έχουν ήδη περιγραφεί και εντοπιστεί από προηγούμενα τμήματα της εκάστοτε κανονικής έκφρασης. Αυτή τη φορά θα μελετήσουμε έναν ακόμα μηχανισμό που θα διευρύνει την εκφραστική μας δύναμη, ενώ θα αναφερθούμε και σε μερικές ιδιαιτερότητες που διέπουν τη λειτουργία των μηχανών κανονικών εκφράσεων. Με τα εφόδια που έχουμε ήδη αποκτήσει, καθώς κι όλα όσα θα γνωρίσουμε, θα είμαστε σε θέση να συντάσσουμε σύντομες και ιδιαίτερα εύστοχες κανονικές εκφράσεις.

Έλεγχος ύπαρξης

Στην έκδοση 5 της γλώσσας Perl εισήχθησαν δύο μηχανισμοί που ονομάζονται lookahead και lookbehind, και γενικά αναφέρονται ως lookarounds. Με τη βοήθειά τους μπορούμε να περιγράψουμε ένα string και να επιβεβαιώσουμε/εξασφαλίσουμε ότι βρίσκεται πριν (lookbehind) ή μετά (lookahead) από όσα περιγράφει η υπόλοιπη κανονική έκφραση. Επιπρόσθετα, με τους ίδιους μηχανισμούς μπορούμε να επιβεβαιώσουμε/εξασφαλίσουμε ότι ένα string δεν βρίσκεται πριν (negative lookbehind) ή μετά (negative lookahead) από όσα περιγράφει η υπόλοιπη κανονική έκφραση. Αλλά σ’ αυτό το σημείο ενδέχεται να ‘χετε μπερδευτεί από τις αφηρημένες περιγραφές. Ας δούμε μερικά παραδείγματα και θα κατανοήσετε αμέσως τη λειτουργία και τη χρησιμότητα των εν λόγω μηχανισμών.

Lookahead. Ας υποθέσουμε ότι θέλουμε να δούμε όλες τις επιλογές που έχουν ενεργοποιηθεί στο αρχείο ρυθμίσεων του SSH server του συστήματός μας. Ουσιαστικά, αυτό που ψάχνουμε είναι όλες οι γραμμές του αρχείου /etc/ssh/sshd_config που τελειώνουν με το yes. Από αυτές τις γραμμές, όμως, θέλουμε μόνο την εκάστοτε επιλογή. Επομένως, αναζητάμε τις λέξεις εκείνες που βρίσκονται πριν από το yes κι όχι το ίδιο το yes. Για να πετύχουμε το σκοπό μας αρκεί να χρησιμοποιήσουμε το μηχανισμό lookahead ως εξής:

grep -P --color=auto ".*(?= yes$)" /etc/ssh/sshd_config

UsePrivilegeSeparation yes

PermitRootLogin yes

StrictModes yes

RSAAuthentication yes

PubkeyAuthentication yes

IgnoreRhosts yes

#IgnoreUserKnownHosts yes

#PasswordAuthentication yes

#KerberosOrLocalPasswd yes

#KerberosTicketCleanup yes

#GSSAPICleanupCredentials yes

X11Forwarding yes

PrintLastLog yes

TCPKeepAlive yes

UsePAM yes

Όπως βλέπετε, το string που θέλουμε να επιβεβαιώσουμε ότι έπεται όσων αναζητάμε τοποθετείται μέσα σε παρενθέσεις. Συγκεκριμένα, μετά από την αριστερή παρένθεση βάζουμε το λατινικό ερωτηματικό, στη συνέχεια το χαρακτήρα της ισότητας (ίσον) κι αμέσως μετά το string που θέλουμε να τσεκάρουμε αν εμφανίζεται. Η γενική μορφή των lookahead είναι αυτή: (?=regex). Στο παράδειγμά μας στη θέση του regex βρίσκεται η έκφραση yes$. Προφανώς αυτή η έκφραση περιγράφει το string yes, το οποίο όμως βρίσκεται στο τέλος της γραμμής (αυτό εξασφαλίζεται με το σημείο αναφοράς που συμβολίζει το τέλος του string ή της γραμμής, δηλαδή τον χαρακτήρα $). Τελικά, με την κανονική έκφραση που δίνουμε στο grep ζητάμε όλους τους χαρακτήρες οι οποίοι ακολουθούνται από ένα yes και τον χαρακτήρα τερματισμού της γραμμής. Η ίδια αναζήτηση θα μπορούσε να πραγματοποιηθεί και χωρίς το lookahead, ως εξής:

grep -P --color=auto ".* yes$" /etc/ssh/sshd_config 

UsePrivilegeSeparation yes

PermitRootLogin yes

StrictModes yes

RSAAuthentication yes

PubkeyAuthentication yes

IgnoreRhosts yes

#IgnoreUserKnownHosts yes

#PasswordAuthentication yes

#KerberosOrLocalPasswd yes

#KerberosTicketCleanup yes

#GSSAPICleanupCredentials yes

X11Forwarding yes

PrintLastLog yes

TCPKeepAlive yes

UsePAM yes

Παρατηρήστε ότι σε αυτή την περίπτωση τ’ αποτελέσματα της αναζήτησης περιλαμβάνουν και το τελικό yes κάθε γραμμής. Αυτό ίσως να μην μας ενοχλεί καθόλου στο συγκεκριμένο παράδειγμα, αλλά υπάρχουν και περιπτώσεις που σίγουρα θα μας ενοχλούσε.

Negative Lookahead. Έστω ότι ψάχνουμε την τελευταία εμφάνιση μιας λέξης σε μια πρόταση. (Εντάξει, το ομολογούμε: Αυτό το παράδειγμα είναι ελάχιστα χρήσιμο, αλλά θα αναδείξει με τον καλύτερο τρόπο τη χρησιμότητα του negative lookahead.) Ας υποθέσουμε ότι θέλουμε να εντοπίσουμε την τελευταία εμφάνιση της λέξης thing μέσα στην ακόλουθη πρόταση: The major difference between a thing that might go wrong and a thing that cannot possibly go wrong is that when a thing that cannot possibly go wrong goes wrong it usually turns out to be impossible to get at or repair. (Πρόκειται για ένα απόφθεγμα του Douglas Adams.) Ας υποθέσουμε επίσης ότι έχουμε τοποθετήσει αυτή την πρόταση στο αρχείο quote.txt. Όπως είπαμε, αυτό που θέλουμε να πετύχουμε είναι ο εντοπισμός της τελευταίας εμφάνισης της λέξης thing. Με άλλα λόγια, θέλουμε να βρούμε το string thing, το οποίο όμως δεν ακολουθείται από το string thing. Αυτό μπορεί να γίνει ως εξής:

grep -P --color=auto 'thing(?!.*thing)' quote.txt

The major difference between a thing that might go wrong and a thing that cannot possibly go wrong is that when a thing that cannot possibly go wrong goes wrong it usually turns out to be impossible to get at or repair.

Το string που δεν θέλουμε να ακολουθεί εκείνο που αναζητάμε, τοποθετείται και πάλι μέσα σε παρενθέσεις. Αυτή τη φορά, όμως, μετά από την αριστερή παρένθεση ακολουθεί ένα λατινικό ερωτηματικό κι ένα θαυμαστικό. Η σύνταξη του negative lookahead διαφοροποιείται από εκείνη του (positive) lookahead ως προς έναν χαρακτήρα: Στη θέση του συμβόλου της ισότητας (ίσον) χρησιμοποιείται ένα θαυμαστικό. Η γενική μορφή των negative lookahead είναι: (?!regex)

Στο παράδειγμά μας, μέσα στο negative lookahead τοποθετήσαμε την έκφραση .*thing. Δηλαδή, τοποθετήσαμε μια κανονική έκφραση που περιγράφει ένα string με οποιουσδήποτε και οσουσδήποτε χαρακτήρες και το οποίο τελειώνει με τη λέξη thing. Έτσι η μηχανή των κανονικών εκφράσεων θα αναζητήσει μια εμφάνιση της λέξης thing, η οποία όμως δεν ακολουθείται από κάποιο string που τελειώνει με τη λέξη thing. Απλούστατο.

Lookbehind. Ας υποθέσουμε τώρα ότι θέλουμε να εντοπίσουμε όλα τα σχόλια μέσα στον πηγαίο κώδικα ενός προγράμματος σε C, τα οποία περιλαμβάνουν μια συγκεκριμένη λέξη. Για παράδειγμα, τα σχόλια που περιέχουν τη λέξη sample. Αυτό μπορεί να γίνει πανεύκολα ως εξής:

grep -P --color=auto '(?<=//).*sample.*' code.txt

volatile byte sample; // new sample

volatile byte sample_cnt; // sample counter for delays

// Will be used for sample generation

Εδώ χρησιμοποιούμε το lookbehind για να εντοπίσουμε τα strings που περιέχουν τη λέξη sample και έπονται των χαρακτήρων //, οι οποίοι σηματοδοτούν την έναρξη ενός σχολίου. Η σύνταξη του lookbehind ομοιάζει με εκείνη του lookahead. Η μόνη διαφορά έγκειται στην προσθήκη του συμβόλου < ανάμεσα στο λατινικό ερωτηματικό και στον χαρακτήρα της ισότητας. Ιδού γενική μορφή ενός lookbehind: (?<=regex). Όπως και στην περίπτωση του lookahead, η ίδια αναζήτηση θα μπορούσε να πραγματοποιηθεί και με έναν απλούστερο τρόπο:

grep -P --color=auto '//.*sample.*' code.txt

Σε αυτή την περίπτωση, ωστόσο, τα αποτελέσματα θα περιλάμβαναν και τους χαρακτήρες // μαζί με τα σχόλια.

Negative lookbehind. Ας υποθέσουμε τώρα ότι θέλουμε να απομονώσουμε όλους τους αριθμούς που εμφανίζονται στον πηγαίο κώδικα ενός προγράμματος και οι οποίοι δεν αποτελούν δείκτες σε κάποιον πίνακα, ούτε τμήμα της ονομασίας κάποιας μεταβλητής. Με άλλα λόγια θέλουμε όλους τους αριθμούς που δεν βρίσκονται αμέσως μετά από μία αριστερή αγκύλη, δηλαδή δεν αποτελούν δείκτες σε πίνακα, ούτε έχουν ακριβώς στα αριστερά τους κάποιο γράμμα, δηλαδή δεν αποτελούν τμήμα της ονομασίας κάποιας μεταβλητής.

Τα ονόματα των μεταβλητών δεν επιτρέπεται να ξεκινούν με αριθμητικά ψηφία. Τουλάχιστον όχι στη C και σε πολλές άλλες γλώσσες. Επομένως, δεν χρειάζεται να ελέγξουμε αν υπάρχουν γράμματα στα δεξιά του αριθμού.

Τελικά, θέλουμε όλους τους αριθμούς για τους οποίους ικανοποιούνται οι δύο παραπάνω συνθήκες. Επειδή αυτές οι συνθήκες περιλαμβάνουν μια άρνηση (δεν θέλουμε να εμφανίζεται κάτι) κι επειδή τσεκάρουν κάτι που βρίσκεται πριν από το ζητούμενο (τους αριθμούς) θα στηριχτούμε στο negative lookback:

grep -P --color=auto '(?<!\[)(?<!\w)(\d*)' code.txt

Η σύνταξη των negative lookback μοιάζει με εκείνη των negative lookahead, μόνο που ανάμεσα στο ερωτηματικό και στο θαυμαστικό τοποθετείται ο χαρακτήρας <. Ιδού η γενική μορφή: (?<!regex). Μελετήστε λίγο και το παράδειγμα. Όπως θα δείτε, για να εξασφαλίσουμε ότι ικανοποιούνται οι δύο συνθήκες συμπεριλάβαμε δύο negative lookbacks – ένα για κάθε συνθήκη. Πριν προχωρήσουμε, σημειώστε ότι ο ειδικός χαρακτήρας \w αναφέρεται σε όλους τους χαρακτήρες που μπορεί να περιλαμβάνει μια λέξη, ενώ ο ειδικός χαρακτήρας \d συμβολίζει όλα τα αριθμητικά ψηφία. Ουσιαστικά, αυτοί οι ειδικοί χαρακτήρες αποτελούν συντομεύσεις για τις κλάσεις [a-zA-Z0-9_] και [0-9] αντίστοιχα. Μετά από τα δύο negative lookback ακολουθεί η περιγραφή ενός αριθμού με οσαδήποτε ψηφία. Παρατηρείστε ότι αυτό το κομμάτι της έκφρασης το έχουμε κλείσει σε παρενθέσεις. Αναρωτιέστε γιατί; Σε αυτό το σημείο χρειάζεται λίγη παραπάνω προσοχή: Χωρίς τις παρενθέσεις, όταν το grep θα συναντούσε κάποιον αριθμό μέσα στο όνομα μιας μεταβλητής ή μέσα σε αγκύλες θα αγνοούσε μόνο το πρώτο ψηφίο του αριθμού. Βλέπετε, το δεύτερο ψηφίο δεν θα είχε στα αριστερά του κάποιον χαρακτήρα λέξης, ούτε την αριστερή αγκύλη. Με τη χρήση των παρενθέσεων εξασφαλίζουμε ότι οι δύο συνθήκες που ελέγχουμε με τα negative lookback αναφέρονται στο σύνολο των αριθμητικών ψηφίων του εκάστοτε αριθμού κι όχι μόνο στο πρώτο του ψηφίο.

Σημεία προσοχής

Κατ’ αρχάς πρέπει να ξεκαθαρίσουμε ότι τα παραδείγματα που μελετήσαμε, ανεξάρτητα από το αν και κατά πόσο είναι χρήσιμα, είχαν κυρίως μία αποστολή: Να παρουσιάσουν τη σύνταξη των lookaround. Είναι πολύ σημαντικό να έχουμε κατά νου ότι οι εν λόγω μηχανισμοί δεν προσδιορίζουν κάποια θέση. Τα lookaround εξασφαλίζουν μόνο την ύπαρξη ή την απουσία κάποιων strings, μέσα στο ευρύτερο string. Μπορείτε να φαντάζεστε τα lookaround σαν λογικούς ελέγχους (if) των οποίων η έκβαση (ναι ή όχι, 1 ή 0) καθορίζουν το αν η μηχανή κανονικών εκφράσεων θα παρουσιάσει κάποιο αποτέλεσμα ή όχι. Θα επανέλθουμε σε επόμενο άρθρο, όπου και θα παρουσιάσουμε χρήσιμα παραδείγματα των κανονικών εκφράσεων. Πρέπει τέλος να σημειώσουμε ότι, ειδικά στην περίπτωση των lookbehind (των negative αλλά και των positive), δεν μπορούμε να δώσουμε string με απροσδιόριστο μήκος. Αυτό σημαίνει ότι μέσα σε ένα lookback δεν μπορούμε να συμπεριλάβουμε τους χαρακτήρες επανάληψης (*, + και ?). Αυτός ο περιορισμός ισχύει για σχεδόν όλες τις μηχανές κανονικών εκφράσεων, εκτός από εκείνη της Perl και του .NET.

Αχόρταγες μηχανές

Στο προηγούμενο άρθρο της σειράς αναφέραμε ότι οι περισσότερες μηχανές κανονικών εκφράσεων είναι ενθουσιώδεις (eager) και βιάζονται να εμφανίσουν κάποιο αποτέλεσμα. Αυτή η συμπεριφορά επηρεάζει το χειρισμό των εναλλακτικών επιλογών μέσα σε ένα alternation (regex_1 | regex_2 | ... | regex_n). Έτσι οι διάφορες μηχανές παρουσιάζουν ως αποτέλεσμα την πρώτη επιλογή που συναντούν σε ένα alternation, χωρίς να ελέγξουν αν υπάρχει κάποια καλύτερη. Εκτός από αυτή την ιδιοτροπία οι μηχανές κανονικών εκφράσεων έχουν και μία ακόμα, αρκετά ύπουλη: Είναι αχόρταγες (greedy). Το παράδειγμα που ακολουθεί είναι διαφωτιστικό.

Ας υποθέσουμε ότι θέλουμε να απομονώσουμε όλα τα strings που περικλείονται από εισαγωγικά, μέσα σε ένα κείμενο. Ας υποθέσουμε, επίσης, ότι το κείμενο στο οποίο θα πραγματοποιήσουμε την αναζήτηση είναι το ακόλουθο:

This is a line with “a string” in quotes.

This is another line with “a string” in quotes an “another one”, also in quotes.

One more line with “string 1”, “string 2” and “string 3”, all in quotes.

Η αναζήτησή μας θα μπορούσε να γίνει κάπως έτσι:

grep -P --color=auto '".*"' text.txt

This is a line with “a string” in quotes.

This is another line with “a string” in quotes and “another one”, also in quotes.

One more line with “string 1”, “string 2” and “string 3”, all in quotes.

Βλέπετε το λάθος; Στην πρώτη γραμμή η αναζήτηση λειτούργησε ακριβώς όπως αναμέναμε κι απομόνωσε το string με τα εισαγωγικά. Στη δεύτερη και στην τρίτη γραμμή, όμως, τα επιμέρους strings αντιμετωπίστηκαν ως ένα συνεχόμενο. Σε αυτές τις γραμμές η μηχανή εντόπισε από ένα string, το οποίο ξεκινά με την πρώτη εμφάνιση των εισαγωγικών και τελειώνει με την τελευταία τους εμφάνιση. Αυτή η συμπεριφορά οφείλεται στην αχόρταγη φύση της μηχανής των κανονικών εκφράσεων. Όπως γνωρίζουμε ήδη, ο χαρακτήρας της τελείας λειτουργεί ως μπαλαντέρ κι αναφέρεται σε οποιονδήποτε άλλο χαρακτήρα (ακόμα και στα εισαγωγικά). Ο αστερίσκος αποτελεί έναν χαρακτήρα επανάληψης που περιγράφει οσεσδήποτε εμφανίσεις του αμέσως προηγούμενου χαρακτήρα – εν προκειμένω της τελείας. Έτσι, με την κανονική έκφραση που έχουμε δώσει στο grep περιγράφουμε ένα string οποιουδήποτε μεγέθους και οποιασδήποτε σύνθεσης, το οποίο περικλείεται σε εισαγωγικά. Όμως η μηχανή των κανονικών εκφράσεων είναι αχόρταγη κι έτσι παρουσιάζει σαν αποτέλεσμα το μεγαλύτερο string που ικανοποιεί αυτή την περιγραφή.

Πρόχειρη λύση

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

grep -P --color=auto '"[^"]*?"' text.txt

This is a line with “a string” in quotes.

This is another line with “a string” in quotes an “another one”, also in quotes.

One more line with “string 1”, “string 2” and “string 3”, all in quotes.

Πλέον, οι χαρακτήρες των εισαγωγικών που συναντά η μηχανή ταυτίζονται είτε με τον αρχικό είτε με τον τελικό χαρακτήρα της κανονικής έκφρασης. Έτσι, η μηχανή καταφέρνει να απομονώσει όλα τα επιμέρους strings με επιτυχία.

Επιβάλλοντας τη λιτότητα

Η λύση που μόλις παρουσιάσαμε λειτουργεί άψογα αλλά όχι πάντα. Υπάρχουν περιπτώσεις που θα ήταν δύσκολο κι ενδεχομένως χρονοβόρο να σχηματίσουμε τις κατάλληλες κλάσεις, οι οποίες θα αντικαθιστούσαν το χαρακτήρα του μπαλαντέρ με επιτυχία. Εξάλλου, αν κάθε φορά που θέλουμε να χρησιμοποιήσουμε το μπαλαντέρ καταφεύγουμε στη συγκρότηση μιας κλάσης χαρακτήρων, η τελική κανονική έκφραση θα είναι μακροσκελής και δυσανάγνωστη. Για αυτούς τους λόγους, οι μηχανές κανονικών εκφράσεων επιτρέπουν στο χρήστη να αλλάξει τη συμπεριφορά τους από greedy σε lazy. Με απλά λόγια, μπορούν να μετατραπούν από αχόρταγες σε λιτοδίαιτες. Για να πετύχουμε κάτι τέτοιο, αρκεί να τοποθετήσουμε ένα λατινικό ερωτηματικό ακριβώς δίπλα από τον εκάστοτε χαρακτήρα των επαναλήψεων (τον αστερίσκο ή το σύμβολο της πρόσθεσης). Αξιοποιώντας αυτή τη δυνατότητα, το πρόβλημα που παρουσιάσαμε παραπάνω λύνεται ως εξής:

grep -P --color=auto '".*?"' text.txt

This is a line with “a string” in quotes.

This is another line with “a string” in quotes an “another one”, also in quotes.

One more line with “string 1”, “string 2” and “string 3”, all in quotes.

Όπως βλέπετε, με τη χρήση του λατινικού ερωτηματικού αμέσως μετά τον αστερίσκο η αναζήτηση επέστρεψε ακριβώς αυτό που περιμέναμε: κάθε επιμέρους string ξεχωριστά. Βλέπετε, το λατινικό ερωτηματικό επιβάλει στη μηχανή των κανονικών εκφράσεων να μην αναζητά το μεγαλύτερο δυνατό string (greedy) που αρχίζει και τελειώνει με εισαγωγικά, αλλά το μικρότερο δυνατό (lazy).

Άρθρα της σειράς

Μέρος 14: special chars, escaping, classes, ranges, negation, repetition

Μέρος 24: anchors, concatenation, alternation, eagerness, backreference

Μέρος 34: lookahead, lookbehind, greedy machines, lazy machines

Μέρος 44: backreference, anchors, character escaping, lookahead

~Spir@l Evolut10n

Σας άρεσε το post; Αν ναι μπορείτε να στηρίξετε το ðhacker, χωρίς κατ’ ανάγκη να ξοδέψετε χρήματα.