Featured image of post コード探偵ロックの事件簿【Immutable Object】書き換えられた証拠品〜静かなる改竄と不変の鍵〜

コード探偵ロックの事件簿【Immutable Object】書き換えられた証拠品〜静かなる改竄と不変の鍵〜

渡したオブジェクトが知らない間に書き換えられるAliasing Bugを、Immutable Object(不変オブジェクト)で構造的に解決する。Perl/Mooでのis => 'ro'、witherパターン、MooX::StrictConstructor、浅い不変性の罠まで実装付きで解説。

事務所への来客——ではなく、現場への出張

右のモニターにはGitのdiff画面。フロントチームが料金計算ライブラリの返却値を書き換えているコードを、俺はここ二日かけて追跡していた。

証拠は揃った。誤請求の原因はフロントチームが discount_rate を書き換えたことだ。俺のライブラリのバグじゃない。上司にそう報告すれば終わり——のはずだった。

「14時にLCIから人が来るから対応よろしく」

チャットの通知。上司からだ。LCI? 聞いてない。「先週言った」と返ってきたが、記憶にない。そもそもLCIが何なのかも知らない。

14時きっかりに、通路に見慣れない人影が現れた。スーツではなく、くたびれたジャケット。手には古い革のブリーフケース。社内のエンジニアにはいない空気をまとっている。

その男は俺のデスク脇に立つと、モニターのGitのdiffを横目で見ながら言った。

「証拠をお持ちのようだね。——犯人は別にいると確信しているその表情、依頼人の九割がそうだ。ところが、証拠品が書き換えられたのはなぜだと思う?」

「……あんたがLCI? 俺は呼んだ覚えはないんだが」

「呼んだのは君の上司だよ。私はロック。レガシー・コード・インベスティゲーション」

名乗りながら、もう画面を覗き込んでいる。遠慮という概念がないらしい。

「さて、ワトソン君。そのdiffを見せてもらおうか」

は? ワトソン? ——俺の名前は——まあいい。さっさと終わらせて帰ってもらおう。

不承不承、画面を向けた。腕を組んだまま、問題を説明する。

「料金計算ライブラリが RateConfig オブジェクトを返す。割引率、税率、基本料金が入ってる。フロントチームがそのオブジェクトの discount_rate を表示用に書き換えた。そのオブジェクトが別のAPIリクエストでも使い回されて、割引率が狂った」

一呼吸置いて、言い切った。

「バグはフロントチームのコードだ。俺のライブラリじゃない」

ロックはコードを読みながら、静かに一つうなずいた。それから、画面から目を離さずに言った。

「証拠品が改竄された事件だね。ところで、証拠品に鍵をかけなかったのは誰かね?

「……何が言いたい」

「見せたまえ、この RateConfig のクラス定義を」

コードの指紋——鍵のない証拠品

ロックが指さしたのは、俺が10年保守してきた RateConfig のクラス定義だった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package RateConfig;
use Moo;
use Types::Standard qw(Num Str ArrayRef);

has base_rate      => (is => 'rw', isa => Num, required => 1);
has discount_rate  => (is => 'rw', isa => Num, required => 1);
has tax_rate       => (is => 'rw', isa => Num, required => 1);
has currency       => (is => 'rw', isa => Str, required => 1);
has applied_rules  => (is => 'rw', isa => ArrayRef[Str], default => sub { [] });

1;

「5つの属性、すべて rw——読み書き自由だ」

ロックの指が is => 'rw' の行をなぞる。

rw にしたのは、設定値を後から微調整する需要があったからだ。ライブラリの内部でも再計算後に値を更新するケースがある」

我ながら筋の通った説明だと思った。だがロックは首を小さく振った。

「内部で使う書き込み権限を、外部の全利用者にも公開している。フロントチームが $config->discount_rate(0.15) と書いた。このコードはエラーになるかね?」

「ならない。rw だから」

