用于处理点和逗号作为有效小数分隔符的正则表达式/ perl代码

时间:2009-09-28 15:16:40

标签: regex perl

我正在尝试创建一种方法,在我不知道最终用户使用这两种互斥方式编写数字的方式中,提供“尽力而为”的十进制输入解析:

  • “”。作为千位分隔符和“,”作为小数分隔符
  • “,”作为千位分隔符和“。”作为小数点分隔符

该方法在下面的代码中实现为parse_decimal(..)。此外,我已经定义了20个测试用例,显示了该方法的启发式方法应该如何工作。

虽然下面的代码通过了测试,但它非常可怕且难以理解。我确信有一种更紧凑和可读的方法来实现该方法。可能包括更聪明地使用正则表达式。

我的问题很简单:鉴于下面的代码和测试用例,你如何改进parse_decimal(...)以使其在传递测试时更加紧凑和可读?

澄清:

  • 澄清#1:正如评论中所指出的,案例^\d{1,3}[\.,]\d{3}$是不明确的,因为无法逻辑确定哪个字符用作千位分隔符,哪个用作小数分隔符。在不明确的情况下,我们将简单地假设使用美式小数:“,”作为千位分隔符和“。”作为小数分隔符。
  • 澄清#2:如果您认为任何测试用例都是错误的,请说明应该更改哪些测试以及如何更改。

相关代码包括测试用例:

#!/usr/bin/perl -wT

use strict;
use warnings;
use Test::More tests => 20;

ok(&parse_decimal("1,234,567") == 1234567);
ok(&parse_decimal("1,234567") == 1.234567);
ok(&parse_decimal("1.234.567") == 1234567);
ok(&parse_decimal("1.234567") == 1.234567);
ok(&parse_decimal("12,345") == 12345);
ok(&parse_decimal("12,345,678") == 12345678);
ok(&parse_decimal("12,345.67") == 12345.67);
ok(&parse_decimal("12,34567") == 12.34567);
ok(&parse_decimal("12.34") == 12.34);
ok(&parse_decimal("12.345") == 12345);
ok(&parse_decimal("12.345,67") == 12345.67);
ok(&parse_decimal("12.345.678") == 12345678);
ok(&parse_decimal("12.34567") == 12.34567);
ok(&parse_decimal("123,4567") == 123.4567);
ok(&parse_decimal("123.4567") == 123.4567);
ok(&parse_decimal("1234,567") == 1234.567);
ok(&parse_decimal("1234.567") == 1234.567);
ok(&parse_decimal("12345") == 12345);
ok(&parse_decimal("12345,67") == 12345.67);
ok(&parse_decimal("1234567") == 1234567);

sub parse_decimal($) {
    my $input = shift;
    $input =~ s/[^\d,\.]//g;
    if ($input !~ /[,\.]/) {
        return &parse_with_separators($input, '.', ',');
    } elsif ($input =~ /\d,\d+\.\d/) {
        return &parse_with_separators($input, '.', ',');
    } elsif ($input =~ /\d\.\d+,\d/) {
        return &parse_with_separators($input, ',', '.');
    } elsif ($input =~ /\d\.\d+\.\d/) {
        return &parse_with_separators($input, ',', '.');
    } elsif ($input =~ /\d,\d+,\d/) {
        return &parse_with_separators($input, '.', ',');
    } elsif ($input =~ /\d{4},\d/) {
        return &parse_with_separators($input, ',', '.');
    } elsif ($input =~ /\d{4}\.\d/) {
        return &parse_with_separators($input, '.', ',');
    } elsif ($input =~ /\d,\d{3}$/) {
        return &parse_with_separators($input, '.', ',');
    } elsif ($input =~ /\d\.\d{3}$/) {
        return &parse_with_separators($input, ',', '.');
    } elsif ($input =~ /\d,\d/) {
        return &parse_with_separators($input, ',', '.');
    } elsif ($input =~ /\d\.\d/) {
        return &parse_with_separators($input, '.', ',');
    } else {
        return &parse_with_separators($input, '.', ',');
    }
}

