3.8 对象方法里的缓存
对象方法,它经常不理解地把缓存的值保存在独立的散列里。考虑一个投资银行写的程序里的Investor对象。该对象表现了银行的一个客户:
package Investor;
# Compute total amount currently invested
sub total {
my $self = shift;
# ... complex computation performed here ...
return $total;
}
如果$total不会改变,就可以缓存它,用对象的本身作为缓存散列的键:
# Compute total amount currently invested
{ my %cache;
sub total {
my $self = shift;
return $cache{$self} if exists $cache{$self};
# ... complex computation performed here ...
return $cache{$self} = $total;
}
}
然而,这个技术有一个严重的问题。当用一个对象作为一个散列键时,Perl把它转换成一个字符串。典型的散列键将看上去像Investor=HASH(0x80ef8dc)。十六进制数字是对象的数据实际存放的地址。本质上,这个键对任何两个对象都不一样,即冒着错误的缓存命中的风险,恢复一个对象的总数,却想着它属于另一个不同的对象。在Perl里,这些散列键确实对系统中任何给定的时刻存在的所有对象都不同,但对已回收的对象没有保证。如果一个对象被销毁了而一个新的对象被创建了,那个新的对象恰好存在于旧的对象先前占有的内存地址,那么混淆如下:
# here 90,000 is returned from the cache
$old_total = $old_object->total();
undef $old_object;
$new_object = Investor->new();
$new_total = $new_object->total();
这里要求新的投资者的投资总数。它应该是0,因为投资者是新的。然而,->{total}方法恰好查看缓存,以刚被销毁的$old_object用过的同样的散列键;这个方法看到90000存在那里,并错误地返回它。这个问题可以用一个DESTROY方法解决,它从缓存里删除一个对象的数据,或者在程序里对每个对象关联一个唯一的不再复用的ID数字,并用这个ID数字作为散列键,但是有个更直接的解决方法。
在一个面向对象编程上下文,缓存散列的技术是独特的,因为有个更自然的地方存放缓存的数据:就像数字存在于它的对象自身。一个缓存的总数变为另一个属性,后者可以或不被每个独立的对象携带:
# Compute total amount currently invested
sub total {
my $self = shift;
return $self->{cached_total} if exists $self->{cached_total};
# ... complex computation performed here ...
return $self->{cached_total} = $total;
}
这里的逻辑和之前的完全一样,唯一的不同是:这个方法为每个对象把总数保存在对象自身,而不是在一个辅助的散列。这避免了辅助散列带来的散列键冲突的问题。
这个技术的另一个优点是,用来保存缓存的总数的存储空间在对象销毁时会自动回收。用辅助的散列,每个缓存的值会一直存在,甚至在其所属的对象都被销毁以后。
最后,把缓存的信息分别存放在每个对象里在对象到期时带来了更灵活的控制。在例子里,total计算某个投资者已经投资的总数。缓存这个总数是合适的,因为投资者不会太频繁地新投入钱。但是永久缓存它可能也不合适。在这个例子里,无论何时一个投资者投资更多的钱,都需要以某种方式告知total函数,缓存的总数不再正确了,必须要丢掉再重新计算。这就称为缓存值的到期(expiring)。
用辅助散列的技术,要是不在缓存散列的作用域里增加一个特殊需求的方法,就无法做到这一点,如下:
# Compute total amount currently invested
{ my %cache;
sub total {
my $self = shift;
return $cache{$self} if exists $cache{$self};
# ... complex computation performed here ...
return $cache{$self} = $total;
}
sub expire_total {
my $self = shift;
delete $cache{$self};
}
}
sub invest {
my ($self, $amount, ...) = @_;
$self->expire_total;
...
}
用面向对象技术,则不必增加特别的方法,因为每个方法都可以在它需要的时候直接使缓存的总数到期:
# Compute total amount currently invested
sub total {
my $self = shift;
return $self->{cached_total} if exists $self->{cached_total};
# ... complex computation performed here ...
return $self->{cached_total} = $total;
}
sub invest {
my ($self, $amount, ...) = @_;
delete $self->{cached_total};
...
}
对于对象方法,经常需要把每个计算过的值缓存在与它有联系的对象中,而不是一个独立的散列。但之前介绍的momoize函数没有这么做,但是不难建立一个这么做的:
### Code Library: memoize-method
sub memoize_method {
my ($method, $key) = @_;
return sub {
my $self = shift;
return $self->{$key} if exists $self->{$key};
return $self->{$key} = $method->($self, @_);
};
}
$method是一个指向真实方法的引用。$key是缓存的值,将被保存于每个对象的位置的名字。这里返回的存根函数适合用做一个方法。当存根被执行时,它取回其行为被调用的对象,就像别的方法一样,然后它在对象里查找成员数据$key,看是否有值缓存在那里。如果有,存根返回缓存的值;如果没有,它调用真实的方法,把结果缓存入对象,并返回新缓存的结果。
为使用这个,可以写成如下这样:
*Investor::total = memoize_method(\&Investor::total, 'cached_total');
$investor_bob->total;
这把存根安装入符号表以替代原始的方法。或者,可以使用:
$memoized_total = memoize_method(\&Investor::total, 'cached_total');
$investor_bob->$memoized_total;
这两者不完全一样。在前一种情况,所有对->total的调用将使用方法的带记忆的版本,包括来自继承方法的子类的调用。在后一种情况,只有当使用->memoized(...)以明确地要求时,才得到方法的带记忆的版本。