6

Стоит ли использовать re.compile в Python?

1

Вопрос: Есть ли преимущества в использовании re.compile для регулярных выражений в Python?

Я изучаю использование регулярных выражений в Python и заметил, что можно использовать функцию re.compile() для компиляции шаблона регулярного выражения, как в следующем примере:

import re

h = re.compile('hello')
h.match('hello world')

Вместо этого можно просто вызывать re.match() напрямую:

import re

re.match('hello', 'hello world')

В чем разница между этими подходами? Есть ли какие-либо преимущества в использовании re.compile() вместо простого вызова re.match()? Буду благодарен за любые пояснения или рекомендации!

5 ответ(ов)

5

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

EDIT: После быстрого взгляда на код библиотеки Python 2.5 я вижу, что Python на самом деле компилирует и КЭШИРУЕТ регулярные выражения каждый раз, когда вы их используете (включая вызовы re.match()), поэтому вы на самом деле только изменяете время компиляции регулярного выражения, и экономить время не следует, разве что время, затраченное на проверку кэша (поиск по ключу во внутреннем dict).

Из модуля re.py (комментарии мои):

def match(pattern, string, flags=0):
    return _compile(pattern, flags).match(string)

def _compile(*key):

    # Проверка кэша в начале функции
    cachekey = (type(key[0]),) + key
    p = _cache.get(cachekey)
    if p is not None: return p

    # ...
    # Происходит фактическая компиляция при отсутствии в кэше
    # ...

    # Кэширует скомпилированное регулярное выражение
    if len(_cache) >= _MAXCACHE:
        _cache.clear()
    _cache[cachekey] = p
    return p

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

1

Основное преимущество использования re.compile заключается в возможности отделить определение регулярного выражения от его использования.

Даже такое простое выражение, как 0|[1-9][0-9]* (целое число в десятичной системе без ведущих нулей), может быть достаточно сложным, и вы, вероятно, предпочли бы не вводить его снова, проверять на опечатки и потом снова проверять на ошибки, когда начнете отлаживать. Кроме того, приятнее использовать имя переменной, такое как num или num_b10, чем писать 0|[1-9][0-9]*.

Конечно, можно хранить строки и передавать их в re.match, но это менее читабельно:

num = "..."
# потом, гораздо позже:
m = re.match(num, input)

В сравнении с компиляцией:

num = re.compile("...")
# потом, гораздо позже:
m = num.match(input)

Хотя разница невелика, последняя строка второго примера звучит более естественно и проще, если вы собираетесь использовать ее повторно.

1

Для справки:

$ python -m timeit -s "import re" "re.match('hello', 'hello world')"
100000 циклов, лучший из 3: 3.82 микросекунд на цикл

$ python -m timeit -s "import re; h=re.compile('hello')" "h.match('hello world')"
1000000 циклов, лучший из 3: 1.26 микросекунд на цикл

Итак, если вам часто приходится использовать один и тот же регулярное выражение, имеет смысл воспользоваться re.compile (особенно для более сложных выражений).

Применимы стандартные аргументы против преждевременной оптимизации, но я не думаю, что вы теряете много в ясности и понимании, используя re.compile, если у вас есть подозрения, что ваши регулярные выражения могут стать узким местом в производительности.

Обновление:

На Python 3.6 (подозреваю, что ранее приведенные замеры были сделаны в Python 2.x) и на оборудовании 2018 года (MacBook Pro) я получаю следующие результаты:

% python -m timeit -s "import re" "re.match('hello', 'hello world')"
1000000 циклов, лучший из 3: 0.661 микросекунд на цикл

% python -m timeit -s "import re; h=re.compile('hello')" "h.match('hello world')"
1000000 циклов, лучший из 3: 0.285 микросекунд на цикл

% python -m timeit -s "import re" "h=re.compile('hello'); h.match('hello world')"
1000000 циклов, лучший из 3: 0.65 микросекунд на цикл

% python --version
Python 3.6.5 :: Anaconda, Inc.

Я также добавил тест (обратите внимание на различия в использовании кавычек между последними двумя запусками), который показывает, что re.match(x, ...) по сути эквивалентно re.compile(x).match(...), т.е. никакого кэширования скомпилированного представления за кулисами, похоже, не происходит.

0

Вот простой тестовый случай:

~$ for x in 1 10 100 1000 10000 100000 1000000; do python -m timeit -n $x -s 'import re' 're.match("[0-9]{3}-[0-9]{3}-[0-9]{4}", "123-123-1234")'; done
1 loops, best of 3: 3.1 usec per loop
10 loops, best of 3: 2.41 usec per loop
100 loops, best of 3: 2.24 usec per loop
1000 loops, best of 3: 2.21 usec per loop
10000 loops, best of 3: 2.23 usec per loop
100000 loops, best of 3: 2.24 usec per loop
1000000 loops, best of 3: 2.31 usec per loop

