目錄

名稱

Memoize - 透過以空間換取時間來讓函式執行得更快

語法

use Memoize;
memoize('slow_function');
slow_function(arguments);    # Is faster than it was before

這通常就是您需要知道的所有資訊。不過,還有許多選項可用

memoize(function, options...);

選項包括

	NORMALIZER => function
	INSTALL => new_name

	SCALAR_CACHE => 'MEMORY'
        SCALAR_CACHE => ['HASH', \%cache_hash ]
	SCALAR_CACHE => 'FAULT'
	SCALAR_CACHE => 'MERGE'

	LIST_CACHE => 'MEMORY'
        LIST_CACHE => ['HASH', \%cache_hash ]
	LIST_CACHE => 'FAULT'
	LIST_CACHE => 'MERGE'

說明

Memoizing 函式會透過以空間換取時間來讓函式執行得更快。它會將函式的回傳值快取到一個表格中。如果您再次使用相同的引數呼叫函式,memoize 會介入並從表格中提供值,而不是讓函式重新計算值。

範例

以下是一個極端的範例。考慮由下列函式定義的費氏數列

# Compute Fibonacci numbers
sub fib {
  my $n = shift;
  return $n if $n < 2;
  fib($n-1) + fib($n-2);
}

這個函式非常慢。為什麼?要計算 fib(14),它首先要計算 fib(13) 和 fib(12),然後將結果相加。但是,要計算 fib(13),它首先必須計算 fib(12) 和 fib(11),然後再回來重新計算 fib(12),即使答案是一樣的。而且,在它想要計算 fib(12) 的兩次中,它都必須從頭開始計算 fib(11),然後在每次想要計算 fib(13) 時都必須再做一次。這個函式會重新計算太多舊結果,因此執行時間非常長---fib(14) 會對自己進行 1,200 次額外的遞迴呼叫,來計算和重新計算它已經計算過的內容。

此函數很適合用於記憶化。如果你記憶化上述的 fib 函數,它將只計算一次 fib(14),也就是第一次需要時,然後將結果儲存在一個表格中。然後如果你再次要求 fib(14),它將從表格中提供結果。在計算 fib(14) 時,它不會計算 fib(12) 兩次,而是只計算一次;第二次需要值時,它會從表格中取得。它不會計算 fib(11) 四次;它只計算一次,接下來的三次從表格中取得。它不會對 fib 進行 1,200 次遞迴呼叫,而是進行 15 次。這讓函數快了約 150 倍。

你可以透過重寫函數來自行進行記憶化,如下所示

	# Compute Fibonacci numbers, memoized version
	{ my @fib;
  	  sub fib {
	    my $n = shift;
	    return $fib[$n] if defined $fib[$n];
	    return $fib[$n] = $n if $n < 2;
	    $fib[$n] = fib($n-1) + fib($n-2);
	  }
        }

或者你可以使用這個模組,如下所示

use Memoize;
memoize('fib');

# Rest of the fib function just like the original version.

這讓開啟和關閉記憶化變得容易。

以下是一個更簡單的範例:我寫了一個簡單的射線追蹤器;程式會朝某個方向查看,找出它在看什麼,然後將該物件的 color 值(通常是像 red 這樣的字串)轉換成紅色、綠色和藍色的像素值,如下所示

for ($direction = 0; $direction < 300; $direction++) {
  # Figure out which object is in direction $direction
  $color = $object->{color};
  ($r, $g, $b) = @{&ColorToRGB($color)};
  ...
}

由於圖片中的物件相對較少,因此只有少數顏色會被反覆查詢。記憶化 ColorToRGB 讓程式快了數個百分點。

詳細資料

此模組只輸出一個函數,memoize。此套件中的其他函數與你無關。

你應該說

memoize(function)

其中 function 是你想要記憶化的函數名稱,或對它的參照。memoize 會傳回對函數的新記憶化版本的參照,或在發生非致命錯誤時傳回 undef。目前沒有非致命錯誤,但未來可能會有。

如果 function 是函數名稱,則 memoize 會隱藏舊版本,並在舊名稱下安裝新的記憶化版本,因此 &function(...) 實際上會呼叫記憶化版本。

選項

