Является ли str.replace (..). Replace (..) ad nauseam стандартной идиомой в Python? - PullRequest
28 голосов
/ 20 марта 2010

Например, скажем, я хотел, чтобы функция экранировала строку для использования в HTML (как в escape-фильтре Django ):

    def escape(string):
        """
        Returns the given string with ampersands, quotes and angle 
        brackets encoded.
        """
        return string.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace("'", '&#39;').replace('"', '&quot;')

Это работает, но быстро становится уродливым и, похоже, имеет плохую алгоритмическую производительность (в этом примере строка многократно просматривается 5 раз). Что было бы лучше, это что-то вроде этого:

    def escape(string):
        """
        Returns the given string with ampersands, quotes and angle 
        brackets encoded.
        """
        # Note that ampersands must be escaped first; the rest can be escaped in 
        # any order.
        return replace_multi(string.replace('&', '&amp;'),
                             {'<': '&lt;', '>': '&gt;', 
                              "'": '&#39;', '"': '&quot;'})

Существует ли такая функция или стандартная идиома Python использует то, что я написал раньше?

Ответы [ 9 ]

20 голосов
/ 20 марта 2010

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

Текущий фрагмент пересекает строку 5 раз, каждый раз делая одну вещь. Вы предлагаете пройти его один раз, возможно, делая пять дел каждый раз (или, по крайней мере, каждый раз делая что-то). Не ясно, что это автоматически сделает мне лучшую работу. В настоящее время используется алгоритм O (n m) (при условии, что длина строки больше, чем в правилах), где n - длина строки, а m - количество правил замещения. Я думаю, вы могли бы уменьшить алгоритмическую сложность до чего-то вроде O (n log (m)), и в конкретном случае мы находимся - где все исходные вещи имеют только один символ (но не в случае несколько вызовов replace в общем случае) - O (n), но это не имеет значения, поскольку m равно 5 , но n не ограничено .

Если m поддерживается постоянным, то сложность обоих решений действительно сводится к O (n). Мне не ясно, что стоит попытаться превратить пять простых проходов в один сложный, фактическое время которого я не могу угадать в настоящий момент. Если бы в этом было что-то, что могло бы улучшить его масштаб, я бы подумал, что это гораздо более стоящая задача.

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

14 голосов
/ 20 марта 2010

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

def escape1(input):
        return input.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace("'", '&#39;').replace('"', '&quot;')

translation_table = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    "'": '&#39;',
    '"': '&quot;',
}

def escape2(input):
        return ''.join(translation_table.get(char, char) for char in input)

import re
_escape3_re = re.compile(r'[&<>\'"]')
def _escape3_repl(x):
    s = x.group(0)
    return translation_table.get(s, s)
def escape3(x):
    return _escape3_re.sub(_escape3_repl, x)

def escape4(x):
    return unicode(x).translate(translation_table)

test_strings = (
    'Nothing in there.',
    '<this is="not" a="tag" />',
    'Something & Something else',
    'This one is pretty long. ' * 50
)

import time

for test_i, test_string in enumerate(test_strings):
    print repr(test_string)
    for func in escape1, escape2, escape3, escape4:
        start_time = time.time()
        for i in xrange(1000):
            x = func(test_string)
        print '\t%s done in %.3fms' % (func.__name__, (time.time() - start_time))
    print

Запуск этого дает вам:

'Nothing in there.'
    escape1 done in 0.002ms
    escape2 done in 0.009ms
    escape3 done in 0.001ms
    escape4 done in 0.005ms

'<this is="not" a="tag" />'
    escape1 done in 0.002ms
    escape2 done in 0.012ms
    escape3 done in 0.009ms
    escape4 done in 0.007ms

'Something & Something else'
    escape1 done in 0.002ms
    escape2 done in 0.012ms
    escape3 done in 0.003ms
    escape4 done in 0.007ms

'This one is pretty long. <snip>'
    escape1 done in 0.008ms
    escape2 done in 0.386ms
    escape3 done in 0.011ms
    escape4 done in 0.310ms

Похоже, что замена их одна за другой идет быстрее всего.

