Основы программирования в Python

Алла Тамбовцева, НИУ ВШЭ

Введение в регулярные выражения

Большой эпиграф

Алиса попыталась прочесть надпись на боку шара, которая выглядела так:

«С ‒ гнездо ‒ В ‒ спящая птица ‒ Н ‒ стая бабочек ‒ Я УЛ ‒ гнездо ‒ А.»

‒ Ничего не понимаю, ‒ сказала Алиса.

‒ К сожалению, я тоже забыл, ‒ сказал робот.

‒ Может, это Свиная улица? ‒ спросила Алиса.

‒ Нет, ‒ обиделся робот, ‒ у нас не может быть такого названия.

‒ Улица Свидания, ‒ подсказал турист с Альдебарана.

‒ Нет, ‒ сказал робот, ‒ для свиданий у нас парк, а не улица.

‒ Все просто, ‒ сказал турист-двадцатитрехног. ‒ Это Северная улица.

‒ Нет, возразил робот, ‒ север у нас совершенно в другой стороне.

Каждый из туристов пытался помочь Алисе и предложить свое название.

Когда исчерпались все названия на космическом языке, некоторые стали предлагать слова на своих родных языках.

‒ Совенкуня улица! ‒ кричал двухголовый веганец.

‒ Улица Справгенупяря?

‒ Улица Сдерв-ван-ни-ван-ня?

<...>

И неизвестно, сколько бы времени это продолжалось, если бы какой-то мальчишка не кинул бутербродом в шарб да так метко, что попал точно в стайку бабочек. Бабочки взлетели, и оказалось, что они скрывали за собой буквы "ИРНА". Получилось «С..В..НИРНАЯ УЛ..». ‒ Вспомнил! ‒ воскликнул робот. ‒ Это Cувенирная улица!

Кир Булычёв, Миллион приключений </p> </i>

Регулярные выражения ‒ выражения, последовательности символов, которые позволяют искать совпадения в тексте. Выражаясь более формально, они помогают найти подстроки определенного вида в строке. Еще о регулярных выражениях можно думать как о шаблонах, в которые мы можем подставлять текст, и этот текст либо соответствует шаблону, либо нет. В самом простом случае в качестве регулярного выражения может использоваться обычная строка. Например, чтобы найти в предложении Кошка сидит под столом. слово Кошка, ничего специального применять не нужно, достаточно воспользоваться оператором in:

In [32]:
sent = 'Кошка сидит под столом.'
'Кошка' in sent
Out[32]:
True

Если нас интересует слово кошка в любом регистре, то это уже более интересная задача. Правда, ее все еще можно решить без регулярных выражений, приведя все слова в sent к нижнему регистру. А что, если у нас будет текст подлиннее, и в нем необходимо "обнаружить" кошку в разных падежах? И еще производные слова вроде кошечка? Тут уже удобнее написать некоторый шаблон, чтобы не создавать длинный список слов с разными формами слова кошка. И на помощь придут регулярные выражения. Прежде, чем знакомиться с ними в Python, посмотрим на общие правила построения регулярных выражений, которые верны всегда, не только в Python и не только в программировании вообще.

  • Промежутки, заключенные в квадратные скобки, позволяют найти цифры или буквы разных алфавитов и разных регистров
[0-9] соответствует любой цифре

[A-Z] соответствует любой заглавной букве английского алфавита

[a-z] соответствует любой строчной букве английского алфавита

[А-Я] и [а-я] ‒ аналогично для букв русского алфавита
  • Для цифр есть специальный символ \d (от digit). Добавление обратного слэша называется экранированием: так мы отмечаем, что ищем именно цифру, а не просто букву d.

  • Для пробела тоже существует свой символ ‒ \s (от space). Этот символ соответсвуют ровно одному пробелу в тексте.

  • Любой знак, отличный от пробела, обозначается как \S (заглавная буква здесь отвечает за отрицание).

Для разбора дальнейших символов в регулярных выражениях, создадим небольшой набор слов (не очень осмысленный, но удобный):

