Практика · Регулярні вирази> Лекція: 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 · Наші даніОдин рядок журналу — одне замовлення. У ньому дата, час, рівень важливості,номер, імʼя клієнта, пошта, телефон і сума.¶

In [1]:
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(). Порівняймо обидва способи: результат однаковий,а читабельність — ні.¶

In [2]:
# спосіб 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 грн

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

Ще три питання, на які регулярка не потрібна взагалі. Просте правило: якщо тиможеш назвати підрядок словами, бери метод рядка.

In [3]:
рядок = рядки[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 розумієяк «межа слова».¶

In [4]:
звичайний = "\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 для кирилиці.¶

In [5]:
текст = "Олена 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Тепер задача, для якої регулярка справді потрібна: пошта, телефони й сумимають змінну форму, назвати їх підрядком неможливо.¶

In [6]:
# [\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() і групи. І він ледачий: не будує список у памʼяті.¶

In [7]:
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 · Жадібний, лінивий і заперечений класТри способи описати «тег». Перший дає одну довжелезну знахідку, другий і третій —чотири правильні. Третій ще й швидший, бо йому нема чого віддавати назад.¶

In [8]:
розмітка = "<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 — будь-який, що правильнопочинається.¶

In [9]:
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, а не текст.¶

In [10]:
# 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.¶

In [11]:
загальна_сума = 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 підставляють вміст першої та другої групи.¶

In [12]:
# ділимо телефон на три частини, середню викидаємо
замасковано = 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 грн

✅ усі три телефони замасковано

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

In [13]:
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() бере шаблон — і томурозуміє «кома або крапка з комою, з будь-якими пробілами навколо».¶

In [14]:
сировина = "кава, чай;сік ,  вода"

методом = сировина.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 дозволяє крапці ловити перехід рядка.¶

In [15]:
з_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 змушує рушій перебрати всі способи розділитиланцюжок. Кількість способів подвоюється з кожним символом — і це виднона звичайному годиннику.¶

In [16]:
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 · Підсумковий розбірСкладемо все докупи: з тексту журналу отримати список записів, порахуватисуму за рівнями важливості й переконатись, що результат саме той, який мипорахували б руками.¶

In [17]:
сума_за_рівнем = {}
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", "", значення) — найкоротший спосіб лишити самі цифри.- Вимірюючи час, повторюй вимір кілька разів і бери мінімум: перший запуск завжди дорожчий через кеш шаблонів.