sub parse_with_separators($$$) {
    my $input = shift;
    my $decimal_separator = shift;
    my $thousand_separator = shift;
    my $output = $input;
    $output =~ s/\Q${thousand_separator}\E//g;
    $output =~ s/\Q${decimal_separator}\E/./g;
    return $output;
}

5 个答案:

答案 0 :(得分:5)

这类似于自动猜测输入字符编码的程序 - 它有时可能会起作用,但通常是一种非常糟糕的策略,会导致错误和混淆不确定的行为。

例如,如果您看到“123,456”,则表示您没有足够的信息来猜测这意味着什么。

所以我会谨慎对待这个问题,并且永远不要将这种技术用于任何重要的事情。

答案 1 :(得分:3)

这些问题的想法是查看代码并找出你输入两次的地方。当你看到它时,努力将其删除。我的程序处理测试数据中的所有内容,而且我不必重复执行程序逻辑结构。这让我专注于数据而不是程序流程。

首先,让我们清理你的测试。你确实有一组要测试的对,所以让我们将它们放入数据结构中。您可以根据需要在数据结构中添加或删除项目,测试将自动调整:

use Test::More 'no_plan';

my @pairs = (
     #  got          expect
    [ "1,234,567",  1234567  ],
    [ "1,234567",   1.234567 ],
    [ "1.234.567",  1234567  ],
    [ "1.234567",   1.234567 ],
    [ "12,345",     12345    ],
    [ "12,345,678", 12345678 ],
    [ "12,345.67",  12345.67 ],
    [ "12,34567",   12.34567 ],
    [ "12.34",      12.34    ],
    [ "12.345",     12345    ],  # odd case!
    [ "12.345,67",  12345.67 ],
    [ "12.345.678", 12345678 ],
    [ "12.34567",   12.34567 ],
    [ "123,4567",   123.4567 ],
    [ "123.4567",   123.4567 ],
    [ "1234,567",   1234.567 ],
    [ "1234.567",   1234.567 ],
    [ "12345",      12345    ],
    [ "12345,67",   12345.67 ],
    [ "1234567",    1234567  ],
);

现在您已将其置于数据结构中,您的大量测试将缩短为短foreach循环:

foreach my $pair ( @pairs ) {
     my( $original, $expected ) = @$pair;
     my $got = parse_number( $original );
     is( $got, $expected, "$original translates to $expected" );
     }

parse_number例程同样压缩成这个简单的代码。你的诀窍是在源代码中一遍又一遍地找出你在做什么,而不是那样做。我没有试图找出奇怪的调用约定和条件的长链,而是将数据规范化。我弄清楚哪些情况是奇怪的,然后把它们变成非奇怪的情况。在这段代码中,我将有关分隔符的所有知识浓缩成少数正则表达式,并返回两个可能列表中的一个,以向我展示千位分隔符和小数分隔符。一旦我有了,我完全删除千位分隔符并使小数分隔符完全停止。当我发现更多情况时,我只是添加了一个正则表达式,在该情况下返回true:

sub parse_number
    {
    my $string = shift;

    my( $separator, $decimal ) = do {
        local $_ = $string;
        if( 
            /\.\d\d\d\./           || # two dots
            /\.\d\d\d,/            || # dot before comma
            /,\d{4,}/              || # comma with many following digits
            /\d{4,},/              || # comma with many leading digits
            /^\d{1,3}\.\d\d\d\z/   || # odd case of 123.456
            0
            )
            { qw( . , ) }
        else { qw( , . ) }      
        };

    $string =~ s/\Q$separator//g;
    $string =~ s/\Q$decimal/./;

    $string;
    }

这是我在Mastering Perl的动态子例程章节中讨论的事情。虽然我不会在这里讨论它,但我可能会将这一系列正则表达式转换为某种类型的管道并使用grep。

