الوحدة re في بايثون

من موسوعة حسوب

تقّدم الوحدة re مجموعة من العمليات الخاصة بمطابقة التعابير النمطية (Regular Expressions) وهي مشابهة إلى حدّ كبير للعمليات الموجودة في لغة Perl.

يمكن أن تكون الأنماط والسلاسل النصية المراد العثور عليها سلاسل نصية بترميز Unicode من نوع (str) أو سلاسل نصية ذات 8 بتات من نوع (bytes)، ولكن لا يجوز المزج بين النوعين، بمعنى أنّه لا يمكن مطابقة سلسلة نصية مع نمط من نوع البايتات والعكس صحيح أيضًا، وبنفس الطريقة، عند الرغبة في إجراء عملية استبدال يجب أن تكون سلسلة الاستبدال النصية من نوع واحد يطابق نوع كلّ من النمط وسلسلة البحث النصية.

تستخدم التعابير النمطية المحرف ('\') للإشارة إلى أشكال خاصة من الأنماط أو للسماح باستخدام الحروف الخاصة كما هي ودون تنفيذ وظيفته الخاصة، وتستخدم بايثون المحرف ذاته لأداء الوظيفة عينها في السلاسل النصية، الأمر الذي يؤدي إلى حدوث بعض الأخطاء، فعلى سبيل المثال لمطابقة الخط المائل العكسي يجب استخدام النمط '\\\\'، وذلك لأنّ التعبير النمطي يجب أن يكون '\\'، وفي الوقت ذاته يجب تمثيل كل خط مائل عكسي في السلاسل النصية الاعتيادية باستخدام '\\'.

ولحل مشكلة التضارب هذه يمكن استخدام السلاسل النصية الخام في بايثون، وهي السلاسل النصية التي تكون مسبوقة بالحرف r. لا تُعامل الخطوط المائلة العكسية في السلاسل النصية الخام معاملة خاصة، لذا فإنّ التعبير r"\n"‎ هو سلسلة نصية تتضمن حرفين هما '\' و 'n'، أما التعبير ‎"\n"‎ فهو سلسلة نصية تتضمن حرفًا واحدًا هو حرف السطر الجديد، ومن هنا اعتاد المطوّرون على استخدام السلاسل النصية الخام لكتابة الأنماط المستخدمة في التعابير النمطية.

من الضروري الانتباه إلى أنّ معظم العمليات الخاصة بالتعابير النمطية متوفّرة كدوالّ على مستوى الوحدة (module-level functions) ويمكن تطبيقها على التعابير النمطية المصرّفة. والدوالّ هي اختصارات لا تتطلب إجراء عملية التصريف على كائن regex في البداية، ولكنّها تفتقد بعض المعاملات التي تضبط أداء هذه الدوال.

دوال الوحدة re

تقدّم الوحدة re مجموعة الدوال التالية:

re.compile()

تصرّف الدالة التعبير النمطي إلى كائن تعبير نمطي regex يمكن استخدامه للمطابقة بواسطة توابعه الخاصّة match()‎ و search() وغيرها.

re.search()

تفحص الدالة السلسلة النصية بحثًا عن أول موقع يحصل فيه تطابق مع التعبير النمطي المعطى، وتعيد كائن match المقابل.

re.match()

تفحص الدالة بداية السلسلة النصية بحثًا عن حالة تطابق مع التعبير النمطي المعطى، وتعيد كائن match المقابل.

re.fullmatch()

تتحقّق الدالة ممّا إذا كانت السلسلة النصية بأكملها مطابقة للتعبير النمطي المعطى.

re.split()

تقسم الدالة السلسلة النصية عند كل مكان تحدث فيه عملية مطابقة للنمط المعطى.

re.findall()

تعيد الدالة جميع حالات التطابق غير المتداخلة للنمط المطبق على السلسلة النصية المعطاة على هيئة قائمة من السلاسل النصية.

re.findeiter()

تعيد الدالة كائنًا قابلًا للتكرار iterator ينتج عنه كائنات match لجميع حالات التطابق غير المتداخلة والناتجة من تطبيق التعبير النمطي المعطى على السلسلة النصية المعطاة.

re.sub()

تبدل الدالة حالات التطابق غير المتداخلة في أقصى اليسار -والناتجة عن تطبيق التعبير النمطي المعطى على السلسلة النصية المعطاة- بسلسلة نصية أو بالقيمة المعادة من دالة.

re.subn()

تؤدي هذه الدالة نفس عمل الدالة re.sub()‎ ولكنّها تعيد الناتج على هيئة صفّ (السلسلة الجديدة، عدد الاستبدالات المجراة).

re.escape()

تهرّب الدالة جميع المحارف الخاصّة في التعبير النمطي المعطى باستثناء حروف الترميز ASCII، والأعداد والشرطة السفلية.

re.purge()‎

تفرغ هذه الدالة الذاكرة المخبئية cache الخاصة بالتعبير النظامي.

استثناءات الوحدة re

تقدّم الوحدة استثناء واحدًا فقط وهو:

re.error()

تطلق اللغة هذا الاستثناء عند تمرير سلسلة نصية غير صالحة كتعبير نمطي إلى إحدى الدوال السابقة (كأن تتضمن السلسلة أقواسًا غير متطابقة)، أو عند حدوث أخطاء أخرى أثناء عملية التصريف أو المطابقة. لا يعد خلوّ السلسلة النصية من التطابقات خطأً على الإطلاق.

الكائنان regex و match

تتضمّن الوحدة re كائنين لهما أهمّية كبيرة في مسألة التعامل مع التعابير النمطية، وهما الكائنان regex و match.

ينشأ كائن التعبير النمطي regex من تصريف التعبير النمطي بواسطة الدالة re.compile()‎، ويقدّم هذا الكائن مجموعة من الخصائص والتوابع التي يمكن من خلالها إجراء العديد من العمليات على التعابير النمطية المصرّفة.

أما الكائن match فينشأ كنتيجة لتطبيق التابعين regex.match()‎ و regex.search() على التعبير النمطي المصرّف، ويقدّم بدوره مجموعة من الخصائص والتوابع التي يمكن من خلالها إجراء عدد من العمليات على نتائج المطابقة المجراة على التعابير النمطية المصرّفة.

أمثلة

فيما يلي بعض الأمثلة التي توضّح العمليات التي يمكن إجراؤها باستخدام التعابير النمطية:

التحقّق من المزدوجات

سنستخدم الدالة المساعدة التالية في هذا المثال لعرض كائنات التطابق بصورة أجمل:

def displaymatch(match):
    if match is None:
        return None
    return '<Match: %r, groups=%r>' % (match.group(), match.groups())

لنفترض أنّنا نقوم بكتابة برنامج للعب البوكر، حيث تمثّل يد اللاعب بواسطة سلسلة نصية ذات خمسة أحرف، يمثّل كل حرف منها نوعًا من أنواع الورق، فالحرف "a" يمثّل الواحد، "k" يمثّل الملك، "q" يمثّل الملكة، "j" يمثّل الجوكر، "t" يمثّل العدد 10، والأعداد "2" إلى "9" تمثّل الأوراق التي تحمل الأرقام ذاتها. للتأكد من أن السلسلة النصية المعطاة صالحة، يمكن استخدام الشيفرة التالية:

>>> valid = re.compile(r"^[a2-9tjqk]{5}$")
>>> displaymatch(valid.match("akt5q"))  # صالحة.
"<Match: 'akt5q', groups=()>"
>>> displaymatch(valid.match("akt5e")) # غير صالحة.
>>> displaymatch(valid.match("akt"))    # غير صالحة.
>>> displaymatch(valid.match("727ak")) # صالحة.
"<Match: '727ak', groups=()>"

تمتلك اليد الأخيرة في الشيفرة السابقة زوجًا من القيم المتساوية، ولمطابقة ذلك باستخدام تعبير نمطي، يمكن استخدام الإشارات الخلفية وكما يلي:

>>> pair = re.compile(r".*(.).*\1")
>>> displaymatch(pair.match("717ak"))    # 7 زوج من.
"<Match: '717', groups=('7',)>"
>>> displaymatch(pair.match("718ak"))     # لا أزواج
>>> displaymatch(pair.match("354aa"))    # زوج من الواحد.
"<Match: '354aa', groups=('a',)>"

ولمعرفة نوع الورق في الزوج يمكن استخدام التابع match.group()‎ بالطريقة التالية:

>>> pair.match("717ak").group(1)
'7'
>>> pair.match("718ak").group(1)
Traceback (most recent call last):
  File "<pyshell#23>", line 1, in <module>
   re.match(r".*(.).*\1", "718ak").group(1)
AttributeError: 'NoneType' object has no attribute 'group'
>>> pair.match("354aa").group(1)
'a'

لاحظ أن السلسلة النصية الثانية "718ak" تسبّبت في إطلاق الخطأ AttributeError لأنّ التابع regex.match()‎ يعيد القيمة None (نظرًا لعدم وجود حالة تطابق) والتي لا تمتلك بدورها الخاصية group.

محاكاة الدالة scanf()‎

لا تقدّم بايثون في الوقت الحاضر أيّ دالة تكافئ الدالة scanf()‎، لكن التعابير النمطية أكثر قوّة من السلاسل النصية بتنسيق scanf()‎ بصورة عامة، على الرغم من أن الأولى أكثر توسّعًا.

يربط الجدول التالي بين رموز الدالة scanf()‎ وبين التعابير النمطية التي تكافئ في عملها عمل هذه الرموز بصورة تقريبية.

الرمز في دالة scanf()‎ التعبير النمطي
%c .
%5c ‎.{5}‎
%d [-+]?\d+‎
%e, ‎%E, ‎%f, ‎%g [-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?‎
%i [-+]?(0[xX][\dA-Fa-f]+|0[0-7]*|\d+)‎
%o [-+]?[0-7]+‎
%s \S+‎
%u \d+‎
%x, ‎%X [-+]?(0[xX])?[\dA-Fa-f]+‎

لاستخلاص اسم الملف والأعداد من سلسلة نصية مثل:

/usr/sbin/sendmail - 0 errors, 4 warnings

يمكن استخدام الدالة scanf()‎ كما يلي:

%s - %d errors, %d warnings

هذا التعبير النمطي مكافئ للتعبير السابق:

(\S+) - (\d+) errors, (\d+) warnings

التابع search()‎ مقابل التابع match()‎

تقدّم بايثون نوعين من العمليات الأساسية المستندة إلى التعابير النمطية: الأولى هي re.match() والتي تتحقّق من وجود التطابق في بداية السلسلة النصية فقط، والثانية re.search()‎ والتي تتحقّق من وجود التطابق في كامل السلسلة النصية (هذا ما تقوم به لغة Perl كذلك على نحو افتراضي).

فعلى سبيل المثال:

>>> re.match("c", "abcdef")    # لا تطابق
>>> re.search("c", "abcdef")   # هناك تطابق
<_sre.SRE_Match object; span=(2, 3), match='c'>

يمكن استخدام التعابير النمطية المبدوءة بالمحرف '^' مع التابع search()‎ وذلك لتقييد عملية البحث عند بداية السلسلة:

>>> re.match("c", "abcdef")    # لا تطابق
>>> re.search("^c", "abcdef")  # لا تطابق
>>> re.search("^a", "abcdef")  # هناك تطابق
<_sre.SRE_Match object; span=(0, 1), match='a'>

لاحظ أنّه في حال استخدام الراية MULTILINE فإنّ التابع match()‎ يطابق في بداية السلسلة النصية فقط، في حين أنّ استخدام تعبير نمطي يبدأ بالمحرف '^' مع التابع search()‎ يؤدي إلى إجراء عملية البحث في بداية كلّ سطر:

>>> re.match('X', 'A\nB\nX', re.MULTILINE)  # لا يوجد تطابق
>>> re.search('^X', 'A\nB\nX', re.MULTILINE)  # يوجد تطابق
<_sre.SRE_Match object; span=(4, 5), match='X'>

إنشاء دليل الهاتف

يقطع التابع split()‎ السلسلة النصية إلى قائمة من العناصر بحسب النمط الممرّ للتابع. هذا التابع مفيد جدًّا في تحويل البيانات النصية إلى بنى بيانات يمكن قراءتها وتعديلها في بايثون بسهولة. يستخدم المثال التالي هذا التابع لإنشاء دليل الهاتف.

في البداية يجب توفير المدخلات، وهي عادة ما تكون ضمن ملف، ولكن استُخدمت السلاسل النصية ذات علامات الاقتباس الثلاثية في هذا المثال:

>>> text = """Ross McFluff: 834.345.1254 155 Elm Street
...
... Ronald Heathmore: 892.345.3428 436 Finley Avenue
... Frank Burger: 925.541.7625 662 South Dogwood Way
...
...
... Heather Albrecht: 548.326.4584 919 Park Place"""

من الملاحظ أنّ المدخلات مفصولة عن بعضها البعض بمحرف سطر جديد أو أكثر. يمكن تحويل السلسلة النصية إلى قائمة تضمّ جميع الأسطر غير الفارغة في السلسلة النصية كلّ على حدة:

>>> entries = re.split("\n+", text)
>>> entries
['Ross McFluff: 834.345.1254 155 Elm Street',
'Ronald Heathmore: 892.345.3428 436 Finley Avenue',
'Frank Burger: 925.541.7625 662 South Dogwood Way',
'Heather Albrecht: 548.326.4584 919 Park Place']

ثم يقسّم كل عنصر من عناصر القائمة إلى قائمة تضمّ الاسم الأول، والاسم الأخير، ورقم الهاتف، وعنوان السكن. يُستخدم المعامل maxsplit في التابع split()‎ لأنّ عنوان السكن يحتوي على مسافات بيضاء، والمسافة البيضاء هي النمط المستخدم لتقسيم عناصر القائمة:

>>> [re.split(":? ", entry, 3) for entry in entries]
[['Ross', 'McFluff', '834.345.1254', '155 Elm Street'],
['Ronald', 'Heathmore', '892.345.3428', '436 Finley Avenue'],
['Frank', 'Burger', '925.541.7625', '662 South Dogwood Way'],
['Heather', 'Albrecht', '548.326.4584', '919 Park Place']]

يطابق النمط ‎:?‎ النقطتين الرأسيتين بعد الاسم الأخير، وذلك لتجنب ظهورها في القائمة الناتجة. يمكن فصل رقم المنزل عن اسم الشارع بإعطاء القيمة 4 للمعامل maxsplit:

>>> [re.split(":? ", entry, 4) for entry in entries]
[['Ross', 'McFluff', '834.345.1254', '155', 'Elm Street'],
['Ronald', 'Heathmore', '892.345.3428', '436', 'Finley Avenue'],
['Frank', 'Burger', '925.541.7625', '662', 'South Dogwood Way'],
['Heather', 'Albrecht', '548.326.4584', '919', 'Park Place']]

تشويه النصوص

تستبدل الدالة sub()‎ جميع حالات التطابق مع النمط المعطى بسلسلة نصية أو النتيجة المعادة من دالة معينة. يبين المثال التالي النتائج المعادة من استخدام الدالة sub()‎ لتغيير مواقع الحروف في كل كلمة ضمن الجملة مع استثناء الحرفين الأول والأخير من كل كلمة:

>>> def repl(m):
...     inner_word = list(m.group(2))
...     random.shuffle(inner_word)
...     return m.group(1) + "".join(inner_word) + m.group(3)
>>> text = "Professor Abdolmalek, please report your absences promptly."
>>> re.sub(r"(\w)(\w+)(\w)", repl, text)
'Poefsrosr Aealmlobdk, pslaee reorpt your abnseces plmrptoy.'
>>> re.sub(r"(\w)(\w+)(\w)", repl, text)
'Pofsroser Aodlambelk, plasee reoprt yuor asnebces potlmrpy.'

العثور على جميع الأحوال Adverbs

تعيد الدالة findall()‎ جميع حالات تطابق نمط معيّن في السلسلة المعطاة، وليس حالة التطابق الأولى فقط كما في الدالة search()‎. فعلى سبيل المثال إن احتاج الكاتب أن يعثر على جميع الأحوال في نصّ معين، فيمكن استخدام الدالة findall()‎ بالنحو التالي:

>>> text = "He was carefully disguised but captured quickly by police."
>>> re.findall(r"\w+ly", text)
['carefully', 'quickly']

العثور على جميع الأحوال وتحديد مواقعها

إن كان المطلوب العثور على المزيد من المعلومات المتعلّقة بجميع حالات التطابق، فيمكن الاستفادة من الدالة finditer()‎ التي تعيد كائنات match بدلًا من السلاسل النصية. في المثال السابق إن أراد الكاتب أن يعثر على جميع الأحوال ويحدّد مواقعها ضمن نصّ معين، فيمكن استخدام الدالة finditer() وكما يلي:

>>> text = "He was carefully disguised but captured quickly by police."
>>> for m in re.finditer(r"\w+ly", text):
...     print('%02d-%02d: %s' % (m.start(), m.end(), m.group(0)))
07-16: carefully
40-47: quickly

صيغة السلاسل النصية الخام

تحافظ صيغة السلاسل النصية الخام (r"text"‎) على سلامة التعابير النمطية، فبدونها يجب أن يسبق كل خطّ مائل عكسي في التعبير النمطي بخطّ آخر مماثل لتهريبه. يعرض المثال التالي سطرين برمجيين يؤديان الوظيفة ذاتها:

>>> re.match(r"\W(.)\1\W", " ff ")
<_sre.SRE_Match object; span=(0, 4), match=' ff '>
>>> re.match("\\W(.)\\1\\W", " ff ")
<_sre.SRE_Match object; span=(0, 4), match=' ff '>

يجب تهريب محرف الخط المائل العكسي عند الحاجة إلى مطابقته باستخدام التعابير النمطية، ولتهريب هذا المحرف باستخدام صيغة السلاسل النصية الخام يكفي إضافة خط آخر قبله r"\\"‎، ولكن لتهريب هذا المحرف باستخدام صيغة السلاسل النصية الاعتيادية في بايثون فيجب إضافة خطّين إضافيين "\\\\". عرض المثال التالي سطرين برمجيين يؤديان الوظيفة ذاتها:

>>> re.match(r"\\", r"\\")
<_sre.SRE_Match object; span=(0, 1), match='\\'>
>>> re.match("\\\\", r"\\")
<_sre.SRE_Match object; span=(0, 1), match='\\'>

إنشاء مرمّز باستخدام التعابير النمطية

يحلّل المرمّز Tokenizer أو الماسح السلسلة النصية لتصنيف الحروف إلى مجموعات مختلفة، وإنشاء المرمّز هو الخطوة الأولى في إنشاء المصرّف أو المفسّر.

يمكن تحديد أصناف النصوص باستخدام التعابير النمطية، وتتلخّص الفكرة في إنشاء مجموعة من التعابير النمطية التي تُدمج بعد ذلك في تعبير نمطي رئيسي ثم المرور على مجموعة من حالات التطابق المتعاقبة:

import collections
import re

Token = collections.namedtuple('Token', ['typ', 'value', 'line', 'column'])

def tokenize(code):
    keywords = {'IF', 'THEN', 'ENDIF', 'FOR', 'NEXT', 'GOSUB', 'RETURN'}
    token_specification = [
        ('NUMBER',  r'\d+(\.\d*)?'),  # عدد صحيح أو عشري
        ('ASSIGN',  r':='),           # عامل الإسناد
        ('END',     r';'),            # نهاية الجملة
        ('ID',      r'[A-Za-z]+'),    # المعرّفات
        ('OP',      r'[+\-*/]'),      # عوامل حسابية
        ('NEWLINE', r'\n'),           # نهايات الأسطر
        ('SKIP',    r'[ \t]+'),       # تجاوز المسافات البيضاء وعلامات الجدولة
        ('MISMATCH',r'.'),            # الحروف الأخرى
    ]
    tok_regex = '|'.join('(?P<%s>%s)' % pair for pair in token_specification)
    line_num = 1
    line_start = 0
    for mo in re.finditer(tok_regex, code):
        kind = mo.lastgroup
        value = mo.group(kind)
        if kind == 'NEWLINE':
            line_start = mo.end()
            line_num += 1
        elif kind == 'SKIP':
            pass
        elif kind == 'MISMATCH':
            raise RuntimeError(f'{value!r} unexpected on line {line_num}')
        else:
            if kind == 'ID' and value in keywords:
                kind = value
            column = mo.start() - line_start
            yield Token(kind, value, line_num, column)

statements = '''
    IF quantity THEN
        total := total + price * quantity;
        tax := price * 0.05;
    ENDIF;
'''

for token in tokenize(statements):
    print(token)

يعطي المرمّز المخرجات التالية:

Token(typ='IF', value='IF', line=2, column=4)
Token(typ='ID', value='quantity', line=2, column=7)
Token(typ='THEN', value='THEN', line=2, column=16)
Token(typ='ID', value='total', line=3, column=8)
Token(typ='ASSIGN', value=':=', line=3, column=14)
Token(typ='ID', value='total', line=3, column=17)
Token(typ='OP', value='+', line=3, column=23)
Token(typ='ID', value='price', line=3, column=25)
Token(typ='OP', value='*', line=3, column=31)
Token(typ='ID', value='quantity', line=3, column=33)
Token(typ='END', value=';', line=3, column=41)
Token(typ='ID', value='tax', line=4, column=8)
Token(typ='ASSIGN', value=':=', line=4, column=12)
Token(typ='ID', value='price', line=4, column=15)
Token(typ='OP', value='*', line=4, column=21)
Token(typ='NUMBER', value='0.05', line=4, column=23)
Token(typ='END', value=';', line=4, column=27)
Token(typ='ENDIF', value='ENDIF', line=5, column=4)
Token(typ='END', value=';', line=5, column=9)

انظر أيضًا

مصادر