Я знаю, используем регулярные выражения!

Итак, вы смотрите на слова, и, по крайней мере в английском, это означает, что вы смотрите на последовательности символов. У вас есть правила, которые говорят, что нужно искать разные комбинации символов, потом совершать с ними различные действия. Похоже, это работа для регулярных выражений!

import re

def plural(noun):
if re.search('[sxz]$', noun): UNIQd2f4acf57a9a5326-ref-00000003-QINU
return re.sub('$', 'es', noun) UNIQd2f4acf57a9a5326-ref-00000006-QINU
elif re.search('[^aeioudgkprt]h$', noun):
return re.sub('$', 'es', noun)
elif re.search('[^aeiou]y$', noun):
return re.sub('y$', 'ies', noun)
else:
return noun + 's'

  1. ↑ Это регулярное выражение, но оно использует синтаксис, который вы не видели в главе Регулярные Выражения. Квадратные скобки означают «найти совпадения ровно с одним из этих символов». Поэтому [sxz] означает «s или x, или z», но только один из них. Символ $ должен быть знаком вам. Он ищет совпадения с концом строки. Все регулярное выражение проверяет, заканчивается ли noun на s, x или z.
  2. ↑ Упомянутая функция re.sub() производит замену подстроки на основе регулярного выражения.

Рассмотрим замену с помощью регулярного выражения внимательнее.

>>> import re
>>> re.search('[abc]', 'Mark') ①
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.sub('[abc]', 'o', 'Mark') ②
'Mork'
>>> re.sub('[abc]', 'o', 'rock') ③
'rook'
>>> re.sub('[abc]', 'o', 'caps') ④
'oops'

  1. Содержит ли строка Mark символы a, b или c? Да, содержит a.
  2. Отлично, теперь ищем a, b или c и заменяем на o. Mark становится Mork.
  3. Та же функция превращает rock в rook.
  4. Вы могли подумать, что этот код caps преобразует в oaps, но он этого не делает. re.sub заменяет все совпадения, а не только первое найденное. Так что, данное регулярное выражение превратит caps в oops, потому что оба символа c и a заменяются на o.

Вернемся снова к функции plural()…

def plural(noun):
if re.search('[sxz]$', noun):
return re.sub('$', 'es', noun) ①
elif re.search('[^aeioudgkprt]h$', noun): ②
return re.sub('$', 'es', noun)
elif re.search('[^aeiou]y$', noun): ③
return re.sub('y$', 'ies', noun)
else:
return noun + 's'

  1. Здесь вы заменяете конец строки (найденный с помощью символа $) на строку es. Другими словами, добавляете es к строке. Вы могли бы совершить то же самое с помощью конкатенации строк, например, как noun + 'es', но я предпочел использовать регулярные выражения для каждого правила, по причинам которые станут ясны позже.
  2. Взгляните-ка, это регулярное выражение содержит кое-что новое. Символ ^ в качестве первого символа в квадратных скобках имеет особый смысл: отрицание. [^abc] означает «любой отдельный символ кроме a, b или c». Так что [^aeioudgkprt] означает любой символ кроме a, e, i, o, u, d, g, k, p, r или t. Затем за этим символом должен быть символ h, следом за ним — конец строки. Вы ищете слова, заканчивающиеся на H, которую можно услышать.
  3. То же самое здесь: найти слова, которые заканчиваются на Y, в которых символ перед Y — не a, e, i, o или u. Вы ищете слова, заканчивающиеся на Y, которая звучит как I.

Давайте внимательнее рассмотрим регулярные выражения с участием отрицания.

>>> import re
>>> re.search('[^aeiou]y$', 'vacancy') ①
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.search('[^aeiou]y$', 'boy') ②
>>>
>>> re.search('[^aeiou]y$', 'day')
>>>
>>> re.search('[^aeiou]y$', 'pita') ③
>>>

  1. vacancy подходит, потому что оно заканчивается на cy, и c — не a, e, i, o или u.
  2. boy не подходит, потому что оно заканчивается на oy, а вы конкретно указали, что символ перед y не может быть o. day не подходит, потому что он заканчивается на ay.
  3. pita не подходит, потому что оно не заканчивается на y.

