42

我开始觉得使用正则表达式会降低代码的可维护性。正则表达式的简洁和强大有一些不好的地方。Perl 将其与默认运算符等副作用结合在一起。

我确实有用至少一个句子给出基本意图和至少一个匹配内容的示例来记录正则表达式的习惯。

因为正则表达式是建立起来的,所以我觉得绝对有必要评论表达式中每个元素的最大组成部分。尽管如此,即使是我自己的正则表达式也让我摸不着头脑,就好像我在读克林贡语一样。

你是否故意降低你的正则表达式?您是否将可能更短且功能更强大的步骤分解为更简单的步骤?我已经放弃了嵌套正则表达式。是否存在由于可维护性问题而避免使用的正则表达式构造?

不要让这个例子模糊了这个问题。

如果迈克尔·阿什(Michael Ash )的以下内容中存在某种错误,那么您是否有做任何事情但完全扔掉它的前景?

^(?:(?:(?:0?[13578]|1[02])(\/|-|\.)31)\1|(?:(?:0?[13-9]|1[0-2])(\/|-|\.)(?:29|30)\2))(?:(?:1[6-9]|[2-9]\d)?\d{2})$|^(?:0?2(\/|-|\.)29\3(?:(?:(?:1[6-9]|[2-9]\d)?(?:0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00))))$|^(?:(?:0?[1-9])|(?:1[0-2]))(\/|-|\.)(?:0?[1-9]|1\d|2[0-8])\4(?:(?:1[6-9]|[2-9]\d)?\d{2})$

根据请求,可以使用上面的 Ash 先生的链接找到确切的目的。

比赛01.1.02 | 2001 年 11 月 30 日 | 2000 年 2 月 29 日

不匹配02/29/01 | 2002 年 13 月 1 日 | 2002 年 11 月 00 日

4

13 回答 13

32

使用Expresso,它给出了正则表达式的分层英文分解。

或者

Darren Neimke 的这个提示