動くということは、許可されているということだ

ロックは画面から手を離し、真っ直ぐ俺を見た。

「ドアに鍵をかけずに『勝手に入るな』と紙を貼っているのと同じだよ、ワトソン君」

反論しかけて、口をつぐんだ。ドキュメントに「返却値を変更しないこと」と書いた記憶がある。紙は貼った。でも、鍵はかけていない。

「……じゃあ ro にすればいいだけの話だろ。今からでも直せる」

ro に変えたとしよう。するとフロントチームはこう言うはずだ——『割引率を変えた表示をしたいのに変えられない。計算ライブラリが使いにくい』」

「そんなの知らない。書き換えるなと言ってるんだ」

「禁止するだけでは、もう一方の手が塞がれたまま残る。禁止と代替案はセットで提供するものだ。鍵をかけるなら、正面玄関も用意する。——それが wither だ」

「wither?」

鮮やかなリファクタリング——不変の鍵と正面玄関

ロックがブリーフケースから取り出したのは、年代物のThinkPadだった。角が擦り切れている。俺のデスクの隣にそれを置き、画面を開いた。

ステップ1: ro への変更と門番

「まず全属性を ro にする。そして MooX::StrictConstructor を入れる」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package RateConfig;
use v5.36;
use Moo;
use MooX::StrictConstructor;
use Types::Standard qw(Num Str ArrayRef);

has base_rate      => (is => 'ro', isa => Num, required => 1);
has discount_rate  => (is => 'ro', isa => Num, required => 1);
has tax_rate       => (is => 'ro', isa => Num, required => 1);
has currency       => (is => 'ro', isa => Str, required => 1);
has applied_rules  => (is => 'ro', isa => ArrayRef[Str], default => sub { [] });

1;

StrictConstructorro にすれば十分だろ」

「不変オブジェクトはコンストラクタでしか値を設定できない。だからこそ、コンストラクタに渡す引数が正しいことが死活的に重要だ」

ロックはThinkPadに一行打ち込んで見せた。

1
RateConfig->new(discont_rate => 0.1, ...);

「タイプミスに気づくかね?」

discountdiscont になっている。一文字違い。

StrictConstructor がなければ、このタイプミスは黙って無視される。discount_rateundef だ。可変オブジェクトなら後から代入し直せるが、不変オブジェクトでは『後から直す』が存在しない。構築時の正しさが全てなのだ」

……確かに。ro にしたなら、入口の検査を厳しくしないと意味がない。黙ってうなずいた。

ステップ2: wither——正面玄関の設置

「次に、フロントチームが必要としている『割引率を変えた設定』を、安全に取得する手段を提供する」

1
2
3
4
5
6
7
8
9
sub with_discount_rate ($self, $new_rate) {
    return ref($self)->new(
        base_rate     => $self->base_rate,
        discount_rate => $new_rate,
        tax_rate      => $self->tax_rate,
        currency      => $self->currency,
        applied_rules => [ $self->applied_rules->@* ],
    );
}

with_discount_rate は、元のオブジェクトを一切触らず、新しい割引率を持つ別のインスタンスを返す」

ロックがThinkPadで呼び出し側のコードを打つ。

1
my $display_config = $config->with_discount_rate(0.15);

「フロントチームはこう書ける。元の $config は汚れない」

「毎回 new するのか。コストは?」

待ってましたとばかりに、ロックが返す。

RateConfig のインスタンス生成は何マイクロ秒だね? 先月の誤請求の修正にかかった工数は何時間だね?」

痛いところを突いてくる。だがそれだけじゃ納得しない。パフォーマンスの話をしている。

「——それに」とロックは続けた。「可変オブジェクトを安全に渡すには、呼び出しのたびに防御的コピーが必要だ。オブジェクトを返すたびに、呼び出し側が書き換えるかもしれないからコピーを作って渡す。99%のクライアントが書き換えなくても、1%のために全員がコストを払う」