您可以傳遞一些選用選項給 memoize,以稍微改變其行為方式。若要提供選項,請像這樣呼叫 memoize

	memoize(function, NORMALIZER => function,
			  INSTALL => newname,
                          SCALAR_CACHE => option,
	                  LIST_CACHE => option
			 );

這些選項都是選用的;您可以包含其中一些、全部或不包含任何選項。

INSTALL

如果您提供一個函數名稱給 INSTALL,memoize 會在您提供的名稱下安裝新的 memoize 版本函數。例如,

memoize('fib', INSTALL => 'fastfib')

fib 的 memoize 版本安裝為 fastfib;若沒有 INSTALL 選項,它會將舊的 fib 取代為 memoize 版本。

若要防止 memoize 在任何地方安裝 memoize 版本,請使用 INSTALL => undef

NORMALIZER

假設您的函數如下所示

# Typical call: f('aha!', A => 11, B => 12);
sub f {
  my $a = shift;
  my %hash = @_;
  $hash{B} ||= 2;  # B defaults to 2
  $hash{C} ||= 7;  # C defaults to 7

  # Do something with $a, %hash
}

現在,呼叫函數如下所示,它們完全等效

f(OUCH);
f(OUCH, B => 2);
f(OUCH, C => 7);
f(OUCH, B => 2, C => 7);
f(OUCH, C => 7, B => 2);
(etc.)

但是,除非您告訴 Memoize 這些呼叫是等效的,否則它不會知道,它會分別計算函數調用的這些值,並將它們分別儲存。

若要防止這種情況,請提供一個 NORMALIZER 函數,將程式引數轉換成字串,等效引數會轉換成相同的字串。fNORMALIZER 函數可能如下所示

sub normalize_f {
  my $a = shift;
  my %hash = @_;
  $hash{B} ||= 2;
  $hash{C} ||= 7;

  join(',', $a, map ($_ => $hash{$_}) sort keys %hash);
}

上述的每個引數清單都來自 normalize_f 函數,看起來完全相同,如下所示

OUCH,B,2,C,7

您會告訴 Memoize 使用這個正規化器,方式如下

memoize('f', NORMALIZER => 'normalize_f');

memoize 知道,如果兩個引數清單的正規化版本相同,則它可以安全地查詢它為一個引數清單計算的值,並將其作為使用另一個引數清單呼叫函數的結果傳回,即使引數清單看起來不同。

預設正規化器只是將引數與字元 28 串接在一起。(在 ASCII 中,這稱為 FS 或控制-\。)這對於只有一個字串引數的函數總是正確運作,而且當引數從不包含字元 28 時也是如此。但是,它可能會混淆某些引數清單

normalizer("a\034", "b")
normalizer("a", "\034b")
normalizer("a\034\034b")

例如。

由於雜湊鍵是字串,預設正規化器不會區分 undef 和空字串。當函數的引數是參考時,它也不會運作。例如,考慮一個函數 g,它取得兩個引數:一個數字和一個數字陣列的參考

g(13, [1,2,3,4,5,6,7]);

預設正規化器會將其轉換成類似 "13\034ARRAY(0x436c1f)" 的內容。這沒問題,但後續的數字陣列可能會儲存在不同的位置,即使它包含相同的資料。如果發生這種情況,Memoize 會認為引數不同,即使它們是等效的。在這種情況下,適用的正規化器如下所示

sub normalize { join ' ', $_[0], @{$_[1]} }

對於上述範例,這會產生金鑰「13 1 2 3 4 5 6 7」。

正規化器的另一個用途是當函數依賴於引數中以外的資料時。假設您有一個函數,它傳回一個值,該值取決於當天的目前小時數

	sub on_duty {
          my ($problem_type) = @_;
	  my $hour = (localtime)[2];
          open my $fh, "$DIR/$problem_type" or die...;
          my $line;
          while ($hour-- > 0)
            $line = <$fh>;
          } 
	  return $line;
	}

在 10:23,此函數產生資料檔案的第 10 行;在下午 3:45,它產生第 15 行。預設情況下,Memoize 只會看到 $problem_type 參數。若要修正此問題,請在正規化器中包含目前小時

sub normalize { join ' ', (localtime)[2], @_ }

函數的呼叫內容(純量或清單內容)會傳遞至正規化器。這表示如果備忘函數在清單內容中處理其參數的方式與在純量內容中處理的方式不同,您可以讓正規化器函數根據 wantarray 的結果選擇其行為。即使在清單內容中呼叫,正規化器仍應傳回單一字串。