.NET 允许通过 RegExOptions.IgnorePatternWhitespace 编译器选项和嵌入在模式字符串的每一行中的 (?#...) 语法使用嵌入式注释来创作正则表达式模式。

这允许在每一行中嵌入类似伪代码的注释,并对可读性产生以下影响:

Dim re As New Regex ( _
    "(?<=       (?# Start a positive lookBEHIND assertion ) " & _
    "(#|@)      (?# Find a # or a @ symbol ) " & _
    ")          (?# End the lookBEHIND assertion ) " & _
    "(?=        (?# Start a positive lookAHEAD assertion ) " & _
    "   \w+     (?# Find at least one word character ) " & _
    ")          (?# End the lookAHEAD assertion ) " & _
    "\w+\b      (?# Match multiple word characters leading up to a word boundary)", _
    RegexOptions.Multiline Or RegexOptions.IgnoreCase Or RegexOptions.IgnoreWhitespace _
)

这是另一个 .NET 示例(需要RegexOptions.MultilineandRegexOptions.IgnorePatternWhitespace选项):

static string validEmail = @"\b    # Find a word boundary
                (?<Username>       # Begin group: Username
                [a-zA-Z0-9._%+-]+  #   Characters allowed in username, 1 or more
                )                  # End group: Username
                @                  # The e-mail '@' character
                (?<Domainname>     # Begin group: Domain name
                [a-zA-Z0-9.-]+     #   Domain name(s), we include a dot so that
                                   #   mail.somewhere is also possible
                .[a-zA-Z]{2,4}     #   The top level domain can only be 4 characters
                                   #   So .info works, .telephone doesn't.
                )                  # End group: Domain name
                \b                 # Ending on a word boundary
                ";

如果您的 RegEx 适用于常见问题,另一种选择是将其记录并提交给RegExLib,在那里将对其进行评级和评论。没有什么能比得上很多双眼睛……

另一个 RegEx 工具是The Regulator

于 2009-04-02T04:18:03.310 回答
19

我通常只是尝试将所有正则表达式调用包装在它们自己的函数中,并使用有意义的名称和一些基本注释。我喜欢将正则表达式视为一种只写的语言,只有编写它的人才能阅读(除非它真的很简单)。我完全希望有人可能需要完全重写表达式,如果他们不得不改变它的意图,这可能是为了更好地保持正则表达式训练的活力。

于 2009-04-02T04:16:46.510 回答
17

好吧,PCRE /x 修饰符的全部目的是让您编写更易读的正则表达式,就像在这个简单的例子中一样:

my $expr = qr/
    [a-z]    # match a lower-case letter
    \d{3,5}  # followed by 3-5 digits
/x;
于 2009-04-02T04:19:58.640 回答
8

有些人将 RE 用于错误的事情(我正在等待关于如何使用单个 RE 检测有效 C++ 程序的第一个 SO 问题)。

我通常会发现,如果我的 RE 不能容纳在 60 个字符之内,最好是一段代码,因为它几乎总是更具可读性。

无论如何,我总是在代码中详细记录 RE 应该实现的目标。这是因为我从痛苦的经历中知道,让其他人(甚至是六个月后的我)进来并尝试理解是多么困难。

我不相信它们是邪恶的,尽管我确实相信某些使用它们的人是邪恶的(不是看着你,Michael Ash :-)。它们是一个很好的工具,但就像电锯一样,如果你不知道如何正确使用它们,你就会把腿砍掉。

更新:实际上,我刚刚点击了那个怪物的链接,它是为了验证 1600 年到 9999 年之间的 m/d/y 格式日期。这是完整代码更具可读性和可维护性的经典案例.

您只需将其分成三个字段并检查各个值。如果我的一个手下给我买了这个,我几乎会认为这是一种值得终止的罪行。我当然会把它们送回去以正确地写出来。

于 2009-04-02T04:15:19.993 回答
5

这是分解成可消化部分的相同正则表达式。除了更具可读性之外,一些子正则表达式本身也很有用。更改允许的分隔符也容易得多。

#!/usr/local/ActivePerl-5.10/bin/perl

use 5.010; #only 5.10 and above
use strict;
use warnings;

my $sep         = qr{ [/.-] }x;               #allowed separators    
my $any_century = qr/ 1[6-9] | [2-9][0-9] /x; #match the century 
my $any_decade  = qr/ [0-9]{2} /x;            #match any decade or 2 digit year
my $any_year    = qr/ $any_century? $any_decade /x; #match a 2 or 4 digit year

#match the 1st through 28th for any month of any year
my $start_of_month = qr/
    (?:                         #match
        0?[1-9] |               #Jan - Sep or
        1[0-2]                  #Oct - Dec
    )
    ($sep)                      #the separator
    (?: 
        0?[1-9] |               # 1st -  9th or
        1[0-9]  |               #10th - 19th or
        2[0-8]                  #20th - 28th
    )
    \g{-1}                      #and the separator again
/x;

#match 28th - 31st for any month but Feb for any year
my $end_of_month = qr/
    (?:
        (?: 0?[13578] | 1[02] ) #match Jan, Mar, May, Jul, Aug, Oct, Dec
        ($sep)                  #the separator
        31                      #the 31st
        \g{-1}                  #and the separator again
        |                       #or
        (?: 0?[13-9] | 1[0-2] ) #match all months but Feb
        ($sep)                  #the separator
        (?:29|30)               #the 29th or the 30th
        \g{-1}                  #and the separator again
    )
/x;

#match any non-leap year date and the first part of Feb in leap years
my $non_leap_year = qr/ (?: $start_of_month | $end_of_month ) $any_year/x;

#match 29th of Feb in leap years
#BUG: 00 is treated as a non leap year
#even though 2000, 2400, etc are leap years
my $feb_in_leap = qr/
    0?2                         #match Feb
    ($sep)                      #the separtor
    29                          #the 29th
    \g{-1}                      #the separator again
    (?:
        $any_century?           #any century
        (?:                     #and decades divisible by 4 but not 100
            0[48]       | 
            [2468][048] |
            [13579][26]
        )
        |
        (?:                     #or match centuries that are divisible by 4
            16          | 
            [2468][048] |
            [3579][26]
        )
        00                      
    )
/x;

my $any_date  = qr/$non_leap_year|$feb_in_leap/;
my $only_date = qr/^$any_date$/;

say "test against garbage";
for my $date (qw(022900 foo 1/1/1)) {
    say "\t$date ", $date ~~ $only_date ? "matched" : "didn't match";
}
say '';

#comprehensive test

my @code = qw/good unmatch month day year leap/;
for my $sep (qw( / - . )) {
    say "testing $sep";
    my $i  = 0;
    for my $y ("00" .. "99", 1600 .. 9999) {
        say "\t", int $i/8500*100, "% done" if $i++ and not $i % 850;
        for my $m ("00" .. "09", 0 .. 13) {
            for my $d ("00" .. "09", 1 .. 31) {
                my $date = join $sep, $m, $d, $y;
                my $re   = $date ~~ $only_date || 0;
                my $code = not_valid($date);
                unless ($re == !$code) {
                    die "error $date re $re code $code[$code]\n"
                }
            }
        }
    }
}

sub not_valid {
    state $end = [undef, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    my $date      = shift;
    my ($m,$d,$y) = $date =~ m{([0-9]+)[-./]([0-9]+)[-./]([0-9]+)};
    return 1 unless defined $m; #if $m is set, the rest will be too
    #components are in roughly the right ranges
    return 2 unless $m >= 1 and $m <= 12;
    return 3 unless $d >= 1 and $d <= $end->[$m];
    return 4 unless ($y >= 0 and $y <= 99) or ($y >= 1600 and $y <= 9999);
    #handle the non leap year case
    return 5 if $m == 2 and $d == 29 and not leap_year($y);

    return 0;
}

sub leap_year {
    my $y    = shift;
    $y = "19$y" if $y < 1600;
    return 1 if 0 == $y % 4 and 0 != $y % 100 or 0 == $y % 400;
    return 0;
}
于 2009-04-02T10:29:58.687 回答
4

我学会了避免使用最简单的正则表达式。我更喜欢其他模型,例如 Icon 的字符串扫描或 Haskell 的解析组合器。在这两种模型中,您都可以编写与内置字符串操作具有相同权限和状态的用户定义代码。如果我在 Perl 中编程,我可能会在 Perl 中安装一些解析组合器——我已经为其他语言做过。

一个非常好的替代方法是使用解析表达式语法,就像 Roberto Ierusalimschy 使用他的LPEG包所做的那样,但与解析器组合器不同,这是您无法在一个下午完成的事情。但是,如果有人已经为您的平台完成了 PEG,那么它是正则表达式的一个非常好的替代品。

于 2009-04-02T04:25:45.427 回答
4

我发现一个很好的方法是将匹配过程简单地分解为几个阶段。它的执行速度可能不那么快,但您还可以获得额外的好处,即能够在更细粒度的级别上判断为什么没有发生匹配。

另一种方法是使用 LL 或 LR 解析。即使使用 perl 的非 fsm 扩展,某些语言也不能表达为正则表达式。

于 2009-04-02T04:42:21.643 回答
4

哇,太丑了 看起来它应该可以工作,以一个不可避免的错误为模,将 00 处理为两位数的年份(它应该是四分之一时间的闰年,但没有世纪你无法知道它应该是什么)。有很多冗余可能应该被考虑到子正则表达式中,我将为三个主要案例创建三个子正则表达式(这是我今晚的下一个项目)。我还为分隔符使用了不同的字符以避免转义正斜杠,将单个字符交替更改为字符类(这很高兴让我们避免转义句点),并更改\d[0-9]因为前者匹配任何数字字符(包括U+1815 MONGOLIAN DIGIT FIVE: ᠕) 在 Perl 5.8 和 5.10 中。

警告,未经测试的代码:

#!/usr/bin/perl

use strict;
use warnings;

my $match_date = qr{
    #match 29th - 31st of all months but 2 for the years 1600 - 9999
    #with optionally leaving off the first two digits of the year
    ^
    (?: 
        #match the 31st of 1, 3, 5, 7, 8, 10, and 12
        (?: (?: 0? [13578] | 1[02] ) ([/-.]) 31) \1
        |
        #or match the 29th and 30th of all months but 2
        (?: (?: 0? [13-9] | 1[0-2] ) ([/-.]) (?:29|30) \2)
    )
    (?:
        (?:                      #optionally match the century
            1[6-9] |         #16 - 19
            [2-9][0-9]       #20 - 99
        )?
        [0-9]{2}                 #match the decade
    )
    $
    |
    #or match 29 for 2 for leap years
    ^
    (?:
    #FIXME: 00 is treated as a non leap year 
    #even though 2000, 2400, etc are leap years
        0?2                      #month 2
        ([/-.])                  #separtor
        29                       #29th
        \3                       #separator from before
        (?:                      #leap years
            (?:
                #match rule 1 (div 4) minus rule 2 (div 100)
                (?: #match any century
                    1[6-9] |
                    [2-9][0-9]
                )?
                (?: #match decades divisible by 4 but not 100
                    0[48]       | 
                    [2468][048] |
                    [13579][26]
                )
                |
                #or match rule 3 (div 400)
                (?:
                    (?: #match centuries that are divisible by 4
                        16          | 
                        [2468][048] |
                        [3579][26]
                    )
                    00
                )
            )
        )
    )
    $
    |
    #or match 1st through 28th for all months between 1600 and 9999
    ^
    (?: (?: 0?[1-9]) | (?:1[0-2] ) ) #all months
    ([/-.])                          #separator
    (?: 
        0?[1-9] |                #1st -  9th  or
        1[0-9]  |                #10th - 19th or
        2[0-8]                   #20th - 28th
    )
    \4                               #seprator from before
    (?:                              
        (?:                      #optionally match the century
            1[6-9] |         #16 - 19
            [2-9][0-9]       #20 - 99
        )?
        [0-9]{2}                 #match the decade
    )
    $
}x;
于 2009-04-02T05:23:04.167 回答
3

有些人在遇到问题时会想“我知道,我会使用正则表达式”。现在他们有两个问题。— comp.lang.emacs 中的 Jamie Zawinski。

保持正则表达式尽可能简单(KISS)。在您的日期示例中,我可能会为每种日期类型使用一个正则表达式。

或者更好的是,将其替换为库(即日期解析库)。

我还会采取措施确保输入源有一些限制(即只有一种类型的日期字符串,最好是ISO-8601)。

还,

  • 当时的一件事(提取值可能除外)
  • 如果正确使用高级构造是可以的(如简化表达式并因此减少维护)

编辑:

“高级构造导致维护问题”

我最初的观点是,如果使用得当,它应该会导致更简单的表达式,而不是更难的表达式。更简单的表达式应该减少维护。

我已经更新了上面的文字以说明这一点。

我要指出,正则表达式本身几乎没有资格作为高级构造。不熟悉某个构念并不会使它成为一种高级构念,而只是一个不熟悉的构念。这并没有改变正则表达式强大、紧凑并且——如果使用得当——优雅的事实。就像手术刀一样,它完全掌握在使用它的人手中。

于 2009-04-02T09:14:39.417 回答
1

我仍然可以使用它。我只是使用Regulator。它允许您做的一件事是保存正则表达式及其测试数据。

当然,我也可以添加评论。


这是 Expresso 制作的。我以前从未使用过它,但现在,Regulator 失业了:

// 使用 System.Text.RegularExpressions;

///
/// 为 C# 构建的正则表达式 on: Thu, Apr 2, 2009, 12:51:56 AM
/// 使用 Expresso 版本:3.0.3276,http://www.ultrapico.com
///  
/// 正则表达式的描述:
///  
/// 从 3 个选项中选择
/// ^(?:(?:(?:0?[13578]|1[02])(\/|-|\.)31)\1|(?:(?:0?[13-9 ]|1[0-2])(\/|-|\.)(?:29|30)\2))(?:(?:1[6-9]|[2-9]\d) ?\d{2})$
/// 行或字符串的开头
/// 匹配表达式但不捕获它。[(?:(?:0?[13578]|1[02])(\/|-|\.)31)\1|(?:(?:0?[13-9]|1[0- 2])(\/|-|\.)(?:29|30)\2)]
/// 从 2 个选项中选择
/// (?:(?:0?[13578]|1[02])(\/|-|\.)31)\1
/// 匹配表达式但不捕获它。[(?:0?[13578]|1[02])(\/|-|\.)31]
/// (?:0?[13578]|1[02])(\/|-|\.)31
/// 匹配表达式但不捕获它。[0?[13578]|1[02]]
/// 从 2 个选项中选择
/// 0?[13578]
/// 0,零或一重复
/// 此类中的任何字符:[13578]
/// 1[02]
/// 1
/// 此类中的任何字符:[02]
/// [1]:一个编号的捕获组。[\/|-|\.]
/// 从 3 个选项中选择
/// 字面量/
/// -
/// 字面量。
/// 31
/// 对捕获编号的反向引用:1
/// (?:(?:0?[13-9]|1[0-2])(\/|-|\.)(?:29|30)\2)
/// 返回
/// 新队
/// 匹配表达式但不捕获它。[(?:0?[13-9]|1[0-2])(\/|-|\.)(?:29|30)\2]
/// (?:0?[13-9]|1[0-2])(\/|-|\.)(?:29|30)\2
/// 匹配表达式但不捕获它。[0?[13-9]|1[0-2]]
/// 从 2 个选项中选择
/// 0?[13-9]
/// 0,零或一重复
/// 此类中的任何字符:[13-9]
/// 1[0-2]
/// 1
/// 此类中的任何字符:[0-2]
/// [2]:一个编号的捕获组。[\/|-|\.]
/// 从 3 个选项中选择
/// 字面量/
/// -
/// 字面量。
/// 匹配表达式但不捕获它。[29|30]
/// 从 2 个选项中选择
/// 29
/// 29
/// 30
/// 30
/// 对捕获编号的反向引用:2
/// 返回
/// 新队
/// 匹配表达式但不捕获它。[(?:1[6-9]|[2-9]\d)?\d{2}]
/// (?:1[6-9]|[2-9]\d)?\d{2}
/// 匹配表达式但不捕获它。[1[6-9]|[2-9]\d],零次或一次重复
/// 从 2 个选项中选择
/// 1[6-9]
/// 1
/// 此类中的任何字符:[6-9]
/// [2-9]\d
/// 此类中的任何字符:[2-9]
/// 任意数字
/// 任意数字,正好 2 次重复
/// 行尾或字符串
/// ^(?:0?2(\/|-|\.)29\3(?:(?:(?:1[6-9]|[2-9]\d)?(?: 0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00))))$
/// 行或字符串的开头
/// 匹配表达式但不捕获它。[0?2(\/|-|\.)29\3(?:(?:(?:1[6-9]|[2-9]\d)?(?:0[48]|[ 2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00)))]
/// 0?2(\/|-|\.)29\3(?:(?:(?:1[6-9]|[2-9]\d)?(?:0[48] |[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00)))
/// 0,零次或一次重复2
/// [3]:一个编号的捕获组。[\/|-|\.]
/// 从 3 个选项中选择
/// 字面量/
/// -
/// 字面量。
/// 29
/// 对捕获编号的反向引用:3
/// 匹配表达式但不捕获它。[(?:(?:1[6-9]|[2-9]\d)?(?:0[48]|[2468][048]|[13579][26])|(?:( ?:16|[2468][048]|[3579][26])00))]
/// 匹配表达式但不捕获它。[(?:1[6-9]|[2-9]\d)?(?:0[48]|[2468][048]|[13579][26])|(?:(?:16 |[2468][048]|[3579][26])00)]
/// 从 2 个选项中选择
/// (?:1[6-9]|[2-9]\d)?(?:0[48]|[2468][048]|[13579][26])
/// 匹配表达式但不捕获它。[1[6-9]|[2-9]\d],零次或一次重复
/// 从 2 个选项中选择
/// 1[6-9]
/// 1
/// 此类中的任何字符:[6-9]
/// [2-9]\d
/// 此类中的任何字符:[2-9]
/// 任意数字
/// 匹配表达式但不捕获它。[0[48]|[2468][048]|[13579][26]]
/// 从 3 个选项中选择
/// 0[48]
/// 0
/// 此类中的任何字符:[48]
/// [2468][048]
/// 此类中的任何字符:[2468]
/// 此类中的任何字符:[048]
/// [13579][26]
/// 此类中的任何字符:[13579]
/// 此类中的任何字符:[26]
/// (?:(?:16|[2468][048]|[3579][26])00)
/// 返回
/// 新队
/// 匹配表达式但不捕获它。[(?:16|[2468][048]|[3579][26])00]
/// (?:16|[2468][048]|[3579][26])00
/// 匹配表达式但不捕获它。[16|[2468][048]|[3579][26]]
/// 从 3 个选项中选择
/// 16
/// 16
/// [2468][048]
/// 此类中的任何字符:[2468]
/// 此类中的任何字符:[048]
/// [3579][26]
/// 此类中的任何字符:[3579]
/// 此类中的任何字符:[26]
/// 00
/// 行尾或字符串
/// ^(?:(?:0?[1-9])|(?:1[0-2]))(\/|-|\.)(?:0?[1-9]| 1\d|2[0-8])\4(?:(?:1[6-9]|[2-9]\d)?\d{2})$
/// 行或字符串的开头
/// 匹配表达式但不捕获它。[(?:0?[1-9])|(?:1[0-2])]
/// 从 2 个选项中选择
/// 匹配表达式但不捕获它。[0?[1-9]]
/// 0?[1-9]
/// 0,零或一重复
/// 此类中的任何字符:[1-9]
/// 匹配表达式但不捕获它。[1[0-2]]
/// 1[0-2]
/// 1
/// 此类中的任何字符:[0-2]
/// 返回
/// 新队
/// [4]:一个编号的捕获组。[\/|-|\.]
/// 从 3 个选项中选择
/// 字面量/
/// -
/// 字面量。
/// 匹配表达式但不捕获它。[0?[1-9]|1\d|2[0-8]]
/// 从 3 个选项中选择
/// 0?[1-9]
/// 0,零或一重复
/// 此类中的任何字符:[1-9]
/// 1\d
/// 1
/// 任意数字
/// 2[0-8]
/// 2
/// 此类中的任何字符:[0-8]
/// 对捕获编号的反向引用:4
/// 匹配表达式但不捕获它。[(?:1[6-9]|[2-9]\d)?\d{2}]
/// (?:1[6-9]|[2-9]\d)?\d{2}
/// 匹配表达式但不捕获它。[1[6-9]|[2-9]\d],零次或一次重复
/// 从 2 个选项中选择
/// 1[6-9]
/// 1
/// 此类中的任何字符:[6-9]
/// [2-9]\d
/// 此类中的任何字符:[2-9]
/// 任意数字
/// 任意数字,正好 2 次重复
/// 行尾或字符串
///  
///
///
公共静态正则表达式正则表达式 = 新正则表达式(
      "^(?:(?:(?:0?[13578]|1[02])(\\/|-|\\.)31)\\1|\r\n(?:(?:0 ?[13-9]"+
      "|1[0-2])(\\/|-|\\.)(?:29|30)\\2))\r\n(?:(?:1[6-9]|[ 2-9]\\d)?\\d"+
      "{2})$|^(?:0?2(\\/|-|\\.)29\\3(?:(?:(?:1[6-9]|[2-9] \\d)?(?:0["+
      "48]|[2468][048]|[13579][26])|\r\n(?:(?:16|[2468][048]|[3579][2"+
      "6])00))))$|^(?:(?:0?[1-9])|(?:1[0-2]))\r\n(\\/|-|\ \.)(?:0?[1-9"+
      "]|1\\d|2[0-8])\\4(?:(?:1[6-9]|[2-9]\\d)?\\d{2})$" ,
    RegexOptions.CultureInvariant
    | RegexOptions.Compiled
    );

于 2009-04-02T04:12:27.550 回答
1

我认为维护正则表达式的答案不在于评论或正则表达式结构。

如果我的任务是调试您给出的示例,我会坐在一个正则表达式调试工具(如Regex Coach)前,并逐步检查它必须处理的数据的正则表达式。

于 2009-04-02T04:13:49.077 回答
1

最近发布了一个关于使用嵌入式评论评论正则表达式的问题有有用的答案,特别是来自@mikej

有关提高正则表达式可读性的更多想法,请参阅 Martin Fowler 在 ComposedRegex 上的帖子。总之,他主张将复杂的正则表达式分解成更小的部分,这些部分可以被赋予有意义的变量名。例如

于 2009-09-04T13:49:34.390 回答
0

我不希望正则表达式是可读的,所以我只保留它们原样,并在需要时重写。

于 2009-04-02T04:10:41.080 回答