「不変ならコピー不要でそのまま渡せる。防御的コピーが消える分、差し引きで得をするのだよ」

防御的コピー。考えたことがなかった。確かに、可変オブジェクトを返すなら、呼び出し側が何をするかわからない以上、毎回クローンして渡すのが正しい。その「毎回」を消せるなら——差し引きの計算は成り立つ。

「……なるほどな」

ステップ3: 浅い不変性の罠

俺はロックの画面をじっと見ていた。そしてあることに気づいた。

「待て。applied_rulesArrayRef だ。ro にしても push @{$config->applied_rules}, 'evil' はできるだろ。Perlはリファレンスの再代入を防ぐだけで、中身は触れる」

ロックの口元がわずかに緩んだ。こいつが笑うところを初めて見た。

「よく気づいたね、ワトソン君。さすがPerl歴が長い。is => 'ro'参照の再代入を防ぐだけで、参照先のデータ構造は守らない。これを浅い不変性と呼ぶ」

「深い不変性が必要ってことか」

「対策は二つ」

ロックがコードを書き換える。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
has applied_rules => (
    is       => 'ro',
    isa      => ArrayRef[Str],
    default  => sub { [] },
);

sub BUILD ($self, $args) {
    # 防御的コピー: 外部の参照から切り離す
    $self->{applied_rules} = [ $self->{applied_rules}->@* ];
}

sub applied_rules_list ($self) {
    return $self->{applied_rules}->@*;  # リストで返す
}

「一つ、コンストラクタで配列のコピーを取る。外部から渡された参照をそのまま格納しない。二つ、アクセサではリファレンスではなくリストで返す。リファレンスを外に出さなければ、外から触れない

BUILD でコピー。返却はリスト。リファレンスという「通路」を断つ発想か。

「wither でも同じだ。applied_rules => [ $self->applied_rules->@* ] とコピーを取っている。新旧のオブジェクトが同じ配列リファレンスを共有してはならない」

俺は自分のモニターに目を戻した。10年間 is => 'rw' で書いてきた RateConfig が、別のクラスに見えた。

事件の終わり——テストが証明する不変の力

ロックが「テストを書いてみたまえ」と言った。言われなくても書くつもりだった。

ターミナルを開き、テストコードを叩く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use Test::More;
use Test::Exception;

# 不変性テスト: 属性の書き換えが構造的に不可能
my $config = RateConfig->new(
    base_rate => 1000, discount_rate => 0.1,
    tax_rate => 0.08, currency => 'JPY',
);
dies_ok { $config->discount_rate(0.2) } '属性の書き換えは例外';

# wither テスト: 元のオブジェクトが汚れない
my $new_config = $config->with_discount_rate(0.2);
is $config->discount_rate, 0.1, '元のオブジェクトは不変';
is $new_config->discount_rate, 0.2, '新しいオブジェクトは新しい値';

# StrictConstructor テスト: タイプミスを検出
dies_ok {
    RateConfig->new(
        base_rate => 1000, discont_rate => 0.1,  # タイプミス
        tax_rate => 0.08, currency => 'JPY',
    );
} 'タイプミスは例外';

# 浅い不変性テスト: リファレンス先の変更を防ぐ
my $rules_config = RateConfig->new(
    base_rate => 1000, discount_rate => 0.1,
    tax_rate => 0.08, currency => 'JPY',
    applied_rules => ['rule_a'],
);
my @rules = $rules_config->applied_rules_list;
push @rules, 'evil';
is_deeply [$rules_config->applied_rules_list], ['rule_a'],
    '外部からの改竄は内部に影響しない';

テスト実行。全てグリーン。

「……通った」

ロックがThinkPadを閉じ、ブリーフケースにしまう。

rw は許可証だった。ro は鍵だ。wither は正面玄関だ。StrictConstructor は門番だ。——四つ揃って初めて、証拠品は安全になる

