重探Perl的七宗罪


很久以前,在Perl 4时代,Tom Christiansen写了一篇名为《The Seven Deadly Sins of Perl》的文章,描述了当时Perl语言中最大的问题。几年后,他又回顾了这份清单,看看Perl 5如何解决了这些问题。那是在1996年;我认为是时候更新一下了。我们现在在哪里?

Tom的原始七宗罪

1. 隐式行为和隐藏上下文依赖

每隔一周左右,有人出现在comp.lang.perl.misc中询问为什么这个不工作

        while (<FILE>) {
          my $line = <FILE>
          # ...
        }

“它似乎跳过了每一行,”他们说。嗯。当然,它跳过了每一行,因为当<...>操作符是while循环的条件时,它会神奇地变成defined($_ = <...>)。但然后很多人在其他方向上感到困惑

        if (something) {
          <FILE>
          print "The next line is ``$_''.\n";
        }

<...> 在它是while循环的条件时才会改变$_。我想知道如果<...> 总是将内容读入$_除非某处进行了赋值,会更好吗?哦,无论如何,都太迟了。

我看到一种趋势,远离这些。这里有一个例子,由于趋势,这个例子有些晦涩。在正则表达式中,^$通常分别匹配字符串的开始和结束。正则表达式/^Hello/在字符串的开始寻找Hello。如果你的字符串包含许多行,由\n字符分隔,你可能希望有一个正则表达式,在任意行的开始寻找Hello。你在Perl 4(以及Perl 3、2和1)中这样做的方法是将变量$*设置为真值。这改变了整个程序中每个正则表达式的意义。如果你设置了它,忘记设置它,你可能会在其他正则表达式不应匹配时得到一个糟糕的惊喜。如果你正在编写一个将被其他程序使用的库,你必须在每个使用^$的正则表达式中添加

        { local $* = 0;
          $target =~ /^regex/;
        }

