Практика · Регулярні вирази> Лекція: lecture.html · Тест: quiz.html ·> Домашнє завдання: homework.mdНаскрізний приклад той самий, що в лекції, — три рядки з журналу подій магазину.Що зробимо:1. переконаємось, що в половині випадків регулярка зайва;2. подивимось, чому шаблон пишуть сирим рядком r"...";3. перевіримо, що \w у Python розуміє українські літери;4. витягнемо пошту, телефони й ціни через findall і finditer;5. порівняємо жадібний і лінивий квантифікатори;6. напишемо перевірку формату через fullmatch;7. розберемо журнал на записи іменованими групами;8. замаскуємо телефони через sub — рядком і функцією;9. поріжемо текст через re.split;10. виміряємо катастрофічний відкат власним секундоміром.Кожен крок закінчується assert — якщо він мовчить, усе зійшлося.¶
0 · Наші даніОдин рядок журналу — одне замовлення. У ньому дата, час, рівень важливості,номер, імʼя клієнта, пошта, телефон і сума.¶
import re
# наскрізний приклад теми: три рядки, які могли прилетіти з файла журналу
ЖУРНАЛ = """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 грн"""
рядки = ЖУРНАЛ.split("\n")
print("рядків у журналі:", len(рядки))
print("довжина всього тексту:", len(ЖУРНАЛ), "символів")
print(рядки[0])
рядків у журналі: 3 довжина всього тексту: 220 символів 2026-03-15 09:41 INFO #1043 Олена kava@lviv.ua +380671234567 249.50 грн
1 · Випадок, де регулярка зайваНайважливіший крок практики. Завдання: відібрати рядки з рівнем ERROR.Регулярка це вміє. Але рівень — це третє поле, розділене пробілами, тобтойого дає звичайний .split(). Порівняймо обидва способи: результат однаковий,а читабельність — ні.¶
# спосіб 1: звичайні методи рядка — рівень це просто третє поле
помилки_методом = []
for рядок in рядки:
поля = рядок.split() # ріже по будь-якій кількості пробілів
рівень = поля[2] # 0 — дата, 1 — час, 2 — рівень
if рівень == "ERROR":
помилки_методом.append(рядок)
# спосіб 2: те саме регулярним виразом
помилки_регуляркою = [рядок for рядок in рядки if re.search(r"\bERROR\b", рядок)]
assert помилки_методом == помилки_регуляркою, "способи розійшлися!"
print("✅ обидва способи дали однаковий результат")
print("знайдено рядків:", len(помилки_методом))
print(помилки_методом[0])
print()
print("Висновок: тут перший спосіб простіший — рівень має фіксовану позицію,")
print("а регулярка додає власний синтаксис без жодної користі.")
✅ обидва способи дали однаковий результат знайдено рядків: 1 2026-03-16 11:03 ERROR #1045 Ірина iryna@mail.com +380631112233 75.00 грн Висновок: тут перший спосіб простіший — рівень має фіксовану позицію, а регулярка додає власний синтаксис без жодної користі.
Ще три питання, на які регулярка не потрібна взагалі. Просте правило: якщо тиможеш назвати підрядок словами, бери метод рядка.
рядок = рядки[0]
# «чи є в рядку гривні» — це оператор in, і крапка
print("чи є грн: ", "грн" in рядок)
# «чи починається з 2026» — це startswith
print("чи 2026 на початку:", рядок.startswith("2026"))
# «скільки полів» — це split без аргументів
print("полів у рядку: ", len(рядок.split()))
# і лише останнє питання потребує шаблону: «які тут числа з крапкою»
print("суми в рядку: ", re.findall(r"\d+\.\d{2}", рядок))
assert "грн" in рядок and рядок.startswith("2026")
print("\n✅ три з чотирьох питань закрито без модуля re")
чи є грн: True чи 2026 на початку: True полів у рядку: 9 суми в рядку: ['249.50'] ✅ три з чотирьох питань закрито без модуля re
2 · Сирі рядки: чому r"..."Зворотний скісний обробляє спершу Python і лише потім модуль re.У звичайному рядку "\b" — це один невидимий символ забою (backspace).У сирому рядку r"\b" — це два символи, і саме їх модуль re розумієяк «межа слова».¶
звичайний = "\b" # Python перетворив два символи на один
сирий = r"\b" # префікс r вимкнув обробку — символи лишились як є
print("len(\"\\b\") =", len(звичайний), "— це символ забою, код", ord(звичайний))
print("len(r\"\\b\") =", len(сирий), "— це скісний і літера b")
# наслідок: без r шаблон шукає невидимий символ і не знаходить нічого
без_r = re.search("\b249", рядки[0])
з_r = re.search(r"\b249", рядки[0])
print()
print('re.search("\\b249", рядок) →', без_r)
print('re.search(r"\\b249", рядок) →', з_r)
assert без_r is None, "без сирого рядка збігу бути не могло"
assert з_r is not None and з_r.group() == "249"
print("\n✅ шаблон працює лише в сирому рядку")
len("\b") = 1 — це символ забою, код 8
len(r"\b") = 2 — це скісний і літера b
re.search("\b249", рядок) → None
re.search(r"\b249", рядок) → <re.Match object; span=(62, 65), match='249'>
✅ шаблон працює лише в сирому рядку
3 · \w у Python — це весь UnicodeГоловна несподіванка теми. За замовчуванням \w охоплює будь-які літери,а не тільки латиницю. Прапорець re.ASCII звужує клас — і разом із нимламається \b для кирилиці.¶
текст = "Олена kava 42 ٣" # укр. слово, лат. слово, звичайна й арабська цифри
слова_за_замовчуванням = re.findall(r"\w+", текст)
слова_ascii = re.findall(r"\w+", текст, re.ASCII)
print("\\w+ за замовчуванням:", слова_за_замовчуванням)
print("\\w+ з re.ASCII: ", слова_ascii)
print()
print("\\d+ за замовчуванням:", re.findall(r"\d+", текст))
print("\\d+ з re.ASCII: ", re.findall(r"\d+", текст, re.ASCII))
assert "Олена" in слова_за_замовчуванням, "українське слово мало потрапити у \\w"
assert "Олена" not in слова_ascii, "з re.ASCII кирилиця у \\w не входить"
print("\n✅ те, що обіцяла лекція: \\w бачить українські літери, поки немає re.ASCII")
\w+ за замовчуванням: ['Олена', 'kava', '42', '٣'] \w+ з re.ASCII: ['kava', '42'] \d+ за замовчуванням: ['42', '٣'] \d+ з re.ASCII: ['42'] ✅ те, що обіцяла лекція: \w бачить українські літери, поки немає re.ASCII
4 · Витягаємо дані: findallТепер задача, для якої регулярка справді потрібна: пошта, телефони й сумимають змінну форму, назвати їх підрядком неможливо.¶
# [\w.+-]+ — імʼя скриньки, @ — літерал, [\w-]+ — домен, \. — саме крапка
пошта = re.findall(r"[\w.+-]+@[\w-]+\.\w+", ЖУРНАЛ)
# +380 і рівно девʼять цифр; плюс екрануємо, бо інакше це квантифікатор
телефони = re.findall(r"\+380\d{9}", ЖУРНАЛ)
# сума — цифри, крапка, рівно два знаки; (?=...) вимагає «грн» далі, але не бере його
суми = re.findall(r"\d+\.\d{2}(?= грн)", ЖУРНАЛ)
print("пошта: ", пошта)
print("телефони:", телефони)
print("суми: ", суми)
assert пошта == ["kava@lviv.ua", "petro@ua.net", "iryna@mail.com"]
assert телефони == ["+380671234567", "+380509876543", "+380631112233"]
assert суми == ["249.50", "1290.00", "75.00"]
print("\n✅ усі три списки збіглися з очікуваними")
пошта: ['kava@lviv.ua', 'petro@ua.net', 'iryna@mail.com'] телефони: ['+380671234567', '+380509876543', '+380631112233'] суми: ['249.50', '1290.00', '75.00'] ✅ усі три списки збіглися з очікуваними
finditer — коли потрібне не лише значення, а й місцеfindall віддає список рядків. finditer віддає обʼєкти збігу, у яких є.group(), .span() і групи. І він ледачий: не будує список у памʼяті.¶
print("номер позиція")
позиції = []
for збіг in re.finditer(r"#\d+", ЖУРНАЛ):
позиції.append(збіг.span())
print(f"{збіг.group():<7} {збіг.span()}")
# перший номер стоїть саме там, де його показує інтерактив 2 у лекції
assert позиції[0] == (23, 28), "перший #1043 мав починатись на позиції 23"
print("\n✅ спан першого збігу: (23, 28) — як у лекції")
номер позиція #1043 (23, 28) #1044 (96, 101) #1045 (170, 175) ✅ спан першого збігу: (23, 28) — як у лекції
5 · Жадібний, лінивий і заперечений класТри способи описати «тег». Перший дає одну довжелезну знахідку, другий і третій —чотири правильні. Третій ще й швидший, бо йому нема чого віддавати назад.¶
розмітка = "<b>замовлення #1043</b> на <i>249.50 грн</i>"
жадібно = re.findall(r"<.+>", розмітка) # .+ ковтає до останньої дужки
ліниво = re.findall(r"<.+?>", розмітка) # .+? зупиняється на першій
клас = re.findall(r"<[^>]+>", розмітка) # у клас дужка просто не входить
print("жадібно <.+> →", жадібно)
print("ліниво <.+?> →", ліниво)
print("клас <[^>]+> →", клас)
print()
print("довжина першого збігу: жадібно", len(жадібно[0]), "· ліниво", len(ліниво[0]))
assert len(жадібно) == 1 and len(жадібно[0]) == len(розмітка), "жадібний мав злипнутись"
assert ліниво == клас == ["<b>", "</b>", "<i>", "</i>"]
print("\n✅ лінивий квантифікатор і заперечений клас дали однаковий результат")
жадібно <.+> → ['<b>замовлення #1043</b> на <i>249.50 грн</i>'] ліниво <.+?> → ['<b>', '</b>', '<i>', '</i>'] клас <[^>]+> → ['<b>', '</b>', '<i>', '</i>'] довжина першого збігу: жадібно 44 · ліниво 3 ✅ лінивий квантифікатор і заперечений клас дали однаковий результат
6 · search, match, fullmatchПеревірка формату — це завжди fullmatch. З search пройде будь-який рядок,у якому потрібне є десь усередині, а з match — будь-який, що правильнопочинається.¶
def телефон_правильний(значення):
"""Чи є значення телефоном формату +380XXXXXXXXX цілком, без хвостів."""
return re.fullmatch(r"\+380\d{9}", значення) is not None
зразки = ["+380671234567", "+380671234567 грн", "0671234567", "+38067123456789"]
for зразок in зразки:
print(f"{зразок:<20} fullmatch → {телефон_правильний(зразок)}")
print()
# а ось чому не match: він дивиться лише на початок
print("search(r'\\d+', '#1043') →", re.search(r"\d+", "#1043"))
print("match (r'\\d+', '#1043') →", re.match(r"\d+", "#1043"))
assert телефон_правильний("+380671234567") is True
assert телефон_правильний("+380671234567 грн") is False, "fullmatch не терпить хвоста"
assert re.match(r"\d+", "#1043") is None, "match дивиться лише на нульову позицію"
print("\n✅ перевірка формату працює саме так, як треба")
+380671234567 fullmatch → True +380671234567 грн fullmatch → False 0671234567 fullmatch → False +38067123456789 fullmatch → False search(r'\d+', '#1043') → <re.Match object; span=(1, 5), match='1043'> match (r'\d+', '#1043') → None ✅ перевірка формату працює саме так, як треба
7 · Іменовані групи: із тексту в записиНайкорисніша конструкція теми. Один шаблон із re.VERBOSE перетворює рядокжурналу на словник — далі це вже звичайні дані Python, а не текст.¶
# 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+)\s+ # #1043 — решітку екрануємо, бо VERBOSE
(?P<клієнт>\w+)\s+ # Олена
(?P<пошта>\S+@\S+)\s+ # kava@lviv.ua
(?P<телефон>\+\d+)\s+ # +380671234567
(?P<сума>\d+\.\d{2}) # 249.50
""", re.VERBOSE)
записи = [збіг.groupdict() for збіг in ЗАПИС.finditer(ЖУРНАЛ)]
for запис in записи:
print(запис["дата"], запис["рівень"], "#" + запис["номер"],
запис["клієнт"], запис["сума"], "грн")
assert len(записи) == 3, "мали розібратись усі три рядки"
assert записи[1]["клієнт"] == "Петро"
assert записи[2]["сума"] == "75.00"
print("\n✅ журнал перетворено на", len(записи), "словники")
2026-03-15 INFO #1043 Олена 249.50 грн 2026-03-15 WARN #1044 Петро 1290.00 грн 2026-03-16 ERROR #1045 Ірина 75.00 грн ✅ журнал перетворено на 3 словники
Числа лишаються рядкамиРегулярний вираз повертає текст, завжди. Перетворення в число — окремийкрок, і саме тут спрацьовує все, що ми знаємо з теми 04.¶
загальна_сума = 0.0
for запис in записи:
загальна_сума += float(запис["сума"]) # '249.50' → 249.5
print("сума всіх замовлень:", загальна_сума, "грн")
print("тип поля у словнику:", type(записи[0]["сума"]).__name__)
assert загальна_сума == 249.50 + 1290.00 + 75.00
print("\n✅ підсумок зійшовся:", загальна_сума)
сума всіх замовлень: 1614.5 грн тип поля у словнику: str ✅ підсумок зійшовся: 1614.5
8 · sub: заміна рядком і заміна функцієюСпершу класика — замаскувати телефони перед тим, як показати журнал стажерові.У заміні \1 і \2 підставляють вміст першої та другої групи.¶
# ділимо телефон на три частини, середню викидаємо
замасковано = re.sub(r"(\+380\d{2})\d{5}(\d{2})", r"\1*****\2", ЖУРНАЛ)
for рядок in замасковано.split("\n"):
print(рядок)
assert "+380671234567" not in замасковано, "телефон мав зникнути"
assert "+38067*****67" in замасковано
assert замасковано.count("*****") == 3, "мали замаскуватись усі три телефони"
print("\n✅ усі три телефони замасковано")
2026-03-15 09:41 INFO #1043 Олена kava@lviv.ua +38067*****67 249.50 грн 2026-03-15 09:42 WARN #1044 Петро petro@ua.net +38050*****43 1290.00 грн 2026-03-16 11:03 ERROR #1045 Ірина iryna@mail.com +38063*****33 75.00 грн ✅ усі три телефони замасковано
Тепер потужніший режим: замість рядка передаємо функцію. Їй приходитьобʼєкт збігу, а вона повертає рядок заміни — тобто заміну можна обчислити.
def округлити(збіг):
"""Отримує обʼєкт збігу, повертає рядок заміни — суму, округлену до гривні."""
сума = float(збіг.group())
return str(round(сума))
округлено = re.sub(r"\d+\.\d{2}(?= грн)", округлити, ЖУРНАЛ)
for рядок in округлено.split("\n"):
print(рядок.split("#")[1]) # друкуємо лише хвіст рядка, щоб було видно суму
assert "250 грн" in округлено, "249.50 мало округлитись до 250"
assert "1290 грн" in округлено and "75 грн" in округлено
print("\n✅ суми округлено обчисленням, а не константою")
1043 Олена kava@lviv.ua +380671234567 250 грн 1044 Петро petro@ua.net +380509876543 1290 грн 1045 Ірина iryna@mail.com +380631112233 75 грн ✅ суми округлено обчисленням, а не константою
9 · re.split: роздільник заданий формоюЗвичайний .split() знає один роздільник. re.split() бере шаблон — і томурозуміє «кома або крапка з комою, з будь-якими пробілами навколо».¶
сировина = "кава, чай;сік , вода"
методом = сировина.split(",") # знає лише кому
шаблоном = re.split(r"\s*[,;]\s*", сировина) # і кому, і крапку з комою, і пробіли
print("сировина:", repr(сировина))
print(".split(\",\") →", методом)
print("re.split() →", шаблоном)
assert методом != шаблоном, "у цьому прикладі способи мають розійтись"
assert шаблоном == ["кава", "чай", "сік", "вода"]
print("\n✅ ось випадок, де регулярка справді потрібна: роздільників два")
сировина: 'кава, чай;сік , вода'
.split(",") → ['кава', ' чай;сік ', ' вода']
re.split() → ['кава', 'чай', 'сік', 'вода']
✅ ось випадок, де регулярка справді потрібна: роздільників два
10 · Прапорціre.MULTILINE змушує ^ і $ працювати для кожного рядка, re.IGNORECASEприбирає різницю регістрів, re.DOTALL дозволяє крапці ловити перехід рядка.¶
з_multiline = re.findall(r"^\d{4}", ЖУРНАЛ, re.MULTILINE)
без_multiline = re.findall(r"^\d{4}", ЖУРНАЛ)
print("^\\d{4} з re.MULTILINE →", з_multiline)
print("^\\d{4} без прапорця →", без_multiline)
print()
print("\\bwarn\\b з IGNORECASE →", re.findall(r"\bwarn\b", ЖУРНАЛ, re.IGNORECASE))
print("INFO.*ERROR з DOTALL →", re.search(r"INFO.*ERROR", ЖУРНАЛ, re.DOTALL) is not None)
print("INFO.*ERROR без DOTALL →", re.search(r"INFO.*ERROR", ЖУРНАЛ) is not None)
assert len(з_multiline) == 3 and len(без_multiline) == 1
print("\n✅ саме ті числа, що обіцяв підпис до інтерактиву 2: 3 проти 1")
^\d{4} з re.MULTILINE → ['2026', '2026', '2026']
^\d{4} без прапорця → ['2026']
\bwarn\b з IGNORECASE → ['WARN']
INFO.*ERROR з DOTALL → True
INFO.*ERROR без DOTALL → False
✅ саме ті числа, що обіцяв підпис до інтерактиву 2: 3 проти 1
11 · Катастрофічний відкат власним секундоміромШаблон (a+)+b на тексті без b змушує рушій перебрати всі способи розділитиланцюжок. Кількість способів подвоюється з кожним символом — і це виднона звичайному годиннику.¶
import time
def виміряти(шаблон, текст):
"""Скільки секунд рушій витратить, доводячи, що збігу немає."""
старт = time.perf_counter()
re.search(шаблон, текст)
return time.perf_counter() - старт
print("символів (a+)+b a+b")
виміри = {}
for n in (16, 19, 22):
текст = "a" * n # жодної літери b — збігу не буде
небезпечно = виміряти(r"(a+)+b", текст)
безпечно = виміряти(r"a+b", текст)
виміри[n] = небезпечно
print(f"{n:<10} {небезпечно:.4f} с {безпечно:.6f} с")
print()
print(f"додали 6 символів — час зріс у {виміри[22] / виміри[16]:.0f} разів")
assert виміри[22] > виміри[16] * 5, "зростання мало бути принаймні пʼятикратним"
print("\n✅ шість зайвих символів коштували десятки разів більше роботи")
print(" (на 40 символах це вже години — саме тому вкладених квантифікаторів уникають)")
символів (a+)+b a+b 16 0.0079 с 0.000096 с
19 0.0498 с 0.000008 с
22 0.4010 с 0.000018 с
додали 6 символів — час зріс у 51 разів ✅ шість зайвих символів коштували десятки разів більше роботи (на 40 символах це вже години — саме тому вкладених квантифікаторів уникають)
12 · Підсумковий розбірСкладемо все докупи: з тексту журналу отримати список записів, порахуватисуму за рівнями важливості й переконатись, що результат саме той, який мипорахували б руками.¶
сума_за_рівнем = {}
for збіг in ЗАПИС.finditer(ЖУРНАЛ):
запис = збіг.groupdict()
рівень = запис["рівень"]
# накопичуємо суму окремо для кожного рівня, щоб побачити, де гроші
сума_за_рівнем[рівень] = сума_за_рівнем.get(рівень, 0.0) + float(запис["сума"])
for рівень, сума in сума_за_рівнем.items():
print(f"{рівень:<6} {сума:>8.2f} грн")
очікуємо = {"INFO": 249.50, "WARN": 1290.00, "ERROR": 75.00}
assert сума_за_рівнем == очікуємо, "розбір розійшовся з ручним підрахунком"
print("\n✅ розбір збігся з тим, що ми порахували руками")
INFO 249.50 грн WARN 1290.00 грн ERROR 75.00 грн ✅ розбір збігся з тим, що ми порахували руками
---## Завдання трьох рівнів### 🟢 Рівень 1 — БазаДодай до ЖУРНАЛ четвертий рядок із власними даними (інша дата, інший рівень,інший телефон). Переконайся, що ЗАПИС.finditer розбере і його, асума_за_рівнем оновиться.Зроблено, якщо: len(записи) == 4, і assert на нову суму проходить.### 🟡 Рівень 2 — ПлюсНапиши функцію нормалізувати_телефон(значення), яка приймає телефон убудь-якому з форматів +380671234567, 0671234567, +38 (067) 123-45-67і повертає єдиний вигляд +380671234567, а для непридатного значення —None. Всередині спершу прибери все, що не цифра (re.sub(r"\D", "", ...)),і лише потім перевір довжину й початок.Зроблено, якщо: функція правильно обробляє всі три формати й повертаєNone на рядку "телефон невідомий".### 🔴 Рівень 3 — ВикликВиміряй катастрофічний відкат точніше: для n від 12 до 22 побудуй списокчасів для (a+)+b і для a+b. Порахуй, у скільки разів зростає час прикожному додатковому символі для першого шаблону. Потім знайди безпечнийшаблон, який розпізнає ту саму мову («один або більше a, потім b»), і покаживимірюванням, що він лінійний.Зроблено, якщо: відношення сусідніх часів для (a+)+b тримається білядвійки, а для безпечного шаблону час на 22 символах не перевищує часна 12 символах більш ніж удвічі.## Підказки- Регулярка повертає рядки. Перш ніж рахувати — int() або float().- Якщо шаблон нічого не знаходить, спершу надрукуй його repr(): часто проблема в тому, що забули r і Python зʼїв скісний.- re.sub(r"\D", "", значення) — найкоротший спосіб лишити самі цифри.- Вимірюючи час, повторюй вимір кілька разів і бери мінімум: перший запуск завжди дорожчий через кеш шаблонів.