SCALAR_CACHE, LIST_CACHE

通常,Memoize 會將函數的傳回值快取至一般的 Perl hash 變數。不過,您可能希望將值快取至磁碟,以便在程式執行期間持續存在,或者您可能希望將一些其他有趣的語意與快取值關聯起來。

Memoize 的內部有一個小複雜性:實際上有兩個快取,一個用於純量值,一個用於清單值。當您的函數在純量內容中呼叫時,其傳回值會快取在一個 hash 中,而當您的函數在清單內容中呼叫時,其值會快取在另一個 hash 中。您可以使用這些選項獨立控制兩個內容的快取行為。

LIST_CACHESCALAR_CACHE 的參數必須是下列四個字串之一

	MEMORY
	FAULT
	MERGE
        HASH

否則它必須是對陣列的參考,其第一個元素是這四個字串之一,例如 [HASH, arguments...]

MEMORY

MEMORY 表示函數的傳回值會快取在一般的 Perl hash 變數中。在程式結束後,hash 變數不會持續存在。這是預設值。

HASH

HASH 讓您可以指定您提供的特定 hash 將用作快取。您可以在事先繫結此 hash,以賦予它您想要的任何行為。

繫結的 hash 可以具有任何語意。它通常繫結至磁碟資料庫,以便快取值儲存在資料庫中,並在需要時再次從資料庫中擷取,而磁碟檔案通常會在程式結束後持續存在。請參閱 perltie 以取得關於 tie 的更完整詳細資料。

一個典型的範例是

use DB_File;
tie my %cache => 'DB_File', $filename, O_RDWR|O_CREAT, 0666;
memoize 'function', SCALAR_CACHE => [HASH => \%cache];

這會將快取儲存在名稱為 $filenameDB_File 資料庫中。快取會在程式結束後持續存在。下次執行程式時,它會發現快取已從程式的上一次執行中填入。或者,您可以建立在背景中執行並填入快取檔案的批次程式來強制填入快取。然後,當您執行實際程式時,備忘函式會很快,因為其所有結果都已預先計算。

使用 HASH 的另一個原因是提供您自己的雜湊變數。然後,您可以檢查或修改雜湊的內容以更精細地控制快取管理。

TIE

此選項不再受支援。它仍然有文件記載,只是為了協助偵錯使用它的舊程式。舊程式應轉換為改用 HASH 選項。

memoize ... ['TIE', PACKAGE, ARGS...]

只是

        require PACKAGE;
	{ tie my %cache, PACKAGE, ARGS...;
          memoize ... [HASH => \%cache];
        }
FAULT

FAULT 表示您不希望在純量(或清單)內容中呼叫函式,而且如果 Memoize 偵測到此類呼叫,它應中止程式。錯誤訊息之一為

`foo' function called in forbidden list context at line ...
`foo' function called in forbidden scalar context at line ...
MERGE

MERGE 通常表示備忘函式不會區分清單和純量內容,而且應將這兩個內容的傳回值儲存在一起。LIST_CACHE => MERGESCALAR_CACHE => MERGE 兩者表示相同的意思。

考慮此函式

	sub complicated {
          # ... time-consuming calculation of $result
          return $result;
        }

無論 complicated 函式是在清單或純量內容中呼叫,它都會傳回相同的數字 $result

通常,下列程式碼會導致呼叫 complicated 兩次,即使 complicated 已備忘

$x = complicated(142);
($y) = complicated(142);
$z = complicated(142);

第一次呼叫會將結果(例如 37)快取到純量快取中;第二次會將清單 (37) 快取到清單快取中。第三次呼叫不會呼叫實際的 complicated 函式;它會從純量快取取得值 37。

很明顯地,第二次呼叫 complicated 是浪費時間,而且儲存其傳回值是浪費空間。指定 LIST_CACHE => MERGE 會讓 memoize 對純量和清單內容傳回值使用相同的快取,因此第二次呼叫會使用第一次呼叫填入的純量快取。complicated 最後只會被呼叫一次,而且後續呼叫都會從快取傳回 37,不論呼叫內容為何。

在純量上下文中列出值

考慮此函式

sub iota { return reverse (1..$_[0]) }

此函數通常會傳回一個清單。假設您將其記憶並合併快取

