在Python中高效匹配多个正则表达式

17

当你有正则表达式时,编写词法分析器非常容易。今天我想用Python编写一个简单的通用分析器,写出了以下代码:

import re
import sys

class Token(object):
    """ A simple Token structure.
        Contains the token type, value and position. 
    """
    def __init__(self, type, val, pos):
        self.type = type
        self.val = val
        self.pos = pos

    def __str__(self):
        return '%s(%s) at %s' % (self.type, self.val, self.pos)


class LexerError(Exception):
    """ Lexer error exception.

        pos:
            Position in the input line where the error occurred.
    """
    def __init__(self, pos):
        self.pos = pos


class Lexer(object):
    """ A simple regex-based lexer/tokenizer.

        See below for an example of usage.
    """
    def __init__(self, rules, skip_whitespace=True):
        """ Create a lexer.

            rules:
                A list of rules. Each rule is a `regex, type`
                pair, where `regex` is the regular expression used
                to recognize the token and `type` is the type
                of the token to return when it's recognized.

            skip_whitespace:
                If True, whitespace (\s+) will be skipped and not
                reported by the lexer. Otherwise, you have to 
                specify your rules for whitespace, or it will be
                flagged as an error.
        """
        self.rules = []

        for regex, type in rules:
            self.rules.append((re.compile(regex), type))

        self.skip_whitespace = skip_whitespace
        self.re_ws_skip = re.compile('\S')

    def input(self, buf):
        """ Initialize the lexer with a buffer as input.
        """
        self.buf = buf
        self.pos = 0

    def token(self):
        """ Return the next token (a Token object) found in the 
            input buffer. None is returned if the end of the 
            buffer was reached. 
            In case of a lexing error (the current chunk of the
            buffer matches no rule), a LexerError is raised with
            the position of the error.
        """
        if self.pos >= len(self.buf):
            return None
        else:
            if self.skip_whitespace:
                m = self.re_ws_skip.search(self.buf[self.pos:])

                if m:
                    self.pos += m.start()
                else:
                    return None

            for token_regex, token_type in self.rules:
                m = token_regex.match(self.buf[self.pos:])

                if m:
                    value = self.buf[self.pos + m.start():self.pos + m.end()]
                    tok = Token(token_type, value, self.pos)
                    self.pos += m.end()
                    return tok

            # if we're here, no rule matched
            raise LexerError(self.pos)

    def tokens(self):
        """ Returns an iterator to the tokens found in the buffer.
        """
        while 1:
            tok = self.token()
            if tok is None: break
            yield tok


if __name__ == '__main__':
    rules = [
        ('\d+',             'NUMBER'),
        ('[a-zA-Z_]\w+',    'IDENTIFIER'),
        ('\+',              'PLUS'),
        ('\-',              'MINUS'),
        ('\*',              'MULTIPLY'),
        ('\/',              'DIVIDE'),
        ('\(',              'LP'),
        ('\)',              'RP'),
        ('=',               'EQUALS'),
    ]

    lx = Lexer(rules, skip_whitespace=True)
    lx.input('erw = _abc + 12*(R4-623902)  ')

    try:
        for tok in lx.tokens():
            print tok
    except LexerError, err:
        print 'LexerError at position', err.pos

这个方法是有效的,但我有点担心它效率太低。有没有正则表达式技巧可以让我更高效/优雅地编写它?

具体来说,是否有方法可以避免线性循环遍历所有的正则表达式规则以找到符合条件的规则?

6个回答

12

我建议使用re.Scanner类,它在标准库中没有被记录,但它非常值得使用。这里是一个例子:

import re

scanner = re.Scanner([
    (r"-?[0-9]+\.[0-9]+([eE]-?[0-9]+)?", lambda scanner, token: float(token)),
    (r"-?[0-9]+", lambda scanner, token: int(token)),
    (r" +", lambda scanner, token: None),
])

>>> scanner.scan("0 -1 4.5 7.8e3")[0]
[0, -1, 4.5, 7800.0]

1
我认为令牌应该是(文本,标签)对的列表。仅返回匹配值序列对于后续解析并不是非常有用。 - Meow
很不幸,这个功能没有被实现成生成器。它一次性解析整个内容并返回一个列表。 - Sven Marnach

8
您可以使用“|”运算符将所有正则表达式合并为一个,让正则表达式库来区分标记。需要注意的是,要确保标记的优先级(例如避免将关键字匹配为标识符)。

