十个基本开发实践
以下十点建议来自 Perl最佳实践,这是Damian Conway编写的一本关于Perl编程和开发准则的新书。
1. 首先设计模块的接口
任何模块最重要的方面不是它如何实现提供的功能,而是它最初如何提供这些功能。如果模块的API过于笨拙、复杂、广泛、破碎,甚至名称不佳,开发者将避免使用它。他们会编写自己的代码。这样,设计不良的模块实际上可能会降低系统的整体可维护性。
设计模块接口需要经验和创造力。也许确定接口应该如何工作的最简单方法是通过“预演测试”:在实现模块本身之前编写将使用模块的代码示例。在设计完成后,这些示例不会浪费。你通常可以将它们回收用于演示、文档示例或测试套件的主体。
然而,关键是要像模块已经可用一样编写代码,并以你最喜欢的方式编写模块。
一旦你对要创建的接口有了一些想法,就将你的“预演测试”转换为实际的测试(参见提示#2)。然后,只需要简单的编程就能使模块以代码示例和测试想要的方式工作。
当然,模块可能无法以你最喜欢的方式工作,在这种情况下,尝试以这种方式实现它将帮助你确定API中哪些方面不切实际,并让你确定可能可接受的替代方案。
2. 在编写代码之前编写测试用例
在所有软件开发中,最单一的最佳实践可能是首先编写测试套件。
测试套件是软件行为的一个可执行、自我验证的规范。如果你有一个测试套件,你可以在开发过程中的任何时刻验证代码是否按预期工作。如果你有一个测试套件,你可以在维护周期中的任何更改之后验证代码是否仍然按预期工作。
先编写测试。在你知道接口将是什么之后立即编写(参见#1)。在你开始编写应用程序或模块之前编写它们。除非你有测试,否则你没有明确的规范来说明软件应该做什么,也没有知道它是否这样做的方法。
编写测试总感觉像是一项任务,而且是一项不产生成效的任务:你还没有任何东西可以测试,为什么还要编写测试?然而,大多数开发者几乎会自动编写驱动软件来以临时方式测试他们新的模块:
> cat try_inflections.pl
# Test my shiny new English inflections module...
use Lingua::EN::Inflect qw( inflect );
# Try some plurals (both standard and unusual inflections)...
my %plural_of = (
'house' => 'houses',
'mouse' => 'mice',
'box' => 'boxes',
'ox' => 'oxen',
'goose' => 'geese',
'mongoose' => 'mongooses',
'law' => 'laws',
'mother-in-law' => 'mothers-in-law',
);
# For each of them, print both the expected result and the actual inflection...
for my $word ( keys %plural_of ) {
my $expected = $plural_of{$word};
my $computed = inflect( "PL_N($word)" );
print "For $word:\n",
"\tExpected: $expected\n",
"\tComputed: $computed\n";
}
这样的驱动程序实际上比编写测试套件更难,因为你必须担心以易于阅读的方式格式化输出。使用驱动程序也比使用测试套件更难,因为每次运行它时,你必须浏览格式化的输出,并“凭眼睛”验证一切是否正常。这也容易出错;眼睛并不是为了在大量几乎相同文本的中间挑选出微小差异而优化的。
与其拼凑一个驱动程序,不如使用标准的Test::Simple模块来编写测试程序简单。与其使用print
语句显示正在测试的内容,你只需调用ok()
子程序,将条件作为其第一个参数,将实际测试的内容作为第二个参数:
> cat inflections.t
use Lingua::EN::Inflect qw( inflect);
use Test::Simple qw( no_plan);
my %plural_of = (
'mouse' => 'mice',
'house' => 'houses',
'ox' => 'oxen',
'box' => 'boxes',
'goose' => 'geese',
'mongoose' => 'mongooses',
'law' => 'laws',
'mother-in-law' => 'mothers-in-law',
);
for my $word ( keys %plural_of ) {
my $expected = $plural_of{$word};
my $computed = inflect( "PL_N($word)" );
ok( $computed eq $expected, "$word -> $expected" );
}
请注意,此代码使用qw( no_plan )
参数加载Test::Simple
。通常该参数会是tests => count
,表示预期测试的数量,但在这里测试是由运行时的%plural_of
表生成的,因此最终的数量将取决于该表中条目的数量。在加载模块时指定固定数量的测试,如果你在编译时知道这个数字,那么该模块还可以“元测试”以验证你是否执行了你期望的所有测试。
Test::Simple
程序比原始驱动代码更简洁易读,输出也更紧凑且信息丰富。
> perl inflections.t
ok 1 - house -> houses
ok 2 - law -> laws
not ok 3 - mongoose -> mongooses
# Failed test (inflections.t at line 21)
ok 4 - goose -> geese
ok 5 - ox -> oxen
not ok 6 - mother-in-law -> mothers-in-law
# Failed test (inflections.t at line 21)
ok 7 - mouse -> mice
ok 8 - box -> boxes
1..8
# Looks like you failed 2 tests of 8.
更重要的是,这个版本验证每个测试的正确性所需的工作量要少得多。你只需扫描左侧的边缘,寻找not
和注释行。
你可能更喜欢使用Test::More模块而不是Test::Simple
。然后你可以通过使用is()
子程序分别指定实际值和预期值,而不是使用ok()
。
use Lingua::EN::Inflect qw( inflect );
use Test::More qw( no_plan ); # Now using more advanced testing tools
my %plural_of = (
'mouse' => 'mice',
'house' => 'houses',
'ox' => 'oxen',
'box' => 'boxes',
'goose' => 'geese',
'mongoose' => 'mongooses',
'law' => 'laws',
'mother-in-law' => 'mothers-in-law',
);
for my $word ( keys %plural_of ) {
my $expected = $plural_of{$word};
my $computed = inflect( "PL_N($word)" );
# Test expected and computed inflections for string equality...
is( $computed, $expected, "$word -> $expected" );
}
除了不再需要自己输入eq
之外,这个版本还会产生更详细的错误信息。
> perl inflections.t
ok 1 - house -> houses
ok 2 - law -> laws
not ok 3 - mongoose -> mongooses
# Failed test (inflections.t at line 20)
# got: 'mongeese'
# expected: 'mongooses'
ok 4 - goose -> geese
ok 5 - ox -> oxen
not ok 6 - mother-in-law -> mothers-in-law
# Failed test (inflections.t at line 20)
# got: 'mothers-in-laws'
# expected: 'mothers-in-law'
ok 7 - mouse -> mice
ok 8 - box -> boxes
1..8
# Looks like you failed 2 tests of 8.
Perl 5.8附带的Test::Tutorial文档为Test::Simple
和Test::More
提供了温和的介绍。
3. 为模块和应用程序创建标准 POD 模板
文档常常显得不愉快的主要原因之一是“空白页效应”。许多程序员根本不知道如何开始或说什么。
可能使编写文档不那么令人畏惧(因此,更有可能真正发生)的简单方法之一是绕过初始的空屏幕,提供开发者可以剪切和粘贴到他们的代码中的模板。
对于模块,该文档模板可能看起来像这样
=head1 NAME
<Module::Name> - <One-line description of module's purpose>
=head1 VERSION
The initial template usually just has:
This documentation refers to <Module::Name> version 0.0.1.
=head1 SYNOPSIS
use <Module::Name>;
# Brief but working code example(s) here showing the most common usage(s)
# This section will be as far as many users bother reading, so make it as
# educational and exemplary as possible.
=head1 DESCRIPTION
A full description of the module and its features.
May include numerous subsections (i.e., =head2, =head3, etc.).
=head1 SUBROUTINES/METHODS
A separate section listing the public components of the module's interface.
These normally consist of either subroutines that may be exported, or methods
that may be called on objects belonging to the classes that the module
provides.
Name the section accordingly.
In an object-oriented module, this section should begin with a sentence (of the
form "An object of this class represents ...") to give the reader a high-level
context to help them understand the methods that are subsequently described.
=head1 DIAGNOSTICS
A list of every error and warning message that the module can generate (even
the ones that will "never happen"), with a full explanation of each problem,
one or more likely causes, and any suggested remedies.
=head1 CONFIGURATION AND ENVIRONMENT
A full explanation of any configuration system(s) used by the module, including
the names and locations of any configuration files, and the meaning of any
environment variables or properties that can be set. These descriptions must
also include details of any configuration language used.
=head1 DEPENDENCIES
A list of all of the other modules that this module relies upon, including any
restrictions on versions, and an indication of whether these required modules
are part of the standard Perl distribution, part of the module's distribution,
or must be installed separately.
=head1 INCOMPATIBILITIES
A list of any modules that this module cannot be used in conjunction with.
This may be due to name conflicts in the interface, or competition for system
or program resources, or due to internal limitations of Perl (for example, many
modules that use source code filters are mutually incompatible).
=head1 BUGS AND LIMITATIONS
A list of known problems with the module, together with some indication of
whether they are likely to be fixed in an upcoming release.
Also, a list of restrictions on the features the module does provide: data types
that cannot be handled, performance issues and the circumstances in which they
may arise, practical limitations on the size of data sets, special cases that
are not (yet) handled, etc.
The initial template usually just has:
There are no known bugs in this module.
Please report problems to <Maintainer name(s)> (<contact address>)
Patches are welcome.
=head1 AUTHOR
<Author name(s)> (<contact address>)
=head1 LICENSE AND COPYRIGHT
Copyright (c) <year> <copyright holder> (<contact address>).
All rights reserved.
followed by whatever license you wish to release it under.
For Perl code that is often just:
This module is free software; you can redistribute it and/or modify it under
the same terms as Perl itself. See L<perlartistic>. This program is
distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
当然,你的模板提供的具体细节可能与这里显示的不同,根据你的其他编码实践。最可能的差异将是在许可证和版权方面,但你可能还有关于版本编号、诊断消息的语法或作者归属的内部约定。
4. 使用版本控制系统
对源代码的创建和修改进行控制对于稳健的团队合作开发至关重要。而且不仅仅是源代码:你应该对文档、数据文件、文档模板、makefile、样式表、变更日志以及系统所需的任何其他资源进行版本控制。
就像你不应该在没有撤销命令的编辑器中工作或使用不能合并文档的文字处理器一样,你也不应该使用不能回滚的文件系统或不能整合许多贡献者工作的开发环境。
程序员会犯错误,偶尔这些错误可能是灾难性的。他们会重新格式化包含最新版本代码的磁盘。或者他们会在编辑器宏中打字错误,将零写入关键核心模块的源代码。或者两个开发人员无意中同时编辑同一文件,一半的更改将丢失。版本控制系统可以防止这些问题。
此外,有时最好的调试技巧就是放弃,停止尝试让昨天的修改正常工作,将代码回滚到已知的稳定状态,然后重新开始。不那么极端的做法是将你的代码当前状态与存储库中最新的稳定版本进行比较(甚至只是逐行diff
)通常可以帮助你隔离最近的“改进”,并确定哪个是问题所在。
如RCS、CVS、Subversion、Monotone、darcs
、Perforce、GNU arch或BitKeeper这样的版本控制系统可以防止灾难,确保在维护出现严重问题时,你始终有一个可用的回退位置。不同的系统具有不同的优势和局限性,许多局限性源于对版本控制究竟是什么的根本不同看法。尝试各种版本控制系统,找到最适合你的系统是个不错的想法。《Subversion的实用版本控制》由Mike Mason(实用图书出版社,2005年)著,《CVS精华》由Jennifer Vesperman(O’Reilly,2003年)著,都是有用的起点。
5. 创建一致的命令行界面
命令行界面有随时间增长的强烈倾向,随着你向应用程序添加新功能,会积累新的选项。不幸的是,这种界面的演变很少是经过设计、管理和控制的,所以特定应用程序接受的标志、选项和参数可能是临时性的和独特的。
这也意味着它们可能与其他相关应用程序提供的独特临时性的标志、选项和参数不一致。结果是不可避免地出现一套程序,每个程序都以独特和古怪的方式运行。例如
> orchestrate source.txt -to interim.orc
> remonstrate +interim.rem -interim.orc
> fenestrate --src=interim.rem --dest=final.wdw
Invalid input format
> fenestrate --help
Unknown option: --help.
Type 'fenestrate -hmo' for help
在这里,orchestrate
实用工具期望其输入文件作为其第一个参数,而-to
标志指定其输出文件。相关的remonstrate
工具使用-infile
和+outfile
选项,其中输出文件排在前面。fenestrate
程序似乎需要GNU风格的“长选项”:--src=infile
和--dest=outfile
,除了显然帮助标志的奇怪名称之外。总之,这是一团糟。
当你提供一套程序时,所有程序都应看起来以相同的方式运行,使用相同的标志和选项来实现所有应用程序中相同的功能。这使用户能够利用现有的知识——而不是不断向你提问。
这三个程序应该这样工作
> orchestrate -i source.txt -o dest.orc
> remonstrate -i source.orc -o dest.rem
> fenestrate -i source.rem -o dest.wdw
Input file ('source.rem') not a valid Remora file
(type "fenestrate --help" for help)
> fenestrate --help
fenestrate - convert Remora .rem files to Windows .wdw format
Usage: fenestrate [-i <infile>] [-o <outfile>] [-cstq] [-h|-v]
Options:
-i <infile> Specify input source [default: STDIN]
-o <outfile> Specify output destination [default: STDOUT]
-c Attempt to produce a more compact representation
-h Use horizontal (landscape) layout
-v Use vertical (portrait) layout
-s Be strict regarding input
-t Be extra tolerant regarding input
-q Run silent
--version Print version information
--usage Print the usage line of this summary
--help Print this summary
--man Print the complete manpage
在这里,每个接受输入和输出文件的程序都使用相同的两个标志来完成
> substrate -i dest.wdw -o dest.sub
任何不能猜出这一点的人可能可以猜出这一点
> substrate --help
可能会提供帮助和安慰。
使界面一致的一个很大部分是确保那些界面组件的一致性。以下是一些可能有助于设计一致和可预测界面的约定
要求在每项命令行数据之前使用标志,除了文件名。
用户不希望记住你的应用程序需要“输入文件、输出文件、块大小、操作、回退策略”,并且需要它们以精确的顺序
> lustrate sample_data proc_data 1000 normalize log
他们希望能够明确地表达他们的意思,在任何适合他们的顺序中
> lustrate sample_data proc_data -op=normalize -b1000 --fallback=log
为每个文件名也提供一个标志,特别是当程序可以针对不同目的提供文件时。
用户可能也不希望记住两个位置性文件名的顺序,因此让他们对这些参数进行标记,并按他们喜欢的任何顺序指定它们。
> lustrate -i sample_data -op normalize -b1000 --fallback log -o proc_data
使用单个
-
前缀表示简短标志,最多三个字母(例如:-v
、-i
、-rw
、-in
、-out
)。经验丰富的用户喜欢使用简短标志,因为这可以减少输入并限制命令行混乱。在这些快捷方式中,不要让他们输入两个连字符。
使用双
--
前缀表示较长的标志(例如:--verbose
、--interactive
、--readwrite
、--input
、--output
)。完整的单词标志可以提高命令行的可读性(例如在shell脚本中)。双横线还有助于区分较长的标志名称和附近的文件名。
如果标志需要关联的值,可以在标志和值之间允许可选的
=
。有些人喜欢将值与其前面的标志在视觉上进行关联
> lustrate -i=sample_data -op=normalize -b=1000 --fallback=log -o=proc_data
另一些人则不这样认为
> lustrate -i sample_data -op normalize -b1000 --fallback log -o proc_data
还有一些人希望两者兼而有之
> lustrate -i sample_data -o proc_data -op=normalize -b=1000 --fallback=log
让用户自己选择。
允许单字母选项在单个连字符之后“捆绑”。
必须为一系列标志重复输入连字符是件令人烦恼的事
> lustrate -i sample_data -v -l -x
允许经验丰富的用户也写作
> lustrate -i sample_data -vlx
为每个单字母标志提供多字母版本。
简短标志对经验丰富的用户来说可能很好,但对新手来说可能很麻烦:难以记住,更难以识别。不要强迫人们这样做。为每个简洁标志提供一个详细的替代方案;容易记住的全词,在shell脚本中也有助于自我说明。
始终允许
-
作为特殊文件名。一个广泛使用的约定是,当期望输入文件时使用连字符(
-
)意味着“从标准输入读取”,当期望输出文件时使用连字符意味着“写入标准输出”。始终允许
--
作为文件列表标记。另一个广泛使用的约定是,命令行中出现双连字符(
--
)标志着任何带标志选项的结束,并表明剩余的参数是文件名列表,即使其中一些看起来像标志。
6. 达成一致的布局风格并使用 perltidy
自动化
格式化。缩进。风格。代码布局。无论你选择称之为什么,这都是编程规范中最具争议的方面之一。在代码布局问题上,发生的争论和冲突比其他任何编程方面都要多。
这里最好的做法是什么?你应该使用经典的Kernighan和Ritchie风格吗?或者采用BSD代码格式?或者采用GNU项目指定的布局方案?或者符合Slashcode编码指南?
当然不是!众所周知,<插入你的个人编码风格在这里> 是唯一的真实布局风格,唯一合理的选择,自自古以来就被<插入你最喜欢的编程神祇在这里>所规定!任何其他选择都是明显荒谬的,故意异端邪说,明显是黑暗之作!
这正是问题所在。在决定布局风格时,很难判断理性选择何时结束,理性化习惯何时开始。
采用一致设计的代码布局方法,并将其应用于所有编码,是最佳实践编程的基本要求。良好的布局可以提高程序的可读性,帮助检测程序中的错误,并使代码的结构更容易理解。布局很重要。
然而,大多数编码风格——包括前面提到的四种——在提供这些好处方面几乎同样出色。虽然有一个一致的代码布局方案确实非常重要,但你最终决定的具体代码布局方案并不重要!唯一重要的是,你采用了一种单一、连贯的风格;一种适用于你整个编程团队的风格;一旦同意了这种风格,你就要在所有开发中一致地应用它。
从长远来看,最好是自己和你的团队培养出一种一致、合理且易读的编码风格。然而,要实现这一点所需的时间和投入并不总是可用的。在这种情况下,一个合理的折衷方案是规定一个标准代码格式化工具,在提交、审查或以其他方式公开之前,必须将其应用于所有代码。
现在有一个出色的Perl代码格式化工具可用:perltidy
。它提供了丰富的用户可配置选项,用于缩进、块定界符定位、列对齐和注释定位。
使用perltidy
,你可以将这样的代码
if($sigil eq '$'){
if($subsigil eq '?'){
$sym_table{substr($var_name,2)}=delete $sym_table{locate_orig_var($var)};
$internal_count++;$has_internal{$var_name}++
} else {
${$var_ref} =
q{$sym_table{$var_name}}; $external_count++; $has_external{$var_name}++;
}} elsif ($sigil eq '@'&&$subsigil eq '?') {
@{$sym_table{$var_name}} = grep
{defined $_} @{$sym_table{$var_name}};
} elsif ($sigil eq '%' && $subsigil eq '?') {
delete $sym_table{$var_name}{$EMPTY_STR}; } else
{
${$var_ref}
=
q{$sym_table{$var_name}}
}
转换为可读的代码
if ( $sigil eq '$' ) {
if ( $subsigil eq '?' ) {
$sym_table{ substr( $var_name, 2 ) }
= delete $sym_table{ locate_orig_var($var) };
$internal_count++;
$has_internal{$var_name}++;
}
else {
${$var_ref} = q{$sym_table{$var_name}};
$external_count++;
$has_external{$var_name}++;
}
}
elsif ( $sigil eq '@' && $subsigil eq '?' ) {
@{ $sym_table{$var_name} }
= grep {defined $_} @{ $sym_table{$var_name} };
}
elsif ( $sigil eq '%' && $subsigil eq '?' ) {
delete $sym_table{$var_name}{$EMPTY_STR};
}
else {
${$var_ref} = q{$sym_table{$var_name}};
}
强制每个人都使用一个通用的工具来格式化他们的代码也可以是一种避免围绕代码布局讨论中经常出现的无休止的反对、敌意和教条的简单方法。如果perltidy
为他们做所有的工作,那么采用新指南的开发者儿乎不需要付出任何努力。他们可以简单地设置一个编辑宏,这样每当他们需要时,就会“整理”他们的代码。
7. 在注释段落中编写代码
段落是一组执行单一任务的语句集合:在文学中,它是一系列传达单一思想的句子;在编程中,是一系列实现算法单个步骤的指令序列。
将每段代码分解为执行单一任务的序列,每个序列之间放置一个空行。为了进一步提高代码的可维护性,在每个这样的段落的开头放置一条单行注释,描述语句序列做什么。例如
# Process an array that has been recognized...
sub addarray_internal {
my ($var_name, $needs_quotemeta) = @_;
# Cache the original...
$raw .= $var_name;
# Build meta-quoting code, if requested...
my $quotemeta = $needs_quotemeta ? q{map {quotemeta $_} } : $EMPTY_STR;
# Expand elements of variable, conjoin with ORs...
my $perl5pat = qq{(??{join q{|}, $quotemeta \@{$var_name}})};
# Insert debugging code if requested...
my $type = $quotemeta ? 'literal' : 'pattern';
debug_now("Adding $var_name (as $type)");
add_debug_mesg("Trying $var_name (as $type)");
return $perl5pat;
}
段落很有用,因为人类一次只能关注少数信息。段落是汇总少量相关信息的途径之一,这样产生的“块”就可以适应读者有限的短期记忆中的单个槽位。段落使写作的物理结构反映并强调其逻辑结构。
在每个段落的开始处添加注释可以进一步通过明确总结每个块的目的来增强块化(注意:目的,而非行为)。段落注释需要解释代码为什么存在以及它实现了什么,而不仅仅是转述它正在执行的精确计算步骤。
然而,这里段落的内容只是次要的。关键的是段落之间的垂直间距。如果没有它们,即使保留注释,代码的可读性也会大大下降。
sub addarray_internal {
my ($var_name, $needs_quotemeta) = @_;
# Cache the original...
$raw .= $var_name;
# Build meta-quoting code, if required...
my $quotemeta = $needs_quotemeta ? q{map {quotemeta $_} } : $EMPTY_STR;
# Expand elements of variable, conjoin with ORs...
my $perl5pat = qq{(??{join q{|}, $quotemeta \@{$var_name}})};
# Insert debugging code if requested...
my $type = $quotemeta ? 'literal' : 'pattern';
debug_now("Adding $var_name (as $type)");
add_debug_mesg("Trying $var_name (as $type)");
return $perl5pat;
}
8. 抛出异常而不是返回特殊值或设置标志
在失败时返回特殊错误值或设置特殊错误标志是一种非常常见的错误处理技术。从整体上讲,它们是Perl自带的内置函数几乎全部错误通知的基础。例如,内置函数eval
、exec
、flock
、open
、print
、stat
和system
在错误时都返回特殊值。不幸的是,它们并不都使用相同的特殊值。其中一些在失败时还会设置一个标志。遗憾的是,这个标志并不总是相同的。有关详细信息,请参阅perlfunc
手册页。
除了明显的连贯性问题外,通过标志和返回值进行错误通知还存在另一个严重缺陷:开发者可以无声地忽略标志和返回值,而忽略它们对程序员来说几乎不需要任何努力。事实上,在void上下文中,忽略返回值是Perl的默认行为。忽略突然出现在特殊变量中的错误标志同样简单:你只需不费心去检查这个变量。
此外,由于忽略返回值是void上下文的默认行为,所以没有语法标记表示它。无法通过查看程序立即看到故意忽略返回值的位置,这意味着也无法确保它不是意外被忽略的。
总的来说:无论程序员的意图如何(或缺乏意图),错误指示器正在被忽略。这不是好的编程方式。
忽略错误指示器经常导致程序以完全错误的方向传播错误。例如
# Find and open a file by name, returning the filehandle
# or undef on failure...
sub locate_and_open {
my ($filename) = @_;
# Check acceptable directories in order...
for my $dir (@DATA_DIRS) {
my $path = "$dir/$filename";
# If file exists in an acceptable directory, open and return it...
if (-r $path) {
open my $fh, '<', $path;
return $fh;
}
}
# Fail if all possible locations tried without success...
return;
}
# Load file contents up to the first <DATA/> marker...
sub load_header_from {
my ($fh) = @_;
# Use DATA tag as end-of-"line"...
local $/ = '<DATA/>';
# Read to end-of-"line"...
return <$fh>;
}
# and later...
for my $filename (@source_files) {
my $fh = locate_and_open($filename);
my $head = load_header_from($fh);
print $head;
}
locate_and_open()
子程序只是假设对 open
的调用是成功的,立即返回文件句柄($fh
),无论实际结果如何。可能期望的是调用 locate_and_open()
的人会检查返回值是否为有效的文件句柄。
然而,当然,“任何人”并没有进行检查。主循环不是测试失败,而是立即将失败值传播“跨过”块,到循环中的其他语句。这导致对 loader_header_from()
的调用将错误值“向下”传播。正是在那个子程序中,尝试将失败值作为文件句柄处理最终导致程序崩溃。
readline() on unopened filehandle at demo.pl line 28.
这种错误报告发生在程序中与实际发生位置完全不同的地方的代码尤其难以调试。
当然,你可以认为错误在于编写循环的人,因为他们使用 locate_and_open()
而不检查其返回值。在狭义上,这是完全正确的——但更深层次的错误在于最初编写 locate_and_open()
的人,或者至少,假设调用者会始终检查其返回值的人。
人类并不像那样。石头几乎不会从天上掉下来,所以人类很快就会得出结论,它们永远不会掉下来,就不再抬头看。火灾很少在他们家里发生,所以人类很快就会忘记它们可能会发生,就不再每月检查烟雾报警器。同样,程序员不可避免地将“几乎从不失败”缩略为“从不失败”,然后简单地停止检查。
这就是为什么很少有人会费心验证他们的 print
语句。
if (!print 'Enter your name: ') {
print {*STDLOG} warning => 'Terminal went missing!'
}
信任但不验证是人性。
正因为如此,返回错误指示器并不是最佳实践。错误(应该是)不寻常的事件,因此错误标记几乎永远不会返回。对这些繁琐且不优雅的检查几乎永远不会做任何有用的事情,所以最终它们会被悄悄地省略。毕竟,省略测试几乎总是工作得很好。不做麻烦的事情要容易得多。尤其是当不麻烦是默认行为的时候!
当发生错误时,不要返回特殊的错误值;而是抛出一个异常。异常的巨大优势是它们反转了通常的默认行为,将未捕获的错误立即引起注意。另一方面,忽略异常需要故意和明显的努力:你必须提供一个显式的 eval
块来中和它。
如果 locate_and_open()
子程序中的错误抛出异常,那么它将更加简洁和健壮。
# Find and open a file by name, returning the filehandle
# or throwing an exception on failure...
sub locate_and_open {
my ($filename) = @_;
# Check acceptable directories in order...
for my $dir (@DATA_DIRS) {
my $path = "$dir/$filename";
# If file exists in acceptable directory, open and return it...
if (-r $path) {
open my $fh, '<', $path
or croak( "Located $filename at $path, but could not open");
return $fh;
}
}
# Fail if all possible locations tried without success...
croak( "Could not locate $filename" );
}
# and later...
for my $filename (@source_files) {
my $fh = locate_and_open($filename);
my $head = load_header_from($fh);
print $head;
}
注意,主 for
循环没有任何变化。使用 locate_and_open()
的开发者仍然假设不会出错。现在,这种期望有一些依据,因为如果真的出错了,抛出的异常将自动终止循环。
即使在您是那种虔诚地检查每个返回值以防止失败的小心谨慎的人,异常也是一个更好的选择
SOURCE_FILE:
for my $filename (@source_files) {
my $fh = locate_and_open($filename);
next SOURCE_FILE if !defined $fh;
my $head = load_header_from($fh);
next SOURCE_FILE if !defined $head;
print $head;
}
不断检查返回值以防止失败会在代码中充斥着验证语句,这通常会大大降低其可读性。相比之下,异常允许算法在不插入任何错误处理基础设施的情况下实现。您可以将在代码之外的因素错误处理,或者将其委托给周围的eval
之后,或者根本不处理它
for my $filename (@directory_path) {
# Just ignore any source files that don't load...
eval {
my $fh = locate_and_open($filename);
my $head = load_header_from($fh);
print $head;
}
}
9. 在开始调试之前添加新测试用例
任何调试过程的第一步是通过尽可能简短地演示系统的错误行为来隔离系统的错误行为。如果您很幸运,这可能已经为您完成
To: DCONWAY@cpan.org
From: sascha@perlmonks.org
Subject: Bug in inflect module
Zdravstvuite,
I have been using your Lingua::EN::Inflect module to normalize terms in a
data-mining application I am developing, but there seems to be a bug in it,
as the following example demonstrates:
use Lingua::EN::Inflect qw( PL_N );
print PL_N('man'), "\n"; # Prints "men", as expected
print PL_N('woman'), "\n"; # Incorrectly prints "womans"
一旦您已经提炼出一个简短的故障示例,就将其转换为一系列测试,例如
use Lingua::EN::Inflect qw( PL_N );
use Test::More qw( no_plan );
is(PL_N('man') , 'men', 'man -> men' );
is(PL_N('woman'), 'women', 'woman -> women' );
不过,不要立即尝试修复问题。相反,立即将这些测试添加到您的测试套件中。如果该测试已经很好地设置,那么这通常只是添加几个条目到表格那么简单
my %plural_of = (
'mouse' => 'mice',
'house' => 'houses',
'ox' => 'oxen',
'box' => 'boxes',
'goose' => 'geese',
'mongoose' => 'mongooses',
'law' => 'laws',
'mother-in-law' => 'mothers-in-law',
# Sascha's bug, reported 27 August 2004...
'man' => 'men',
'woman' => 'women',
);
关键是:如果原始测试套件没有报告这个错误,那么那个测试套件就是有问题的。它只是没有很好地完成它的任务(找到错误)。首先通过添加导致它失败的测试来修复测试套件
> perl inflections.t
ok 1 - house -> houses
ok 2 - law -> laws
ok 3 - man -> men
ok 4 - mongoose -> mongooses
ok 5 - goose -> geese
ok 6 - ox -> oxen
not ok 7 - woman -> women
# Failed test (inflections.t at line 20)
# got: 'womans'
# expected: 'women'
ok 8 - mother-in-law -> mothers-in-law
ok 9 - mouse -> mice
ok 10 - box -> boxes
1..10
# Looks like you failed 1 tests of 10.
一旦测试套件能够正确地检测到问题,那么您就能够知道您是否已经正确地修复了实际的错误,因为测试将再次变得沉默。
当测试套件覆盖了问题的全部表现范围时,这种方法对调试最为有效。当为错误添加测试用例时,不仅添加一个针对最简单情况的测试。还要确保包括明显的变体
my %plural_of = (
'mouse' => 'mice',
'house' => 'houses',
'ox' => 'oxen',
'box' => 'boxes',
'goose' => 'geese',
'mongoose' => 'mongooses',
'law' => 'laws',
'mother-in-law' => 'mothers-in-law',
# Sascha's bug, reported 27 August 2004...
'man' => 'men',
'woman' => 'women',
'human' => 'humans',
'man-at-arms' => 'men-at-arms',
'lan' => 'lans',
'mane' => 'manes',
'moan' => 'moans',
);
您对错误的测试越彻底,您修复它的就越完整。
10. 不要优化代码—对其进行基准测试
如果您需要删除数组中重复元素的功能,您自然会认为像这样的“一劳永逸”的语句
sub uniq { return keys %{ { map {$_=>1} @_ } } }
比两个语句更有效
sub uniq {
my %seen;
return grep {!$seen{$_}++} @_;
}
除非您非常熟悉Perl解释器的内部(在这种情况下,您已经有更严重的问题需要处理),否则对两种结构的相对性能的直觉恰恰是:无意识的猜测。
唯一确定两个(或更多)替代方案中哪一个将表现更好的方法是实际地为每个选项计时。标准的Benchmark模块使这变得简单
# A short list of not-quite-unique values...
our @data = qw( do re me fa so la ti do );
# Various candidates...
sub unique_via_anon {
return keys %{ { map {$_=>1} @_ } };
}
sub unique_via_grep {
my %seen;
return grep { !$seen{$_}++ } @_;
}
sub unique_via_slice {
my %uniq;
@uniq{@_} = ();
return keys %uniq;
}
# Compare the current set of data in @data
sub compare {
my ($title) = @_;
print "\n[$title]\n";
# Create a comparison table of the various timings, making sure that
# each test runs at least 10 CPU seconds...
use Benchmark qw( cmpthese );
cmpthese -10, {
anon => 'my @uniq = unique_via_anon(@data)',
grep => 'my @uniq = unique_via_grep(@data)',
slice => 'my @uniq = unique_via_slice(@data)',
};
return;
}
compare('8 items, 10% repetition');
# Two copies of the original data...
@data = (@data) x 2;
compare('16 items, 56% repetition');
# One hundred copies of the original data...
@data = (@data) x 50;
compare('800 items, 99% repetition');
cmpthese()
子例程接受一个数字,后跟一个测试哈希的引用。该数字指定运行每个测试的确切次数(如果该数字为正),或者运行测试的绝对CPU秒数(如果该数字为负)。典型值是大约10,000次重复或十秒CPU时间,但如果测试太短而无法产生准确的基准,该模块将警告您。
测试哈希的键是您的测试名称,相应的值指定要测试的代码。这些值可以是字符串(这些字符串被eval
求值以产生可执行代码)或子例程引用(直接调用)。
上面的基准测试代码将打印出类似以下的内容
[8 items, 10% repetitions]
Rate anon grep slice
anon 28234/s -- -24% -47%
grep 37294/s 32% -- -30%
slice 53013/s 88% 42% --
[16 items, 50% repetitions]
Rate anon grep slice
anon 21283/s -- -28% -51%
grep 29500/s 39% -- -32%
slice 43535/s 105% 48% --
[800 items, 99% repetitions]
Rate anon grep slice
anon 536/s -- -65% -89%
grep 1516/s 183% -- -69%
slice 4855/s 806% 220% --
每个打印的表格都有一个单独的行,对应于每个命名的测试。第一列列出每个候选者的绝对速度(每秒重复次数),其余列允许您比较任何两个测试的相对性能。例如,在最终测试中,通过追踪grep
行到anon
列可以揭示使用grep
的解决方案比使用匿名哈希快1.83倍(即183%)。进一步追踪同一行也表明,grep
ping比切片慢69%(即快69%)。
总体来说,从三次测试的结果来看,基于切片的解决方案在这台特定机器上的这组特定数据中始终是最快的。此外,随着数据集规模的增加,切片的扩展性也明显优于其他两种方法。
然而,这两个结论实际上只基于三个数据点(即三次基准测试)。要更全面地比较三种方法,您还需要测试其他可能性,例如非重复项的长列表或仅重复的短列表。
更好的做法是在您将要“去重”的真实数据上测试。
例如,如果这些数据是一个包含25万单词的已排序列表,重复最少,并且必须保持排序,那么就测试这个列表。
our @data = slurp '/usr/share/biglongwordlist.txt';
use Benchmark qw( cmpthese );
cmpthese 10, {
# Note: the non-grepped solutions need a post-uniqification re-sort
anon => 'my @uniq = sort(unique_via_anon(@data))',
grep => 'my @uniq = unique_via_grep(@data)',
slice => 'my @uniq = sort(unique_via_slice(@data))',
};
不出所料,这个基准测试表明,在大型已排序数据集中,grep
解决方案明显优于其他方案。
s/iter anon slice grep
anon 4.28 -- -3% -46%
slice 4.15 3% -- -44%
grep 2.30 86% 80% --
更有趣的是,当两种基于哈希的方法不重新排序时,grep
解决方案的基准测试仍然显示其速度略快。这表明,在早期基准测试中看到的切片解决方案的更好扩展性是一个局部现象,最终会因切片哈希变得非常大时的分配、哈希和桶溢出成本的增加而被削弱。
最重要的是,这个最后的例子表明,基准测试只测试了您实际测试的情况,并且您只能从基准测试真实数据中得出有关性能的有用结论。
标签
反馈
这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们。
- More commenting... maybe?
github.polettix.it - Perl Weekly Challenge 121: Invert Bit
blogs.perl.org - Web nostalgia: MojoX::Mechanize
github.polettix.it - On the eve of CPAN Testers
blogs.perl.org - PWC121 - The Travelling Salesman
github.polettix.it - PWC121 - Invert Bit
github.polettix.it - Floyd-Warshall algorithm implementations
github.polettix.it - Perl Weekly Challenge 120: Swap Odd/Even Bits and Clock Angle
blogs.perl.org - How I Uploaded a CPAN Module
blogs.perl.org - App::Easer released on CPAN
github.polettix.it