>>> re.sub('y$', 'ies', 'vacancy') ①
'vacancies'
>>> re.sub('y$', 'ies', 'agency') 'agencies'
>>> re.sub('([^aeiou])y$', r'\1ies', 'vacancy') ②
'vacancies'

  1. Это регулярное выражение преобразует vacancy в vacancies, а agency — в agencies, что вам и нужно. Заметьте, что оно бы преобразовало boy в boies, но этого в функции никогда не произойдет, потому что вы сначала сделали re.search с целью выяснить, следует ли делать re.sub.
  2. Замечу заодно, что возможно объединить эти два регулярных выражения (одно чтобы выяснить применяется ли правило, а другое чтобы собственно его применить) в одно регулярное выражение. Вот так выглядел бы результат. Большая часть должна быть вам знакома: вы используете запоминаемую группу, о которой вы узнали из Учебный пример: Разбор телефонного номера. Группа используется чтобы запомнить символ перед y. Затем в подстановочной строке, вы используете новый синтаксис, \1, который означает «эй, та первая группа, которую ты запомнил? положи ее сюда». Таким образом, вы помните c перед y; когда вы делаете подстановку, вы ставите c на место c, и ies на место y. (Если у вас более одной запоминаемой группы, можете использовать \2 и \3 и так далее.)

Замены с использованием регулярных выражений являются чрезвычайно мощным инструментом, а синтаксис \1 делает их еще более мощным. Но вся операция, объединенная в одно регулярное выражение, также становится сложной для чтения, кроме того такой способ не соотносится напрямую с тем, как вы изначально описали правила формирования множественного числа. Изначально вы спроектировали правила в форме «если слово заканчивается на S, X или Z, то добавьте ES». А если вы смотрите на функцию, то у вас — две строки кода, которые говорят «если слово заканчивается на S, X или Z, то добавьте ES». Еще ближе к оригинальному варианту приблизиться никак не получится.

Список функций

Сейчас вы добавите уровень абстракции. Вы начали с определения списка правил: если верно это, сделай то, иначе обращайтесь к следующему правилу. Давайте временно усложним часть программы, так что вы сможете упростить другую ее часть.

import re

def match_sxz(noun):
return re.search('[sxz]$', noun)

def apply_sxz(noun):
return re.sub('$', 'es', noun)

def match_h(noun):
return re.search('[^aeioudgkprt]h$', noun)

def apply_h(noun):
return re.sub('$', 'es', noun)

def match_y(noun): ①
return re.search('[^aeiou]y$', noun)

def apply_y(noun): ②
return re.sub('y$', 'ies', noun)

def match_default(noun):
return True

def apply_default(noun):
return noun + 's'

rules = ((match_sxz, apply_sxz), ③
(match_h, apply_h),
(match_y, apply_y),
(match_default, apply_default)
)

def plural(noun):
for matches_rule, apply_rule in rules: ④
if matches_rule(noun):
return apply_rule(noun)

  1. Теперь каждое правило-условие совпадения является отдельной функцией которая возвращает результаты вызова функции re.search().
  2. Каждое правило-действие также является отдельной функцией, которая вызывает функцию re.sub() чтобы применить соответствующее правило формирования множественного числа.
  3. Вместо одной функции (plural()) с несколькими правилами у вас теперь есть структура данных rules, являющаяся последовательностью пар функций.
  4. Поскольку правила развернуты в отдельной структуре данных, новая функция plural() может быть сокращена до нескольких строк кода. Используя цикл for, из структуры rules можно извлечь правила условия и замены одновременно. При первой итерации for цикла, match_rules станет match_sxz, а apply_rule станет apply_sxz. Во время второй итерации, если мы до нее дойдем, matches_rule будет присвоено match_h, а apply_rule станет apply_h. Функция гарантированно вернет что-нибудь по окончании работы, потому что последнее правило совпадения (match_default) просто возвращает True, подразумевая, что соответствующее правило замены (apply_default) всегда будет применено.

Причиной, по которой этот пример работает, является тот факт, что в Python все является объектом, даже функции. Структура данных rules содержит функции — не имена функций, а фактические функции-объекты. Когда они присваиваются в for цикле, matches_rule и apply_rule являются настоящими функциями, которые вы можете вызывать. При первой итерации for цикла, это эквивалентно вызову matches_sxz(noun), и если она возвращает совпадение, вызову apply_sxz(noun).

-> Переменная «rules» — это последовательность пар функций. [waр-robin.сom]

Если этот дополнительный уровень абстракции сбивает вас с толку, попробуйте развернуть функцию, чтобы увидеть, что мы получаем то же самое. Весь цикл for эквивалентен следующему:

def plural(noun):
if match_sxz(noun):
return apply_sxz(noun)
if match_h(noun):
return apply_h(noun)
if match_y(noun):
return apply_y(noun)
if match_default(noun):
return apply_default(noun)

Преимуществом здесь является то, что функция plural() упрощена. Она принимает последовательность правил, определенных где-либо, и проходит по ним.

  1. Получить правило совпадения
  2. Правило срабатывает? Тогда применить правило замены и вернуть результат.
  3. Нет совпадений? Начать с пункта 1.

Правила могут быть определены где угодно, любым способом. Для функции plural() абсолютно нет никакой разницы.

Итак, добавление этого уровня абстракции стоило того? Вообще-то пока нет. Попробуем представить, что потребуется для добавления нового правила в функцию. В первом примере этого потребовало бы добавить новую конструкцию if в функцию plural(). Во втором примере, это потребовало бы добавить две функции, match_foo() и apply_foo(), а затем обновить последовательность rules чтобы указать, когда новые правила совпадения и замены должны быть вызваны по отношению к остальным правилам.

Но на самом деле это только средство, чтобы перейти к следующей главе. Двигаемся дальше…

Список шаблонов

Определение отдельных именованных функций для каждого условия и правила замены вовсе не является необходимостью. Вы никогда не вызываете их напрямую; вы добавляете их в последовательность rules и вызываете их через нее. Более того, каждая функция следует одному из двух шаблонов. Все функции совпадения вызывают re.search(), а все функции замены вызывают re.sub(). Давайте исключим шаблоны, чтобы объявление новых правил было более простым.

import re

def build_match_and_apply_functions(pattern, search, replace):
def matches_rule(word): ①
return re.search(pattern, word)
def apply_rule(word): ②
return re.sub(search, replace, word)
return (matches_rule, apply_rule) ③

  1. build_match_and_apply_functions() — это функция, которая динамически создает другие функции. Она принимает pattern, search и replace, затем определяет функцию matches_rule(), которая вызывает re.search() с шаблоном pattern, переданный функции build_match_and_apply_functions() в качестве аргумента, и word, который передан функции matches_rule(), которую вы определяете.
  2. Строим функцию apply тем же способом. Функция apply — это функция, которая принимает один параметр, и вызывает re.sub() с search и replace параметрами, переданными функции build_match_and_apply_functions, и word, переданным функции apply_rule(), которую вы создаете. Подход, заключающийся в использовании значений внешних параметров внутри динамической функции называется замыканиями. По сути вы определяете константы в функции замены: он принимает один параметр (word), но затем действует используя его и два других значения (search и replace), которые были установлены в момент определения функции замены.
  3. В конце концов функция build_match_and_apply_functions() возвращает кортеж с двумя значениями, двумя функциями, которые вы только что создали. Константы, которые вы определили внутри тех функций (pattern внутри функции match_rule()), search и replace в функции apply_rule()) остаются с этими функциями, даже. Это безумно круто.

Если это сбивает вас с толку (и так и должно быть, это весьма странное поведение), картина может проясниться, когда вы увидите как использовать этот подход.