1
如何使它针对每个选项返回正确的类型? - Eli Bendersky
使用捕获组。将正则表达式的一部分括在括号中,可以将其作为捕获组,并从匹配对象中检索出来,例如re.match("(a)|(b)","b").groups() = (None,"b")。第一个组没有匹配成功,第二个组匹配了"b"。 - Rafał Dowgird
但我仍然需要线性遍历捕获组吗? - Eli Bendersky
2
我认为使用命名捕获组,结合匹配对象的lastgroup属性可以避免繁琐的步骤。例如re.match("(?P<ag>a)|(?P<bg>b)","b").lastgroup='bg' - Rafał Dowgird
如果正则表达式的实现够聪明,就不会有问题。你提供的替代方案将以一种方式合并,如果它们具有某些公共前缀,它们将在单个步骤中被识别,并且只有不同的字符会分割字符串。例如,这个模式:“for | foreach | forbidden”和字符串“foreach”,它应该仅匹配前三个字母(公共前缀)一次,然后根据第一个非公共字符选择正确的路径,这里的 'e' 会选择第二个选项并验证剩下的 'ach' 是否匹配。如果不匹配,则其他的也不能匹配。 - SasQ

7
我在Python文档中找到了这个。它非常简单而优雅。
import collections
import re

Token = collections.namedtuple('Token', ['typ', 'value', 'line', 'column'])

def tokenize(s):
    keywords = {'IF', 'THEN', 'ENDIF', 'FOR', 'NEXT', 'GOSUB', 'RETURN'}
    token_specification = [
        ('NUMBER',  r'\d+(\.\d*)?'), # Integer or decimal number
        ('ASSIGN',  r':='),          # Assignment operator
        ('END',     r';'),           # Statement terminator
        ('ID',      r'[A-Za-z]+'),   # Identifiers
        ('OP',      r'[+*\/\-]'),    # Arithmetic operators
        ('NEWLINE', r'\n'),          # Line endings
        ('SKIP',    r'[ \t]'),       # Skip over spaces and tabs
    ]
    tok_regex = '|'.join('(?P<%s>%s)' % pair for pair in token_specification)
    get_token = re.compile(tok_regex).match
    line = 1
    pos = line_start = 0
    mo = get_token(s)
    while mo is not None:
        typ = mo.lastgroup
        if typ == 'NEWLINE':
            line_start = pos
            line += 1
        elif typ != 'SKIP':
            val = mo.group(typ)
            if typ == 'ID' and val in keywords:
                typ = val
            yield Token(typ, val, line, mo.start()-line_start)
        pos = mo.end()
        mo = get_token(s, pos)
    if pos != len(s):
        raise RuntimeError('Unexpected character %r on line %d' %(s[pos], line))

statements = '''
    IF quantity THEN
        total := total + price * quantity;
        tax := price * 0.05;
    ENDIF;
'''

for token in tokenize(statements):
    print(token)

这里的关键在于这一行代码: ```

```
tok_regex = '|'.join('(?P<%s>%s)' % pair for pair in token_specification)

这里的(?P<ID>PATTERN)将匹配结果标记为由ID指定的名称。关于IT技术方面的内容,希望我可以帮到您。

3

re.match是锚定的。您可以给它一个位置参数:

pos = 0
end = len(text)
while pos < end:
    match = regexp.match(text, pos)
    # do something with your match
    pos = match.end()

可以查找pygments,它提供了大量用于不同实现的语法高亮显示的词法分析器,大多数基于正则表达式。


如何帮助?定位?无需切割文本。 - Armin Ronacher
我明白了。那么我猜我就可以节省时间切片所需的时间了? - Eli Bendersky
不仅时间,还有切片的内存。同样重要的是,如果使用锚定符“^”和“$”,它们将按预期工作。 - Armin Ronacher

3

可能将令牌正则表达式组合起来会起作用,但您需要进行基准测试。类似这样的:

x = re.compile('(?P<NUMBER>[0-9]+)|(?P<VAR>[a-z]+)')
a = x.match('9999').groupdict() # => {'VAR': None, 'NUMBER': '9999'}
if a:
    token = [a for a in a.items() if a[1] != None][0]

过滤器是你需要进行一些基准测试的地方...
更新:我测试过了,如果按照所述组合所有令牌并编写一个函数,似乎可以...
def find_token(lst):
    for tok in lst:
        if tok[1] != None: return tok
    raise Exception

你将获得大致相同的速度(可能稍微快一点),我认为加速必须在匹配调用的数量上,但是仍然存在用于标记识别的循环,这当然会拖慢速度。

你可以使用 a.lastgroup 获取最后匹配组的名称,使用 a.group(a.lastgroup) 获取该组的匹配字符串。无需构建整个字典并查找不是 None 的条目。 - Sven Marnach

1

这并不是你问题的直接答案,但你可能想看看ANTLR。根据this文档,Python代码生成目标应该是最新的。

至于你的正则表达式,如果你坚持使用正则表达式,有两种方法可以加速它。第一种是按照在默认文本中找到它们的概率对正则表达式进行排序。你可以为代码添加一个简单的分析器来收集每个令牌类型的令牌计数,并在一些工作负载上运行词法分析器。另一个解决方案是将你的正则表达式进行桶排序(因为你的键空间是字符,相对较小),然后在进行第一次区分之后使用数组或字典执行所需的正则表达式。

然而,我认为如果你要走这条路,你应该真正尝试一些像ANTLR这样的东西,它将更容易维护,更快,更不可能出现错误。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接