Пориньте у Python 3/Регулярні вирази

Деякі люди, коли cтикаються з проблемою, думають: "Я знаю, я використаю регулярні вирази". Після цього вони мають дві проблеми.
Джемі Завінські

Отримання потрібної частки з великого масиву тексту може стати викликом. Рядки в Python мають методи для пошуку та заміни: index(), find(), split(), count(), replace() та подібні. Але ці методи обмежені найпростішими випадками. Наприклад, метод index() шукає єдиний, жорстко зафіксований підрядок, і пошук завжди чутливий до регістру. Щоб здійснити нечутливі до регістру пошуки в рядку s, потрібно застосувати s.lower(), чи s.upper() і переконатись що запит написаний в тому ж регістрі. Методи replace() та split() мають ті ж обмеження.

Якщо вашої цілі можна досягти використовуючи методи для роботи з рядками, ви повинні використовувати їх. Вони швидкі, прості, їх просто читати. Про швидкий, простий і читабельний код можна розказувати багато гарного. Але якщо ви використовуєте багато цих функцій в перемішку з операторами if для обробки особливих випадків, чи якщо ви з'єднуєте виклики split() та join() щоб різати та перемішувати ваші рядки, можливо, вам варто буде перейти до регулярних виразів.

Регулярні вирази - це потужний і (переважно) стандартизований спосіб пошуку, заміни та лексичного аналізу тексту з складними шаблонами символів. Хоча, синтаксис регулярних виразів досить щільний і не схожий на звичайний код, але результат може виявитись більш читабельним ніж саморобне рішення, що використовує велику кількість рядкових функцій. Існує навіть спосіб вставити в регулярний вираз коментарів, тому ви можете включати в них документацію.

Якщо ви користувались регулярними виразами в інших мовах (таких як Perl, JavaScript, чи PHP), їх синтаксис в Python може видатись знайомим. Прочитайте документацію до модуля re, щоб отримати огляд доступних функцій та їх аргументів.


* * *


Приклад: Адреси ред.

Ця серія прикладів викликана проблемою з реального життя, з якою я стикнувся на моїй роботі кілька років тому, коли я хотів отримати та стандартизувати адреси вулиць з старої системи перед тим, як імпортувати її в нову. (Бачте, я не просто даю висмоктані з пальця приклади, вони справді корисні). Цей приклад показує як я підійшов до проблеми.

>>> s = '100 NORTH MAIN ROAD'
>>> s.replace('ROAD', 'RD.')                
'100 NORTH MAIN RD.'

Моєю метою було стандартизувати адреси вулиць так, щоб 'ROAD' завжди записувалось скорочено, як 'RD.'. На перший погляд, це виглядає просто, і я подумав, що можна використати метод для роботи з рядками replace(). В будь-якому випадку дані були завжди в верхньому регістрі, тому чутливість до регістру не мала бути проблемою. І в цьому надзвичайно простому прикладі, s.replace() чудово виконала свою роботу.

>>> s = '100 NORTH BROAD ROAD'
>>> s.replace('ROAD', 'RD.')                
'100 NORTH BRD. RD.'

Життя, на жаль, повне контрприкладів, і я швидко виявив це. Проблемою є те, що 'ROAD' з'являється в адресі двічі, один раз як частина імені вулиці 'BROAD', і один раз як ціле слово. Метод replace() бачить ці два випадки, і сліпо замінює обидвох, тим часом, я бачу як мої адреси псуються.

>>> s[:-4] + s[-4:].replace('ROAD', 'RD.')  
'100 NORTH BROAD RD.'

Щоб розв'язати проблему адрес з більш ніж одним підрядком 'ROAD', можна, наприклад, зробити так: шукати 'ROAD' лише в останніх чотирьох символах адреси (s[-4:1]), і не чіпати решту рядка (s[:-4]). Але ви можете бачити, що це вже досить заплутано. (Якщо б ви замінювали 'STREET' на 'ST.', ви повинні були б використовувати s[:-6] та s[-6:].replace(...).) Ви б хотіли повернутись через пів року щоб зневаджувати це? Я знаю що я б не хотів.

>>> import re

Настав час перейти до регулярних виразів. В мові Python, вся функціональність пов'язана з регулярними виразами міститься в модулі re.

>>> re.sub('ROAD$', 'RD.', s)               
'100 NORTH BROAD RD.'

Погляньте на перший параметр: 'ROAD$'. Це простий регулярний вираз, який співставляється 'ROAD' лише тоді, коли знаходить його в кінці рядка. $ означає "кінець рядка". (Існує також протилежний за значенням символ - ^, який означає "початок рядка"). Використовуючи метод re.sub() шукаємо в рядку s співпадіння з регулярним виразом 'ROAD$' та замінюємо його на 'RD.'. Це спрацьовує для ROAD в кінці рядка s, але не спрацьовує для слова BROAD, тому що воно знаходиться посередині.