patterns = \ ①
(
('[sxz]$', '$', 'es'),
('[^aeioudgkprt]h$', '$', 'es'),
('(qu|[^aeiou])y$', 'y$', 'ies'),
('$', '$', 's') ②
)
rules = [build_match_and_apply_functions(pattern, search, replace) ③
for (pattern, search, replace) in patterns]

  1. Наши правила формирования множественного числа теперь определены как кортеж кортежей строк (не функций). Первая строка в каждой группе — это регулярное выражение, которое вы бы использовали в re.search() чтобы определить, подходит ли данное правило. Вторая и третья строки в каждой группе — это выражения для поиска и замены, которые вы бы использовали в re.sub() чтобы применить правило и преобразовать существительное во множественное число.
  2. В альтернативном правиле есть небольшое изменение. В прошлом примере функция match_default() просто возвращает True, подразумевая, что если ни одно конкретное правило не применилось, код должен просто добавить s в конец данного слова. Функционально данный пример делает то же самое. Окончательное регулярное выражение узнает, заканчивается ли слово ($ ищет конец строки). Конечно же, у каждой строки есть конец, даже у пустой, так что выражение всегда срабатывает. Таким образом, она служит той же цели, что и функция match_default(), которая всегда возвращала True: она гарантирует, что если нет других конкретных выполненных правил, код добавляет s в конец данного слова.
  3. Это волшебная строка. Она принимает последовательность строк в patterns и превращает их в последовательность функций. Как? «Отображением» строк в функцию build_and_apply_functions(). То есть она берет каждую тройку строк и вызывает функцию build_match_and_apply_functions() с этими тремя строками в качестве аргументов. Функция build_match_and_apply_functions() возвращает кортеж из двух функций. Это означает, что rules в конце концов функционально становится эквивалентной предыдущему примеру: список кортежей, где каждый кортеж — это пара функций. Первая функция — это функция совпадения, которая вызывает re.search(), а вторая функция — применение правила (замена), которая вызывает re.sub().

Завершим эту версию скрипта главной точкой входа, функцией plural().

def plural(noun):
for matches_rule, apply_rule in rules: ①
if matches_rule(noun):
return apply_rule(noun)

  1. Поскольку список rules — тот же самый, что и в предыдущем примере (да, так и есть), нет ничего удивительного в том, что функция plural() совсем не изменилась. Она является полностью обобщенной; она принимает список функций-правил и вызывает их по порядку. Ее не волнует, как определены правила. В предыдущем примере они были определены как отдельные именованные функции. Теперь же они создаются динамически сопоставлением результата функции build_match_and_apply_functions() списку обычных строк. Это не играет никакой роли. функция plural() продолжает работать как и раньше.

Файл шаблонов

Вы вынесли весь дублирующийся код и добавили достаточно абстракций для возможности хранить правила формирования множественного числа в списке строк. Следующий логический этап — взять эти строки и расположить их в отдельном файле, где они могут поддерживаться отдельно от использующего их кода.

Во-первых, давайте создадим текстовый файл, содержащий нужные нам правила. Никаких сложных структур данных, просто разделенные на три колонки данные. Назовем его plural4-rules.txt

[sxz]$ $ es
[^aeioudgkprt]h$ $ es
[^aeiou]y$ y$ ies
$ $ s

Теперь давайте посмотрим, как вы можете использовать этот файл с правилами.

import re

def build_match_and_apply_functions(pattern, search, replace): ①
def matches_rule(word):
return re.search(pattern, word)
def apply_rule(word):
return re.sub(search, replace, word)
return (matches_rule, apply_rule)

rules = []
with open('plural4-rules.txt', encoding='utf-8') as pattern_file: ②
for line in pattern_file: ③
pattern, search, replace = line.split(None, 3) ④
rules.append(build_match_and_apply_functions( ⑤
pattern, search, replace))

  1. Функция build_match_and_apply_functions() не изменилась. Вы по-прежнему используете замыкания, чтобы динамически создать две функции, которые будут использовать переменные из внешней функции.
  2. Глобальная функция open() открывает файл и возвращает файловый объект. В данном случае файл, который мы открываем, содержит строки-шаблоны для правил формирования множественного числа. Утверждение with создает то, что называется контекстом: когда блок with заканчивается, Python автоматически закроет файл, даже если внутри блока with было выброшено исключение. Подробнее о блоках with и файловых объектах вы узнаете из главы Файлы.
  3. Форма «for line in <fileobject>» читает данные из открытого файла построчно и присваивает текст переменной line. Подробнее про чтение файлов вы узнаете из главы Файлы.
  4. Каждая строка в файле действительно содержит три значения, но они разделены пустым пространством (табуляцией или пробелами, без разницы). Чтобы разделить их, используйте строковый метод split(). Первый аргумент для split() — None, что означает «разделить любым символом свободного пространства (табуляцией или пробелом, без разницы)». Второй аргумент — 3, что означает «разбить свободным пространством 3 раза, затем оставить остальную часть строки» Строка вида «[sxz]$ $ es» будет разбита и преобразована в список ['[sxz]$', '$', 'es'], что означает что pattern станет '[sxz]$', search — '$', а replace получит значение 'es'. Это довольно мощно для одной маленькой строки кода
  5. В конце концов, вы передаете pattern, search и replace функции build_match_and_apply_function(), которая возвращает кортеж функций. Вы добавляете этот кортеж в список rules, и в завершении rules хранит список функций поиска совпадений и выполнения замен, который ожидает функция plural().