memoize 'iota', SCALAR_CACHE => 'MERGE';

@i7 = iota(7);
$i7 = iota(7);

這裡的第一個呼叫會快取清單 (1,2,3,4,5,6,7)。第二個呼叫並沒有真正的意義。Memoize 無法猜測 iota 在純量上下文中應該具有的行為,而不會實際在純量上下文中呼叫它。通常 Memoize 會在純量上下文中呼叫 iota 並快取結果,但 SCALAR_CACHE => 'MERGE' 選項表示不要這樣做,而是改用快取清單上下文值。但它無法在純量上下文中傳回一個包含七個元素的清單。在這種情況下,$i7 將接收快取清單值的**第一個元素**,即 7。

合併的磁碟快取

MERGE 的另一個用途是當您想要將兩種傳回值儲存在同一個磁碟檔案中時;這讓您不必處理兩個磁碟檔案,而只需處理一個。您可以使用正規化函數來將兩組傳回值分開。例如

        local $MLDBM::UseDB = 'DB_File';
        tie my %cache => 'MLDBM', $filename, ...;

	memoize 'myfunc',
	  NORMALIZER => 'n',
	  SCALAR_CACHE => [HASH => \%cache],
	  LIST_CACHE => 'MERGE',
	;

	sub n {
	  my $context = wantarray() ? 'L' : 'S';
	  # ... now compute the hash key from the arguments ...
	  $hashkey = "$context:$hashkey";
	}

此正規化函數會將純量上下文傳回值儲存在磁碟檔案中,其金鑰以 S: 開頭,而清單上下文傳回值則儲存在金鑰以 L: 開頭的檔案中。

其他功能

unmemoize

有一個 unmemoize 函數,如果您願意,可以匯入它。為什麼您會想要這樣做?以下是一個範例:假設您的快取與 DBM 檔案連結,並且您想要確保在有人中斷程式時,快取會寫入磁碟。如果程式正常結束,這將會自動發生,但如果有人按 Control-C 或其他按鍵,則程式將立即終止,而不會同步資料庫。因此,您可以這樣做

$SIG{INT} = sub { unmemoize 'function' };

unmemoize 接受先前記憶函數的參考或名稱,並取消它所做的任何事,以提供記憶版本,包括在適當的情況下,讓名稱參照未記憶版本。它傳回函數的未記憶版本參考。

如果您要求它取消記憶一個從未記憶的函數,它會出錯。

flush_cache

flush_cache(function) 將清除快取,捨棄**所有**快取資料。參數可以是函數名稱或函數參考。要更精細地控制何時捨棄或過期資料,請參閱此套件中包含的 Memoize::Expire 文件。

請注意,如果快取是連結雜湊,flush_cache 將嘗試呼叫雜湊上的 CLEAR 方法。如果沒有 CLEAR 方法,這將導致執行時期錯誤。

快取清除的另一種方法是使用 HASH 選項(請見上文),要求 Memoize 使用特定雜湊變數作為其快取。然後,你可以隨時以任何你想要的方式檢查或修改雜湊。你可以使用 %hash = () 清除快取。

注意事項

記憶化並非萬靈丹

持續性快取支援

您可以將快取表繫結到任何您想要的繫結雜湊,只要它支援 TIEHASHFETCHSTOREEXISTS。例如,

tie my %cache => 'GDBM_File', $filename, O_RDWR|O_CREAT, 0666;
memoize 'function', SCALAR_CACHE => [HASH => \%cache];

運作良好。對於某些儲存方法,您需要一點膠水。

SDBM_File 沒有提供 EXISTS 方法,因此此套件中包含一個名為 Memoize::SDBM_File 的膠水模組,它確實提供了一個。使用這個模組來取代純粹的 SDBM_File,以將您的快取表儲存在 SDBM_File 資料庫中的磁碟上

tie my %cache => 'Memoize::SDBM_File', $filename, O_RDWR|O_CREAT, 0666;
memoize 'function', SCALAR_CACHE => [HASH => \%cache];

NDBM_File 有相同的問題和相同的解決方案。(使用 Memoize::NDBM_File 來取代純粹的 NDBM_File。)