^ відповідає початку рядка. $ відповідає кінцю рядка.

Продовжуючи історію з очисткою адрес: я невдовзі відкрив, що в попередньому прикладі, співставляння 'ROAD' кінцю рядка, не достатньо добре, бо деякі адреси просто закінчуються назвою вулиці. Це працювало в більшості випадків, але коли назвою вулиці було 'BROAD', регулярний вираз виявляв співпадіння з кінцівкою 'ROAD', а це не те що мені було потрібно.

>>> s = '100 BROAD'
>>> re.sub('ROAD$', 'RD.', s)
'100 BRD.' 
>>> re.sub('\\bROAD$', 'RD.', s)
'100 BROAD'

Що мені справді було потрібно - це співпадіння з словом ROAD на кінці рядка, але тільки тоді, коли це окреме слово, а не частина якогось іншого слова. Щоб виразити це в регулярному виразі використовують символ \b який означає що в цьому місці повинна знаходитись межа (boundary) слова. В Python, це ускладнюється тим фактом, що символ '\' в рядку сам повинен екрануватись. Це іноді називаються "чумою бекслешів", і це є причиною того що писати регулярні вирази в Perl простіше ніж в Python. З іншого боку, Perl змішує регулярні вирази з рештою синтаксису, тому іноді важко відрізнити синтаксичну помилку від помилки в регулярному виразі.

>>> re.sub(r'\bROAD$', 'RD.', s) 
'100 BROAD'

Щоб позбутись надлишку бекслешів, ви можете використати те, що називається "чистим (raw) рядком", додавши перед рядком символ r. Це повідомляє інтерпретатору Python, що нічого в цьому рядку не повинно екрануватись. '\t' - це рядок з одного символу табуляції, а r'\t' - це рядок з двох символів: бекслеша та букви t. Раджу вам завжди використовувати чисті рядки для опису регулярних виразів, бо інакше все робиться занадто заплутаним занадто швидко (а регулярні вирази з самого початку достатньо заплутані).

>>> s = '100 BROAD ROAD APT. 3'
>>> re.sub(r'\bROAD$', 'RD.', s) 
'100 BROAD ROAD APT. 3'

Ох! На жаль, я пізніше знайшов ще більше прикладів, які суперечать моїм міркуванням. В цьому випадку, адреса вулиці містила слово 'ROAD' як окреме слово, але воно не було на кінці рядка, бо адреса містила номер квартири. Через те що 'ROAD' не в кінці рядка виклик re.sub() не здійснював взагалі ніяких замін, і ви отримували незмінний рядок назад, що не те що вам було потрібно.

>>> re.sub(r'\bROAD\b', 'RD.', s)
'100 BROAD RD. APT 3'

Щоб розв’язати цю проблему, я вилучив симовол $ і додав ще один \b. Тепер регулярний вираз читається як "знайди відповідність шаблону 'ROAD' коли це окреме слово де-небудь в рядку", не залежно від того на початку, в кінці чи десь посередині.


* * *


Приклад: Римські числа ред.

