MPがありません。

$liiu->mp == 0

Perl で immutable なデータ構造を作る

この記事はPerl Advent Calendar 202113日目の記事です。
2時間ほど遅刻して申し訳ございません・・・

この記事では PerlInternals::SvREADONLY を利用しimmutableなデータ構造を作る方法について書きます。
検証は perl5.34.0 で行っています。

immutable なデータ構造の利点

まずは immutable なデータ構造の利点について説明します。

immutable なデータ構造とは作成後に内部の状態を変えることのできないデータ構造のことです。

Perlではハッシュや配列でデータ構造を作った後は自由に内部の状態を変更することが可能です。
しかし内部の状態が変更可能なデータ構造をいろんな関数から操作するようなコードを書くと、どこでどのように内部の状態が変化するのか把握することが難しくなるのでコードを読んだりデバッグが難しくなってしまいます。
こういった現象を避けるために関数型言語やそれに影響を受けた言語では内部の状態が変更不可能なデータ構造を作って必要に応じてデータ構造を複製したり差分のみ保持することで読みやすくデバッグがしやすいようなコードを書けるようにしています。
Perlでも最初から immutable なデータ構造を作っておけばそのようなプログラミングスタイルを強制することができます。

また、グローバルな設定値を保持しているようなデータ構造を誤って変更してしまうといったような事故を防ぐ効果も期待できるでしょう。

Internals::SvREADONLY の挙動

次に Perl で immutable なデータ構造を作る上でキモとなる Internals::SvREADONLY の挙動を説明します。

Internals::SvREADONLYperl の値の readonly flag の on/off を切り替える関数です。
readonly flag が on になっている値には再代入することが不可能になるので、ご存知の方も多いのではないでしょうか。
CPANにはこれを利用して再代入不可能な変数を作るようなモジュールがいろいろ存在したりしますし、constantHash::Util の lock 系の関数の内部で使われているAPIでもあります。

スカラ変数の場合

Internals::SvREADONLY の第1引数に再代入不可能にしたい値を、第2引数に真値を与えて呼び出すことでスカラ変数を再代入不可能にできます。

my $scalar = 10;
Internals::SvREADONLY($scalar, 1);
$scalar = 1; # Error: Modification of a read-only value attempted

readonly flag が on の状態で値を再代入してみると、実行時に Modification of a read-only value attempted ... というエラーが発生することがわかると思います。

readonly flag は Internals::SvREADONLY の第2引数に偽値を与えることで off にできるので、Internals::SvREADONLY に先程 readonly にした変数と偽値をあたえると再代入が可能になってしまいます。

Internals::SvREADONLY($scalar, 0);
$scalar = 20;
say $scalar; # 20

このようにこれから紹介する immutable なデータ構造を作る方法は reradonly flag を off にしていくことで再代入が可能になってしまいますので、そこだけご留意お願いします。。

さて、 Internals::SvREADONLY には実は配列とハッシュをそのまま渡すこともできて、その場合の挙動はスカラ変数を渡した場合と少し違うものになります。

配列の場合

配列の場合は再代入不可能 + 要素の追加/削除が不可能になります。

my @ary = (0 .. 4);
Internals::SvREADONLY(@ary, 1); 

# 再代入が不可能になる
@ary = (2 .. 5); # Error: Modification of a read-only value attempted

# 新しい要素の追加も不可能
push @ary, 6; # Error: Modification of a read-only value attempted

# 要素の削除も不可能
pop @ary; # Error: Modification of a read-only value attempted

# 既にある要素の上書きは可能
$ary[0] = 1;

既に存在する要素への再代入は可能なままです。
既に存在する要素への再代入も禁止したい場合は配列の各要素も Internals::SvREADONLY に渡す必要があります。

Internals::SvREADONLY($ary[0], 1);
$ary[0] = 1; # Error: Modification of a read-only value attempted

ハッシュの場合

ハッシュの場合は再代入禁止 + あらたなキーの追加が不可能となります。

my %hash = (
  a => 10,
  b => 20,
);
Internals::SvREADONLY(%hash, 1);

# 再代入は不可能
%hash = (c => 3, d => 4); # Error: Attempt to access disallowed key 'c' in a restricted hash

# 新しいキーの追加も不可能
$hash{c} = 10; # Error: Attempt to access disallowed key 'c' in a restricted hash

# 要素の削除は可能
delete $hash{a};

# 既にある要素の上書きも可能
$hash{b} = 0;

ハッシュの各要素への再代入と各要素の削除も可能なままになっています。
それらへの再代入も禁止したい場合は各要素を Internals::SvREADONLY に渡す必要があります。

Internals::SvREADONLY($ary[0], 1);
delete $hash{a}; #Error: Attempt to delete readonly key 'a' from a restricted hash
$hash{a} = 0; #Error: Modification of a read-only value attempted

エラーメッセージが他と違うのは Hash::Util の内部で使われていることと関係しているからだと思われます。

なお、リファレンスの場合はスカラ変数と同じ挙動となるのでハッシュリファレンスでも配列リファレンスでも再代入が不可能になるだけのようです。

immutablize なデータ構造を作る

以上の Internals::SvREADONLY の挙動を踏まえると、

  1. 内部のデータ構造がハッシュ/配列の場合はそれをデリファレンスしたものを Internals::SvREADONLY に渡す
  2. 内部のデータ構造のハッシュ/配列の各要素を Internals::SvREADONLY に渡す

といったことを再帰的に行えば immutable なデータ構造を作れることがわかると思います。

というわけで実際に実装してみます。

use Test2::V0;
use v5.34;
use warnings;
use utf8;

sub immutablaize {
  my $data = shift;
  if (ref $data eq 'HASH') {
    Internals::SvREADONLY(%$data, 1);
    for my $key (keys %$data) {
      Internals::SvREADONLY($data->{$key}, 1);
      if (ref $data->{$key} eq 'HASH' || ref $data->{$key} eq 'ARRAY') {
        immutablaize($data->{$key});
      }
    }
  }
  elsif (ref $data eq 'ARRAY') {
    Internals::SvREADONLY(@$data, 1);
    for (my $i = 0; $i < @$data; $i++) {
      Internals::SvREADONLY($data->[$i], 1);
      if (ref $data->[$i] eq 'HASH' || ref $data->[$i] eq 'ARRAY') {
        immutablaize($data->[$i]);
      }
    }
  }
  else {
    Internals::SvREADONLY($data, 1);
  }
}

my $data = +{
  a => 1,
  b => 2,
  c => [0 .. 5],
  d => +{
    hoge => '@@@',
    fuga => '+++',
    piyo => [0 .. 10],
  },
};
immutablaize($data);

ok dies { $data->{new} = 10 }, 'key追加';
ok dies { $data->{a} = 2 }, '値の再代入';
ok dies { push $data->{c}->@*, 10 }, '入れ子になっている配列にpush';
ok dies { $data->{d}{puyo} = 10 }, '入れ子になっているハッシュにkey追加';
ok dies { push $data->{d}{piyo}->@*, 10 }, '入れ子になっている配列にpush';

実際にコードを動かしてみるとわかると思いますが、入れ子になっているデータ構造の中身も含めてすべての中身を変更不可能にすることができたので、このコードで immutable なデータ構造を作れるようになったと言えるかと思います。

終わりに

このように Internals::SvREADONLY を駆使することで Perl でも immutable なデータ構造が作れることができます。
データ構造の中身を再帰的に操作するのでパフォーマンスが求められる部分では使いにくそうですが、グローバルな設定値をデータ構造で保持している部分などでは使えるのではないでしょうか。