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

3.10 可供选择的记忆术

大多数纯函数提供一个缓存的机会。尽管乍一看纯函数很少,它们只以一定频率出现。纯函数特别普遍的地方是在排序中用做比较器函数。

Perl内置的sort操作符是通用的,它可以把一列任何种类的数据以程序要求的任何次序排序。默认状态下,它把一列字符串以字母表次序排序,但是程序员可以任意提供一个比较器函数(comparator function),告诉Perl怎样重排sort的参数列表。比较器函数被反复调用,每次带有待排序列表中的两个不同元素,如果两个元素次序正确,就必须返回一个负值;如果两个元素次序不正确,就返回一个正值,如果无所谓,就返回零。通常,一个比较器函数的返回值只依赖它的参数的值,待比较的两个列表条目,所以它是一个纯函数。

最简单的比较器函数的例子也许是按大小比较数字的比较器了:

@sorted_numbers = sort { $a <=> $b } @numbers;

这里{ $a <=> $b } 就是比较器函数。sort操作符检查列表@numbers,把$a和$b设置成将要比较的数字,然后调用比较器函数。<=>是一个特殊的Perl操作符,如果$a小于$b,就返回一个负值;如果$a大于$b,就返回一个正值;如果$a与$b相等,就返回零。cmp是一个针对字符串的类似的操作符,如果不提供一个明确的比较器,Perl就默认使用它。

一个可选的语法是使用一个具名函数而不是一个裸露的块:

@sorted_numbers = sort numerically @numbers;

sub numerically { $a <=> $b }

这等价于裸露块版本。

一个更有趣的例子是把一列形如"Apr 16, 1945"的日期字符串按时间次序排序:

### Code Library: chrono-1
@sorted_dates = sort chronologically @dates;

%m2n =
   ( jan =>      1, feb =>      2, mar =>      3,
     apr =>      4, may =>      5, jun =>      6,
     jul =>      7, aug =>      8, sep =>      9,
     oct =>     10, nov =>     11, dec =>     12, );
sub chronologically {
  my ($am, $ad, $ay) =
    ($a =~ /(\w{3}) (\d+), (\d+)/);
  my ($bm, $bd, $by) =
    ($b =~ /(\w{3}) (\d+), (\d+)/);

              $ay <=>         $by
  || $m2n{lc $am} <=> $m2n{lc $bm}
  ||          $ad <=>         $bd;
}

要被比较的两个日期字符串,和先前一样,放在$a和$b里,然后拆分成$ay,$by,$am等。$ay与$by,是年份,最先比较。这里的||操作符是个一般的习惯用法,在排序的比较器中用第二个关键词排序。||操作符返回它的左操作数,除非那是零,那种情况它就返回右操作数。如果年份相同,那么$ay <=> $by返回零,||操作符把控制传递到进入月份的表达式部分,用来解开关联的部分。但是如果年份不同,那么第一个<=>的结果是非零,这就是||表达式的结果,指示sort如何把$a与$b排序到结果列表中,而不必再看月份和日子了。如果控制传递到了$am <=> $bm部分,会发生同样的事情。月份被比较;如果结果是结论性的,那么函数立即返回,如果月份相同,控制传递到最后的日子比较的决胜局。

从内部看,Perl的sort操作符已经被多种算法实现,运行时间是O(n log n)。这意味着对一个大n倍的列表排序一般会花费比n倍稍微长的时间。如果列表规模加倍,运行时间比双倍更多。下面的表比较了参数列表的长度和比较器函数通常的调用次数:

   Length             # calls            calls / element
        5                   7                 1.40
       10                  26                 2.60
       20                  60                 3.00
       40                 195                 4.87
       80                 417                 5.21
      100                 569                 5.69
     1000                9502                 9.50
    10000              139136                13.91

我如此得到“# call”列的,依照指示的长度产生一列随机数,然后用一个比较器函数排序,每次被调用,计数器就增加。调用的次数将因列表和比较器函数而不同,但这些值是典型的。

现在考察有10 000个日期的列表。139 136次比较器函数的调用,每次调用执行两次模式匹配操作,所以一共有278 272次模式匹配。这意味着每个日期平均拆分成年、月、日27.8次。由于给定日期的这三个组成部分不会改变,显然26.8次模式匹配是浪费的。

首先想到的是,使chronologically函数带记忆,但这实际上行不通。尽管sort将以相同的日期反复调用chronologically函数,但它不会对同一对(pair)日期调用两次(除非,当然,输入列表包含重复的日期)。由于散列键必须结合两个参数,带记忆的函数将永远不会有一次缓存命中。