Швидше за все ви вже бачили римські числа, навіть якщо ви їх і не впізнали. Їх можна побачити на фасадах старих будинків (MCMXLVI замість "збудовано в 1946), чи при нумерації розділів в деяких книгах. Така система запису чисел з’явилась ще в римській імперії, звідки й назва.

В римській системі числення існує кілька символів, які повторююься і комбінуються різним чином щоб утворювати числа:

  • I - 1
  • V - 5
  • X - 10
  • L - 50
  • C - 100
  • D - 500
  • M - 1000

А ось деякі загальні правила утворення римських чисел:

  • Іноді символи аддитивні. I це 1, II це 2, і III це 3. VI це 6 (буквально, "5 і 1"), VII це 7, і VIII - 8.
  • Символи що позначають степені десяти (I, X, C, та M) можуть повторюватись до трьох разів. Далі потрібно віднімати від наступного за значенням символа, зазвичай кратного 5. Не можна записувати 4 як IIII, замість цього пишуть IV (на 1 менше ніж 5). 40 записується як XL (на 10 менше ніж 50), 41 як XLI, 42 як XLII, 43 як XLIII, а 44 як XLIV (на 10 менше ніж 50, і ще на одиницю менше ніж 5).
  • Іноді символи ... пряма протилежність аддитивним. Ставлячи деякі символи перед іншими, ви віднімаєте їх від загального значення. Наприклад, щоб записати 9, ви віднімаєте 1 від 10, записуючи це як IX. 8 це VIII, але 9 - це IX, бо не можна писати VIIII (дивись попереднє правило). 90 це XC, 900 - CM.
  • Символи що не є степенями 10 не повторюються двічі підряд. 10 записується як X, і ніколи як VV. 100 - завжди C, і ніколи не LL.
  • Римські цифри читаються зліва направо, і порядок символів має велике значення. DC - це 600, а CD - зовсім інше значення - 400 (на 100 менше ніж 500). CI це 101, а IC навіть не є правильним римським числом, бо не можна віднімати 1 одразу від 100. Число 99 записується як XCIX (на 10 менше ніж 100, і ще на 1 менше ніж 10).

Перевірка тисяч ред.

Що потрібно щоб перевірити що рядок є правильним римським числом? Давайте проаналізуємо це по одній цифрі за раз. Так як римські числа завжди записуються від найбільших до найменших цифр, почнемо з найбільших - тисяч. Для чисел починаючи від значення 1000 і вище, тисячі в них записуються послідовністю символів M.

>>> import re 
>>> pattern = '^M?M?M?$'

Цей шаблон складається з трьох частин. ^ співставляється тільки з тим що знаходиться на початку рядка. Без нього відповідності шаблону знаходились би в довільних місцях рядка, а це не те що вам потрібно. Вам навпаки, потрібно переконатись, що символи M, якщо вони є, знаходяться на початку рядка. M? відповідає або символу M, або відсутності будь-яких символів. Так як воно повторене тричі, то шаблону відповідатиме від нуля до трьох символів M підряд. $ співставляється з кінцем рядка. В поєднанні з символом ^ на початку, він означає, що рядок повинен відповідати шаблону повністю, без жодних невідповідних символів на початку чи в кінці.

>>> re.search(pattern, 'M') 
<_sre.SRE_Match object at 0106FB58>

Основою модуля re є функція search(), яка бере регулярний вираз (pattern), і рядок ('M'), і намагається знайти відповідності. Якщо знаходить, то повертає об’єкт який містить методи що описують знайдену відповідність, інакше повертає None. Нас на поки що цікавить лише наявність чи відсутність співпадіння, а це можна вияснити лише просто за значенням яке повертається. 'M' відповідає шаблону, тому що співпадає з першим символом M в шаблоні, а решта (як і він сам) позначені як не обов’язкові.

>>> re.search(pattern, 'MM') 
<_sre.SRE_Match object at 0106C290>

'MM' теж відповідає шаблону, бо співставляється першим двом M?M?, а третє не враховується.

>>> re.search(pattern, 'MMM') 
<_sre.SRE_Match object at 0106AA38>

'МММ' відповідає всім трьом символам шаблону.

>>> re.search(pattern, 'MMMM')

'MMMM' не відповідає шаблону. Перші три символи M утворюють відповідність з шаблоном, але далі регулярний вираз вимагає закінчення рядка (через символ $), а так як рядок ще не закінчився (є четверте M), то повертається None.

>>> re.search(pattern, '') 
<_sre.SRE_Match object at 0106F4A8>

Що цікаво, порожній рядок теж відповідає шаблону, бо всі три символи в ньому необов’язкові.

Перевірка сотень ред.

? означає що входження шаблону перед ним необов’язкове

Сотні перевіряти дещо складніше ніж тисячі, тому що існує кілька взаємновиключних способів їх запису залежно від значення.

  • 100 = C
  • 200 = CC
  • 300 = CCC
  • 400 = CD
  • 500 = D
  • 600 = DC
  • 700 = DCC
  • 800 = DCCC
  • 900 = CM

Тому, існує чотири можливі шаблони:

  • CM
  • CD
  • Від нуля до трьох символів C
  • D після якого іде від нуля до трьох символів C

Останні два можна поєднати:

  • Необов’язкове D після якого іде від нуля до трьох символів C

Наступний приклад покаже як перевірити сотні в римських числах.

>>> import re 
>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)$'

Цей шаблон починається як попередній, символом відповідності початку рядка ^, і перевіркою тисяч M?M?M?. Далі в дужках іде нова частина, яка описує набір трьох взаємновиключних шаблонів розділених вертикальними рисками: CM, CD, та D?C?C?C?. Парсер регулярних виразів перевіряє ці три шаблони по порядку, бере перший, який містить відповідність рядку, і ігнорує всі інші.

>>> re.search(pattern, 'MCM') 
<_sre.SRE_Match object at 01070390>

