《高阶Perl》——3.6 CAVEATS

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函数,通过记忆术或任何别的方法,因为它已经几乎和任何函数所可能达到的速度一样快了。

时间: 2024-12-26 09:34:04

《高阶Perl》——3.6 CAVEATS的相关文章

《高阶Perl》——导读

前 言 在编程圈子里有一句著名的俗语,一个优秀的Fortran程序员可以用任何语言写Fortran程序.然而,让人悲哀的是,不管他们是否愿意,Fortran程序员用任何语言写Fortran程序.类似地,作为Perl程序员,我们也在用Perl写C程序,不管我们是否愿意.这让人羞愧,因为Perl是一门比C更富有表现力的语言.我们本可以做得更好,以C程序员梦想不到的方式使用Perl,但是我们没有那样做. 怎么会这样呢?Perl的设计初衷是一方面作为C的替代品,另一方面作为UNIX脚本语言(如Bourn

《高阶Perl》——3.2 内联缓存

3.2 内联缓存 给一个函数添加缓存的最直接的方式就是给函数一个私有的散列.在这个例子里,可以使用一个数组代替散列,因为fib()的参数总是一个非负整数.但是一般需要使用一个散列,那么将会看到: ### Code Library: fib-cached # Compute the number of pairs of rabbits alive in month n { my %cache; sub fib { my ($month) = @_; unless (exists $cache{$m

《高阶Perl》——3.5 MEMOIZE模块

3.5 MEMOIZE模块 本书不是关于Perl模块的内部细节的,而是关于一些Memoize模块内部使用的并和稍后要做的事情有直接联系的技术,所以现在简短地介绍一下. Memoize得到一个函数名(或引用)作为它的参数.它制造一个新的函数,后者维护一个缓存并在其中查找它的参数.如果新的函数在缓存里找到了参数,就返回缓存的值:如果没找到,就调用原始函数,把返回的值保存入缓存,并把它返回给原始的主调者. 制造完这个新的函数,Memoize就把它装入Perl的符号表以代替原始的函数,那样当你认为你在调

《高阶Perl》——3.10 可供选择的记忆术

3.10 可供选择的记忆术 大多数纯函数提供一个缓存的机会.尽管乍一看纯函数很少,它们只以一定频率出现.纯函数特别普遍的地方是在排序中用做比较器函数. Perl内置的sort操作符是通用的,它可以把一列任何种类的数据以程序要求的任何次序排序.默认状态下,它把一列字符串以字母表次序排序,但是程序员可以任意提供一个比较器函数(comparator function),告诉Perl怎样重排sort的参数列表.比较器函数被反复调用,每次带有待排序列表中的两个不同元素,如果两个元素次序正确,就必须返回一个

《高阶Perl》——3.8 对象方法里的缓存

3.8 对象方法里的缓存 对象方法,它经常不理解地把缓存的值保存在独立的散列里.考虑一个投资银行写的程序里的Investor对象.该对象表现了银行的一个客户: package Investor; # Compute total amount currently invested sub total { my $self = shift; # ... complex computation performed here ... return $total; } 如果$total不会改变,就可以缓存

《高阶Perl》——3.7 键的生成

3.7 键的生成 先前的记忆器至少有一个严重的问题.它需要把函数的参数转变成一个散列键,它的做法是使用join: my $key = join ',', @_; 这对只带一个参数的函数有效,也对参数不含逗号的函数有效,包括所有的参数是数字的函数.但是如果函数的参数可能包含逗号,它可能失效,因为以下两个调用却计算出了相同的键: func("x,", "y"); func("x", ",y"); 第一次调用,返回的值将以键&quo

《高阶Perl》——1.2 阶乘

1.2 阶乘 假设有一个有n个不同条目的列表.为了具体点,假设这些条目是字母表的字母.这样一个列表有多少种不同的排列次序呢?显然,因为答案依赖于n,所以它是n的函数.这个函数称为阶乘函数(factorial function).n的阶乘就是n个不同条目的不同的排列次序的数量.通常以一个后缀!标记,这样n的阶乘就是n!.一般不同的次序称为排列(permutation). 下面计算一些阶乘.显然,只有一个条目的列表只有一种排列,所以1!=1.两个条目的列表有两种排列:A-B和B-A.少许笔算可以发现

《高阶Perl》——3.12 速度的好处

3.12 速度的好处 此刻有个说法比较诱人,那就是记忆术只比手动缓存技术改善了一点,因为它做了同样的事情,仅有的额外的好处是你可以快速启动或者关闭它.但这不完全正确.当工具之间的速度和便利的差别够大,它会改变你思考和使用工具的方式.自动地使一个函数带记忆仅需要消耗手动书写代码的1/100的时间.这和飞机与牛车的速度差别一样.说飞机只是更快的牛车就忽略了本质:量变如此巨大以致它也变成了实质的质变. 例如,有了自动的记忆术,就有可能为函数增加缓存行为而不必预先仔细考虑性能细节.记忆术这么简单以至于可

《高阶Perl》——1.4 层次化数据

1.4 层次化数据 已经介绍的例子显示了递归过程的风采,但是它们遗漏了重要的一点.在介绍汉诺塔问题时,我说过当你想解决的问题可以简化成同样问题的更简形式时,递归是很有用的.但是这样的问题是普遍的这一点可能不清楚. 多数递归的函数是为处理递归的数据结构的建立.一个递归的数据结构就像一个列表.一棵树或者一个堆,它们根据相同数据结构的更小的实例定义而成.最常见的例子可能是文件系统目录结构.文件可以是: 文件可以是一个目录,含有一列文件,其中的一些可以是目录,再含有另一些文件,以此类推.处理这样的结构最