而本文是采取稍微不同的做法,只使函数中浪费的部分带记忆。这将需要能处理一个返回列表的函数的memoize()版本。

### Code Library: chrono-2
@sorted_dates = sort chronologically @dates;

%m2n =
   ( jan =>      1, feb =>      2, mar =>      3,
     apr =>      4, may =>      5, jun =>      6,
     jul =>      7, aug =>      8, sep =>      9,
     oct =>     10, nov =>     11, dec =>     12, );

sub chronologically {
  my ($am, $ad, $ay) = split_date($a);
  my ($bm, $bd, $by) = split_date($b);

              $ay <=>         $by
  || $m2n{lc $am} <=> $m2n{lc $bm}
  ||          $ad <=>         $bd;
}

sub split_date {
  $_[0] =~ /(\w{3}) (\d+), (\d+)/;
}

如果对split_date设置缓存,仍将进行278 272次调用,但是268 272次将导致缓存命中,只有剩下的10 000次需要模式匹配。唯一的挑战是将不得不手动写缓存代码,因为split_date返回一个列表,而memoize函数只能正确处理返回标量的函数。

此刻,可以向三个方向行进。可以增强memoize函数能正确处理列表上下文返回。(或者可以使用CPAN Memoize模块,它能对返回列表的函数正确工作。)可以手写缓存代码。但更有益的是绕过这个问题,通过用一个返回标量的函数替代split_date。如果标量构造正确,将能免除chronologically中复杂的||逻辑,而仅使用一个简单的字符串比较。

这里有一个策略:拆分日期,和先前一样,但是不再返回一列字段,而是把一列字段放入一个单一的字符串。字段将按照要检查的次序出现在字符串中,年份最先,然后是月份,然后是日期。对"Apr 16, 1945"的字符串将是"19450416"。当用cmp比较字符串时,Perl将尽快停止比较,因此如果一个字符串以"1998..."开头,而另一个以"1996..."开头,Perl一看到第四个字符就知道了结果,不再操心检查月份和日期。字符串的比较是非常快的,多半可以战胜一系列<=>和||。

修改后的代码如下:

### Code Library: chrono-3
@sorted_dates = sort chronologically @dates;
%m2n =
   ( jan =>      1, feb =>      2, mar =>      3,
     apr =>      4, may =>      5, jun =>      6,
     jul =>      7, aug =>      8, sep =>      9,
     oct =>     10, nov =>     11, dec =>     12, );
sub chronologically {
  date_to_string($a) cmp date_to_string($b)
}
sub date_to_string {
  my ($m, $d, $y) = ($_[0] =~ /(\w{3}) (\d+), (\d+)/);
  sprintf "%04d%02d%02d", $y, $m2n{lc $m}, $d;
}

现在可以使date_to_string带记忆。这能否战胜先前的版本,依赖sprintf加cmp是否比<=>加||更快。一般需要一个基准比较测试,它证明带sprintf的代码要快大约两倍。

排序经常是在程序里需要尽可能压榨出最多性能的地方之一。对一个10 000个日期的列表,可以精确地调用sprintf 10 000次(一旦date_to_string已经带记忆),但是仍然要调用date_to_string 278 272次。随着日期列表变长,这种不对称也将增长,函数调用的时间最终将超过排序的运行时间。
可以通过简化缓存处理和削减268 272次额外的函数调用获得更多的速度优势。为此,回到手写的缓存代码:

### Code Library: chrono-orc
{ my %cache;
  sub chronologically {
    ($cache{$a} ||= date_to_string($a))
       cmp
    ($cache{$b} ||= date_to_string($b))
  }
}

这里使用||=操作符,看上去几乎是为缓存应用定制的。$x ||= $y产生$x的值,如果$x是真的;如果不是,它把$y赋值给$x并产生$y的值。$cache{$a ||= date_to_string($a)}查看$cache{$a}是否有一个真值,如果有,那就是用cmp操作符比较时使用过的值。如果还没有任何东西缓存,那么$cache{$a}是假,然后chronologically就调用date_to_string,把结果存在缓存里,并在比较时使用这个结果。这种内联的缓存技术就称为Orcish Maneuver,因为它的本质特性是||和缓存。

使date_to_string带记忆,产生2.5倍的加速;用Orcish Maneuver代替记忆术产生额外的两倍加速。

机敏的读者将会注意到Orcish Maneuver不总是完全正确。在这个例子里,date_to_string不可能总是返回一个假值。但是短暂返回计算每个投资者的投资总数的例子:

