Featured image of post コード探偵ロックの事件簿【Type Object】二十の棺〜クラス階層の墓場から抜け出す方法〜

コード探偵ロックの事件簿【Type Object】二十の棺〜クラス階層の墓場から抜け出す方法〜

商品カテゴリ追加のたびにサブクラスが増殖する問題を、Type Objectパターンで型をデータに還し、YAMLからの動的追加とメソッド委譲で解決するリファクタリング事例

I. 画面の向こう側

リモートミーティングの接続音が鳴り、画面が開いた。私の側はカメラON、背景はいつものオフィスの自席。相手の画面は——真っ暗だった。

「あの、映像が映っていないようですが」

「——音声で十分だ。私が見たいのは君の顔じゃない、君のコードだよ」

社内Slackの技術相談チャンネルで見た書き込みを思い出す。「妙な探偵ごっこの人だけど、コードは直る」。レガシー・コード・インベスティゲーション、通称LCI。藁にもすがる思いで連絡した相手が、これか。

「画面を共有してくれたまえ。クラス図があるなら、それを最初に見せてほしい」

促されるままにIDEのクラス図ビューを共有した。Product を頂点に、BookProductFoodProductElectronicsProduct——サブクラスが縦にずらりと並ぶ。

数秒の沈黙。そして、画面の向こうからかすかに何かを数える声が聞こえた。

「……十一、十二、十三。十三の棺だね」

「棺?」

「系図だよ、これは」とロックさんは言った。「この一族はね、代を重ねるごとに弱体化している。BookProductElectronicsProductの中身を見比べてごらん。双子のように瓜二つだ。それなのに、別々の棺に入れられている」

私は事情を説明した。商品カテゴリが四半期ごとに増える。そのたびにサブクラスを追加している。そして先月の企画会議で決まったこと——来期に20カテゴリ追加。

「二十の棺を並べるつもりかね、ワトソン君」

「あの、私には名前が——」

しかしロックさんはすでに別の話をしていた。

「いまの十三に二十を足すと三十三だ。三十三の棺を手作業で彫るのは、たとえ腕利きの棺桶職人でも気が遠くなる。そもそも、棺が必要ないとしたら?」

II. コードの指紋

「コードを見せてくれたまえ。棺の蓋を——失礼、サブクラスの定義を開いてくれ」

私は BookProduct のファイルを開いた。

1
2
3
4
5
6
7
8
package BookProduct {
    use Moo;
    extends 'Product';

    sub tax_rate ($self)      { return 0.10 }
    sub shipping_fee ($self)  { return 0 }
    sub display_label ($self) { return '📚 ' . $self->name }
}

「次。ElectronicsProduct

1
2
3
4
5
6
7
8
package ElectronicsProduct {
    use Moo;
    extends 'Product';

    sub tax_rate ($self)      { return 0.10 }
    sub shipping_fee ($self)  { return 500 }
    sub display_label ($self) { return '💻 ' . $self->name }
}

「もう一つ。FoodProduct

1
2
3
4
5
6
7
8
package FoodProduct {
    use Moo;
    extends 'Product';

    sub tax_rate ($self)      { return 0.08 }
    sub shipping_fee ($self)  { return 800 }
    sub display_label ($self) { return '🍱 ' . $self->name }
}

「3つ並べたね。違いはどこにある?」

私はコードを見比べた。税率の数値と、送料の数値と、アイコンの絵文字。それだけだ。構造はまったく同じで、3行のメソッドが返す値が違うだけ。

「数値が違うだけです。構造は同じですね」

「その通り。これは遺体ではなく、ただの名札だよ」とロックさんは言った。「クラスという重厚な棺に収められているのは、せいぜい3つの数値——いわばメモ書きだ。犯人の名はサブクラス爆発。カテゴリが増えるたびにクラスを量産するこの習慣そのものが、犯行の手口だ」

「でも、カテゴリごとに税率と送料計算が違うんです。サブクラスにするしかなくないですか?」

「本当にそうかね? 違うのがデータだけなら、クラスで表現する必要はない」

III. 型をデータに還す

「これから君のクラス階層を全部消す」

「消したら何も動かないんですけど」

「消すのはサブクラスだ。基底クラスは残す。そして、消した分をデータで置き換える。Type Objectパターンと呼ばれる手法だ」

ロックさんが画面共有を要求してきた。こちらが共有しているのに、向こうも共有し始めた。黒い画面にコードが現れる。

「まず、タイプオブジェクトを作る。君のサブクラスがやっていることを整理しよう。各サブクラスは2つの役割を持っている。『自分が何者か』を定義することと、『自分のやり方で振る舞う』こと。前者はデータ、後者はメソッドだ。問題は、この両方が1つのクラスに閉じ込められていることにある」

 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