'MCM' відповідає шаблону, тому що перше M відповідає, друге і третє необов’язкові, і CM відповідає (тому частини CD і D?C?C?C? навіть не беруться до розгляду). MCM це римський запис числа 1900.

>>> re.search(pattern, 'MD') 
<_sre.SRE_Match object at 01073A50>

'MD' містить відповідність, тому що перше M відповідає рядку, друге і третє ігноруються, а шаблону D?C?C?C? відповідає символу D (три символи C необов’язкові і ігноруються). MD - це римський запис числа 1500.

>>> re.search(pattern, 'MMMCCC') 
<_sre.SRE_Match object at 010748A8>

'MMMCCC' відповідає шаблону, тому що всі три M знаходять відповідність, і шаблон D?C?C?C? відповідає CCC (D необов’язкове і ігнорується). MMMCCC - римський запис числа 3300.

>>> re.search(pattern, 'MCMC')

'MCMC' не відповідає шаблону. Перше M знаходить відповідність, два наступні ігноруються, потім знаходиться відповідність шаблону CM, проте далі іде символ $ який відповідає кінцю рядка, але ми ще не на кінці рядка (є ще один символ C, який не має відповідності). Він не співставляється шаблону D?C?C?C?, тому що вже був використаний взаємновиключний шаблон CM.

>>> re.search(pattern, '') 
<_sre.SRE_Match object at 01071D98>

Що цікаво, порожній рядок все ще відповідає нашому шаблону, бо всі символи M необов’язкові, а порожній рядок відповідає шаблону D?C?C?C? де всі символи теж необов’язкові.

Хух! Бачите як швидко регулярні вирази стають паскудними? А ми розглянули тільки тисячі і сотні в римських числах. Щоправда якщо ви з цим розібрались, то розібратись з десятками і одиницями буде просто, бо вони працюють за такою ж схемою. Але давайте розглянемо інший спосіб запису шаблону.


* * *


Використання синтаксису {n,m} ред.

{1,4} відповідає від 1 до 4 повторень шаблону що знаходиться перед ним

В попередньому розділі ми розглядали шаблони де символам дозволялось входити в рядок від нуля до трьох разів. Існує інший спосіб запису такого шаблону, який вважається більш читабельним. Але давайте спершу глянемо на метод яким ми користувались в попередньому розділі.

>>> pattern = '^M?M?M?$'

Цей шаблон означає що спочатку рядка може стояти M, а може і не обов’язково, і так само необов’язково можуть стояти ще дві букви M, і далі рядок повинен закінчитись.

Альтернативний спосіб записати цей же шаблон виглядає так:

pattern = '^M{0,3}$'

Він означає: на початку рядка має стояти від 0 до 3 символів M, і потім рядок закінчується. Замість 0 і 3 можна ставити будь-які інші числа.

Перевірка десятків та одиниць ред.

Спочатку додамо перевірку десятків:

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$' 
>>> re.search(pattern, 'MCMXL')
<_sre.SRE_Match object at 0x008EEB48>

Спробую проілюструвати як відбувається зіставлення шаблона з рядком 'MCMXL' дещо іншим способом ніж в оригінальній книжці. Бо при всій повазі до автора оригіналу, він пише багато слів, але з них важко видобувати сенс. Думаю так як напишу я буде наочніше.

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCML')
<_sre.SRE_Match object at 0x008EEB48>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCMLX')
<_sre.SRE_Match object at 0x008EEB48>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCMLXXX')
<_sre.SRE_Match object at 0x008EEB48>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCMLXXXX') 
>>>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

Цей рядок не відповідає шаблону, тому що шаблон очікує кінець рядка (виділено червоним), а рядок натомість містить ще один X

(A|B) відповідає або шаблону A або шаблону B, але не обидвом водночас.

Вираз для одиниць працює так само. Я опущу деталі, і покажу одразу кінцевий результат:

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$'

Як би він виглядав, якби ми використали новий синтаксис з фігурними дужками? Так як в прикладі нижче.

>>> pattern = '^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$' 
>>> re.search(pattern, 'MDLV')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

>>> re.search(pattern, 'MMDCLXVI')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

>>> re.search(pattern, 'MMMDCCCLXXXVIII')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

>>> re.search(pattern, 'I')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

Якщо ви читаючи це зрозуміли кожен вираз з першого разу - ви розумніші за автора цієї книжки. Але регулярні вирази можуть бути ще заплутанішими, і інколи зрозуміти їх призначення в програмі дуже важко.

Тому давайте розглянемо інший спосіб запису регулярних виразів, який спрощує їх прочитання іншими людьми.


* * *


Багатослівні регулярні вирази ред.