Сделанное улучшение заключается в том, что вы полностью вынесли правила во внешний файл, так что он может поддерживаться отдельно от использующего его кода. Код — это код, данные — это данные, а жизнь хороша.

Генераторы

Но ведь будет круто если обобщенная функция plural() будет разбирать файл с правилами? Извлеки правила, найди совпадения, примени соответствующие изменения, переходи к следующему правилу. Это все, что функции plural() придется делать, и больше ничего от нее не требуется.

def rules(rules_filename):
with open(rules_filename, encoding='utf-8') as pattern_file:
for line in pattern_file:
pattern, search, replace = line.split(None, 3)
yield build_match_and_apply_functions(pattern, search, replace)

def plural(noun, rules_filename='plural5-rules.txt'):
for matches_rule, apply_rule in rules(rules_filename):
if matches_rule(noun):
return apply_rule(noun)
raise ValueError('no matching rule for {0}'.format(noun))

Как черт возьми это работает? Давайте сначала посмотрим на пример с пояснениями.

>>> def make_counter(x):
... print('entering make_counter')
... while True:
... yield x ①
... print('incrementing x')
... x = x + 1
...
>>> counter = make_counter(2) ②
>>> counter ③
<generator object at 0x001C9C10>
>>> next(counter) ④
entering make_counter
2
>>> next(counter) ⑤
incrementing x
3
>>> next(counter) ⑥
incrementing x
4

  1. Присутствие ключевого слова yield в make_counter означает, что это не обычная функция. Это особый вид функции, которая генерирует значения по одному. Вы можете думать о ней как о продолжаемой функции. Её вызов вернёт генератор, который может быть использован для генерации последующих значений x.
  2. Чтобы создать экземпляр генератора make_counter, просто вызовите его как и любую другую функцию. Заметьте, что фактически это не выполняет кода функции. Вы можете так сказать, потому что первая строка функции make_counter() вызывает print(), но ничего до сих пор не напечатано.
  3. Функция make_counter() возвращает объект-генератор.
  4. Функция next() принимает генератор и возвращает его следующее значение. Первый раз, когда вы вызываете next() с генератором counter, он исполняет код в make_counter() до первого утверждения yield, затем возвращает значение, которое было возвращено yield. В данном случае, это будет 2, поскольку изначально вы создали генератор вызовом make_counter(2).
  5. Повторный вызов next() с тем же генератором продолжает вычисления точно там, где они были прерваны, и продолжает до тех пор, пока не встретит следующий yield. Все переменные, локальные состояния и т. д. сохраняются во время yield, и восстанавливаются при вызове next(). Далее строка кода, ожидающая исполнения, вызывает print(), который печатает incrementing x. После этого следует утверждение x = x + 1. Затем снова исполняется цикл while, и первое, что в нём встречается — утверждение yield x, которое сохраняет все состояние и возвращает текущее значение x (сейчас это 3).
  6. После второго вызова next(counter) происходит всё то же самое, только теперь x становится равным 4.

Поскольку make_counter входит в бесконечный цикл, вы бы теоретически могли заниматься этим бесконечно, а он продолжал бы увеличивать x и возвращать его значения. Но вместо этого давайте посмотрим на более продуктивное использование генераторов.