package ProductType {
    use Moo;
    use Types::Standard qw(Str Num CodeRef);

    has name         => (is => 'ro', isa => Str, required => 1);
    has tax_rate     => (is => 'ro', isa => Num, required => 1);
    has shipping_fee => (is => 'ro', isa => Num, required => 1);
    has icon         => (is => 'ro', isa => Str, required => 1);

    has shipping_strategy => (
        is      => 'ro',
        isa     => CodeRef,
        default => sub {
            sub ($product) { $product->type->shipping_fee }
        },
    );

    sub calc_shipping_fee ($self, $product) {
        return $self->shipping_strategy->($product);
    }

    sub display_label ($self, $product_name) {
        return $self->icon . ' ' . $product_name;
    }
}

ProductType——これがタイプオブジェクトだ。さっきまで13個のサブクラスに散らばっていた税率、送料、アイコンが、このクラスのインスタンスに集約される」

「データだけ外に出しても、振る舞いの部分はどうなるんですか? カテゴリごとに送料計算のロジックが違うこともありますよね。重量ベースとか、地域ベースとか」

私がいちばん聞きたかったことを口にした。データを移すだけなら、誰でも思いつく。だが送料計算のように、カテゴリごとにロジックが違うかもしれない部分はどうするのか。

「よい質問だ、ワトソン君。答えは委譲だ」

「……もういいです、その呼び方は」

ロックさんは聞いていなかった。

「さっきのProductTypeをもう一度見てくれ。shipping_strategyという属性がある。デフォルトではshipping_feeの値をそのまま返す——つまり大半のカテゴリは固定送料で、データだけで済む。だが、送料のロジックを変えたいカテゴリもあるだろう。そのときはこうする」

画面にもう一つのコードが現れた。

1
2
3
4
5
6
7
8
9
my $heavy_type = ProductType->new(
    name         => 'heavy_item',
    tax_rate     => 0.10,
    shipping_fee => 0,
    icon         => '📦',
    shipping_strategy => sub ($product) {
        return $product->price >= 10000 ? 0 : 1500;
    },
);

shipping_strategyにコードリファレンスを渡す。価格が1万円以上なら送料無料、それ以下なら1500円。Strategyパターンとほぼ同じ構造だ。タイプオブジェクトがデータ戦略の両方を保持する」

「完全に任意のロジックが必要な場合は?」

「スクリプトやDSLをタイプオブジェクトに持たせることもあり得る。だが——そこまで行くと、君は自分だけのプログラミング言語を作ることになる。やめておきたまえ。大半のケースでは、有限の戦略セットとデータの組み合わせで十分だ」

私は納得した。振る舞いが「データ」で済むならデフォルト戦略がそのまま使われ、「ロジック」が要るならコードリファレンスで差し替える。サブクラスのオーバーライドと同じことを、より軽い仕組みで実現できるということだ。

「次に、型付きオブジェクトの側を見せよう。タイプオブジェクトにすべてを委譲するProductクラスだ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package Product {
    use Moo;
    use Types::Standard qw(Str Num InstanceOf);

    has name  => (is => 'ro', isa => Str,                      required => 1);
    has price => (is => 'ro', isa => Num,                      required => 1);
    has type  => (is => 'ro', isa => InstanceOf['ProductType'], required => 1);

    sub tax_rate ($self) {
        return $self->type->tax_rate;
    }

    sub shipping_fee ($self) {
        return $self->type->calc_shipping_fee($self);
    }

    sub display_label ($self) {
        return $self->type->display_label($self->name);
    }

    sub price_with_tax ($self) {
        return int($self->price * (1 + $self->tax_rate));
    }
}

Productクラスはたった一つ。type属性にタイプオブジェクトへの参照を持ち、税率も送料もラベルも、すべてタイプオブジェクトに問い合わせる。これが型付きオブジェクトだ」

画面に映るコードを追いながら、私は構造を理解し始めていた。サブクラスが13個あったのは、カテゴリごとの「違い」を表現するためだった。でもその「違い」の正体は、数個の属性値と、せいぜい1〜2個のロジックの差異でしかない。それなら、クラスで表現する必要はない。

「つまり、サブクラスの代わりにProductTypeのインスタンスを並べる。BookProductクラスの代わりに、ProductType->new(name => 'book', tax_rate => 0.10, ...)というオブジェクトが存在するわけですか」

「正確だ。そしてインスタンスなら、こうできる」

 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
34
35
36
37
38
39
40
41
42
43
package ProductTypeRegistry {
    use Moo;
    use Types::Standard qw(HashRef InstanceOf);
    use Carp qw(croak);

    has _types => (
        is       => 'ro',
        isa      => HashRef[InstanceOf['ProductType']],
        default  => sub { {} },
        init_arg => undef,
    );

    sub register ($self, $type) {
        $self->_types->{$type->name} = $type;
        return $self;
    }

    sub get ($self, $name) {
        return $self->_types->{$name}
            // croak "Unknown product type: $name";
    }

    sub all_types ($self) {
        return values $self->_types->%*;
    }

    sub load_from_hash ($self, $definitions) {
        for my $name (keys $definitions->%*) {
            my $def = $definitions->{$name};
            my %args = (
                name         => $name,
                tax_rate     => $def->{tax_rate},
                shipping_fee => $def->{shipping_fee},
                icon         => $def->{icon},
            );
            if (my $strategy = $def->{shipping_strategy}) {
                $args{shipping_strategy} = $strategy;
            }
            $self->register(ProductType->new(%args));
        }
        return $self;
    }
}