С использованием re.compile:

~$ for x in 1 10 100 1000 10000 100000 1000000; do python -m timeit -n $x -s 'import re' 'r = re.compile("[0-9]{3}-[0-9]{3}-[0-9]{4}")' 'r.match("123-123-1234")'; done
1 loops, best of 3: 1.91 usec per loop
10 loops, best of 3: 0.691 usec per loop
100 loops, best of 3: 0.701 usec per loop
1000 loops, best of 3: 0.684 usec per loop
10000 loops, best of 3: 0.682 usec per loop
100000 loops, best of 3: 0.694 usec per loop
1000000 loops, best of 3: 0.702 usec per loop

Как видно, использование re.compile оказывается быстрее в этом простом случае, даже если вы выполняете матч всего один раз. Это связано с тем, что регулярные выражения компилируются в объект, что позволяет избежать повторной компиляции для каждого вызова. Поэтому, если вы планируете использовать одно и то же выражение несколько раз, стоит использовать re.compile для оптимизации производительности.

0

Я согласен с Honest Abe, что match(...) в приведённых примерах различаются. Это не одно и то же, и, соответственно, результаты будут разными. Чтобы упростить свой ответ, я использую A, B, C, D для указанных функций. О, да, на самом деле мы имеем дело с 4 функциями в re.py, а не 3.

Запуск следующего кода:

h = re.compile('hello')                   # (A)
h.match('hello world')                    # (B)

равнозначен запуску этого кода:

re.match('hello', 'hello world')          # (C)

Поскольку, если посмотреть на исходный код re.py, (A + B) означает:

h = re._compile('hello')                  # (D)
h.match('hello world')

А (C) фактически является:

re._compile('hello').match('hello world')

Таким образом, (C) не является тем же, что (B). На самом деле (C) вызывает (B) после вызова (D), который также вызывается из (A). Другими словами, (C) = (A) + (B). Поэтому сравнение (A + B) внутри цикла даст тот же результат, что и (C) внутри цикла.

Тестовый скрипт George regexTest.py это доказал:

noncompiled took 4.555 seconds.           # (C) в цикле
compiledInLoop took 4.620 seconds.        # (A + B) в цикле
compiled took 2.323 seconds.              # (A) один раз + (B) в цикле

Всем интересно, как достигнуть результата в 2.323 секунды. Чтобы убедиться, что compile(...) вызывается только один раз, нам нужно сохранить скомпилированный объект regex в памяти. Если мы используем класс, мы можем сохранить объект и повторно использовать его всякий раз, когда вызывается наша функция.

class Foo:
    regex = re.compile('hello')
    def my_function(self, text):
        return self.regex.match(text)

Если мы не используем класс (что является моей просьбой сегодня), тогда у меня нет комментариев. Я все еще учусь использовать глобальную переменную в Python, и я осознаю, что глобальные переменные — это не лучший подход.

Еще один момент — я считаю, что подход с использованием (A) + (B) имеет преимущество. Вот некоторые факты, которые я заметил (пожалуйста, поправьте меня, если я не прав):

  1. Вызов A один раз приведёт к одному поиску в _cache, за которым следует один вызов sre_compile.compile() для создания объекта regex. При вызове A дважды будет два поиска и один компилирование (поскольку объект regex кэшируется).
  2. Если _cache очищается между вызовами, объект regex будет освобождён из памяти, и Python нужно будет снова его компилировать. (Некоторые утверждают, что Python не пересчитывает.)
  3. Если мы сохраняем объект regex, используя (A), он всё равно попадет в _cache и будет каким-то образом очищен. Но наш код сохраняет ссылку на него, и объект regex не будет освобождён из памяти. Поэтому Python не нужно будет компилировать его снова.
  4. Разница в 2 секунды в тесте George с циклом, использующим скомпилированный regex по сравнению с скомпилированным один раз, в основном обусловлена временем, необходимым для построения ключа и поиска в _cache. Это не значит, что это время компиляции regex.
  5. Тест George reallycompile показывает, что произойдет, если компиляция действительно выполняется каждый раз: это будет в 100 раз медленнее (он уменьшил количество итераций цикла с 1,000,000 до 10,000).

Вот случаи, когда (A + B) лучше, чем (C):

  1. Если мы можем кэшировать ссылку на объект regex внутри класса.
  2. Если нам нужно повторно вызывать (B) (внутри цикла или несколько раз), мы должны кэшировать ссылку на объект regex вне цикла.

Случаи, когда (C) достаточно хорош:

  1. Мы не можем кэшировать ссылку.
  2. Мы используем его лишь время от времени.
  3. В целом, у нас не так много regex (предполагается, что скомпилированный объект никогда не будет очищен).

Напоследок, вот A B C:

h = re.compile('hello')                   # (A)
h.match('hello world')                    # (B)
re.match('hello', 'hello world')          # (C)

Спасибо за внимание.

Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь