ביטויים רגולריים ב-Python

ביטויים רגולריים הם שפה יעילה להתאמת דפוסי טקסט. בדף הזה יש מבוא בסיסי לביטויים רגולריים שמספיק לתרגילים שלנו ב-Python, ומראה איך הביטויים הרגולריים פועלים ב-Python. השדה 're' של Python מספקת תמיכה בביטוי רגולרי.

ב-Python, חיפוש של ביטוי רגולרי נכתב בדרך כלל כך:

match = re.search(pat, str)

השיטה re.search() מקבלת תבנית של ביטוי רגולרי ומחרוזת ומחפשת את הדפוס הזה בתוך המחרוזת. אם החיפוש יצליח, search() יחזיר אובייקט תואם או None. לכן, מיד מיד לאחר החיפוש מופיעה הצהרת if כדי לבדוק אם החיפוש הצליח, כפי שמוצג בדוגמה הבאה שמחפשת את הדפוס 'מילה': ואחריה מילה בת 3 אותיות (פרטים בהמשך):

import re

str = 'an example word:cat!!'
match = re.search(r'word:\w\w\w', str)
# If-statement after search() tests if it succeeded
if match:
  print('found', match.group()) ## 'found word:cat'
else:
  print('did not find')

הקוד match = re.search(pat, str) שומר את תוצאת החיפוש במשתנה שנקרא "match". לאחר מכן, הצהרת האם בודקת את ההתאמה – אם True החיפוש הצליח ו-match.group() הוא הטקסט התואם (למשל 'word:cat'). אחרת, אם ההתאמה היא False (אין ליתר ביטחון), החיפוש לא הצליח ואין טקסט תואם.

'r' בתחילת מחרוזת הדפוס, הפרמטר python אינו 'גולמי' מחרוזת שעוברת דרך לוכסנים הפוכים ללא שינוי, וזה שימושי מאוד לביטויים רגולריים (ל-Java אין מאוד צורך בתכונה הזו!). אני ממליץ תמיד לכתוב מחרוזות תבנית עם 'r' וכהרגל.

תבניות בסיסיות