「レジストリだ。ハッシュリファレンスからタイプオブジェクトを一括登録できる。このハッシュリファレンスの出所が設定ファイル——たとえばYAML——なら?」

「待ってください」と私は言った。「20カテゴリ追加って、YAMLに20ブロック書き足すだけってことですか?」

「概ねその通りだ。Perlのコードには一切触れない。ProductクラスもProductTypeクラスも、カテゴリが5個だろうと50個だろうと変わらない」

IV. 三十三の名前

リファクタリング後のテストを走らせた。

1
2
3
4
5
6
7
8
9
ok 1 - テストA: タイプオブジェクト経由の税率
ok 2 - テストB: タイプオブジェクト経由の送料
ok 3 - テストC: 税込価格の計算
ok 4 - テストD: 表示ラベル
ok 5 - テストE: 新しいカテゴリの追加 — コード変更なし
ok 6 - テストF: レジストリの型一覧
ok 7 - テストG: 存在しないタイプの取得はエラー
ok 8 - テストH: カスタム送料戦略
All tests successful.

全テスト通過。

「本当に20カテゴリ、コード変更なしで追加できるんですか?」

「ビルドすら必要ない。YAMLに新しいブロックを書き足すだけで、新しい型が生まれる。サブクラスという棺は、もう誰のためにも彫らなくていい」

画面の向こうからキーボードを打つ音がした。ロックさんが何かをタイプしている。

「テストEを見たかね? pet_supplies——ペット用品というカテゴリを、コードを一行も変えずにレジストリに追加した。税率も送料もラベルも、すべてデータから読み込まれる。二十だろうと百だろうと、手順は同じだ」

私はサブクラスを1個ずつ手作りしていたあの日々を思い返した。BookProductをコピーして、クラス名を変えて、税率と送料の数値を書き換えて、テストを追加して——カテゴリが増えるたびに同じことを繰り返していた。その作業の正体は、実はただの「名札書き」だったということだ。

「来週から20カテゴリのYAML定義を書きます。既存の13カテゴリも移行して、サブクラスは全部消します」

「賢明だ。一つだけ忠告しておく」

ロックさんの声が、珍しく静かだった。

「タイプオブジェクトを作ったら、次の誘惑は『全部をデータ駆動にしたい』だ。税率も、表示ロジックも、承認フローも。だが突き詰めると、君は設定ファイルの中にプログラミング言語を再発明することになる。型をデータに還すのは正しい。だが、すべてをデータに還す必要はない。データで表現すべき範囲を決めるのは、設計者——つまり君の仕事だ」

「はい。まずは今の13カテゴリの移行から始めます」

ビデオ通話の終了ボタンを押す前に、ロックさんが最後に言った。

「系図を増やすな。名簿にしたまえ、ワトソン君」

通話が切れた。画面に残ったのは、13行のサブクラス定義と、それを置き換えるYAMLのスケルトンだけだった。来週から、私の仕事は「棺桶職人」から「名簿係」に変わる。

悪くない異動だと思った。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
サブクラス爆発(Subclass Explosion)— カテゴリごとにサブクラスを量産Type Object — 型をデータ(オブジェクト)として表現新カテゴリ追加にコード変更不要。YAMLの編集のみで完結
データの重複 — 同じ構造のサブクラスが値だけ違うProductTypeRegistry による一元管理型情報のSingle Source of Truth化
振る舞いの硬直化 — サブクラスのオーバーライドでしか差異を表現できないshipping_strategy による戦略委譲固定データとカスタムロジックを同一のインターフェースで扱える

推理のステップ

  1. サブクラスの中身を比較する: 各サブクラスが返す値を並べ、構造が同一で値だけが異なることを確認する。違いが「データ」だけなら、クラスで表現する必要はない
  2. タイプオブジェクトを定義する: サブクラスが持っていた属性(税率、送料、アイコン等)を1つのクラスのアトリビュートに集約する。各カテゴリはこのクラスのインスタンスになる
  3. 振る舞いの委譲を設計する: データだけで差異が表現できない場合は、CodeRef属性(戦略)をタイプオブジェクトに持たせる。デフォルト動作を定義しつつ、個別カテゴリで上書き可能にする
  4. レジストリで型を管理する: タイプオブジェクトをハッシュで管理し、名前からの検索・一括登録を可能にする。データソース(YAML等)からの読み込みもレジストリ経由で行う
  5. 型付きオブジェクトを簡素化する: Product クラスは type 属性でタイプオブジェクトを参照し、すべての型固有の問い合わせを委譲する。サブクラスは不要になる

ロックより

型というものは、コードの中に閉じ込めておく必要はない。データとして外に出せば、新しい型を生み出すのにプログラマの手を煩わせなくて済む。ただし、自由には限度がある。「全部をデータで」と欲張った先には、設定ファイルの中に新しいプログラミング言語が生まれるという喜劇が待っている。何をデータに還し、何をコードに留めるか——その境界線を引くのが設計者の仕事だ。名札を書くのにいちいち墓碑を彫る必要はないのだから。

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