Storable 根本不是一個繫結雜湊類別。您可以使用它將雜湊儲存在磁碟上並再次擷取它,但您無法在雜湊位於磁碟上時修改它。因此,如果您想要將快取表儲存在 Storable 資料庫中,請使用 Memoize::Storable,它會在 Storable 上放置一個類似雜湊的前端。雜湊表實際上會保存在記憶體中,並在您記憶化函式時從您的 Storable 檔案中載入,並在您取消記憶化函式時(或在您的程式結束時)儲存回去

        tie my %cache => 'Memoize::Storable', $filename;
	memoize 'function', SCALAR_CACHE => [HASH => \%cache];

        tie my %cache => 'Memoize::Storable', $filename, 'nstore';
	memoize 'function', SCALAR_CACHE => [HASH => \%cache];

包含 nstore 選項,以讓 Storable 資料庫以 網路順序 撰寫。(請參閱 Storable 以取得有關此項目的更多詳細資料。)

除非繫結套件提供 CLEAR 方法,否則 flush_cache() 函式會引發執行時期錯誤。

過期支援

請參閱 Memoize::Expire,它是一個外掛模組,可將過期功能新增到 Memoize。如果您不喜歡 Memoize::Expire 實作的政策類型,可以輕鬆撰寫您自己的外掛模組來實作您想要的任何政策。Memoize 附帶幾個範例。實作 LRU 政策的過期管理員可以在 CPAN 上取得,名稱為 Memoize::ExpireLRU。

錯誤

測試套件已經好很多了,但仍有進步空間。

goto &f 在執行緒 Perl 中運作的方式有些問題,這可能是因為 @_ 的詞彙範圍。這是 Perl 中的錯誤,在解決之前,備忘功能會看到略有不同的 caller(),而且在執行緒 Perl 中執行速度會比非執行緒 Perl 慢一點。

某些版本的 DB_File 不允許您儲存長度為 0 的金鑰下的資料。這表示如果您有一個已備忘的功能 f,而且快取在 DB_File 資料庫中,則 f() 的值(f 未帶任何引數呼叫)不會備忘。如果這是個大問題,您可以提供一個正規化函式,將 "x" 加到每個金鑰的前面。

另請參閱

https://perl.plover.com/MiniMemoize/ 中有一篇文章探討備忘和 Memoize 的內部結構,這篇文章出現在 The Perl Journal 第 13 期。

Mark-Jason Dominus 的著作《高階 Perl》(2005 年,ISBN 1558607013,由 Morgan Kaufmann 出版) 非常詳細地探討備忘(以及許多其他主題)。它可以在線上免費取得。如需更多資訊,請瀏覽 https://hop.perl.plover.com/

感謝

非常感謝 Florian Ragwitz 提供管理和封裝協助,感謝 John Tromp 提供錯誤報告,感謝 Jonathan Roy 提供錯誤報告和建議,感謝 Michael Schwern 提供其他錯誤報告和修補程式,感謝 Mike Cariaso 協助我找出關於過期處理的正確做法,感謝 Joshua Gerth、Joshua Chamas、Jonathan Roy (再次)、Mark D. Anderson 和 Andrew Johnson 提供更多關於過期的建議,感謝 Brent Powers 提供 Memoize::ExpireLRU 模組,感謝 Ariel Scolnicov 提供關於 Fibonacci 函式的令人愉快的訊息,感謝 Dion Almaer 提供關於預設正規化的發人省思的建議,感謝 Walt Mankowski 和 Kurt Starsinic 在執行緒 Perl 下調查問題時提供許多協助,感謝 Alex Dudkevich 報告原型函式中的錯誤並檢查我的修補程式,感謝 Tony Bass 提供許多有用的建議,感謝 Jonathan Roy (再次) 找到 unmemoize() 的用途,感謝 Philippe Verdret 對於 Hook::PrePostCall 的深入探討,感謝 Nat Torkington 提供我忽略的建議,感謝 Chris Nandor 提供可攜性的建議,感謝 Randal Schwartz 建議 flush_cache 函式,以及感謝 Jenda Krynicky 成為世界的一盞明燈。

特別感謝 5.8.0 的 pumpking Jarkko Hietaniemi,感謝他在整合過程中納入這個模組,以及他耐心且有用的指導。

作者

Mark Jason Dominus

版權和授權

此軟體的版權為 (c) 2012 Mark Jason Dominus 所有。

這是自由軟體;您可以在與 Perl 5 程式語言系統相同的條款下重新散布或修改它。