Редактировать: Повторный запуск тестов с 1000000 итерациями дает следующее для первых трех строк (четвертая может занять слишком много времени на моем компьютере, чтобы я мог ждать = P):

'Nothing in there.'
    escape1 done in 0.001ms
    escape2 done in 0.008ms
    escape3 done in 0.002ms
    escape4 done in 0.005ms

'<this is="not" a="tag" />'
    escape1 done in 0.002ms
    escape2 done in 0.011ms
    escape3 done in 0.009ms
    escape4 done in 0.007ms

'Something & Something else'
    escape1 done in 0.002ms
    escape2 done in 0.011ms
    escape3 done in 0.003ms
    escape4 done in 0.007ms

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

13 голосов
/ 20 марта 2010

Я предпочитаю что-то чистое, как:

substitutions = [
    ('<', '&lt;'),
    ('>', '&gt;'),
    ...]

for search, replacement in substitutions:
    string = string.replace(search, replacement)
7 голосов
/ 20 марта 2010

Вы можете использовать уменьшить:

reduce(lambda s,r: s.replace(*r),
       [('&', '&amp;'),
        ('<', '&lt;'),
        ('>', '&gt;'),
        ("'", '&#39;'),
        ('"', '&quot;')],
       string)
7 голосов
/ 20 марта 2010

Вот что Джанго делает :

def escape(html):
    """Returns the given HTML with ampersands, quotes and carets encoded."""
    return mark_safe(force_unicode(html).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#39;'))
4 голосов
/ 20 марта 2010

В соответствии с предложением bebraw, вот что я в итоге использовал (в отдельном модуле, конечно):

import re

class Subs(object):
    """
    A container holding strings to be searched for and replaced in
    replace_multi().

    Holds little relation to the sandwich.
    """
    def __init__(self, needles_and_replacements):
        """
        Returns a new instance of the Subs class, given a dictionary holding 
        the keys to be searched for and the values to be used as replacements.
        """
        self.lookup = needles_and_replacements
        self.regex = re.compile('|'.join(map(re.escape,
                                             needles_and_replacements)))

def replace_multi(string, subs):
    """
    Replaces given items in string efficiently in a single-pass.

    "string" should be the string to be searched.
    "subs" can be either:
        A.) a dictionary containing as its keys the items to be
            searched for and as its values the items to be replaced.
        or B.) a pre-compiled instance of the Subs class from this module
               (which may have slightly better performance if this is
                called often).
    """
    if not isinstance(subs, Subs): # Assume dictionary if not our class.
        subs = Subs(subs)
    lookup = subs.lookup
    return subs.regex.sub(lambda match: lookup[match.group(0)], string)

Пример использования:

def escape(string):
    """
    Returns the given string with ampersands, quotes and angle 
    brackets encoded.
    """
    # Note that ampersands must be escaped first; the rest can be escaped in 
    # any order.
    escape.subs = Subs({'<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'})
    return replace_multi(string.replace('&', '&amp;'), escape.subs)

Намного лучше :). Спасибо за помощь.

Редактировать

Неважно, Майк Грэм был прав. Я проверил это, и замена в итоге оказалась на намного медленнее.

Код:

from urllib2 import urlopen
import timeit

def escape1(string):
    """
    Returns the given string with ampersands, quotes and angle
    brackets encoded.
    """
    return string.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace("'", '&#39;').replace('"', '&quot;')

def escape2(string):
    """
    Returns the given string with ampersands, quotes and angle
    brackets encoded.
    """
    # Note that ampersands must be escaped first; the rest can be escaped in
    # any order.
    escape2.subs = Subs({'<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'})
    return replace_multi(string.replace('&', '&amp;'), escape2.subs)

# An example test on the stackoverflow homepage.
request = urlopen('http://stackoverflow.com')
test_string = request.read()
request.close()

test1 = timeit.Timer('escape1(test_string)',
                     setup='from __main__ import escape1, test_string')
test2 = timeit.Timer('escape2(test_string)',
                     setup='from __main__ import escape2, test_string')
print 'multi-pass:', test1.timeit(2000)
print 'single-pass:', test2.timeit(2000)

Выход:

multi-pass: 15.9897229671
single-pass: 66.5422530174

Так много для этого.

3 голосов
/ 20 марта 2010