העוצמה של ביטויים רגולריים היא שהם יכולים לציין דפוסים, ולא רק תווים קבועים. הנה הדפוסים הבסיסיים ביותר שתואמים לתווים בודדים:

  • a, X, 9, < -- תווים רגילים פשוט מתאימים את עצמם בדיוק. תווי המטא שלא מתאימים לעצמם מאחר שיש להם משמעויות מיוחדות: . ^ $ * + ? { [ ] \ | ( ) (פרטים בהמשך)
  • . (נקודה) -- מתאימה לכל תו יחיד מלבד שורה חדשה '\n'
  • \w -- (אותיות קטנות w) תואם ל"מילה" : אות או ספרה או סרגל תחתון [a-zA-Z0-9_]. שימו לב שלמרות שהמילה "מילה" הוא המזכר של זה, הוא מתאים רק לתו אחד של מילה, ולא למילה שלמה. \W (W באותיות רישיות) תואם לכל תו שאינו מילה.
  • \b – תחום בין מילה לבין מילה שאינה מילה
  • \s -- (אותיות קטנות s) תואם לתו רווח לבן יחיד – רווח, שורה חדשה, Return, כרטיסייה, טופס [ \n\r\t\f]. \S (אותיות רישיות S) תואם לכל תו שהוא לא רווח לבן.
  • \t, \n, \r -- כרטיסייה, שורה חדשה, חזרה
  • \d -- ספרה עשרונית [0-9] (חלק מהכלים הישנים של ביטוי רגולרי (regex) לא תומכים ב-\d, אבל כולן תומכות ב-\w ו-\s)
  • ^ = start, $ = end – התאמה להתחלה או לסוף של המחרוזת
  • \ -- מבליטים את ה'מיוחדות' של דמות. לדוגמה, צריך להשתמש בתו \. כדי להתאים נקודה, או \\ כדי להתאים לקו נטוי. אם לא בטוחים שלתו יש משמעות מיוחדת, כמו '@', אפשר להוסיף לפניו קו נטוי, \@. אם זהו אינו רצף בריחה (escape) חוקי, כגון \c, תוכנית python תיפסק עם שגיאה.

דוגמאות בסיסיות

בדיחה: איך קוראים לחזיר עם שלוש עיניים? כלב!

הכללים הבסיסיים של ביטויים רגולריים לחיפוש דפוס בתוך מחרוזת הם:

  • החיפוש ממשיך לאורך המחרוזת מההתחלה ועד הסוף, ונעצר בהתאמה הראשונה שנמצאה
  • צריכה להיות התאמה לכל הדפוס, אבל לא לכל המחרוזת
  • אם הפונקציה match = re.search(pat, str) מצליחה, ההתאמה לא תהיה None וב-match.group() הספציפי הוא הטקסט התואם
  ## Search for pattern 'iii' in string 'piiig'.
  ## All of the pattern must match, but it may appear anywhere.
  ## On success, match.group() is matched text.
  match = re.search(r'iii', 'piiig') # found, match.group() == "iii"
  match = re.search(r'igs', 'piiig') # not found, match == None

  ## . = any char but \n
  match = re.search(r'..g', 'piiig') # found, match.group() == "iig"

  ## \d = digit char, \w = word char
  match = re.search(r'\d\d\d', 'p123g') # found, match.group() == "123"
  match = re.search(r'\w\w\w', '@@abcd!!') # found, match.group() == "abc"

חזרה

הדברים מעניינים יותר כשמשתמשים ב-+ וב-* כדי לציין חזרה בדפוס

  • + -- מופע אחד או יותר של הדפוס משמאל לו, למשל: 'i+' = אחד או יותר
  • * -- 0 או יותר מופעים של התבנית משמאל
  • ? -- התאמה למופע אחד או למופע אחד של הדפוס שמשמאל

הכי שמאלי & הגדול ביותר

ראשית, החיפוש מוצא את ההתאמה השמאלית ביותר לדפוס, ולאחר מכן הוא מנסה להשתמש בחלק גדול ככל האפשר מהמחרוזת - כלומר, + ו-* להגיע רחוק ככל האפשר (הסימן + ו-* נחשב ל'חמדן').

דוגמאות לחזרה

  ## i+ = one or more i's, as many as possible.
  match = re.search(r'pi+', 'piiig') # found, match.group() == "piii"

  ## Finds the first/leftmost solution, and within it drives the +
  ## as far as possible (aka 'leftmost and largest').
  ## In this example, note that it does not get to the second set of i's.
  match = re.search(r'i+', 'piigiiii') # found, match.group() == "ii"

  ## \s* = zero or more whitespace chars
  ## Here look for 3 digits, possibly separated by whitespace.
  match = re.search(r'\d\s*\d\s*\d', 'xx1 2   3xx') # found, match.group() == "1 2   3"
  match = re.search(r'\d\s*\d\s*\d', 'xx12  3xx') # found, match.group() == "12  3"
  match = re.search(r'\d\s*\d\s*\d', 'xx123xx') # found, match.group() == "123"

  ## ^ = matches the start of string, so this fails:
  match = re.search(r'^b\w+', 'foobar') # not found, match == None
  ## but without the ^ it succeeds:
  match = re.search(r'b\w+', 'foobar') # found, match.group() == "bar"

דוגמה לאימיילים

נניח שאתם רוצים למצוא את כתובת האימייל בתוך המחרוזת 'xyz alice-b@google.com סגול קוף'. נשתמש בכתובת הזו כדוגמה פעילה כדי להדגים תכונות נוספות של ביטויים רגולריים. הנה ניסיון להשתמש בתבנית r'\w+@\w+':

  str = 'purple alice-b@google.com monkey dishwasher'
  match = re.search(r'\w+@\w+', str)
  if match:
    print(match.group())  ## 'b@google'

במקרה כזה, החיפוש לא יקבל את כתובת האימייל המלאה כי \w לא תואם לערך '-' או '.' בכתובת. נתקן את הבעיה בעזרת התכונות הבאות של ביטויים רגולריים.

סוגריים מרובעים

אפשר להשתמש בסוגריים מרובעים כדי לציין קבוצה של תווים, כך שהביטוי [abc] תואם לערך 'a'. או 'b' או c. הקודים \w, \s וכו' פועלים גם בתוך סוגריים מרובעים, למעט החריג היחיד שבו הנקודה (.) מציינת נקודה. במקרה של בעיה באימייל, הסוגריים המרובעים הם דרך קלה להוסיף '.' וגם '-' לקבוצת התווים שיכולים להופיע מסביב ל-@ בתבנית '[\w.-]+@[\w.-]+' כדי לקבל את כתובת האימייל המלאה:

  match = re.search(r'[\w.-]+@[\w.-]+', str)
  if match:
    print(match.group())  ## 'alice-b@google.com'
(תכונות נוספות של סוגר מרובע) אפשר גם להשתמש במקף כדי לציין טווח, כך שהמקש [a-z] יתאים לכל האותיות הקטנות. למשל, כדי להשתמש במקף בלי לציין טווח, מוסיפים את המקף האחרון, למשל. [abc-]. סמל למעלה (^) בתחילת קבוצת תופים מרובעים הופך את הפרמטר הזה, כך שהמשמעות של [^ab] היא כל תו מלבד 'a'. או 'b'.

חילוץ קבוצה

ה"קבוצה" של ביטוי רגולרי מאפשרת לבחור חלקים מהטקסט התואם. נניח שבבעיית האימייל אנחנו רוצים לחלץ את שם המשתמש והמארח בנפרד. לשם כך, מוסיפים סוגריים ( ) מסביב לשם המשתמש ולמארח בתבנית, כך: r'([\w.-]+)@([\w.-]+)'. במקרה כזה, הסוגריים לא משנים את ההתאמה של הדפוס, אלא מגדירים 'קבוצות' לוגיות. בתוך הטקסט של ההתאמה. בחיפוש מוצלח, match.group(1) הוא הטקסט התואם לסוגריים השמאליים הראשונים, ו-match.group(2) הוא הטקסט שתואם לסוגריים השמאליים השנייה. הערך הפשוט של match.group() הוא עדיין הטקסט של ההתאמה הרגילה, כרגיל.

  str = 'purple alice-b@google.com monkey dishwasher'
  match = re.search(r'([\w.-]+)@([\w.-]+)', str)
  if match:
    print(match.group())   ## 'alice-b@google.com' (the whole match)
    print(match.group(1))  ## 'alice-b' (the username, group 1)
    print(match.group(2))  ## 'google.com' (the host, group 2)

תהליך עבודה נפוץ עם ביטויים רגולריים הוא שכותבים תבנית עבור הדבר שאתם מחפשים, ומוסיפים קבוצות סוגריים כדי לחלץ את החלקים הרצויים.

Findall

searchall() הוא כנראה הפונקציה החזקה ביותר במודול מחדש. למעלה השתמשנו בפונקציה re.search() כדי למצוא את ההתאמה הראשונה לדפוס. Findall() מוצאת את *כל* ההתאמות ומחזירה אותן כרשימת מחרוזות, כשכל מחרוזת מייצגת התאמה אחת.
  ## Suppose we have a text with many email addresses
  str = 'purple alice@google.com, blah monkey bob@abc.com blah dishwasher'

  ## Here re.findall() returns a list of all the found email strings
  emails = re.findall(r'[\w\.-]+@[\w\.-]+', str) ## ['alice@google.com', 'bob@abc.com']
  for email in emails:
    # do something with each found email string
    print(email)

חיפוש עם קבצים

לגבי קבצים, ייתכן שאתם נוהגים לכתוב לולאה כדי לחזור על השורות של הקובץ, ולאחר מכן לקרוא ל-Findall() בכל שורה. במקום זאת, אפשר לתת ל-Findall() לבצע את האיטרציה במקומכם – הרבה יותר טוב! פשוט מזינים את כל הטקסט של הקובץ ב-Findall() ונותנים לו להחזיר רשימה של כל ההתאמות בשלב אחד (חשוב לזכור ש-f.read() מחזירה את כל הטקסט של קובץ במחרוזת אחת):

  # Open file
  f = open('test.txt', encoding='utf-8')
  # Feed the file text into findall(); it returns a list of all the found strings
  strings = re.findall(r'some pattern', f.read())

Findall וקבוצות Google

אפשר לשלב את מנגנון הסוגריים ( ) של הקבוצה עם findall(). אם הדפוס כולל לפחות 2 קבוצות של סוגריים, במקום להחזיר רשימת מחרוזות, findall() מחזירה רשימה של *tuples*. כל גליל מייצג התאמה אחת של הדפוס, ובתוך הטבת יש את הנתונים group(1), group(2) .. (הקבוצה). אם מוסיפים שני קבוצות של סוגריים לדפוס האימייל, הפונקציה findall() מחזירה רשימה של צמדים.כל אורך 2 מכיל את שם המשתמש והמארח, למשל. (alice, google.com).

  str = 'purple alice@google.com, blah monkey bob@abc.com blah dishwasher'
  tuples = re.findall(r'([\w\.-]+)@([\w\.-]+)', str)
  print(tuples)  ## [('alice', 'google.com'), ('bob', 'abc.com')]
  for tuple in tuples:
    print(tuple[0])  ## username
    print(tuple[1])  ## host

אחרי שהשגתם את רשימת החיבורים, אתם יכולים לעבור עליה בלולאה כדי לבצע חישוב לכל אפקט. אם הדפוס לא כולל סוגריים, הפונקציה findall() מחזירה רשימה של מחרוזות שנמצאו כמו בדוגמאות הקודמות. אם הדפוס כולל קבוצה אחת של סוגריים, הפונקציה findall() מחזירה רשימה של מחרוזות שתואמות לקבוצה הבודדת הזו. (תכונה אופציונלית מוסתרת: לפעמים יש בדפוס קיבוצים מסוג סוגריים ( )), אבל לא רוצים לחלץ אותם. במקרה כזה, צריך לכתוב את הסוגריים עם ?: בהתחלה, למשל: (?: ) והסוגריים השמאליים לא ייספרו כתוצאה קבוצתית).