腕組みを解いた。認めたくないが、認めざるを得ない。

「……俺のコードにも非があったってことだな」

「犯人はフロントチームではない。犯人は is => 'rw' だ。彼らは、許可された操作を実行しただけだ

	classDiagram
    class RateConfig {
        -Num base_rate
        -Num discount_rate
        -Num tax_rate
        -Str currency
        -ArrayRef applied_rules
        +base_rate() Num
        +discount_rate() Num
        +tax_rate() Num
        +currency() Str
        +applied_rules_list() List
        +with_discount_rate(rate) RateConfig
        +with_tax_rate(rate) RateConfig
    }

    class CalcEngine {
        +calculate(config) Num
    }

    class FrontTeam {
        +display(config) void
    }

    CalcEngine --> RateConfig : 生成して返却(不変)
    FrontTeam --> RateConfig : with_discount_rate で新インスタンス取得

ロックからの忠告

ロックがブリーフケースを手に取り、立ち上がった。

「報告書は後日メールで送る。報酬は——」

「上司に請求してくれ。俺は頼んでないからな」

ロックは微かに笑った。今日二度目だ。

「頼まれなくても来るのが探偵というものだよ。——一つ忠告しておこう、ワトソン君。『書き換えるな』と伝えるのは約束だ。『書き換えられない』ようにするのが設計だ。約束は破られるが、設計は破られない」

何か言い返そうとして、やめた。それは正しい。

ロックが通路を去っていく。くたびれたジャケットの背中が、フロアの角を曲がって見えなくなった。

俺は自席に向き直り、RateConfigのコードを開いた。

is => 'rw' にカーソルを合わせる。

is => 'ro' に書き換える。

そこで手が止まった。

フロントチームの display_config のことが頭をよぎる。あいつらは割引率を変えた表示が必要だと言っていた。ro にしただけじゃ、あいつらの仕事が詰まる。

「……witherが要るな」

キーボードに手を置き直した。

俺のコードは正しかった。ただ、正しさを証明する鍵をかけ忘れていた。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
共有される可変オブジェクト(is => 'rw'Immutable Object(is => 'ro'属性の書き換えが構造的に不可能になり、Aliasing Bug を根絶
防御的コピーの不在コンストラクタでの防御的コピー + リストで返却浅い不変性の罠を塞ぎ、リファレンス先の改竄を防止
「変更するな」というドキュメント規約wither メソッド(with_x元のオブジェクトを汚さず、変更需要に安全に応える代替手段
タイプミスの黙殺MooX::StrictConstructorコンストラクタ引数のタイプミスを即座に検出。不変オブジェクトの「構築時の正しさ」を保証

推理のステップ

  1. 全属性を is => 'ro' にする: setter を消し、コンストラクタ以降の書き換えを構造的に禁止する
  2. MooX::StrictConstructor を導入する: 宣言されていない属性名をコンストラクタに渡すと例外を発生させ、タイプミスを即座に検出する
  3. wither メソッドを実装する: with_discount_rate($new) のように、元のオブジェクトを変更せず新しいインスタンスを返すメソッドを提供する
  4. 浅い不変性に対処する: ArrayRef 等の参照型属性は、コンストラクタ(BUILD)で防御的コピーを取り、アクセサではリストで返すことで、リファレンスの漏洩を防ぐ
  5. テストで不変性を証明する: 書き換え不可、wither の独立性、外部参照からの隔離をテストで検証する

ロックより

「書き換えるな」と声を上げるのは、誰にでもできる。だがコードに向かって声を上げても、コードは聞いていない。コードが聞くのは、構造だけだ。

is => 'rw' は扉を開け放つ宣言であり、 is => 'ro' は鍵をかける宣言だ。そして wither は、鍵をかけたまま必要な品物を受け渡す窓口だ。——約束に頼るな。設計で語りたまえ、ワトソン君。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。