По-видимому, довольно часто это реализуется с помощью регулярных выражений. Вы можете найти пример этого в ASPN и здесь .

1 голос
/ 21 мая 2010

хорошо, так что я сел и сделал математику. Пожалуйста, не сердитесь на меня, я отвечаю, конкретно обсуждая решение ΤΖΩΤΖΙΟΥ, но это было бы довольно сложно заключить в комментарий, поэтому позвольте мне сделать это так. На самом деле я также выскажу некоторые соображения, которые имеют отношение к вопросу ФП.

Прежде всего, я обсуждал с ΤΖΩΤΖ элегантность, правильность и жизнеспособность его подхода. Оказывается, это выглядит как предложение, хотя оно использует (изначально неупорядоченный) словарь в качестве регистра для хранения пар подстановки, фактически последовательно возвращает правильные результаты, где я утверждал, что это не так. это потому, что вызов itertools.starmap() в строке 11 ниже получает в качестве второго аргумента итератор для пар одиночных символов / байтов (подробнее об этом позже), который выглядит как [ ( 'h', 'h', ), ( 'e', 'e', ), ( 'l', 'l', ), ... ]. эти пары символов / байтов - то, с чем первый аргумент, replacer.get, вызывается неоднократно. нет никакого шанса столкнуться с ситуацией, когда сначала '>' преобразуется в '&gt;', а затем непреднамеренно снова в '&amp;gt;', поскольку каждый символ / байт рассматривается только один раз для подстановки. так что эта часть в принципе хороша и алгоритмически верна.

следующий вопрос - это жизнеспособность, и это будет включать в себя взгляд на производительность. если жизненно важное задание выполняется правильно за 0,01 с использованием неудобного кода, но 1 с использованием удивительного кода, то неудобное может считаться предпочтительным на практике (но только в том случае, если потеря в 1 секунду фактически недопустима). вот код, который я использовал для тестирования; он включает в себя ряд различных реализаций. он написан на python 3.1, так что мы можем использовать греческие буквы юникода для идентификаторов, что само по себе удивительно (zip в py3k возвращает то же самое, что itertools.izip в py2):

import itertools                                                                  #01
                                                                                  #02
_replacements = {                                                                 #03
  '&': '&amp;',                                                                   #04
  '<': '&lt;',                                                                    #05
  '>': '&gt;',                                                                    #06
  '"': '&quot;',                                                                  #07
  "'": '&#39;', }                                                                 #08
                                                                                  #09
def escape_ΤΖΩΤΖΙΟΥ( a_string ):                                                  #10
  return ''.join(                                                                 #11
    itertools.starmap(                                                            #12
      _replacements.get,                                                          #13
      zip( a_string, a_string ) ) )                                               #14
                                                                                  #15
def escape_SIMPLE( text ):                                                        #16
  return ''.join( _replacements.get( chr, chr ) for chr in text )                 #17
                                                                                  #18
def escape_SIMPLE_optimized( text ):                                              #19
  get = _replacements.get                                                         #20
  return ''.join( get( chr, chr ) for chr in text )                               #21
                                                                                  #22
def escape_TRADITIONAL( text ):                                                   #23
  return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\    #24
    .replace("'", '&#39;').replace('"', '&quot;')                                 #25

это временные результаты:

escaping with SIMPLE            took 5.74664253sec for 100000 items
escaping with SIMPLE_optimized  took 5.11457801sec for 100000 items
escaping TRADITIONAL in-situ    took 0.57543013sec for 100000 items
escaping with TRADITIONAL       took 0.62347413sec for 100000 items
escaping a la ΤΖΩΤΖΙΟΥ          took 2.66592320sec for 100000 items

говорит о том, что первоначальный постер обеспокоен тем, что «традиционный» метод становится «ужасно быстрым и, по-видимому, имеет плохую алгоритмическую производительность», он выглядит частично необоснованным, если рассматривать его в этом контексте. это на самом деле работает лучше всего; когда мы спрятаны в вызове функции, мы получаем 8% -ное снижение производительности («вызов методов стоит дорого», но в целом вам все равно следует это делать). для сравнения, реализация ΤΖΩΤΖΙΟΥ занимает примерно в 5 раз больше времени, чем традиционный метод, что, учитывая его более высокую сложность, должно конкурировать с давно отточенными, оптимизированными строковыми методами, что неудивительно.