,否则你可能会被意外的$*设置所惊吓。当然,大多数库没有这样做。这里还有一个例子:数组索引通常从0开始,因为$[的值通常是0;如果你将$[设置为1,那么数组从1开始,这让Fortran程序员很高兴,所以我们看到了perl3手册页上的这样的例子

        foreach $num ($[ .. $#entry) {
          print "  $num\t'",$entry[$num],"'\n";
        }

,当然,你也可以将$[设置为17,以便数组从17而不是从0或1开始。这是一种破坏模块作者的绝好方法。

幸运的是,理智最终占据了上风。现在人们认识到这些功能是错误的。Perl5-Porters邮件列表现在为这种功能有一个口号:它们被称为“远程操作’。其原则是,程序某一处的声明不应该极大地、隐秘地改变程序其他部分的运行行为。一些旧版本的远程操作功能已被改造成更安全版本。例如,在Perl 5中,你不应该使用$*。相反,你需要在匹配操作符的末尾加上/m,表示只改变^$的语义,仅针对那个正则表达式。预期的从Fortran程序员的大量涌入从未成真,因此$[已被高度弃用,并且只影响当前文件中的代码;它不能用来破坏模块。

不幸的是,理智并未得到充分体现,因为无处不在的$/变量(输入记录分隔符字符串)仍然存在,并且可以独自造成很多破坏。

这种情况可能很快就会改变。Perl5-Porters邮件列表上的热门话题是“行纪律”,这意味着每个文件句柄都会有自己关于行终止符的私有概念,以及关于输入的其他属性,如字符表示。这将允许它透明地将EBCDIC数据转换为ASCII,或者更确切地说,将Latin-1转换为Unicode。但问题可能不会完全消失;变量如$/$\$"$,将与我们长期共存。

预测:远程操作现在被认为是一个坏习惯,旧版本的远程操作功能正在转换为更安全的版本。但问题可能还会持续很长时间,目前很少有模块采取预防措施来防止出现奇特的$"或其他类似的变量。

2. 将逻辑或和逻辑非转换为括号?

汤姆通过这句话指的是与括号(或缺少括号)的方式有关的一些奇怪的上下文陷阱,这会极大地改变表达式的意义。很少有初学者程序员理解上下文。这里有一个很好的例子:这行代码是可行的

        $n = sprintf("%d %d", 32, 49);

这也一样

        @n = (32, 49);
        $n = sprintf("%d %d", @n);

但如果你尝试这个,你会得到一个惊喜

        @args = ("%d %d", 32, 49);
        $n = sprintf(@args);

(如果你将sprintf替换为printf,惊喜就会消失。呃。)

当人们应该写my($x) = ...时,他们会遇到my $x = ...的问题。他们会有相反的问题,即当应该写my $line = <FILE>时,他们却写了my ($line) = <FILE>。当然,他们想知道如何找到数组中元素的数量。

人们还希望认为表达式中的(...)会构建一个列表,因此他们会在事物周围添加括号以使它们成为列表,但这是永远不会成功的,因为等号左边的部分决定了等号右边的部分是否为列表。事实上,这是一个相当好的经验法则,只要你愿意忽略x运算符:'foo' x 3构建字符串'foofoofoo',但('foo') x 3构建列表('foo', 'foo', 'foo'),不论是否有等号。

预测:你不必喜欢它,但你必须学会与之共存。

3. 全局变量

这个问题的一个典型例子是以下代码

        while (<FILE>) {
          print if some_function();
        }

这看起来无害,不是吗?但是它是欺骗性的,因为some_function()调用了other_function(),而other_function()调用了模块Joe::Database中的joes_function(),然后joes_function()调用了load_database(),而load_database()有484行长,中间某处写着

        while (<DATABASE>) {
          push @records, <DATABASE> if /pattern/;
        }

这段代码会覆盖$_的值,这在某种程度上是可以接受的,但问题在于它会覆盖主程序中$_的值,而主程序打算在some_function最终返回时打印出$_的值。相反,它会打印出空字符串。程序失败,电厂爆炸,污染了地球和海洋。饥荒和疾病席卷全球。所有人都死了。哦,真尴尬。

今天,perl5-porters收到了一封询问为什么这段代码会销毁数组的邮件

        my @array=(1,2,3);
        foreach (@array) {
          open FILE, "<test";
          while (<FILE>) {
            ...
          }
          close FILE;
        }

这是一个很好的问题。另一个很好的问题可能是为什么这段代码不会输出一系列的3

        my @array=(1,2,3);
        open FILE, "<test";
        while (<FILE>) {
          foreach (@array) {
          ...
          }
          print;
        }
        close FILE;

答案:因为foreach会自动保存其索引变量的旧值,并在循环结束时恢复原始值。

这个问题在近几年有所改善。在foreach循环中自动本地化是第一步。鼓励人们使用新的for my $x (...)while (my $x = ...)语法也将有助于解决这个问题;任何让人们停止使用$_的事情都是朝着正确方向迈出的一步。

预测:事情有所改善,但也可能已经达到了改善的极限。必须教育模块作者在更改之前本地化$_,而任何依赖于模块作者教育的进步可能都是注定要失败的。

4. 引用与非引用

汤姆的抱怨似乎是他觉得引用语法太复杂了。我认为没有人会反对这一点。引用语法很糟糕。它也不会变得更好。

预测:末日和阴霾。

5. 没有原型

Perl 4的一个常见抱怨是,你无法编写像push这样的函数

        my_push(@array, 1, 2, 3);

@array会被展开成元素列表,而my_push将无法操作原始数组。这个问题通过原型功能得到了解决;现在你可以这样编写my_push

        sub my_push(\@@) {
          my $aref = shift;
          push @$aref, @_;
        }

原型仍然有一些微小但令人烦恼的漏洞。你不能编写一个像printf那样具有可选文件句柄参数的函数,也不能编写像sort那样具有可选代码块参数的函数,或者像tied那样具有任何类型的变量参数的函数。你可以编写一个像lc那样接受单个可选参数的函数,但它不会被像lc那样解析

        $fred = 'Flooney';
        sub my_lc (;$) {
          if (@_) { lc $_[0] } else { lc $_ }
        }

        print lc $fred, "\n";
        print my_lc $fred, "\n";

Too many arguments for main::my_lc at /tmp/lc line 7, near ""\n";"
Execution of /tmp/lc aborted due to compilation errors.

原型最糟糕的地方可能是其名称。当ANSI在1989年标准化C语言时,最大的变化是添加了`prototypes’以启用函数参数的编译时类型检查。C程序员了解到,你应该为所有函数原型化以启用这些检查,这样你就不会传递一个指向想要整数的函数的指针,或者任何其他东西。人们有这样一个想法,即Perl原型是为了同样的目的,事实上并非如此。它们做的是完全不同的事情,而且它们不能保护你免受这种情况的影响

        sub foo ($);

        foo(@x);   # whoops, should have been foo($x) instead.

C程序员可能希望这会提供一个编译时错误,说“嘿,傻瓜!你本想用标量,却用了数组!”这将使调试更容易。不,Perl将原型视为你希望将数组自动转换为标量的指示,并将数组中元素的数量传递给foo()。这将使调试更难,而不是更容易。

预测:原型剩余的技术问题相当小,可能最终会得到解决。函数参数更好的类型检查也可能最终到来;长期以来一直在讨论函数声明的形式

        sub foo (Dog, Cat) { ... }

这可以确保您传递的是适当的类对象,并且对此的支持是逐渐增加的。例如,请参阅 perldoc fields。然而,这可能会加剧那些试图让 C 语言程序员停止在编译时进行类型检查使用原型的问题。预计这里会有更多的混乱,而不是更少。

6. 没有编译器对 I/O 或 Regex 对象的支持

在汤姆的第一份报告和第二份报告之间,这个问题得到了很大改善,以至于他认为问题已经解决。我认为在正则表达式方面,这一成就主要归功于伊利亚·扎哈列维奇;我不知道 I/O 方面是谁的功劳;可能是拉里·沃尔和格雷厄姆·巴。自从汤姆的最后一篇报告以来,这个问题已经得到更好的解决:您可以说

        open my $fh, $filename;

并且 $fh 将自动初始化为一个打开指定文件的文件句柄;当 $fh 超出作用域时,文件将自动关闭。这意味着您再也不必担心使用全局文件句柄名称了。这是 C 语言 local 的另一个有用用途,再见吧。

预测:基本上已经解决,尽管还有一些遗留问题。

7. 随机异常模型

汤姆说:“在库、模块或类中,没有标准的异常处理模型或指南,这意味着您不知道应该捕获什么,不应该捕获什么。库会抛出异常还是只返回 false?”

这个问题依然存在。每个模块都做不同的事情。C 语言程序员曾经抱怨说,必须显式检查每个系统调用的错误返回值,这使得他们的代码大小增加了四倍;在 Perl 中,这个问题更严重,因为每个检查看起来都略有不同。

模块持续发出无法关闭的警告信息。模块调用 die 并在您本以为只会得到简单错误返回时让您感到惊讶。感谢上帝,自从 1996 年以来,标准模块在这方面已经得到了很大程度的清理。

这里还有一个问题:在 Perl 中,异常和 die 是同一回事,这有时会让人感到惊讶。最近,有人向 perl5-porters 发邮件,询问一个库函数将要运行一个子进程。fork() 成功,但 exec() 失败,所以子进程调用了 die。这通常是正确的做法。然而,在这个例子中,库函数是在一个 eval 块中调用的,该块捕获了子进程的 die。原始进程仍在等待子进程完成,但子进程却认为自己已经是父进程了!

为合理化已经打下了一些基础;Perl 的最新版本允许您用 die 抛出任何类型的对象,而不仅仅是字符串。使用这些对象,您可以在程序中传播复杂类型的异常。但据我所知,这些特性很少使用。有几个模块提供了 try-catch-cleanup 语法,但据我所知,它们也很少使用。而且,还没有广泛接受的模块行为指南。

预测:这是一个社会问题,而不是技术问题。唯一的解决办法是教育,可能由一位或多位战士牵头。


为了替换已经解决的问题(缺少原型和编译器对 I/O 和 Regex 对象的支持),我想在这个列表中增加两个新的问题

8. 文档太大

Perl 1的文档有2,000行,已经相当大了。当前开发版本的文档有72,000行,还不包括只有开发者才能看到的像Changes这样的文档。对于初学者来说,很难知道从哪里开始,而对于任何人来说,也很难找到任何特定信息的位置。现有的文档中包含了一些像perlhistperlapio这样的内容,本应该放在某个子目录中,而不是和perlfunc放在一起。

手册一直在变厚,因为虽然很容易看到并欣赏到任何特定添加的价值,但很难欣赏到0.01%更大的文档集带来的负面影响。所以有人会来提出他们被X搞糊涂了,手册应该对X有更详细的解释,而这只需要几行。没有人愿意反对增加几行,因为没有明显的害处,但这样做了14,000次后,手册的实用性就严重受损。

同样,很难认真提出缩短手册的建议。缩短手册至少和缩短程序一样困难。你不能随意删减东西;你必须重新组织和重写。

除了全部扔掉并重新开始之外,一些可能有助于改进的事情:过去几年中,趋势似乎是参考材料和教程材料的分离。这可能是个好事。现有的文档需要重新组织;不清楚什么是“语法”而不是“运算符”或“函数”。(如果我知道如何做到这一点,我会说的。)现在整体结构是平的;我认为如果文档只是分为几个子目录,如“教程”、“内部”、“参考”、“社交”等,这可能是一个进步。Perl需要提供更好的文档浏览工具,也许更重要的是,需要在互联网上有一个更好的搜索界面。更好地索引现有文档将有助于实现这一点。

预测:较差。除了更多类似的工作之外,几乎没有人在做文档。每个人都想写;没有人想索引。

9. API太复杂。

编写Perl扩展太难。你必须理解XS。现有的XS文档非常简略。

如果你只是想将现有的C函数粘接到Perl中,SWIG和h2xs这样的包在这里会有很大帮助。如果你想做任何一点不同寻常的事情,你就得自己来了。

这里有什么帮助?更好的文档。现有perlxs手册中讨论的例子是rpcb_gettime函数的接口,不管那是什么。如果你的系统上没有,你大概没有,你就不能尝试这个例子。XS手册页和perlguts手册页之间的依赖性太多;有人需要审查这些内容并将它们重新组织成一系列可以按顺序阅读的文档。

我曾问Larry为什么XS这么复杂,他说这样做是为了提高效率。如果有一种扩展粘合剂,即使效率稍低,也更容易编写,那会很好。

预测:混合。像SWIG和Ken Fox的C++套件这样的粘合制造商似乎正在成熟。但文档问题没有得到解决,真正的问题是Perl的内部结构过于复杂和不合理,可能是无法解决的。Topaz(Perl 6)项目可能会解决这个问题。

标签

反馈

这篇文章有什么问题?通过在GitHub上打开一个issue或pull request来帮助我们