Python з нуля · Блок 5 · Тема 23

Шаблон замість переліку

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

У темі 05 ми розібрали методи рядка: .split(), .find(), .startswith(), .replace(). Кожен із них відповідає на просте питання про конкретний підрядок: чи є він, де він, заміни його. У темі 12 ми навчились обходити рядки циклом, у 14 — загортати перевірки у функції, у 18 — чесно падати, коли дані не такі, як обіцяли. Тепер зʼявляється задача, у якій усього цього замало.

Наскрізний приклад теми — три рядки з журналу подій маленького інтернет-магазину. Такий файл ти прочитаєш засобами теми 20 і отримаєш звичайний рядок:

2026-03-15 09:41 INFO #1043 Олена kava@lviv.ua +380671234567 249.50 грн
2026-03-15 09:42 WARN #1044 Петро petro@ua.net +380509876543 1290.00 грн
2026-03-16 11:03 ERROR #1045 Ірина iryna@mail.com +380631112233 75.00 грн

Питання, на які треба відповісти: скільки тут номерів замовлень, які саме адреси пошти трапляються, чи всі телефони мають правильний формат, як заховати останні цифри телефонів перед тим, як показати журнал стажерові. І головне — усе це має працювати і на трьох рядках, і на трьохстах тисячах, де замість WARN буде warn, замість #1043#7, а після ціни хтось поставить два пробіли.

01 / МежаКоли регулярка доречна, а коли зайва

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

Правило одне: регулярний вираз виправданий тоді, коли шаблон справді змінний. Якщо ти можеш назвати підрядок словами — «починається з 2026-», «містить @», «розділене крапкою з комою» — то це робота для методів рядка. Вони швидші, читаються без коментарів і не мають власного синтаксису, у якому можна помилитись.

завданняпростий спосібчи потрібна регулярка
чи є «грн»"грн" in рядокні, ніколи
чи починається з дати 2026рядок.startswith("2026")ні
розбити по пробілахрядок.split()ні
прибрати крайні пробілирядок.strip()ні
замінити «грн» на «UAH»рядок.replace(…)ні
усі числа з рядкатак
дата у трьох різних форматахтак
розбити по «кома або крапка з комою»так
перевірити формат телефонутак

Різниця між двома половинами таблиці помітна одразу: у верхній ми знаємо що шукаємо, у нижній — лише яким воно має бути. «Усі числа» — це не підрядок, це опис форми: одна або більше цифр підряд. Ось для таких описів і придумали регулярні вирази.

Чому HTML не парсять регуляркою. Це найвідоміший приклад неправильного застосування, і причина в ньому глибша за «незручно». Регулярний вираз описує регулярну мову — таку, для розпізнавання якої достатньо скінченної памʼяті. А HTML вкладений: <div> усередині <div> усередині <div>. Щоб знайти парний закривальний тег, треба рахувати глибину вкладеності, тобто памʼятати необмежену кількість станів. Регулярний вираз цього не вміє принципово, а не через недоробку. Те саме стосується JSON, дужок у виразі й будь-чого вкладеного. Для HTML бери справжній розбірник, для JSON — модуль json (тема 21). Регулярка тут не «поганий інструмент», вона просто інший інструмент.

02 / r"…"Сирі рядки: чому без них усе подвоюється

Перш ніж писати перший шаблон, треба залагодити конфлікт двох мов. Зворотний скісний (backslash) — службовий символ і в Python, і в мові регулярних виразів. Python бачить "\n" і робить із двох символів один — перехід рядка. Регулярний вираз бачить \d і розуміє це як «будь-яка цифра». Проблема в тому, що рядок спершу читає Python і лише потім віддає результат модулю re.

крок 1 · що бачить Python

У літералі "\d" Python не знає екранування \d, тому лишає обидва символи як є — і ще й попереджає про це (SyntaxWarning у 3.12). А от у "\b" він знає: це символ забою (backspace), код 8. Один символ замість двох.

крок 2 · що отримує модуль re

Модулю дістається вже оброблений рядок. Замість шаблону «межа слова» (\b у мові регулярних виразів) він отримує невидимий символ забою — і чесно шукає його в тексті. Не знаходить ніколи.

крок 3 · вихід

Або подвоювати кожен скісний — "\\b\\d+", — або вимкнути обробку екранувань префіксом r: r"\b\d+". Другий варіант коротший і не має жодного мінуса. Тому шаблон завжди пишуть сирим рядком, навіть коли скісних у ньому немає — щоб не думати про це щоразу.

len("\b") → 1 # символ забою
len(r"\b") → 2 # скісний і літера b — те, що треба re

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