ха, хаха, ха-ха, хах, хех
  • Знак . соответствует одному любому символу в строке. Так, регулярное выражение x.x "поймает" слова хах и хех.
  • Знак + соответствует одному или более вхождению символа(ов), который стоит слева от +. Выражение xa+ "поймает" слова xa и хаха.
  • Знак * соответствует нулю или более вхождениям символа, который стоит слева от *. Выражение xaх* "поймает" слова xa и хах.
  • Знак ? соответствует нулю или одному вхождению символа, который стоит слева от ?. Выражение xa? "поймает" слово xa.

Как быть, если с помощью регулярного выражения нужно найти подстроку, содержащую знаки препинания? Те же точки, вопросительные знаки, скобки? Нужно их экранировать ‒ ставить перед ними \, например, \., \,, \?. Это символ будет сообщать Python, что нам нужен именно конкретный символ (точка, запятая, знак вопроса и др.).

В регулярных выражениях можно явно задавать число повторений символов. Если мы знаем точное число символов, то его можно указать в фигурных скобках. Так, выражение а{4} будет соответствовать четырем буквам aподряд. Если точное число повторений нам неизвестно, можно задать диапазон, указав начало и конец отрезка через запятую. Например, такое выражение позволит найти от двух до четырех букв a подряд: a{2,4}. Если известен только левый или правый конец отрезка, то второй конец можно опустить: a{2,} (не менее двух) или a{,4} (не более 4).

В регулярных выражениях также можно использовать условие или. Например, возвращаясь к нашей "смеющейся"строке, если мы напишем выражение x[о|е]х, оно поймает слова хах и хех, а вот вдруг появившийся хох не поймает.

Этими примерами, конечно, синтаксис регулярных выражений не ограничивается, но давайте для начала на этом остановимся. Какие-то примеры будут всплывать по ходу занятий, с какими-то более специфическими случаями вы сможете познакомиться самостоятельно.

Теперь перейдем к Python. Импортируем модуль re для работы с регулярными выражениями:

In [1]:
import re

Создадим какой-нибудь незамысловатый текст с разными датами:

In [2]:
text = "12 ноября 2011 года произошло удивительное событие. А 13 ноября 2012 - еще удивительнее. Даже не будем \
говорить, что произошло 2 декабря 2011 года и 25 декабря 2012 года."

Напишем регулярное выражение, которое будет соответствовать всем цифрам в тексте (не числам), и найдем их все в text с помощью функции findall():

In [3]:
re.findall("\d", text) # отдельно цифры
Out[3]:
['1',
 '2',
 '2',
 '0',
 '1',
 '1',
 '1',
 '3',
 '2',
 '0',
 '1',
 '2',
 '2',
 '2',
 '0',
 '1',
 '1',
 '2',
 '5',
 '2',
 '0',
 '1',
 '2']

Если забыли, что числа можно искать с помощью \d, можно задействовать промежуток (только не забудьте квадратные скобки):

In [5]:
re.findall("[0-9]", text)
Out[5]:
['1',
 '2',
 '2',
 '0',
 '1',
 '1',
 '1',
 '3',
 '2',
 '0',
 '1',
 '2',
 '2',
 '2',
 '0',
 '1',
 '1',
 '2',
 '5',
 '2',
 '0',
 '1',
 '2']

А что, если мы хотим "ловить" не цифры, а числа, то есть последовательности из одной или более цифры. Условию "один и более" соответствует символ +. Попробуем.

In [14]:
re.findall("\d+", text) # отдельно числа
Out[14]:
['12', '2011', '13', '2012', '2']

Получилось! А если сочетания по 1-2 цифры (иногда с пробелом после)? Тут нужен знак ., который отвечает ровно за один символ.

In [10]:
re.findall("\d.", text) # отдельно числа по 1-2 цифры
Out[10]:
['12', '20', '11', '13', '20', '12', '2 ', '20', '11', '25', '20', '12']

Что будет, если мы воспользуемся знаком ?? Он отвечает за наличие 0 или 1 символа, стоящего слева от регулярного выражения.