תהליך עבודה של RE וניפוי באגים

דפוסי ביטויים רגולריים מכילים משמעות רבה מאוד למספר תווים קטן, אבל הם צפופים כל כך עד שאפשר לבזבז זמן רב בניפוי באגים בדפוסים. כדאי להגדיר את סביבת זמן הריצה כדי להריץ תבנית ולהדפיס בקלות את התבנית שמתאימה לה. לדוגמה, על ידי הרצת הקוד על טקסט קטן לבדיקה והדפסת התוצאה של findall() . אם התבנית לא תואמת שום דבר, אפשר לנסות להחליש את התבנית ולהסיר חלקים ממנה כדי שיתקבלו יותר מדי התאמות. אם היא לא תואמת שום דבר, לא תוכלו להתקדם כי אין שום דבר קונקרטי לצפות בו. ברגע שיש התאמה יותר מדי, אפשר לחזק אותו בהדרגה כדי להגיע בדיוק למה שרוצים.

אפשרויות

הפונקציות מחדש משתמשות באפשרויות לשינוי ההתנהגות של התאמת הדפוס. דגל האפשרות מתווסף כארגומנט נוסף ל-search() או findall() וכו', לדוגמה. re.search(pat, str, re.IGNORECASE).

  • IGNORECASE – מתעלמים מהבדלים באותיות רישיות וקטנות לצורך התאמה, אז 'a' מתאים ל-'a' ו-A.
  • DOTALL - אפשר לנקודה (.) להתאים לשורה חדשה – בדרך כלל היא תואמת לכל דבר מלבד שורה חדשה. זה עלול להטעות אותך - לדעתך .* תואם לכל דבר, אבל כברירת מחדל הטקסט לא עובר מעבר לסוף השורה. הערה: \s (רווח לבן) כולל שורות חדשות, כך שאם רוצים להתאים רצף של רווחים שעשויים לכלול שורה חדשה, אפשר פשוט להשתמש בתו \s*
  • MULTILINE – בתוך מחרוזת שמורכבת משורות רבות, צריך לאפשר התאמה בין ^ ו-$ להתחלה ולסוף של כל שורה. בדרך כלל, התווים ^/$ תואמים להתחלה ולסיום של המחרוזת כולה.

אלגוריתם חמדן (Greedy) לעומת אלגוריתם לא חמדן (אופציונלי)

זה חלק אופציונלי שמציג שיטה מתקדמת יותר של ביטויים רגולריים, שלא נדרשת לצורך התרגילים.

נניח שיש בקובץ טקסט עם תגים: <b>foo</b> ו<i>כך גם</i>

נניח שאתם מנסים להתאים כל תג לתבנית '(<.*>)' -- מה הוא מתאים ראשון?

התוצאה קצת מפתיעה, אבל ההיבט החמדן בסיומת * גורם לו להתאים לכל ה-'<b>foo</b> ו<i>כך גם</i>' בתור התאמה אחת גדולה. הבעיה היא שהסיומת *. יכולה להגיע רחוק ככל האפשר, במקום לעצור בהתחלה > (כלומר, 'חמדן').

האם יש תוסף לביטוי רגולרי שבו מוסיפים את התו '?' בסוף, למשל .*? או .+?, ולשנות אותם כך שלא יהיו חמדנים. עכשיו הם מפסיקים ברגע שהם יכולים. אז הדפוס '(<.*?>)' יקבל רק '<b>' כהתאמה הראשונה, ו-'</b>' כהתאמה השנייה, וכך לקבל כל <..> לפי ההתאמה. בדרך כלל, הסגנון הוא .*? ומיד אחריו מופיע סמן בטון (> במקרה הזה) שאליו הסימן .*? הריצה נאלצת להאריך.