03 / АбеткаАбетка шаблону

Шаблон — це рядок, у якому більшість символів означають самі себе, а десяток службових означають «щось із форми». Почнімо з чотирьох найважливіших цеглин.

Літерал. Літера г у шаблоні збігається з літерою г у тексті. Шаблон r"грн" — це те саме, що "грн" in рядок, тільки дорожче. Саме тому такі шаблони й не пишуть.

Крапка . — «будь-який один символ, крім переходу рядка». Шаблон r"#..." знайде решітку й три будь-які символи після неї. Крапка — найширший і найнебезпечніший елемент мови: вона проковтне і цифру, і пробіл, і кому.

Клас символів […] — «будь-який один символ із перелічених». [абв] — це «а», «б» або «в», і рівно один. Усередині квадратних дужок можна задавати діапазон через дефіс: [а-я], [0-9], [A-Za-z]. А якщо одразу після відкривної дужки поставити ^, клас перевертається: [^0-9] — «будь-який символ, крім цифри».

Дві дрібниці про квадратні дужки. Усередині класу більшість службових символів втрачають силу: [.] — це саме крапка, а не «будь-який символ», і екранувати її не треба. Дефіс теж стає звичайним, якщо стоїть першим або останнім: [-+] — це «мінус або плюс». А от українські діапазони пиши обережно: [а-я] не містить літер «і», «ї», «є», «ґ» — у таблиці Unicode вони живуть окремо від основного кириличного блоку. Правильний діапазон для українських малих: [а-щьюяіїєґ].

І нарешті готові класи — скорочення для трьох найчастіших наборів. Саме на них новачки роблять найтихішу помилку теми, тому їх варто помацати руками.

скор.що означаєзаперечення
\dцифра\D — не цифра
\w«словесний» символ: літера, цифра або підкреслення\W — решта
\sпробільний: пробіл, табуляція, перехід рядка\S — не пробільний

Пастка в тому, що в Python ці класи за замовчуванням розуміють увесь Unicode. \w — це не [A-Za-z0-9_], як у багатьох мовах і як підказує здоровий глузд англомовної документації. Українська «я» під \w потрапляє. Грецька «λ» теж. І арабська цифра «٣» під \d теж потрапляє, бо в Unicode вона позначена як десяткова цифра. Це рятує, коли ти пишеш r"\w+" і хочеш ловити слова українською. І це підводить, коли ти пишеш r"^\w+$" для перевірки логіна й раптом дозволяєш логін «Олена٣».

Інтерактив 1 · Що насправді входить у \d, \w і \s

Десять символів проти шести класів. Перемикач re.ASCII показує, як усе змінюється, коли Unicode вимкнено.

свій символ — останній ряд

\d
\w
\s
\w-символів
Головний ряд — «я» і «Ї». За замовчуванням вони словесні, з re.ASCII — ні. Прапорець re.ASCII звужує \d, \w, \s і \b до чистого ASCII одним рухом; його ставлять, коли шаблон описує технічний формат (ідентифікатор, ключ конфігурації), а не людський текст. Зверни увагу й на ½: це «словесний» символ, але не цифра — \w ширший за \d не тільки на літери.

04 / СкількиСкільки разів: квантифікатори

Досі кожна цеглина відповідала рівно за один символ. Квантифікатор (quantifier) стоїть після цеглини й каже, скільки разів вона має повторитись.

записскільки разівприклад
*нуль або більшеr"ко*т" — «кт», «кот», «кооот»
+один або більшеr"\d+" — «7», «1043»
?нуль або одинr"кольор?" — «кольо» і «кольор»
{n}рівно nr"\d{4}" — рік
{n,m}від n до mr"\d{2,4}"
{n,}n або більшеr"\d{3,}"

Ось тут регулярні вирази й починають окупатись. Дата з нашого журналу описується як r"\d{4}-\d{2}-\d{2}" — чотири цифри, дефіс, дві цифри, дефіс, дві цифри. Номер замовлення — r"#\d+", і байдуже, чи там одна цифра, чи сім. Ціна — r"\d+\.\d{2}", де крапку довелось екранувати, бо гола крапка означала б «будь-що».

Найкорисніша комбінація в темі — заперечений клас із плюсом: [^ ]+ означає «якнайдовший шматок без пробілів». Через неї описують «поле до роздільника», і саме вона врятує нас у розділі про жадібність.

Тепер збери шаблон сам. Поле зліва — текст журналу, його можна правити; поле справа — шаблон. Усі збіги підсвічуються прямо в тексті: перший рожевим, решта — бірюзовим.

Інтерактив 2 · Конструктор шаблону

