实战Perl脚本测试(转)
这是一篇关于perl脚本测试的总结性文章,其中提到了很多实用的模块,如果文中介绍的不够详细,请到cpan上搜索该模块并查阅其文档。
1基本语法检查
Perl语言的哲学是“There is more than one way to do it”,很多讨厌Perl的人总是拿Perl的这个特性来攻击Perl,而喜欢Perl的人却又极力推崇它。这里不讨论这个特性是好是坏,但不可否认的是,Perl自由的语法,尤其是灵活特性所带来的诸多陷阱,再加上滥用这种灵活的coder,perl代码要做到“难看、难懂、难维护”,的确很容易。
1.1第一步
拿到一份待测试的perl脚本,第一步,我们检查一下代码里有没有加上“use strict;”以及“use warnings;”。这里我大胆断言一下:如果一个100行以上的脚本从开始书写到完成,都没有加上这两句的话,这个脚本十有八九是有bug的。
之所以我这么说,是因为strict和warnings实在是两个非常重要的模块。简单的说,strict模块帮助我们“避免犯错”,warnings模块帮助我们“发现错误”。
1.1.1 use strict;
strict模块一共有strict "vars"、 strict "refs"、 strict "subs"三部分,以strict "vars"为例,此功能要求脚本在使用变量时必须事先声明。
对于$_、@_此类全局变量而言,它们已经由Perl语言作了预定义,而其它我们需要声明的非全局变量,即所谓的词法变量,我们都必须用my来做声明。这样的限制,规范了变量的使用,以免全局变量、局部变量难以区分,从而造成困惑。
为了更好地解释my,给出如下三点附注:
1、my变量也就是"词法变量",其作用域最大时为其所在的脚本文件范围(当my声明在任何一个{}块之外时),最小时为其所在的最内层的{}块。
2、全局变量除了$_此类预定义变量外,还包括用our或use vars来声明的全局变量。
3、local可以将全局变量临时地"局部化",但它不创建新的变量,因此你在声明一个新变量时,请用 my 或 our。
关于strict模块的具体应用,举个例子:
{ $tmp = 3; }
此处省略500行代码
$tmp ++;
即使是两个不相关的变量,你也很可能用了同一个变量名$tmp进行声明,如果没使用 strict 模块,那么$tmp变量默认将为全局变量,前一个$tmp使用后的值,将污染后一个$tmp,尽管你本来把它们当作两个完全不同的临时变量。
使用my可以规范变量作用范围,把很多潜在的错误消灭在代码最初。而strict模块的作用(严格的来说,是strict "vars"的作用),则是强迫你使用my。
1.1.2 use warnings;
看一个例子,warnings模块是怎么帮我们发现bug的:
$ cat a.txt
1 a b 2
3 c d 4
4 d f
9 k f 1
$ cat b.pl
open FI,"a.txt" or die "cant open a.txt";
while(<FI>){
@ret = split /\s*/,$_;
print $ret[0]+$ret[3],"\n";
}
$ perl b.pl
3
7
4
10
这段代码的本意是,从a.txt中按行取出第1、4个字段,打印出加和值。从代码中,可以看到我们默认a.txt每一行都是至少有4条记录的,但如果出现一个异常,导致a.txt中,有一行只有3列值,这种错误我们希望能够检查出来。
然而,在不加warnings模块检查的情况下,虽然$ret[3]值为空,perl是不会报警的。
我们使用warnings模块的命令行形式的参数-w来执行一遍b.pl :
$ perl -w b.pl
3
7
Use of uninitialized value in addition (+) at b.pl line 4, <FI> line 3.
4
10
可以发现,perl解释器给我们打出了警告,并且指明了代码和数据文件出错的具体地址,从而帮助我们发现了bug 。
1.2 用-c参数检查语法
Perl运行脚本,分成”编译阶段"和"运行阶段"两个过程,在"编译阶段",perl解释器会分析一遍语法,如果语法有问题,则直接报错退出,而不进行其它检查,更不会进入"运行阶段"。
有时候我们想检查语法,却又不想语法检查通过后整个脚本被执行,这时候我们可以用perl的 -c 参数,譬如 perl -c test.pl ,这个参数能帮助我们检查完语法后就直接退出,不管检查是否通过。
不过,它有如下两个缺点:
1、 它只能检查语法,而无法通过-w等参数一并进行告警检查。
2、它会执行BEGIN块,并且会检查use方法加载的模块,但它不会执行INIT和END块,也不会执行require方法加载的模块(因为require方法加载模块是在"运行阶段")。
1.3用-T参数检查注入式漏洞
我们都知道SQL注入,其实perl代码测试也需要注意注入式危险。先来看一个例子:
print "${foo()}";
这句代码的目的是,将用户输入的一个指向标量变量的引用的返回值的函数打印出结果来,可假如foo()被替换成system('rm *'),等我们发现打印的结果不对时,危害已经造成了。 解决的办法是使用污染模式(taint-mode)选项(用-T参数):
$ cat d.pl
sub foo { system 'rm *' };
print ${foo()};
$ perl -T d.pl
Insecure $ENV{PATH} while running with -T switch at d.pl line 1.
1.4其它建议
如果源头上产出的就是一份风格良好的代码,这样代码的质量和测试的效率就高多了。因此,我们在书写代码时,可以参考如下建议(作为要测试perl代码的QA,我们也可以把这些建议反馈给RD们):
1.4.1制定命名规范
譬如,声明引用变量时,变量名加“_ref”后缀;定义包时,以大写字母开头...等待。Perl允许你同一个变量名,同时声明为几种不同的变量类型,譬如 my $a; my @a; 。这种特性给我们带来的代码维护上的困惑远大于它的价值。此类问题我们都可以制定规范约束,具体规范的例子可以参考《Perl最佳实践》的相关章节。
1.4.2注释!注释!!
其实最重要的还是注释,注释虽非多多益善,但函数的输入输出及用途说明、复杂逻辑的说明,这些还是很必要的。我在读perl代码时,加的基本都是中文注释--写起来容易,读起来也容易;我还喜欢把变量代入值,写一个示范语句出来,一个简单的例子比几句话更容易明白。
1.4.3代码格式规范
有兴趣的可以去cpan上搜一下perltidy,或者参考《Perl最佳实践》的相关章节,这里不多介绍。
1.4.4使用别名
对于 $@、$"此类全局变量,如果觉得可维护性很差,可以考虑使用其别名来增加可读性。
譬如代码 undef $/; $cont = < FILE >; 的作用是通过修改$/,将文件句柄FILE的所有内容读入一个变量。一个可读性更强的方法,是将 $/ 用它的别名 $INPUT_RECORD_SEPARATOR 或 $RS 来替代(后者看上去很awk,不是吗?这是因为当初Larry Wall从awk中借鉴了很多语法。)。
具体的别名列表,可以查阅 perldoc perlvar 文档。
1.4.5复杂数据结构解引用时,不妨分解一下
先看一段代码:
%hash = (
a => [ [1,2,3,4,5],
[2,3,4,5,6] ],
b => [ [3,4,5,6,7],
[4,5,6,7,8] ]
);
print ${${$hash{b}}[1]}[4];
为了获取这个"数组的数组的哈希"数据结构的值"8",我使用了 ${${$hash{b}}[1]}[4] 这个复杂的表达式,当然,这已经可读性不错了,毕竟我加了很多大括号,可如果其他人来看,不得不硬着头皮去翻译。当数据结构更复杂时,硬头皮程度指数级增长。
我的建议是把这样一个复杂的解引用语句,转换成几个简单的解引用语句,譬如:
my $_arrayarray_ref = $hash{b};
my $_array_ref = ${$arrayarray_ref}[2];
print ${$_array_ref}[4];
2测试方法
2.1看
2.1.1最简单最常用的方法:print函数
最常用的往往是最简单的,print函数即是如此。在指定位置添加print函数,输出需要查看的变量内容,能帮助我们解决大部分的调试需求。
2.1.2我在哪?
如果有大量的print函数调用,那么输出的结果很可能需要我们仔细辨别哪条信息对应哪条print调用。此时,print函数中添加__LINE__、__FILE__信息能帮我们解决这个问题,前者表示当前行号,后者表示当前所在脚本文件名。
2.1.3我是谁?
用print函数,能查看某个变量的内容,但涉及到复杂的数据结构,print就不能满足需求了。
以下给出两种更强大的方案。
2.1.3.1复杂数据结构 - Data::Dumper模块
简单如一个哈希数据结构,用print函数也没法方便地输出全部内容的。这个时候就应该使用Data::Dumper模块了,使用方法很简单:
{ require Data::Dumper; print Dumper(%hash); }
再复杂的数据结构,Data::Dumper模块都能输出完整内容。
注:为了方便调试代码的管理,以及尽量不影响原先代码,在遇到多行代码时,我会用{}来组成一个代码块,并且使用require而非use -- 以免可能将原本代码中编译阶段的缺少模块的bug给掩盖掉。下同。
2.1.3.2深入了解变量属性 - Devel::Peek
Devel::Peek能帮助我们深入了解变量的细节属性,举个例子,我们也许对Perl的上下文环境的处理方法很是困惑,下面这个例子就是利用Devel::Peek帮助我们了解一个"字符串变量"是如何和一个"整数变量"相加的。
$ cat f.pl
use Devel::Peek;
my $a = 5;
my $b = '5';
Dump($a);
Dump($b);
print "\n === equal ===\n\n" if $a == $b;
Dump($a);
Dump($b);
$ perl f.pl
SV = IV(0x67c368) at 0x67c370
REFCNT = 1
FLAGS = (PADMY,IOK,pIOK)
IV = 5
SV = PV(0x65dbc8) at 0x67c430
REFCNT = 1
FLAGS = (PADMY,POK,pPOK)
PV = 0x676670 "5"\0
CUR = 1
LEN = 16
=== equal ===
SV = IV(0x67c368) at 0x67c370
REFCNT = 1
FLAGS = (PADMY,IOK,pIOK)
IV = 5
SV = PVIV(0x6717f0) at 0x67c430
REFCNT = 1
FLAGS = (PADMY,IOK,POK,pIOK,pPOK)
IV = 5
PV = 0x676670 "5"\0
CUR = 1
LEN = 16
其中,SV表示标量,IV表示整型,PV表示字符串型,IOK标志表明对象含有一个可用的整型值,POK标志表明对象含有一个可用的字符串值。
从例子中,可以看到,$a和$b在刚声明的时候,分别是整型标量和字符串标量。在需要做数值比较时,作为字符串标量的$b将主动地从字符串型转换成整型,从而”既是字符串型,又是整型“。同理,如果你用 $a eq $b 进行比较,则整型变量$a将获取字符串型的属性。
一般情况下,我们不需要了解这么多。如果说我们的工作是测试一个鸡蛋是否变质了,那我们闻和吃一般就够了,而没必要在显微镜下观察鸡蛋的蛋白质等结构。
Devel::Peek就是我们的显微镜。如果的确有兴趣用这台显微镜研究蛋白质结构,请参考 perldoc perlguts 以及 perldoc Devel::Peek 文档。
2.1.4我怎么想? - B::Deparse
有时候,对于某些晦涩的语句,我很想知道perl是怎么理解它的。譬如我想知道 while( < FILE > ) 的终止条件是< FILE >返回空值呢,还是undef,这时候可以使用如下命令:
$ perl -MO=Deparse -e "while( <FILE> ) {}"
while (defined($_ = <FILE>)) {
();
}
-e syntax OK
由此看出,终止条件是<FILE>返回undef。
具体的内容,可以查看B::Deparse文档。
2.1.5用Carp模块获取详细的警告和错误信息
这里推荐Carp模块的carp和croak两种方法,作为warn和die的替代。推荐的理由在于,carp和croak在具有warn和die的功能外,还能输出更多的信息,以方便我们调试。 举例:
$ cat b.pl
use Carp;
sub foo {
unless(defined $_[0]) {
warn "warn message\n";
print " \n";
carp "carp message\n";
}
}
foo();
$ perl b.pl
warn message
carp message
at b.pl line 6
main::foo() called at b.pl line 10
如果还需要获取堆栈信息,可以进一步用cluck替代carp。相对地,die - croak - confess 也构成一组调试方法。
2.1.6比较
可能在某一处,我们希望做一些复杂变量的比较工作。这里我们可以使用一些Test::Builder类的模块,譬如:
{ require Test::Deep; require Test; print Test::Deep(\%a,\%b,"cmp ok") }
常用变量比较方法
2.1.7在运行阶段随时进行查看
有时候,程序运行时间很长,而我们希望在运行过程中,随心所欲地观察一些信息。这种情况下,我们可以自定义信号处理函数,譬如:
$SIG{INT} = sub {require Data::Dumper; print Dump(%hash)};
INT信号默认行为是终止程序,我们可以把它改成输出某个我们想了解的值。如此,你可以在每次需要查看处理进度信息时,按ctrl+c就行了。
2.1.8既是注释,又是debug语句
有一个模块,它能让你添加一些语句,使得这些语句在平常情况下是一些注释,在你需要的时候就是调试信息输出语句。这个模块就是Smart::Comments,这里不多介绍,具体可以查看其文档。
2.2改
2.2.1用POD快速注释掉一段代码
我们知道用 # 符号可以注释掉一行代码,但这样效率太低了,对于整块的代码,我们可以利用POD来快速注释,譬如:
=head
此处省略100行需要被注释的代码
=cut
注意 “=head”和“=cut”都需要顶格写。关于POD的详细资料,可以查看 perldoc pod 文档。
此外,你还可以用"__END__"或"__DATA__"将所在行之后的内容全部“注释”掉。
2.2.2修改代码
直接修改代码,假定某些输入条件,测试后续逻辑的正确性,这是我们常用的方法。
这里需要特别提到的是关于正则表达式,如果我们发现正则表达式语句比较复杂,建议你用几条简单的正则表达式去替代被测的复杂正则表达式语句,并且在同样的 输入条件下,验证输出结果是否一致。这种方法尤其适用于输入是大数据量的情况下,通过大量的实验,我们很可能会发现一些在新旧正则表达式下输出不一致的 case。
2.2.3重载库函数
对于当前脚本中的函数,我们可以直接修改函数实现的代码。可某些情况下,我们想要修改当前脚本所调用的库文件里面的函数。这种情况下,直接修改库文件比较麻烦,尤其是标准的库文件,我们想修改它还需要root权限。一个比较简单的方法是重新实现该函数,从而覆盖掉库文件中的同名函数。
举个例子,我们知道Time::Local模块的timelocal函数是localtime的倒置,假设我需要修改timelocal函数,使得获取到的值都是输入参数的日期的下一年的反转结果。
具体方法如下:
$ cat g.pl
use Time::Local;
use subs 'timelocal'; # 告诉解释器,timelocal函数需要重载
*Time::Local::timelocal = sub { # 用typeglob的方法,将timelocal指向新的函数定义
my @tmp = @_;
$tmp[5] += 1;
return timelocal(@tmp); # 调用原先的timelocal函数
};
my $t = time();
print $t,"\n";
my @a = localtime($t);
print Time::Local::timelocal(@a),"\n";
$ perl g.pl
1291188534
1322724534
从例子中看到,重载后的timelocal函数,使得输出的值比初始值大了一年。
2.3跟踪和调试
2.3.1用Devel::Trace跟踪
大家都知道shell中有-x参数,可以设定了跟踪每一行代码的执行结果,在perl中,有一个模块能起到相似的作用,那就是Devel::Trace。
使用方法很简单,perl -d:Trace test.pl,具体可以CPAN上查看文档。
2.3.2用debug调试
? 基本的debug命令
Debugging task
Debugger command
To run a program under the debugger > perl -d program.pl
To set a breakpoint at the current line DB<1> b
To set a breakpoint at line 42 DB<1> b 42
To continue executing until the next break-point is reached DB<1> c
To continue executing until line 86 DB<1> c 86
To continue executing until subroutine foo is called DB<1>c foo
To execute the next statement DB<1> n
To step into any subroutine call that's part of the next statement DB<1> s
To run until the current subroutine returns DB<1> r
To print the contents of a variable DB<1> p $variable
To examine the contents of a variable DB<1> x $variable
To have the debugger watch a variable or expression, and inform you whenever it changes DB<1>w$variable
DB<1> wexpr($ess)*$ion
To view where you are in the source code DB<1> v
To view line 99 of the source code DB<1> v 99
To get helpful hints on the many other features of the debugger DB<1> h
上面的debug的基本命令,已经能应付绝大部分情况下的调试需求,除此外,这里还想特别介绍一下 a 命令,通过 a LINE COMMAND 的方法,我们可以设置一个在执行第 LINE 行程序之前的动作,而我们可以利用这个COMMAND,设定一些复杂的操作,以更好地获取调试信息。
举个例子:
$cat k.pl
%ha = ( a => [1,2,3],
b => [4,5,6] );
%hb = ( a => [1,2,3],
b => [4,5,7] );
print "1\n";
$ perl -d k.pl
Loading DB routines from perl5db.pl version 1.33
Editor support available.
Enter h or `h h' for help, or `man perldebug' for more help.
main::(k.pl:1): %ha = ( a => [1,2,3],
main::(k.pl:2): b => [4,5,6] );
DB<1> a 5 use Test::More tests => 1; use Test::Differences; print eq_or_diff ${$ha{b}}[2],${$hb{b}}[2],"e or d";
DB<2> n
main::(k.pl:3): %hb = ( a => [1,2,3],
main::(k.pl:4): b => [4,5,7] );
DB<2> n
main::(k.pl:5): print "1\n";
1..1
not ok 1 - e or d
# Failed test 'e or d'
# at (eval 24)[/opt/ActivePerl-5.12/lib/perl5db.pl:638] line 1.
# +---+-----+----------+
# | Ln|Got |Expected |
# +---+-----+----------+
# * 1|6 |7 *
# +---+-----+----------+
0
更多的内容,请参考 perldoc perldebug 文档。
3测试对象
3.1测试.pl脚本文件
这里的.pl脚本文件,指的是被直接运行的脚本。这样的脚本,一般的特点是包含了很多库文件,并且代码比较零散--不像库文件那样绝大部分代码都是一个个封装好的函数。
测试这样的脚本时,除了前面讲到的那些测试方法外,这里再介绍一个测试框架:Test::More。
Test::More是perl脚本的测试框架模块,除了下表整理的一些常用方法,它还有SKIP、TODO等测试内容标记,更强大的地方 是,Test::More由于基于Test::Builder模块,它能配合其它Test::Builder类的测试模块一起工作。
具体参考 perldoc Test::More; perldoc Test::Builder;
? 常用Test::Builder类模块及其方法
模块名
模块简介
方法名
方法简介
Test::More
强大的测试框架 ok 判断真假
is/isnt 字符串比较,类似eq/ne
like/unlike 正则比较,匹配/不匹配
cmp_ok 可以指定操作符地比较
can_ok 被测模块是否导出函数到当前命名空间
isa_ok 对象是否被定义或对象的实例变量确实是已定义的引用
subtest 生成测试子集
pass/fail 直接给出通过/不通过
use_ok 测试加载模块并导入相应符号是否成功
require_ok 类似use_ok
is_deeply 复杂数据结构的比较,加强版的is
3.2测试.pm库文件
如前面所说,库文件一般都是由一个个封装好的函数组成,这样的perl脚本,最适合做单元测试了。单元测试推荐Test::Class模块,具体的内容参考另外一篇文章《perl单元测试》。
3.3性能测试
这里的性能测试主要指的是脚本运行时间的检测,具体依然参考《perl单元测试》。
3.4覆盖率测试
覆盖率测试,具体内容参考《perl单元测试》。。
4 Perl陷阱和缺陷
关于陷阱和缺陷,perl有专门的文档,具体参考 perldoc perltrap 。以下列举的内容是对它的一个补充,另外也是实际工作中更易犯的一些错误。
4.1基本语法
4.1.1真与假
除了""和"0",所有字符串为真
除了 0,所有数字为真
所有引用为真
所有未定义的值为假
空列表"()"、"undef"均为假
4.1.2数组和哈希的初始化,不要用undef
$ cat b.pl
@a = undef;
@b = ();
%c = undef;
print scalar @a,"\n";
print scalar @b,"\n";
print scalar keys %c,"\n";
perl b.pl
1
0
1
从例子可以看出,用undef作为右值,实际上是给数组或哈希初始化了一个值,该值为undef,而非预想的初始为空。
4.1.3对 $_ 的值,及时存取
$_ 是每个perler初学时最早碰到的让人困惑的全局变量,它是缺省的输入和模式搜索空间,很多操作和函数中都会将输出缺省地赋给它。因此,养成及时存取 $_ 变量的习惯,能够避免很多陷阱。
while(<FI>){
$tmp_a = $_;
foreach(@arr){
$tmp_b = $_;
......
}
}
4.1.4对全局变量的修改,注意及时恢复
把一个文件句柄对应的文件内容,赋值给一个scalar变量,常用 undef $/; $cont = < FILE_A > 。但这样将会修改全局变量 $/ ,如果没有改回去的话,后续的while(< FILE_B >)之类所有对文件句柄的钻石符操作,均将一次读入整个文件。
修改的办法,是将变量赋回原先的值:
undef $/;
$cont = <FILE_A>;
$/ = "\n"; # 重置为初始值
while(<FILE_B>)
更好的办法,是用 local 函数,将修改局限在最小的闭合块中:
{
local $/ = undef;
$cont = <FILE_A>;
}
while(<FILE_B>)
4.1.5用foreach、for的两个注意点
先来看一段测试代码
$ cat e.pl
$i = 0;
@a = (1,2,3);
foreach $i (@a) {
$i = -$i;
}
print "$i\n";
print "@a\n";
$ perl e.pl
0
-1 -2 -3
从中我们可以看到两点:
1、 变量 $i 尽管中foreach循环(或for循环)中最后的一次值是3,但退出循环后,其值还是保持进入循环时的值。也就是说,$i 变量在foreach循环中,被local了。
2、 $i 在foreach循环过程中,是@a数组当前值的别名,因此修改$i值,也就是直接修改了@a数组。在for、grep函数中,同样存在这个问题。
4.1.6子例程传参时不会拷贝参数,注意不要修改原值
先来看如下一个例子:
$ cat h.pl
#use Devel::Peek;
my @a = qw(1 2 3);
#Dump(\@a);
sub bezero {
# Dump(\@_);
$_[1] = 0;
print "===\n";
}
print "@a\n";
bezero(@a);
print "@a\n";
$ perl h.pl
1 2 3
===
1 0 3
我们看到,数组 @a 在被当作参数传入函数bezero后,被修改了值。由此,我们可以怀疑,在传参时,@_其实是@a的别名,而非拷贝。为了证实这个猜测,我们可以利用前面 提到的Devel::Peek模块观察这两个数组的地址是否一致。因此我们打开脚本中的注释,然后重新运行脚本,观察结果如下:
$ perl h.pl
SV = IV(0x67bc30) at 0x67bc38
... ....
SV = IV(0x67bc30) at 0x67bc38
... ...
两个数组的地址完全一致。
通过这个例子,我们的结论是:函数传参时,如果你不想修改参数值,记得把参数在函数内部拷贝一份后再使用。
4.1.7用each多次遍历同一hash时,注意及时归位迭代游标
$ cat a.pl
%h = ( a => 1, b => 2 );
while(($a,$b)=each %h){
print "$a $b\n";
print "======\n";
last;
}
while(($a,$b)=each %h){
print "$a $b\n";
}
$ perl a.pl
a 1
======
b 2
这个例子中,有两处使用了each函数,由于前面一个while在获取一次值后,就退出循环,但没有重置迭代游标,导致第二个while从上一次迭代的位置开始。
如果你期望第二次while中也能用each完整地遍历哈希,你需要重置迭代游标。重置的方法是完整地读取一遍该哈希,譬如用keys函数、values函数、把该哈希当作右值赋值给数组或另一个哈希。当然,你用 while(each %h){} 什么也不做地遍历一遍,也能重置迭代游标,只是这样的代码实在有点丑陋。
而我的做法是, 从来不用each函数,而是用keys函数获取key后再取value ,我的哈希遍历方法如下:
foreach my $key (keys %h) {
print "$key $h{$key}\n";
}
虽然多敲了些代码,但这样做我不用担心前面的陷阱,因为keys函数一次就遍历完了哈希。
你可以 perldoc -f each; perldoc -f keys; perldoc -f values; 了解更多。
? Perl内部是怎么维持这个迭代游标的?
1. 在Perl内部,散列称为 HV (hash value),并使用 HE 结构体表示键/值对,使用 HEK 结构体表示所有关键字。
2. Perl用RITER, EITER两个字段来实现访问散列所有元素的单向迭代器。RITER 是作为数组下标的整数,而 EITER 是一个指向 HE 的指针。迭代器是从 RITER = -1 和 EITER = NULL 开始的,完成一次迭代后,RITER和EITER的值将会变化。其中RITER在首次迭代后,值变成1,之后每次成功迭代,值都将累加1,直到迭代完成 后,复位为-1。
知道了这些细节,我们就可以用 Devel::Peek 模块的 Dump 函数更方便地观察迭代过程了,感兴趣的话,你不妨一试。
4.1.8调用函数时,用foo(),而不要用foo或&foo
以如下一段代码为例:
$ cat a.pl
use strict;
use warnings;
sub double {
return 0 if scalar @_ == 0;
return 2 * $_[0];
}
print double - 3;
$ perl a.pl
-6
这里原意是要打印出 double函数的返回值减去3以后的值,也就是应该打印结果为 -3 ,可结果却是 -6 。
我们可以利用前面介绍的 B::Deparse 模块,看一下perl是怎么理解代码的:
$ perl -MO=Deparse a.pl
sub double {
use warnings;
use strict 'refs';
return 0 if scalar @_ == 0;
return 2 * $_[0];
}
use warnings;
use strict 'refs';
print double(-3);
a.pl syntax OK
很清楚地发现,perl把 double -3 理解成了 double(-3)。为了避免这种问题,在函数调用的时候,我们必须明确地写成 double() - 3 ,而如果你就是想表达 double(-3) 的话,也要明确地写成 double(-3) ,以免代码让人理解错。
另外需要注意,不要写成 &double(), 这种写法是perl 4版本的语法,虽然目前perl 5兼容了这种写法,但它会带来一些困惑,譬如:
$curr_pos = tell &get_mask( ); # means: tell(get_mask( ))
$curr_time = time &get_mask( ); # means: time() & get_mask( )
当然,在作为函数引用的时候,还是需要用的,譬如:
$SIG{PIPE} = \&Plumber; # 定义PIPE信号的处理函数
4.2正则匹配
4.2.1变量内插时,注意特殊字符的转义
可以使用quotemeta函数,也可以使用\Q..\E,但总之,在需要变量内插从而构成正则表达式时,注意特殊字符的转义。
4.2.2在用$1获取匹配结果时,确保匹配是成功的
先来看如下一个例子:
$ cat a.pl
$str = 'a2';
$str =~ /(\d)/;
print $1,"\n";
$str =~ /(3)/;
print $1,"\n";
$ perl a.pl
2
2
$1是全局变量,匹配失败时并不会修改它的值,也不会重置为undef,因此,每次使用它时,请确保加上匹配成功的判断,譬如 if( $str =~ /(\d)/ ){ print $1; } 。
4.2.3 /g -- 控制迭代匹配
/g 是全局查找所有匹配的修饰词。在列表环境下,它能一次性返回所有匹配结果,在标量环境下,它每次返回一个匹配结果。标量环境下进行迭代匹配时,需要了解匹配偏移量的知识,否则很容易出错。
我的建议是:
1、列表环境下使用/g,大胆用吧,不用担心。
2、标量环境下使用/g,如果需要对同一个字符串进行多次/g匹配,注意是否需要重置匹配偏移量。
? 与迭代匹配相关的
迭代匹配修饰符: /g、/cg
上次匹配位置断言: \G
上次匹配位置函数: pos($str)
? 不同上下文环境下的/g
列表环境 标量环境
显式 @res = m/(\d)/g; $res = m/(\d)/g;
隐式 print m/(\d)/g; m/(\d)/g;
while( m/(\d)/g ) { }
功能 列表环境下,/g一次性返回所有匹配结果列表 标量环境下,/g迭代匹配,每次返回一个匹配结果
? 用/g、/cg和pos来控制迭代
匹配类型
匹配尝试开始位置
匹配成功时的pos值
匹配失败时的pos设定
m/…/ 字符串起始位置(忽略pos) 重置为undef 重置为undef
m/…/g 字符串的pos位置 匹配结束位置的偏移值 重置为undef
m/…/gc 字符串的pos位置 匹配结束位置的偏移值 不变
4.2.4正则表达式很强大,但不要滥用
首先来做一个练习:给你5分钟,让你写出一个判断"年月日"字符串是否合法的正则表达式。
sleep 5 * 60;
好了,坦白说,你是不是不到两分钟就放弃了?我相信这样的正则表达式肯定可以写出来,因为perl的正则表达式非常强大,它支持"环视"(look around),支持在表达式中嵌入条件判断、代码,你可以把一个正则表达式写成一个由复杂的逻辑判断语句构成的式子。不过,既然如此,何必还要硬着头皮写正则表达式呢?我们干脆用一些 if...else... 语句去判断好了。
况且,即使你花了N分钟写出来了,这样的表达式,可读性也是非常差的。
因此,给一个建议:逻辑复杂的时候,不如用几条语句去代替一个正则表达式。
除此外:
1、对于定长的记录,pack/unpack是更好的选择。
2、分析html、xml等页面时,尤其是很难找到一个固定的字符串去锚定匹配结果时,推荐使用HTML::Parser、HTML::TreeBuilder、XML::Simple之类的模块。
4.3与Shell共舞
perl脚本调用shell命令,shell脚本中执行perl命令行语句,这是经常会碰到的场景。作为胶水语言,perl能与shell很好地共舞,但前提是避免落入如下陷阱。
perl脚本调用shell命令,一般有三种方式:``或qx//、system()函数、exec函数。关于这几种方式的含义和使用方法,可以查看相关文档。这里的内容都是使用中的陷阱。
注:这里写”调用shell命令"并不严谨,我的确切意思是,"向shell提交对外部命令调用的请求"。
4.3.1区分perl中的$?跟shell中的$?
在shell中,我们知道 $? 表示上一条命令执行后的返回状态,而在perl中, $? 表示上一次管道关闭,反勾号(``)命令或者 wait,waitpid,或者 system 函数返回的状态。 它的另一个名字是$CHILD_ERROR。它是一个16位的状态值,高8位是子进程的退出值,在低位,$? & 127 告诉你该进程是因为哪个信号(如果有)退出的,而 $? & 128 汇报该进程的死亡是否产生一个内核的倾倒。
说具体一点,我们需要注意以下两点:
? 不要用 $? 去判断前一条语句是否执行成功
open FI,"a.txt"; die "open a.txt failed" if ($? = 0); 这种写法是错误的,正确的写法是 open FI,"a.txt" or die "open a.txt failed"; 。原因很简单,在perl中,$? 并不是“上一条命令”执行后的返回状态。
? 子进程返回失败时,$? 的值是256,而非1
举例:
$ cat p.pl
system("ls file_not_exist") ;
print $?;
$ perl p.pl
ls: file_not_exist: No such file or directory
256
原因前面讲了,$?的高8位存的是子进程的退出值,因此子进程退出值是1时,$?的值就是256。
4.3.2调用外部命令并获取返回值后,注意chomp
譬如 chomp($host=`hostname -i`) ,如果少了chomp,但$host变量获取到的将是末尾带有\n的ip值,为后续处理埋下了隐患。
4.3.3不要调用shell的内建命令
shell的内建命令,实际上都是shell的内部函数,这些内建命令,你用 which 是查不到路径的。内建命令包括 cd、export、umask、source 等等,具体的你可以 man builtins 查看。
对于内建命令,譬如 cd,你直接用 system("cd") 执行会出错,错误是无法找到该文件或目录(你可以用代码 system("cd"); print $!; 试一下),在命令前加 sh -c 是个解决方案。
不过调用shell的内建命令,效果可不是你预想的那般,测试代码如下:
system("sh -c cd ./dir && touch cba");
system("touch abc");
这段代码试图在 ./dir 目录下,建两个文件,运行后却发现,两个文件都被建在了当前目录,而非 ./dir 目录。
原因在于perl每次在用system()调用外部命令时,都是先做一个fork,而外部命令的执行是在fork的子进程中进行的,它做的 cd 操作,无法更改父进程(也就是perl脚本进程)的工作路径。
解决的办法是用perl自己的函数,譬如用 chdir 替换 system("sh -c cd")、用 umask 替换 system("sh -c umask") 等等。
4.3.4调用外部命令时,注意特殊字符的转义
简单不严谨地说,perl对'('、'<'之类的特殊字符是不需要转义的,而shell需要,因此在有可能涉及此类特殊字符时,需要特别留意。
以下是一段测试代码:
$a = 'abc(123)sdf';
$b = quotemeta $a;
qx/echo $a/;
`echo $a`;
system("echo $a");
exec("echo $a");
试着执行一下,四条语句均会报错。把$a改成$b,四条语句都改为正确。
4.3.4慎用Shell.pm模块
Shell.pm是perl的标准模块,它能够让你在perl脚本中直接运行shell命令,但由于它内部实现时对quoting的处理有缺陷,导致在对包含'('、'<'之类字符的参数处理时依然会有错误。
测试代码如下:
$ cat d.pl
use Shell;
$a = 'abc(123)sdf';
$b = quotemeta $a;
echo($a);
print "=============\n";
echo($b);
$ perl d.pl
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `echo abc(123)sdf'
=============
sh: -c: line 0: syntax error near unexpected token `('
sh: -c: line 0: `echo abc\\(123\\)sdf'
可以看到,Shell模块的转义问题更加使人困惑,不管有没有quotemeta,都会报转义相关的错误。
查一下perldoc Shell文档,可以看到:
BUGS
Quoting should be off by default.
文档告诉我们,转义的问题其实一直都在。
如果你感兴趣,还可以debug到Shell.pm模块内部,看看该模块对转义是怎么处理的,从而了解问题究竟出在哪里。这里先公布一下我的结论:以版本号为0.72_01的Shell.pm为例,问题出在第109~110行(s/(['\\])/\\$1/g;$_ = $_;),这里对转义的处理过于简单了。解决方法是将第110行代码改成$_ = quotemeta $_; 我的实验结果是它解决了转义问题,但我并不确定它是否会带来其它的bug,聪明的你可以深究一下。
4.3.5 shell调用perl语句,注意变量内插
大家在写shell脚本时,经常调用awk、sed命令进行一些文本相关的处理,其实perl也有 one-line 模式,也能被shell调用,并且功能远比awk、sed强大。具体的命令行模式的perl如何使用,请参考 perldoc perlrun 文档。
这里讲一个需要注意的问题(其实类似例子更应该算是shell的”陷阱“,并且在shell脚本调用awk时也同样需要注意):
$ cat log
key:1us time:11us
key:2us time:12us else:null
key:3us type:arr time:13us
$ cat a.sh
pattern='time'
cat $1 | perl -ne '{$sum += $1 if /$pattern:(\d+)us/;} END{print $sum;}'
$ sh a.sh log
6
脚本a.sh的设计功能是从命令行参数中读入一个日志文件,利用perl语句匹配出time值,然后计算出time值的加和。根据设计,运行结果应该是36,而非6。
问题出在对$pattern的传值上,注意到脚本里是用''单引号来引用perl执行语句的,我们知道单引号在shell里是"hard quote",凡在hard quote中的所有meta都会被关闭特殊含义,包括这里的'$'符号。因此这里无法将$pattern的值传入perl语句,导致$pattern的值 为空,从而错误地匹配了 ':(\d+)us' ,并且捕获了它匹配到的第一个值,也就是"key"后面的数字。
如果把单引号改成双引号,能解决$pattern变量的传值问题,但却导致$sum和$1这两个原本perl语句中的变量被shell进行了变量内插,这样的错误依然不能接受。
正确的解决办法是将perl语句的那一段,分情况使用单引号和双引号,更改后的语句和运行结果如下:
$ cat b.sh
pattern='time'
cat $1 | perl -ne '{$sum += $1 if /'"$pattern"':(\d+)us/;} END{print $sum;}'
sh b.sh log
36
(全文完