Perlのeachによるハッシュ走査で発生する内部イテレータの落とし穴

Perlでハッシュを走査する際、each関数を使うと内部に位置情報が保持されるため、ループを途中で抜けた場合に直感と異なる動作が発生することがあります。以下の例を見てください。

#!/usr/bin/perl
use strict;
use warnings;

my %score = (
    alice => 90,
    bob   => 85,
    carol => 78,
    dave  => 92,
    ellen => 88,
);

# 通常の走査
for my $name (keys %score) {
    print "$name => $score{$name}\n";
}

print "\n";

# each を使い、2要素ごとにループを抜ける
for my $round (1..3) {
    print "==== Round $round ====\n";
    my $count = 0;
    while (my ($person, $point) = each %score) {
        print "  $person => $point\n";
        last if ++$count >= 2;
    }
}

期待されるのは、各Roundで同じように先頭の2要素が表示されることですが、実際には次のように出力されます。

alice => 90
bob => 85
carol => 78
dave => 92
ellen => 88

==== Round 1 ====
  alice => 90
  bob => 85
==== Round 2 ====
  carol => 78
  dave => 92
==== Round 3 ====
  ellen => 88

この現象は、each %hashが内部的なイテレータを持っており、次にeachを呼んだ際に前回停止した位置から続行するためです。

解決策

再度先頭から走査したい場合は、keys %hashをリストコンテキストで呼び出すことでイテレータをリセットできます。

for my $round (1..3) {
    print "==== Round $round ====\n";
    my $count = 0;

    # イテレータをリセット
    keys %score;

    while (my ($person, $point) = each %score) {
        print "  $person => $point\n";
        last if ++$count >= 2;
    }
}

このようにすると、すべてのRoundで先頭から2要素が表示されます。

foreach と each の組み合わせ

また、foreach文とeachを組み合わせた場合も直感に反する動作になります。

for my $round (1..3) {
    print "==== Round $round ====\n";
    foreach (my ($person, $point) = each %score) {
        print "  \$_=$_, person=$person, point=$point\n";
    }
}

このコードでは、each %scoreがリストコンテキストでキーと値のペアを返し、foreachがそのリストを1要素ずつ反復処理するため、意図しない表示順になります。

推奨される方法

ハッシュの全要素を順序保証して処理する場合は、keysでインデックスを取得し、通常のforループで走査する方法が安全です。

for my $key (sort keys %score) {
    my $value = $score{$key};
    print "$key => $value\n";
}

eachはメモリ効率が高い反面、内部状態を持つため並列処理や部分的な走査には注意が必要です。用途に応じてkeysやwhileを使い分けることが重要です。

タグ: Perl each Hash Iterator keys

9月25日 22:40 投稿