且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《高阶Perl》——3.6 CAVEATS

更新时间:2022-09-27 12:56:00

3.6 CAVEATS

(这是拉丁文的“警告”。)

显然,记忆术不适合所有的性能问题。它甚至不适合所有的函数。有几类函数就不应该带记忆。

3.6.1 返回值不依赖参数的函数

记忆术最适合那些返回值只依赖它们的参数的函数。想象一下使时间函数带记忆的愚蠢:第一次你调用它,你将得到时刻,随后的调用将会返回一样的时刻。类似地,想象一个带记忆的随机数生成器是多么固执。

或者想象一个返回值是指示某类成功或失败的函数。你不会希望这类函数是带记忆的,每次被调用都返回同一个值。

然而,记忆术适合一些这样的函数。例如,如果一个函数,结果依赖当日钟点的函数,且运行很长时间,那么使它带记忆就很有用了。(详见3.7节是如何处理这个的。)

3.6.2 有边界效应的函数

许多函数被调用是为了得到它们的边界效应而不是返回值。假设写了个程序,把计算机运行状态的报告整理并发送给打印机打印。那也许返回值就没什么意义了,缓存它是愚蠢的。即使返回值有意义,使其带记忆仍然是不合适的。函数在第一次运行后就会运行得很快,因为记忆术,但你的老板不会感动,因为它立即返回了旧的缓存了的返回值,而没有实际打印报告。

3.6.3 返回引用的函数

这个问题有一点精妙。返回的指向值的引用可能被它们的主调者修改的函数肯定不能带记忆。

要看看潜在的问题,参考这个例子:

use Memoize;

sub iota {
  my $n = shift;
  return [1 .. $n];
}
memoize 'iota';

$i10 = iota(10);
$j10 = iota(10);
pop @$i10;
print @$j10;

第一次调用itoa(10)产生一个新的匿名的数组包含数字1到10,并返回指向这个数组的引用。这个引用自动放到缓存里,也存放到了$i10。第二次调用itoa(10)从缓存取回了一样的引用,并存放到了$j10。$i10和$j10现在都指向同一个数组,称它们是数组的别名(alias)。

当通过别名$i10改变数组的值时,这个改变也影响存放在$j10里的值!这也许不是主调者期望的,如果没有使itoa带记忆,这个也不会发生。记忆术应该是一个优化。也就是说它应该加速程序而不改变程序的行为。

禁止使那些返回的引用值可能被主调者改变的函数带记忆,可能非常普遍地适用于面向对象的构造器方法。考虑如下程序:

package Octopus;
sub new {
  my ($class, %args) = @_;
  $args{tentacles} = 8;
  bless \%args => $class;
}

sub name {
  my $self = shift;
  if (@_) { $self->{name} = shift }
  $self->{name};
}
my $junko = Octopus->new(favorite_food => "crab cakes");
$junko->name("Junko");
my $fenchurch = Octopus->new(favorite_food => "crab cakes");
$fenchurch->name("Fenchurch");

# This prints "Fenchurch" -- oops!
print "The name of the FIRST octopus is ", $junko->name, "\n";

在这里程序员想要制造两个不同的章鱼,一个名为“Junko”,另一个名为“Fenchurch”。它们都喜欢蟹肉蛋糕。不幸的是,有人愚蠢地决定使new()带记忆,由于第二次调用的参数是一样的,记忆术的存根返回第一次调用时缓存的返回值,即指向“Junko”对象的引用。程序员认为那里有两只章鱼,但实际上只有一只,冒充两只。

返回值只依赖它们的参数,没有边界效应,且从不返回引用值的函数称为纯函数(pure function)。缓存技术非常适合用于纯函数,尽管它们有时也可以用于非纯函数。

3.6.4 带记忆的时钟

一个简单而有益的缓存非纯函数的例子是由Perl的$^T变量提供的。Perl提供了几个方便的文件操作符,如-M $filename,它返回以它的参数命名的文件最后一次修改至今的天数。为计算这个,Perl向操作系统询问最后一次修改的时间,从当前时间减去前者,再转换成天数。由于-M可能被非常频繁地执行,如果它是快速的,那是很重要的:考虑如下这个:

@result = sort { -M $a <=> -M $b } @files;

它对一系列文件按照末次修改时间排序。找许多文件的末次修改时间已经代价很大了,就没必要再进行数千次的time()函数调用。更糟糕的是,系统可能是精确到秒记录时间的,如果系统时钟在执行sort()过程中发生步进,那结果列表可能是错误的次序。

为避免这些问题,Perl不在一个-M操作执行时查找当前时间。而是当程序开始运行时,Perl把当前时间缓存在特殊的变量$^T里,无论何时执行的-M,都用这个作为当前时间。大多数程序的运行时间短,且大多数不需要从-M得到完全精确的结果,所以这通常是一个好主意。某些运行时间长的程序需要通过定期地执行$^T = time()刷新$^T,以防止-M的结果偏离日期太远。当缓存一个非纯函数,通常的好主意是提供一个到期制度,这样旧的缓存的值最后被抛弃和刷新。允许程序员刷新整个缓存也是考虑周到的。模块Memoize提供了插入缓存到期管理器的机会。

3.6.5 非常快的函数

我曾经和一位程序员交谈,他抱怨他的函数在带记忆后是变慢了而不是变快了。结果发现他尝试加速的函数如下:

sub square { $_[0] * $_[0] }

缓存技术,像所有技术一样,是一个折中。可能的好处是减少了对原始函数的调用。代价就是程序每次调用时都必须检查缓存。先前,有公式hfK,表示了记忆术节约的时间。如果hf < K,那么带记忆的版本就会比不带记忆的版本更慢。h是缓存命中率,在0到1之间。f是函数原先的运行时间,K是检查缓存所需的平均时间。如果f小于K,那么hf < K就是必然的。如果检查缓存的代价大于调用原始函数的代价,记忆术就不起作用。你无法通过避免“不必要的”调用节约时间,因为一开始就直接调用而花费更长的时间发现那个调用是不必要的。

在square例子里,函数做了一次单一的乘法。检查一次缓存需要一次散列查找,这包括一个散列值的计算(许多乘法和加法),然后在散列键桶里索引,也许还有一次链表搜索。这些不可能击败一次单一的乘法。事实上,几乎没什么能击败一次单一的乘法。你无法加速square函数,通过记忆术或任何别的方法,因为它已经几乎和任何函数所可能达到的速度一样快了。