здесь есть еще один алгоритм, ПРОСТОЙ. насколько я вижу, это в значительной степени делает именно то, что делает метод ΤΖΩΤΖΙΟΥ: он перебирает символы / байты в тексте и выполняет поиск каждого из них, затем объединяет все символы / байты вместе и возвращает полученный экранированный текст. Вы можете видеть, что если один из способов сделать это включает в себя довольно длинную и таинственную формулировку, реализация SIMPLE на самом деле понятна с первого взгляда.

Что меня действительно удивляет, так это то, насколько плохо работает ПРОСТОЙ подход: он примерно в 10 раз медленнее традиционного, а также вдвое медленнее, чем метод ΤΖΩΤΖΙΟΥ. я в полном недоумении, может кто-нибудь придумает, почему так должно быть. он использует только самые основные строительные блоки python и работает с двумя неявными итерациями, поэтому он избегает создания одноразовых списков и всего, но он все еще медленный, и я не знаю почему.

позвольте мне завершить этот обзор кода замечанием о достоинствах ΤΖΩРешение. я достаточно ясно дал понять, что нахожу код трудным для чтения и слишком сложным для выполнения поставленной задачи. однако более критично то, что я нахожу способ, которым он обращается с персонажами и следит за тем, чтобы для данного небольшого диапазона символов они вели себя как байты, немного раздражая. конечно, это работает для поставленной задачи, но как только я повторюсь, например. над строкой «ΤΖΩΤΖΙΟΥ» я перебираю соседние байты, представляющие отдельные символы. в большинстве ситуаций это именно то, чего вам следует избегать; Именно поэтому в py3k «строки» теперь являются «объектами Юникода» старых, а «строки» старых становятся «байтами» и «байтовыми массивами». если бы я назначил одну особенность py3k, которая могла бы гарантировать возможно дорогостоящую миграцию кода из серии 2 в серию 3, это было бы это единственное свойство py3k. С тех пор 98% всех моих проблем с кодировкой только что исчезли, и нет никакого хитрого взлома, который мог бы заставить меня серьезно усомниться в моем движении. указанный алгоритм не является «концептуально чистым 8-битным и безопасным в Юникоде», что для меня является серьезным недостатком, учитывая, что это 2010. - 1025 *

1 голос
/ 21 марта 2010

Если вы работаете со строками не-Unicode и Python <3.0, попробуйте альтернативный метод <code>translate:

# Python < 3.0
import itertools

def escape(a_string):
    replacer= dict( (chr(c),chr(c)) for c in xrange(256))
    replacer.update(
        {'&': '&amp;',
         '<': '&lt;',
         '>': '&gt;',
         '"': '&quot;',
         "'": '&#39;'}
    )
    return ''.join(itertools.imap(replacer.__getitem__, a_string))

if __name__ == "__main__":
    print escape('''"Hello"<i> to George's friend&co.''')

$ python so2484156.py 
&quot;Hello&quot;&lt;i&gt; to George&#39;s friend&amp;co.

По вашему желанию это ближе к "одиночному сканированию" входной строки.

EDIT

Я намеревался создать unicode.translate эквивалент, который не ограничивался бы заменой одного символа, поэтому я пришел к ответу выше; Я получил комментарий пользователя «поток», который был почти полностью вне контекста, с единственной правильной точкой: приведенный выше код, как есть, предназначен для работы с байтовыми строками , а не строками юникода . Существует очевидное обновление (то есть unichr ()… xrange (sys.maxunicode + 1)), которое мне сильно не нравится, поэтому я предложил другую функцию, которая работает как с юникодом, так и с байтовыми строками, учитывая, что Python гарантирует:

all( (chr(i)==unichr(i) and hash(chr(i))==hash(unichr(i)))
    for i in xrange(128)) is True

Новая функция:

def escape(a_string):
    replacer= {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#39;',
    }
    return ''.join(
        itertools.starmap(
            replacer.get, # .setdefault *might* be faster
            itertools.izip(a_string, a_string)
        )
    )

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

...