{ my %cache;
  sub by_total_invested {
    ($cache{$a} ||= total_invested($a))
       <=>
    ($cache{$b} ||= total_invested($b))
  }
}

假设Luke Hermit根本没投资过。他第一次出现在by_total_invested时,为Luke调用total_invested,然后得到0。把这个0以Luke为键存放在缓存里。Luke下次出现时,检查缓存并发现存放在那里的值是0。因为这个值是假,所以再次调用total_invested,即使已经命中缓存了。这里的问题是||=操作符无法区分缓存脱靶与缓存命中的值恰好是假。

Lisp玩家给这种现象取了个名字:称为半谓词问题(semipredicate problem)。一个谓词(predicate)就是一个返回布尔值的函数。一个半谓词(semipredicate)能返回一个特定的假值,表示失败,或者许多有意义的真值之一,表示成功。$cache{$a}是一个半谓词,因为它可能返回0,或者无数有用的真值之一。当0也是真值之一时,就遇到麻烦了,因为无法把它与0意味着假区分开。这就是半谓词问题。

在先前的例子里,半谓词问题不会引起太多的麻烦。仅有的代价就是对那些没有投资过的人们会多些额外的total_invested调用。如果发现这些额外的调用在明显地拖慢排序(不经常,但有可能),就可以用下面的版本替换比较器函数:

{ my %cache;
  sub by_total_invested {
    (exists $cache{$a} ? $cache{$a} : ($cache{$a} = total_invested($a)))
        <=>
    (exists $cache{$b} ? $cache{$b} : ($cache{$b} = total_invested($b)))
  }
}

这个版本使用了可靠的exists操作符检查缓存是否被占据。即使存储在缓存的值是假,exists仍将返回真。请注意,不过,这样比简单版本慢10%左右。

还有个方法几乎没有额外的代价,但具有奇异的缺点。它基于这样的秘籍:当Perl里的字符串"0e0"用做一个数字时,它就完全和0一样;e0被Perl解释成科学计数法的指数。但是和普通的0不同,字符串"0e0"是真而不是假。
如果这样写by_total_invested,就几乎没付出额外的代价而避免了半谓词问题:

{ my %cache;
  sub by_total_invested {
    ($cache{$a} ||= total_invested($a) || "0e0")
       <=>
    ($cache{$b} ||= total_invested($b) || "0e0")
  }
}

如果total_invested返回零,函数就缓存"0e0"。下次寻找同一个客户投资的总数时,函数在缓存里看到"0e0",而这个值是真,所以它不会第二次调用total_invested。这个"0e0"就是给<=>操作符比较的值,但是在数字比较中它表现得和0完全一样,这也正是我们期望的。额外的||操作的速度损失,仅存在于total_invested()返回一个假值的时候,是非常小的。

时间: 2024-09-17 04:06:00

《高阶Perl》——3.10 可供选择的记忆术的相关文章

《高阶Perl》——导读

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

《高阶Perl》——1.2 阶乘

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

《高阶Perl》——3.5 MEMOIZE模块

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

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

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

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

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

《高阶Perl》——3.6 CAVEATS

3.6 CAVEATS (这是拉丁文的"警告".) 显然,记忆术不适合所有的性能问题.它甚至不适合所有的函数.有几类函数就不应该带记忆. 3.6.1 返回值不依赖参数的函数 记忆术最适合那些返回值只依赖它们的参数的函数.想象一下使时间函数带记忆的愚蠢:第一次你调用它,你将得到时刻,随后的调用将会返回一样的时刻.类似地,想象一个带记忆的随机数生成器是多么固执. 或者想象一个返回值是指示某类成功或失败的函数.你不会希望这类函数是带记忆的,每次被调用都返回同一个值. 然而,记忆术适合一些这样

《高阶Perl》——1.8 当递归膨胀时

1.8 当递归膨胀时 有时一个问题很明显是递归,然后递归方案很没有效率.一个非常简单的例子是计算Fibonacci数.这是个想象的例子,但它的优点就是非常简单.我们将在3.7节看到一个更实际的例子. 1.8.1 Fibonacci数 Fibonacci数(Fibonacci number)以Leonardo of Pisa 命名,他的别名是Fibonacci,他在13世纪联系一个关于兔子的数学问题探讨了它们.起初,你有一对兔宝宝.兔宝宝一个月就发育成年了,下一个月它们生育了一对新的兔宝宝,这样有

《高阶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.8 对象方法里的缓存

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