这只是通过测试的程序的一部分。我将添加另一个步骤来验证该数字是否是处理脏数据的预期格式,但这并不是那么困难,只是一个简单的编程问题。

答案 2 :(得分:1)

这是一个更短更完整的功能版本:

sub parse_decimal($) {
    my $input = shift;
    my %other = ("." => ",", "," => ".");
    $input =~ s/[^\d,.]//g;
    if ($input !~ /[,.]/) {
        return &parse_with_separators($input, '.', ',');
    } elsif ($input =~ /(\.).*(,)/ or $input =~ /(,).*(\.)/) { # Both separators present
        return &parse_with_separators($input, $2, $1);
    } elsif ($input =~ /([,.])$/ or $input =~ /^([,.])/) { # Number ends or begins with decimal separator
        return &parse_with_separators($input, $1, $other{$1});
    } elsif ($input =~ /\d{4}([,.])/ or $input =~ /([,.])\d{4}/) { # group of 4+ digits next to a separator
        return &parse_with_separators($input, $1, $other{$1});
    } elsif ($input =~ /([,.]).*\1/) { # More than one of the same separator
        return &parse_with_separators($input, $other{$1}, $1);
    } elsif ($input =~ /\d*([,.])\d{0,2}$/) { # Fewer than 2 digits to the right of the separator
        return &parse_with_separators($input, $1, $other{$1});
    } else { # Assume '.' is decimal separator and ',' is thousands separator
        return &parse_with_separators($input, '.', ',');
    }
}

一些重要的事情:

  • 你的“12.345”测试用例也含糊不清。任何形式的\ d {1,3} [,。] \ d \ d \ d都是不明确的。
  • 此功能处理“.123”“123。”
  • 形式的数字
  • 这个函数对输入格式良好做出了可怕而可怕的假设。

答案 3 :(得分:0)

尝试猜测任何事物的区域设置始终是一项持续不断的努力。你有什么用这个功能的?以下测试看起来对我来说是错误的:

ok(&parse_decimal("12.34") == 12.34);
ok(&parse_decimal("12.345") == 12345);

如果我正在解析带有值的单个文档,我会非常恼火地发现这个结果。

我会设计此功能,并在包中使用一些旋钮和拨号来使用区域设置信息(使用localeconv())或ad-hoc值(如this answer中所示。)

修改 好的,让我试着更好地解释一下。对于“单一来源”,我指的是上下文或范围分隔符。我知道你可以从不同的来源导入;这是导入数据的本质。我们也知道我们事先无法知道这些不同来源的编码。

我要做的是对要导入的文件进行初步扫描(只是取样,而不是整个读取)并检查数值。如果我可以从示例中确定区域设置,那么我会尝试使用相同的区域设置导入整个文件。对我来说,一个文件是单一来源,我不希望它突然改变它的语言环境。

这就是我再问一次的原因:这个计划的目的是什么?

答案 4 :(得分:0)

要回答您的问题,不要质疑申请的有效性,

您的算法可归结为以下伪代码:

if (num_separators == 1) {
    if (trailing_digits != 3 || leading_digits > 3) {
         # Replace comma with period
         return output
    }
}

# Replace first separator with nothing, and second with '.'

return output

perl中的以下内容:

my $output = $input;

my $num_separators = (scalar split /[\,\.]/, $input) - 1;

if($num_separators == 1) {
    my ($leading_digits, $trailing_digits) = split /[\,\.]/, $input;

    if(length($trailing_digits) != 3 || length($leading_digits) > 3) {
        $output =~ s/\,/\./;
        return eval($output);
    }
}

if($output =~ /^\d+\.\d+/) {
    # Swap commas and periods if periods are first
    $output =~ tr/\.\,/\,\./;
}

# remove commas
$output =~ s/\,//g;

return eval($output);

不知道这是否更好,但它是一般化的。