Живий пошук по нашому журналу. Шаблон компілюється на кожен натиск клавіші; зламаний шаблон покаже помилку так само, як її покаже Python.

шаблон · пиши як у r"…"

збігів
перший спан
перший збіг
Обовʼязково натисни «зламаний». Шаблон (\d+ не компілюється, і Python зупиняє програму ще до пошуку — re.error. Це добра новина: помилка в шаблоні виявляється відразу, а не через тисячу рядків мовчазно неправильних результатів. Далі спробуй ^\d{4} з галочкою re.MULTILINE і без неї: без неї збіг один, з нею — три.

05 / ЖадібністьЖадібність — головна пастка теми

Усі квантифікатори з таблиці вище жадібні (greedy): вони беруть якнайбільше, а потім віддають назад по одному символу, поки решта шаблону не складеться. Слово «якнайбільше» тут ключове, і воно означає «до кінця рядка», а не «до наступного розумного місця» — бо розумних місць регулярка не знає.

Класична демонстрація — теги. Візьмімо наш номер замовлення, обгорнутий розміткою:

<b>замовлення #1043</b> на <i>249.50 грн</i>

Шаблон r"<.+>" читається як «кутова дужка, потім щось, потім кутова дужка». Людина розуміє це як «один тег». Рушій розуміє інакше: .+ ковтає весь рядок до кінця, тоді відступає назад до останньої дужки — і повертає весь рядок цілком як один збіг.

Полагодити можна двома способами, і обидва варто знати:

  1. Лінивий квантифікатор. Знак питання після *, + або ? перевертає стратегію: *?, +?, ?? беруть якнайменше й додають по одному символу, лише поки решта не складеться. r"<.+?>" знайде чотири теги;
  2. Заперечений клас. r"<[^>]+>" — «дужка, потім щось, у чому немає закривної дужки, потім дужка». Тут нема чого віддавати назад: клас фізично не може перескочити >.

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

Інтерактив 3 · Жадібний проти лінивого

Один текст, один шаблон, два режими. Верхній ряд — жадібний, нижній — той самий шаблон зі знаком питання.

жадібно: збігів
ліниво: збігів
довжина 1-го
довжина 1-го
Що тут видно: лінивий варіант не «шукає краще» — він шукає інше. На шаблоні <b>.*</b> обидва режими дають один збіг однакової довжини, бо тексту з двома парами <b> тут немає. Лінивість не безкоштовна й не завжди правильна: вона просто інша стратегія відкату.

06 / ЯкоріЯкорі: місце, а не символ

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

Якорі — головний спосіб не знайти зайвого. Шаблон r"\d{4}" у нашому журналі знайде і рік 2026, і номер 1043. А r"^\d{4}" із re.MULTILINE — лише рік, бо номер стоїть не на початку рядка. Так само r"\bгрн\b" не спрацює всередині слова «грнище», тоді як r"грн" спрацює.

Про \b і українську. Оскільки \w у Python охоплює весь Unicode, \b теж рахує українські літери словесними — і межа слова стоїть там, де її очікує людина. З прапорцем re.ASCII все ламається: у слові «Олена» кожна літера стає несловесною, межі зникають, і r"\b\w+\b" раптом не знаходить нічого. Це рівно та причина, чому прапорець ставлять свідомо, а не «про всяк випадок».

07 / ФункціїФункції модуля re

Мова шаблонів — половина справи. Друга половина — вісім функцій модуля, і найчастіша помилка новачка живе саме тут: re.match робить не те, що здається з назви.

функціящо робитьщо повертає
searchшукає перший збіг будь-деобʼєкт Match або None
matchперевіряє збіг з початку текстуMatch або None
fullmatchвимагає, щоб збіг покрив увесь текстMatch або None
findallусі збіги одразусписок рядків
finditerусі збіги ліниво, по одномуітератор обʼєктів Match
subзамінює всі збігиновий рядок
subnте саме плюс лічильниккортеж (рядок, скільки)
splitріже текст по збігахсписок рядків

re.match прикріплений до початку тексту, але не до кінця: re.match(r"\d+", "1043 грн") успішно знайде "1043", а re.match(r"\d+", "#1043") поверне None, бо на нульовій позиції стоїть решітка. Саме тому для перевірки «чи весь рядок має такий формат» беруть fullmatch, а для «чи є десь усередині» — search. Функція match потрібна рідше, ніж її використовують; здебільшого людина хотіла search і випадково написала знайому назву.

Інтерактив 4 · search проти match проти fullmatch

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

текст: #1043 на 249.50 грн

search
match
fullmatch
Найкорисніший режим — \d+: search знаходить 1043 на позиції 1, match повертає None, хоча цифри в тексті очевидно є. Тому if re.match(...) у перевірці «чи містить рядок число» — тиха помилка, яка не падає, а просто завжди відповідає «ні».

sub і split: заміна й розбиття за формою

re.sub(шаблон, заміна, текст) замінює всі збіги. Заміна може бути рядком — або функцією, якій передають обʼєкт збігу, а вона повертає рядок. Другий варіант — найпотужніша річ у темі: він дозволяє замінювати не константою, а обчисленням.

re.sub(r"(\+380\d{2})\d{5}(\d{2})", r"\1*****\2", журнал)
+380671234567 → +38067*****67

re.split(шаблон, текст) — це .split(), у якого роздільник заданий формою. re.split(r"[,;]\s*", "кава, чай;сік") дасть три елементи, і не треба двічі викликати .replace(), щоб звести роздільники до одного.

08 / ГрупиГрупи: витягти частину збігу

Досі ми відповідали «так/ні» і «де». Тепер навчимось діставати шматки. Круглі дужки (…) у шаблоні роблять дві речі одночасно: групують частину виразу для квантифікатора й запамʼятовують, що саме туди потрапило.

Обʼєкт збігу віддає ці шматки трьома методами:

Номери працюють, поки груп дві. На пʼятій ти вже рахуєш дужки пальцем, а після редагування шаблону всі номери зсуваються. Тому є іменовані групи: (?P<імʼя>…). Тоді .group("рік") читається без коментаря, а .groupdict() одразу віддає словник — готовий запис для подальшої обробки.

м = re.search(r"(?P<рік>\d{4})-(?P<місяць>\d{2})-(?P<день>\d{2})", рядок)
м.groupdict() → {'рік': '2026', 'місяць': '03', 'день': '15'}

Інтерактив 5 · Що бачить кожна група

Смуги під текстом — межі груп. Нульова група — увесь збіг, решта — те, що спіймали дужки.

group(0)
group(1)
groups()
groupdict()
Порівняй «номери» і «імена»: шаблон той самий, збіг той самий, різниця лише в читабельності коду, який його використовує. А режим «вкладені» показує правило нумерації: зовнішня дужка відкривається раніше, тому вона перша — навіть якщо закривається останньою.
Коли дужки потрібні, а память — ні. Запис (?:…) групує, але нічого не запамʼятовує. Це не мікрооптимізація, а гігієна: r"(?:грн|UAH)" не зсуває номери решти груп і не засмічує .groups(). Ще один наслідок групування відчувається у findall: якщо в шаблоні є групи, він повертає не збіги, а вміст груп — і re.findall(r"(\d+)\.(\d+)", текст) віддасть список кортежів, а не список чисел. Це щоразу дивує.

09 / Прапорціre.compile і прапорці

re.compile(шаблон) перетворює рядок на обʼєкт-шаблон із тими самими методами: ШАБЛОН.search(текст), ШАБЛОН.findall(текст). Компіляція коштує часу, тому модуль тримає внутрішній кеш останніх шаблонів — і на практиці різниця у швидкості між re.search(…) у циклі й заздалегідь скомпільованим обʼєктом невелика.

Справжня причина компілювати інша, і вона про людей:

  1. Шаблон отримує імʼя. ТЕЛЕФОН = re.compile(r"\+380\d{9}") на початку модуля — це документація. У циклі читається ТЕЛЕФОН.findall(рядок), а не стіна зі скісних;
  2. Помилка в шаблоні виявляється при імпорті, а не тоді, коли до цієї гілки коду вперше дійде виконання;
  3. Кеш обмежений (кілька сотень шаблонів) і чиститься цілком при переповненні. Якщо шаблон збирається з даних у циклі, кеш не допоможе.

Прапорці передають третім аргументом функції або другим у compile. Три з них покривають майже все:

прапорецьщо змінюєколи треба
re.IGNORECASEвелика й мала літера стають однаковіWARN, Warn, warn у журналі
re.MULTILINE^ і $ ловлять кожен рядокобробка багаторядкового тексту
re.DOTALLкрапка починає ловити й перехід рядказбіг має перетнути рядки
re.ASCII\d \w \s \b звужуються до ASCIIтехнічні формати
re.VERBOSEпробіли й #-коментарі в шаблоні ігноруютьсядовгий шаблон із поясненнями

Кілька прапорців зʼєднують вертикальною рискою: re.compile(r"^\w+", re.IGNORECASE | re.MULTILINE). А re.VERBOSE варто спробувати на будь-якому шаблоні, довшому за рядок: він дозволяє розбити вираз на кілька рядків із коментарями українською, і це різниця між «шифром» і кодом, який переживе наступний рік.

10 / РушійЯк рушій шукає насправді

Тепер шар для тих, хто вже пише код. Регулярний вираз у Python виконує рушій із відкатом (backtracking engine). Він працює прямолінійно:

  1. стає на позицію 0 тексту й намагається прикласти шаблон зліва направо;
  2. жадібний квантифікатор бере якнайбільше, лінивий — якнайменше;
  3. якщо наступна цеглина не збіглась, рушій відкочується: змушує попередній квантифікатор віддати один символ і пробує знову;
  4. якщо всі варіанти вичерпано, зсувається на одну позицію вправо й починає спочатку.

Останній пункт пояснює, чому re.search дорожчий за re.match: невдалий пошук у рядку довжиною n означає n спроб. Прокрути інтерактив по кроках — і подивись, як саме рушій ковтає, відступає й починає заново.

Інтерактив 6 · Крок за кроком

Спрощений, але чесний рушій: літерали, крапка, класи й квантифікатори * + ?. Кожен крок — одне рішення.

усього кроків
відкатів
Дивись на режим \d+грн: збігу немає, але рушій це зʼясовує не одразу. Він чесно пробує кожну стартову позицію, і на кожній цифрі ще й відкочується. Порожній результат коштує роботи — це важливо памʼятати, коли шаблон прикладають до мільйона рядків.

Катастрофічний відкат

А тепер найдорожча грабля, яку регулярний вираз може вкласти у продакшн. Вона зветься катастрофічним відкатом (catastrophic backtracking) і виникає, коли квантифікатор стоїть усередині іншого квантифікатора, а те, що вони ловлять, перетинається.

r"(a+)+b" на тексті "aaaaaaaaaaaaaaaaaaaaaaaa"

Збігу тут немає — літери b в кінці нема. Але щоб це довести, рушій має перебрати всі способи розділити ланцюжок із n літер «a» між зовнішнім і внутрішнім квантифікаторами. Способів рівно 2n−1. На 24 літерах це вісім мільйонів спроб, на 30 — пів мільярда, на 40 — понад пʼятсот мільярдів. Програма не падає й не повідомляє про проблему: вона просто перестає відповідати.

Інтерактив 7 · Ціна вкладеного квантифікатора

Скільки варіантів перебере рушій, доводячи, що збігу немає. Вісь ліворуч — логарифмічна: кожна поділка вдесятеро більша за попередню.

(a+)+b
a+b
час (a+)+b
у скільки разів
Як не наступити: уникай вкладених квантифікаторів там, де внутрішній і зовнішній ловлять одне й те саме ((a+)+, (\w+\s*)*, (.*)*). Заміняй .* на заперечений клас. І ніколи не збирай шаблон із тексту, який прислав користувач — це готова відмова в обслуговуванні на одному рядку.

Регулярка не памʼятає стану

Повернімось до того, з чого почали. Рушій із відкатом усе одно розпізнає лише регулярні мови: у нього немає лічильника, немає стека, немає памʼяті про те, скільки дужок він уже відкрив. Ось наочна межа:

Практичний наслідок: щойно тобі знадобилось «памʼятати, що було раніше в тексті» — зупинись. Далі йде звичайний цикл із умовами або готовий розбірник формату. Регулярка добре ловить окремі шматки; складати з них структуру — робота Python.

Чому імена груп краще за номери

І остання порада, яка нічого не коштує. Шаблон із номерами груп ламається тихо: варто дописати ще одні дужки на початку — і .group(2) починає повертати щось інше, не викидаючи жодної помилки. Шаблон з іменами ламається голосно: помилкове імʼя дає IndexError: no such group одразу. У парі з re.VERBOSE це перетворює регулярний вираз із заклинання на код, який можна читати вголос.

ЗАПИС = re.compile(r"""
    (?P<дата>\d{4}-\d{2}-\d{2})\s+ # 2026-03-15
    (?P<час>\d{2}:\d{2})\s+ # 09:41
    (?P<рівень>[A-Z]+)\s+ # INFO / WARN / ERROR
    \#(?P<номер>\d+) # #1043
""", re.VERBOSE)

11 / ПідсумокЩо з цього забрати

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

Що далі. Ми навчились витягати дані з тексту. Наступна тема — скрипти командного рядка: як зробити з цього коду програму, якій передають імʼя файла й шаблон аргументами, і яка працює як звичайний інструмент терміналу. А в practice.ipynb ти зараз розбереш наш журнал на записи, замаскуєш телефони через sub, перевіриш формати через fullmatch — і окремо переконаєшся, що в одному з чотирьох завдань регулярка була зайва.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.