Дотепер ми мали справу лише з тим, що я називаю "компактними регулярними виразами". Ви вже могли помітити що їх не просто читати, і навіть якщо ви розумієте як вони працюють, це ще не гарантує що ви зможете зробити це через місяці після їх написання. Тому нам потрібна вбудована документація.

Python дозволяє зробити це за допомогою багатослівних регулярних виразів (verbose regular expressions). Вони відрізняються від звичайних двома особливостями:

  • Розділювальні символи ігноруються. Прогалики, табуляції, символи нового рядка в шаблоні не відповідають прогаликам, табуляціям і символам нового рядка в тексті що порівнюється з шаблоном. Вони взагалі не співставляються з жодними символами в тексті. (Якщо вам потрібний прогалик в багатослівному регулярному виразі, доведеться його екранувати поставивши перед ним зворотній слеш.)
  • Коментарі ігноруються. Коментарі в багатослівних регулярних виразах такі самі як коментарі в коді: це все що знаходиться між символом # і кінцем рядка. В даному випадку це коментарі всередині багаторядкового літералу, а не всередині вашого коду, але принцип їх роботи залишається таким самим.

Спробую пояснити все на прикладі. Давайте повернемось до компактного регулярного виразу з яким ми працювали, і спробуємо зробити його багатослівним. Ось так:

>>> pattern = '''
    ^                   # початок рядка
    M{0,3}              # тисячі - від 0 до 3 M
    (CM|CD|D?C{0,3})    # сотні - 900 (CM), 400 (CD), 0-300 (від 0 до 3 C),
                        #            або 500-800 (D, після якого від 0 до 3 C)
    (XC|XL|L?X{0,3})    # десятки - 90 (XC), 40 (XL), 0-30 (від 0 до 3 X),
                        #        або 50-80 (L, після якого від 0 до 3 X)
    (IX|IV|V?I{0,3})    # одиниці - 9 (IX), 4 (IV), 0-3 (I{0,3}),
                        #        або 5-8 (VI{0-3})
    $                   # кінець рядка
    '''
>>> re.search(pattern, 'M', re.VERBOSE) 
<_sre.SRE_Match object at 0x008EEB48>

Найважливіше про що треба пам’ятати при роботі з багатослівними регулярними виразами - що функціям треба передавати додатковий аргумент: re.VERBOSE. Це константа описана в модулі re для позначення того що регулярний вираз який ми передаємо функції - багатослівний. Як ви можете бачити, такий регулярний вираз містить багато розділювальних символів і коментарів що ігноруються. А якщо видалити всі розділювачі та коментар, це буде точно такий самий регулярний вираз, як ми бачили в попередньому параграфі.

>>> re.search(pattern, 'MCMLXXXIX', re.VERBOSE) 
<_sre.SRE_Match object at 0x008EEB48>
   
   ^                   # початок рядка
   M{0,3}              # тисячі - від 0 до 3 M
   (CM|CD|D?C{0,3})    # сотні - 900 (CM), 400 (CD), 0-300 (від 0 до 3 C),
                       #            або 500-800 (D, після якого від 0 до 3 C)
   (XC|XL|L?X{0,3})    # десятки - 90 (XC), 40 (XL), 0-30 (від 0 до 3 X),
                       #        або 50-80 (L, після якого від 0 до 3 X)
   (IX|IV|V?I{0,3})    # одиниці - 9 (IX), 4 (IV), 0-3 (I{0,3}),
                       #        або 5-8 (VI{0-3})
   $                   # кінець рядка
   
>>> re.search(pattern, 'MMMDCCCLXXXVIII', re.VERBOSE) 
<_sre.SRE_Match object at 0x008EEB48>
   
   ^                   # початок рядка
   M{0,3}              # тисячі - від 0 до 3 M
   (CM|CD|D?C{0,3})    # сотні - 900 (CM), 400 (CD), 0-300 (від 0 до 3 C),
                       #            або 500-800 (D, після якого від 0 до 3 C)
   (XC|XL|L?X{0,3})    # десятки - 90 (XC), 40 (XL), 0-30 (від 0 до 3 X),
                       #        або 50-80 (L, після якого від 0 до 3 X)
   (IX|IV|V?I{0,3})    # одиниці - 9 (IX), 4 (IV), 0-3 (I{0,3}),
                       #        або 5-8 (VI{0-3})
   $                   # кінець рядка
   

MMMDCCCLXXXVIII - римський запис числа 3888, і найдовше римське число яке можна записати не використовуючи розширений синтаксис.

>>> re.search(pattern, 'M') 
>>>