In [11]:
re.findall("\d?", text) # по 1 символу
Out[11]:
['1',
 '2',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '2',
 '0',
 '1',
 '1',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '1',
 '3',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '2',
 '0',
 '1',
 '2',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '2',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '2',
 '0',
 '1',
 '1',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '2',
 '5',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '',
 '2',
 '0',
 '1',
 '2',
 '',
 '',
 '',
 '',
 '',
 '',
 '']

Получили какое-то безобразие. Но это безобразие оправдано: добавив ? мы поставили условие, что в подстроке либо есть ровно одна цифра, либо ее нет. Поэтому мы и получили такой странный список.

Задание 1: написать регулярное выражение, которое будет "ловить" все годы в тексте.

Решение:

In [13]:
re.findall("\d{4}", text) # 4 цифры подряд
Out[13]:
['2011', '2012', '2011', '2012']

Задание 2: написать регулярное выражение, которое будет "ловить" все слова с основой удивительн в тексте.

Решение:

In [18]:
re.findall("удивительн..", text) # из текста знаем, что больше двух букв после не будет
Out[18]:
['удивительное', 'удивительнее']

Теперь давайте вместе напишем регулярное выражение, которое будет соответствовать датам с годами. Как выглядят даты в нашем тексте? Сначала идет одна цифра или более, затем пробел, далее буквенное название месяца, пробел и снова цифры, но теперь уже ровно 4, так как они складываются в год. Как обозначаются цифры мы знаем, русские буквы тоже. пробелу соответствует символ \s (обратный слэш обязателен, так как без него это будет обычная буква s).

In [19]:
re.findall("\d+\s+[а-я]+\s\d{4}", text) # осталось прочитать регулярку по слогам :)
Out[19]:
['12 ноября 2011', '13 ноября 2012', '2 декабря 2011', '25 декабря 2012']

Теперь давайте рассмотрим еще один пример. Пусть у нас есть список твитов, только список учебный, вместо полного текста одни хэштеги.

In [20]:
twits = ["#я не могу молчать", "#я не могу кричать", "#я не могу", "#я справлюсь", "я не могу молчать",
        "#я не могу жить", "#я все могу", "#с кем не бывает"]

Задача: создать новый список, содержащий только твиты, начинающиеся с #я не могу. Сначала напишем регулярное выражение и посмотрим, как оно работает.

In [22]:
for t in twits:
    print(re.findall("#я не могу", t)) 
['#я не могу']
['#я не могу']
['#я не могу']
[]
[]
['#я не могу']
[]
[]

Написать такое выражение совсем несложно, осталось теперь правильно использовать его в цикле.

In [24]:
chosen = []

for t in twits:
    res = re.findall("#я не могу", t)
    if len(res) != 0:
        chosen.append(t) # именно t, не res, так как добавляем твит полностью
chosen
Out[24]:
['#я не могу молчать', '#я не могу кричать', '#я не могу', '#я не могу жить']

Напоследок рассмотрим какую-нибудь задачу, где необходимо применить экранирование. Пусть у нас есть некоторая строка с данными:

In [26]:
data = '20.05.1963, 55, 12.12.2000, 17, 15/15/1111'

И нам нужно выбрать из нее даты, записанные через точку. Напишем регулярное выражение, которое позволит это сделать, но перед этим вспомним, что точку нужно экранировать ‒ ставить перед ней \, чтобы Python понимал, что мы ищем не один любой символ (.), а именно точку как знак препинания.

In [27]:
re.findall("\d+\.\d+.\d{4}", data) # готово
Out[27]:
['20.05.1963', '12.12.2000']

Сегодня мы познакомились только с одной функцией из модуля re, а именно findall. На самом деле, функций больше, есть функция sub(), которая позволяет не только находить подстроку по регулярному выражению, но и заменять ее на другую подстроку, есть функции, которые позволяют определить индекс символа начала и конца совпадения, функция split, которая позволяет разбить строку по найденной подстроке... С некоторыми функциями мы познакомимся позже, когда будем разбирать парсинг, некоторые функции мы опустим, но о них можно почитать в дополнительных материалах и официальной документации. Кроме того, есть очень хороший ресурс regexr.com, который позволяет скопировать нужный текст и в интерактивном режиме следить, какие совпадения находятся при изменении регулярного выражения, введенного в отдельном окне.