הסימן *? המקור של התוסף הוא Perl, והביטויים הרגולריים שכוללים את התוספות של Perl נקראים 'ביטויים רגולריים תואמים ל-Perl – pcre'. Python כולל תמיכה ב-pcre. להרבה שימושים בשורת הפקודה וכו' יש דגל שבו הם מקבלים תבניות pcre.

שיטה ישנה יותר שנמצאת בשימוש נרחב כדי לקודד את הרעיון הזה "כל התווים האלה מלבד עצירה ב-X" משתמש בסגנון של סוגר מרובע. בדוגמה שלמעלה אפשר לכתוב את הדפוס, אבל במקום הסימן *. כדי לקבל את כל התווים, אפשר להשתמש בפקודה [^>]* שמדלגת על כל התווים שאינם > (סימן ^ בהתחלה "הופך" את קבוצת הסוגר המרובע), כך שהוא מתאים לכל תו שלא נמצא בסוגריים המרובעים).

החלפה (אופציונלי)

הפונקציה re.sub(pat, substitute, str) מחפשת את כל המופעים של הדפוס במחרוזת הנתונה, ומחליפה אותם. המחרוזת החלופית יכולה לכלול את התווים ' \1', '\2' שמתייחסות לטקסט מ-group(1), group(2) וכן הלאה מהטקסט התואם המקורי.

לפניכם דוגמה שמחפשת את כל כתובות האימייל ומשנה אותן כך שהמשתמש (\1) יישאר, אבל yo-yo-dyne.com יהיה המארח.

  str = 'purple alice@google.com, blah monkey bob@abc.com blah dishwasher'
  ## re.sub(pat, replacement, str) -- returns new string with all replacements,
  ## \1 is group(1), \2 group(2) in the replacement
  print(re.sub(r'([\w\.-]+)@([\w\.-]+)', r'\1@yo-yo-dyne.com', str))
  ## purple alice@yo-yo-dyne.com, blah monkey bob@yo-yo-dyne.com blah dishwasher

פעילות גופנית

כדי לתרגל ביטויים רגולריים, אפשר לעיין בתרגיל שמות תינוקות.