А цей рядок не співставляється з шаблоном. Чому? Бо ми не передали йому параметр re.VERBOSE, тому функція re.search розглядає шаблон як компактний регулярний вираз, з значущими розділювачами, і буквальними символами дієзів. Python не вміє автоматично визначати чи є регулярний вираз багатослівним, тому вважає кожен регулярний вираз компактним, якщо ви явно не вкажете протилежне.


* * *


Приклад: аналіз телефонних номерів ред.

\d відповідає будь-якій цифрі (0–9). \D відповідає будь-чому окрім цифр.

Поки що ми зосереджувались лише на повному співставленні шаблонів. Або рядок відповідає шаблону або ні. Але регулярні вирази мають набагато більше можливостей. Коли рядок відповідає шаблону, ми можемо виділяти певні його частини, і визначати що саме співпало.

Цей приклад з’явився з іншої реальної задачі, яку мені довелось розв’язувати на попередній роботі. Задача: аналіз американських телефонних номерів. Клієнт хоче мати можливість вводити номер в будь-якій формі (в єдиному полі), але хоче мати можливість зберігати код регіону, код міста, номер і необов’язковий додаток в базі даних окремо. Я обнишпорив веб і знайшов багато прикладів регулярних виразів створених з цією метою, але жоден з них не був достатньо гнучким.

Ось номери які я повинен був розбирати:

  • 800-555-1212
  • 800 555 1212
  • 800.555.1212
  • (800) 555-1212
  • 1-800-555-1212
  • 800-555-1212-1234
  • 800-555-1212x1234
  • 800-555-1212 ext. 1234
  • work 1-(800) 555.1212 #1234

Досить різні! В кожному з цих випадків мені потрібно визначити що код регіону - 800, код міста 555, а решта номера - 1212. У випадках наявності додатку мені потрібно знати, що додаток - 1234.

Що ж, давайте почнемо розробку методу аналізу телефонних номерів. Наступний приклад демонструє перший крок:

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})$')

Регулярні вирази варто читати зліва направо. Цей регулярний вираз починає співставлення з початку рядка, а потім шукає шаблон (\d{3}). Що таке \d{3}? Ну, \d означає "будь-яка десяткова цифра" (від 0 до 9). {3} означає "застосувати попередній шаблон рівно три рази". Це інший вид синтаксису {n,m} який ви бачили раніше. Поміщення шаблону в дужки, означає "запам’ятати цей шматочок як групу, яку я можу отримати пізніше". Потім іде співставлення з дефісом. Потім співставлення з іншою групою з рівно трьох цифр. Потім знову дефіс. Після цього регулярний вираз співставлятиметься з групою з рівно чотирма цифрами, і далі буде вимагатись кінець рядка.

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212')

Щоб отримати доступ до груп які парсер регулярних виразів запам’ятав під час роботи, використовуйте метод groups() об’єкта який повертається методом search(). Він поверне кортеж з стількох груп, скільки їх було задано в регулярному виразі. В нашому випадку задано три групи - дві з трьома цифрами, і одна з чотирма.

>>> phonePattern.search('800-555-1212-1234')

Цей регулярний вираз не є кінцевим, бо як ми бачимо, він не розпізнає телефони з розширенням. Тому нам доведеться його доповнити.

>>> phonePattern.search('800-555-1212-1234').groups()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module> 
AttributeError: 'NoneType' object has no attribute 'groups'

І це є причиною, через яку вам не рекомендується створювати в коді ланцюжки з методів search() та groups(). Якщо метод search() не поверне жодних співпадінь, він поверне None, який не є об’єктом що описує співпадіння з регулярним виразом. Виклик None.groups() поверне цілком очевидне виключення: None не містить методу groups(). (Звичайно, це набагато менш очевидно, коли ви отримуєте таке виключення десь з глибини свого коду. Тут я говорю зі свого досвіду.)

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})-(\d+)$')

Цей регулярний вираз майже ідентичний попередньому, за винятком одного. Як і раніше, він співставляється з початком рядка, потім група трьох цифр, дефіс, ще одна група з трьох, дефіс і група з чотирьох цифр. Новим тут є ще один дефіс і ще одна група з чотирьох цифр.

>>> phonePattern.search('800-555-1212-1234').groups()
('800', '555', '1212', '1234')

Оскільки тепер регулярний вираз містить чотири групи, метод groups() повертає кортеж з чотирьох елементів.

>>> phonePattern.search('800 555 1212 1234')
>>>

Та, на жаль, цей регулярний вираз все ще не є кінцевим, бо він припускає що цифри в телефонному номері розділені дефісами. А що якщо їх будуть розділяти прогаликами, комами чи крапками? Нам потрібне більш загальне рішення для співставляння з кількома типами розділювачів.

>>> phonePattern.search('800-555-1212')
>>>

Уупс! Цей регулярний вираз крім того, ще й не розпізнає ті номери, які розпізнавав попередній, а це не те чого ми хочемо. Якщо телефон має розширення, ми хочемо знати яке воно, але, якщо його нема, ми все одно хочемо отримати інші частини номера.

Наступний приклад демонструє регулярний вираз, здатний справитись із розділювачами між різними частинами номера.

>>> phonePattern = re.compile(r'^(\d{3})\D+(\d{3})\D+(\d{4})\D+(\d+)$')

А зараз приготуйтесь. Цей регулярний вираз співставляється з початком рядка, потім групою з трьох цифр, потім \D+. Це ще що за чортівня? Ну, \D відповідає будь-якому символу окрім десяткової цифри, а + означає "один чи більше разів". Тому, \D+ співставляється з одним чи більше символами, які не є цифрами. Його ми поставили замість дефіса, щоб розпізнавати найрізноманітніші форми розділювачів.

>>> phonePattern.search('800 555 1212 1234').groups()
('800', '555', '1212', '1234')

Застосування \D+ замість дефіса означає що ви тепер можете розпізнавати номери з прогаликами замість дефісів.

>>> phonePattern.search('800-555-1212-1234').groups()
('800', '555', '1212', '1234')

Звісно, дефіси також все ще працюють.

>>> phonePattern.search('80055512121234')
>>>

На жаль, це все ще не кінцева відповідь, бо вона припускає, що повинен бути хоч якийсь розділювач. А що, якщо телефонний номер введено взагалі без прогаликів чи дефісів?

>>> phonePattern.search('800-555-1212')
>>>

Уупс! Це також все ще не розв’язує проблему з необов’язковістю розширення. Тепер в нас є дві проблеми, але ми можемо розв’язати кожну з них за допомогою одної техніки.

Наступний приклад демонструє регулярний вираз для обробки телефонних номерів без розділювачів.

>>> phonePattern = re.compile(r'^(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')

Єдине що ми робимо на цьому кроці - замінюємо всі + на *. Замість \D+ між частинами телефонного номера знаходиться \D*. Пам’ятаєте що + означає "один чи більше"? Так от, * означає "нуль чи більше". Тому зараз ми повинні б розпізнавати навіть телефонні номери, в яких взагалі немає розділювальних символів.

>>> phonePattern.search('80055512121234').groups()
('800', '555', '1212', '1234')

Погляньте-но, воно справді працює. Чому? Регулярний вираз співставляється з початком рядка, потім з групою з трьох цифр (800), потім з нулем нецифрових символів, потім з групою з трьох цифр (555), потім з нулем нецифрових символів, потім з групою з чотирьох цифр (1212), потім з нулем нецифрових символів, потім з групою довільної кількості цифр (1234), і нарешті з кінцем рядка.

>>> phonePattern.search('800.555.1212 x1234').groups()
('800', '555', '1212', '1234')

Інші варіації тепер теж проходять: крапки замість дефісів і прогалик з символом x перед розширенням.

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212', '')

Ну, і нарешті, ми розв’язали іншу проблему, яка діставала нас довгий час: розширення знову необов’язкове. Якщо розширення номера не знайдене, метод groups() все ще повертає кортеж з чотирьох елементів, але четвертий елемент є просто порожнім рядком.

>>> phonePattern.search('(800)5551212 x1234')
>>>

Ненавиджу приносити погані новини, але ми все ще не закінчили. І яка проблема цього разу? Перед кодом регіону можуть бути додаткові символи, але регулярний вираз припускає, що код регіону - перше, що стоїть на початку рядка. Жодних проблем, ви можете використовувати ту ж техніку "нуль, чи більше нецифрових символів", щоб пропустити початкові символи перед кодом регіону.


Наступний приклад продемонструє як можна справитись з символами перед кодом регіону.

>>> phonePattern = re.compile(r'^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')

Регулярний вираз аналогічний виразу в попередньому прикладі, тільки тепер ми намагаємось знайти відповідність шаблону \D* (нуль чи більше нецифрових символів) перед першою групою що запам’ятовується (код регіону). Зауважте, що регулярний вираз не запам’ятовує ці нецифрові символи (бо вони не в дужках). Якщо вони зустрічаються він просто їх пропускає і починає запам’ятовувати групу, як тільки до неї добереться.

>>> phonePattern.search('(800)5551212 ext. 1234').groups()
('800', '555', '1212', '1234')

Тепер ми можемо розпізнавати код регіону, навіть якщо йому передує відкриваюча дужка. (Закриваюча дужка оброблялась і раніше, тому що вона відповідає шаблону "будь-яка кількість нецифрових символів".

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212', '')

Просто перевірка щоб переконатись, що ми не поламали нічого, що працювало раніше. Оскільки перші символи є необов’язковими, шаблон співставляється з рядком так:

^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$
>>> phonePattern.search('work 1-(800) 555.1212 #1234')
>>>

А ось момент, коли через регулярні вирази мені хочеться виколупати собі очі тупим предметом. Чому цей номер не розпізнається? Тому що перед кодом регіону стоїть цифра 1, але ми припустили що всі символи перед кодом регіону будуть нецифровими (\D*). Агррр!

Давайте на секунду повернемось назад. Досі всі регулярні вирази починали співставлятись з початку рядка. Але зараз ви бачите, що на початку рядка може бути невизначена кількість символів, які ми хочемо проігнорувати. Давайте замість того щоб придумати шаблон який відповідатиме тим символам застосуємо інший підхід: просто перестанемо вимагати, аби співставлення відбувалось з початку рядка. Такий підхід демонструється в наступному прикладі:

>>> phonePattern = re.compile(r'(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')

Зверніть увагу на відсутність символа ^ на початку шаблону. Ми більше не вимагаємо починати співставлення з початку рядка. Регулярний вираз добре попрацює над тим, аби вияснити звідки починається потрібна нам частина рядка, і почне співставлення звідти.

>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()
('800', '555', '1212', '1234')

Тепер ви можете успішно парсити телефонний номер що включає довільну кількість довільних символів на початку.

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212', '')
>>> phonePattern.search('80055512121234').groups()
('800', '555', '1212', '1234')

І ніщо з старого функціоналу не повинно було поламатись. Ми перевірили - працює.

Бачите, як швидко регулярні вирази виходять з під контролю? Прогляньте попередні етапи створення нашого шаблону. Ви можете описати різницю між двома послідовними етапами?

І хоча ми поки що розуміємо наш кінцевий регулярний вираз (і він таки є кінцевим, якщо ви знайдете випадок, для якого він працює неправильно, я не хочу про нього знати), давайте запишемо регулярний вираз з коментарями, перш ніж ви забудете, чому ви прийняли ті рішення, які ви прийняли.

>>> phonePattern = re.compile(r'''
                # не вимагати початок рядка, номер може початись будь-де
    (\d{3})     # код регіону містить три цифри (наприклад '800')
    \D*         # необов’язковий розділювач - довільна кількість нецифрових символів
    (\d{3})     # ще три цифри (наприклад '555')
    \D*         # необов’язковий розділювач
    (\d{4})     # основна частина номера - чотири цифри (наприклад '1212')
    \D*         # необов’язковий розділювач
    (\d*)       # розширення необов’язкове і може містити будь-яку кількість символів
    $           # кінець рядка
    ''', re.VERBOSE)
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()
('800', '555', '1212', '1234')

Окрім того що регулярний вираз тепер розтягнувся на багато рядків, він нічим не відрізняється від попереднього, тому не дивно, що він поводиться аналогічно.

>>> phonePattern.search('800-555-1212')
('800', '555', '1212', '')

На всяк випадок, ще одна перевірка. Все працює. Ми завершили. Ура!


* * *


Підсумок ред.

Це тільки вершина айсберга того, що можуть робити регулярні вирази. Іншими словами: незважаючи на те, що я вас ними щойно сильно загрузив, повірте, ви ще нічого не бачили.

Після прочитання цього розділу ви повинні знати наступне:

  • ^ відповідає початку рядка.
  • $ відповідає кінцю рядка.
  • \b відповідає межі слова.
  • \d відповідає будь-якій десятковій цифрі.
  • \D відповідає будь-чому крім цифр.
  • x? відповідає необов’язковому символу x (іншими словами x нуль чи один раз).
  • x* відповідає нулю чи більше символів x.
  • x+ відповідає одному чи більше символів x.
  • x{n,m} відповідає від n до m включно входжень символа x в рядок.
  • (a|b|c) відповідає одному з виразів a, b чи c.
  • (x) загалом створює запам’ятовувану групу. Пізніше можна отримати рядок, який співставився з шаблоном в групі за допомогою методу groups() об’єкта, який повертається з re.search.

Регулярні вирази є надзвичайно потужними, але вони не є коректним рішенням будь-якої проблеми. Вам потрібно вивчити про них достатньо для того, щоб знати коли їх використання окупається, і вони розв’язують ваші проблеми, і коли вони навпаки створюють більше проблем ніж вирішують.

Для подальшого читання ред.

Текст · Замикання та генератори