Featured image of post コードバーテンダー【Refused Bequest】合わない樽での熟成〜遺産を拒む子〜

コードバーテンダー【Refused Bequest】合わない樽での熟成〜遺産を拒む子〜

Refused Bequest(拒否された遺産)とは何か? Perl+Mooのコード例で、継承した親クラスのメソッドを空でオーバーライドする問題と、Roleベース設計・委譲(handles)による解決策を物語形式で解説します。

四回目ともなると、路地裏に足が勝手に向かう。

仕事帰り、駅への道を歩いていたはずが、気がつけばあの薄暗い路地の入り口に立っていた。看板のない店。曲がり角の先にある重い木の扉。先週も、その前の週も、そのまた前の週もここに来た。

「通ってるなあ、私」

独り言が白い息になった。春の夜はまだ冷える。コートの襟を立てて、もう迷わない足取りで路地を抜ける。扉を押すと、いつものカウンター。磨き上げられた木の天板に暖色の照明が落ちている。

「いらっしゃいませ」

マスターの穏やかな声に、ほっとする自分がいた。帰るべき場所がもう一つ増えたような——そんな感覚は、まだ大げさだろうか。

来店——樽が決める味

いつもの席に座る。三回通えば「いつもの席」ができる。マスターもそれを知っていて、カウンターの上におしぼりが置いてある。

「えっと……先週のあの、ボウモアでしたっけ? あれを」

初めて自分から銘柄を口にした。正確に覚えているかどうかは怪しいけれど、二十五年熟成のあの深い琥珀色は印象に残っている。重くて甘くて、少し煙たい味。

マスターが一瞬うなずいた。

「ボウモアでございますね。——ですが、今夜は別のものをお勧めしてもよろしいですか」

「……お願いします」

選ぼうとした。でも、やっぱりマスターに委ねてしまう。自分で注文する日が来るのだろうか。

マスターが棚からボトルを取り出し、グラスに注いだ。先週のボウモアとは明らかに違う。もっと赤い。濃い琥珀色の中に、赤ワインのようなルビーが溶けている。

グレンドロナックでございます。シェリー樽の影響が非常に強いウイスキーです」

香りを嗅ぐと、まずレーズンの甘さが鼻に飛び込んできた。その奥にチョコレートのような苦みと、オレンジの皮のような柑橘。先週のボウモアの煙たさとはまるで別の世界だ。

「甘い……けど深い。先週のとは全然違う」

「同じ麦から作った原酒でも、熟成に使う樽で味は一変します」

マスターがシェリー樽とバーボン樽の違いを、ざっくりと教えてくれた。同じ原酒が、器によって別のウイスキーになる。

樽の選択が味を決めます。合わない樽に入れてしまえば——どれだけ良い原酒も、その力を発揮できません」

カウンターの端に目がいく。取り置きボトル。麻布をかぶったあのボトルは、先週よりまた少し近くなっている気がする。今夜はもう聞かなかった。どうせはぐらかされるのだから。でも視界の隅に入るたびに、何か意味があるような気がしてならない。

扉がゆっくり開いた。

スーツのジャケットを腕にかけた男性が入ってきた。メガネの奥の目が疲れている。眉間にしわが寄っていて、難しいことを考えすぎて消化不良を起こしているような——そんな顔だった。

マスターが穏やかに声をかけた。

「いらっしゃいませ。今夜は何をお召し上がりになりますか」

「……何でもいいです。あ、すみません。ハイボールで」

悩めるくん、と心の中で呼んだ。真面目そうで、でもどこか自信がなさそうな。うちの若手エンジニアの一人に似ていた。

ハイボールを一口飲んで、少し間があった。それから右手でメガネをちょっと直してから、ぽつりと話し始めた。

「あの……社内のフレームワークの設計について、相談できるところだと聞いて来たんですけど」

マスターが穏やかにうなずいた。悩めるくんはタブレットを取り出して、画面をカウンターに向けた。

「うちの会社、すべてのモデルクラスが BaseEntity っていう基底クラスを継承するルールなんです」

私は「基底クラス」が何のことかよくわからなかったけれど、「全部のモデルが一つの土台から作られる」ということは、なんとなく理解できた。

「でも、僕が作った ReadOnlyReport は読み取り専用のレポートで、save とか delete とか update とか——親クラスの機能を使わないんです」

「使わないのに、全部もらっちゃうの?」

私の素朴な疑問に、悩めるくんがうなずいた。

「そうなんです。だから空のメソッドにしたり、呼ばれたらエラーを出すようにしたりして……潰して回ってるんです」

そう言って見せてくれたコードがこれだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package BaseEntity;
use v5.36;
use Moo;
use Types::Standard qw(Str Int);

has id   => (is => 'ro', isa => Int);
has name => (is => 'ro', isa => Str);

sub save ($self) {
    return "saved: " . $self->name;
}

sub delete ($self) {
    return "deleted: " . $self->id;
}

sub update ($self, $data) {
    return "updated: " . $self->name;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package ReadOnlyReport;
use v5.36;
use Moo;
extends 'BaseEntity';

use Types::Standard qw(Str);

has generated_at => (is => 'ro', isa => Str);

sub render ($self) {
    return sprintf("Report #%d: %s (%s)",
        $self->id, $self->name, $self->generated_at);
}

# 🚫 親のメソッドを拒否
sub save ($self)          { die "ReadOnlyReport は保存できません" }
sub delete ($self)        { die "ReadOnlyReport は削除できません" }
sub update ($self, $data) { }  # 何もしない

「先週、別のチームの人がこの ReadOnlyReportsave を呼んで——本番でエラーが出ました。BaseEntity のつもりで使ったんです。形の上では BaseEntity だから」

声が沈んでいた。自分のせいだと思っているらしい。

「設計がおかしいと感じてたんですけど……これでいいんでしょうか。社内のルールだから従ってたんですが」

私はうちのエンジニアを思い出した。先月の1on1で「基底クラスの話」をされた。そのときは何のことかわからなくて流してしまったけれど。

「あ、うちもそういう話聞いたことある。うちのエンジニアが、基底クラスの半分を空でオーバーライドしてるって言ってたの。そういうものなんでしょ?」

何気なく言った。ゲスト客の技術の話が、自分の会社のことと重なって、口から出てしまっただけだ。

テイスティング——拒否された遺産

マスターがタブレットに目を通してから、静かに口を開いた。

ReadOnlyReportBaseEntity を継承している。しかし、savedeleteupdate——親から受け継いだ3つのメソッドを、すべて拒否していますね」

悩めるくんがうつむいた。「はい……savedeletedie で止めて、update は中身を空にしてあります。使わないので」

「この状態を、Refused Bequest と呼びます」

マスターの声は穏やかだったけれど、名前の響きには重みがあった。

「日本語では《拒否された遺産》。親から受け継いだものを、子が——使えない。あるいは、使いたくないという状態です」

拒否された遺産。なんだかドラマのタイトルみたいだ。でも悩めるくんのコードを見ると、確かに「遺産を拒否している」としか言いようがない。

「これ、名前があったんですか」

悩めるくんが驚いた顔をした。設計がおかしいと感じていたのに、それが名前のつく問題だとは思っていなかったらしい。そういうものなのか。名前がつくと、問題から課題に変わる——前にマスターがそんなことを言っていた気がする。

なぜ問題なのか

マスターが指を三本立てた。三回目の来店から、この「三つの理由」が始まるとわかるようになった。

「Refused Bequest が問題になる理由は、3つございます」

「まず、リスコフの置換原則——LSP——の違反です」

私にはアルファベット三文字の意味がわからなかったけれど、マスターの説明はわかりやすかった。

BaseEntity として使えるはずの ReadOnlyReport が、save を呼ばれると例外を投げる。呼び出す側は BaseEntity のつもりで使っているから、実行するまで壊れていることに気づけない。先日の本番障害は、まさにこれが原因です」

悩めるくんが膝の上で拳をぎゅっと握った。「……あの障害、そういう名前がつく問題だったんですね」

「次に、偽の契約です。extends BaseEntity という宣言は、《私は BaseEntity のすべてを引き受けます》という約束です。実際には半分を拒否している。型が嘘をついている状態でございます」

私が口を挟んだ。「嘘ってこと? コードが嘘をつくの?」

マスターがこちらを向いた。

「ええ。ReadOnlyReport は《BaseEntity です》と名乗っていますが、BaseEntity としての振る舞いを果たせません。名刺に書いてある肩書きと実際の仕事が違う——そう考えていただければ」

なるほど。それはビジネスの世界でも、信用を失う最短ルートだ。

「最後に、継承階層の汚染です。BaseEntity に新しいメソッドが追加されるたびに、ReadOnlyReport でもオーバーライドが必要になります。親が成長するほど、子の拒否リストが膨らんでいく」

悩めるくんが深いため息をついた。

「3番目、まさにそれです。先月 BaseEntityarchive メソッドが追加されて……また空のオーバーライドを書きました」

マスターがグレンドロナックのボトルを少し傾けた。深いルビー色の液体が光を受ける。

樽が合わないのに、無理に注ぎ続けている。味は出るどころか、新しく注ぐたびに濁りが増していくのです」

私は悩めるくんに聞いた。隣の席の彼が不機嫌そうに見えたので心配だったのだけれど、不機嫌なのではなく困惑しているのだとわかった。

「ねえ、それっておかしいと思ってたんでしょ? 作ってるときから」

悩めるくんが顔を上げた。「……はい。空のメソッドを書くたびに、何か違うなって。でも——」

「社内のルールだから?」

「はい。すべてのモデルは BaseEntity を継承する。そう決まってるので」

私は首をかしげた。経営者としては、その考え方にひっかかるものがある。

合わないとわかってるのに使い続けるの? うちの会社だったら、合わない仕組みは変えるけど」

悩めるくんが一瞬、言葉を失った。

マスターが何も言わなかった。カウンターを拭く手も止まっていなかった。ただ、ほんの一拍——マスターの視線がどこかに向いて戻ったような気がした。

ブレンド——器を変えるブレンダーの仕事

マスターが口を開いた。

「解決策は、器を変えることです」

私は「樽の選択が味を決める」という先ほどの言葉を思い出した。合わない樽なら、樽を変えればいい。

「継承で丸ごと受け取っていたものを、必要なものだけ選んで組み合わせる。ウイスキーのブレンドと同じです——原酒を丸ごと使うのではなく、必要な味だけを取り出して調合します」

解決策1: Role による合成

「まず、BaseEntity が持っていた《識別する》という性質を、Roleとして切り出します」

マスターがタブレットに新しいコードを表示した。

1
2
3
4
5
6
7
package Identifiable;
use v5.36;
use Moo::Role;
use Types::Standard qw(Str Int);

has id   => (is => 'ro', isa => Int);
has name => (is => 'ro', isa => Str);

Moo::Role——共通の《性質》を定義するための仕組みです。《私は識別できます》という宣言であり、《私はすべてを引き受けます》という宣言ではありません」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package ReadOnlyReport;
use v5.36;
use Moo;
with 'Identifiable';  # 必要な部分だけ合成

use Types::Standard qw(Str);

has generated_at => (is => 'ro', isa => Str);

sub render ($self) {
    return sprintf("Report #%d: %s (%s)",
        $self->id, $self->name, $self->generated_at);
}
# save, delete, update は存在しない

悩めるくんが画面を見比べた。

extendswith に変わっただけですか?」

「似ているようで、構造が根本的に違います」

マスターの声がわずかに力を帯びた。この人が語気を強めるのは珍しい。それだけ重要なことなのだろう。

extends は《すべてを受け取る》宣言です。savedeleteupdate も、望むものも望まないものも全て。一方の with は——《必要なものを選んで合成する》宣言です。idname だけを手に入れて、それ以外は持たない」

私が思わず口を挟んだ。

「全部引き取る遺産相続と、必要なものだけもらう形見分けみたいなもの?」

マスターが——一瞬、何かを言いかけて止まった。それから穏やかな驚きの表情を浮かべた。いつだって冷静なマスターの目が、ほんの少しだけ丸くなった気がした。

「……見事な例えでございます」

ちょっと嬉しかった。マスターに褒められたのは初めてかもしれない。

なぜこれで問題が消えるのか

「3つの問題が、すべて構造的に消えています」

マスターは先ほどと同じように指を三本立てた。

「まず、LSP 違反が消えますReadOnlyReportBaseEntity ではないので、BaseEntity として扱われることがありません。save を呼ぼうとしても、メソッドがそもそも存在しないため、呼び出した瞬間に《メソッドが見つからない》という明確なエラーが出ます。カスタムの die メッセージではなく、Perl が標準で出すエラーです。原因の特定が格段に容易になります」

悩めるくんが小さくうなずいた。「先日の障害は……ReadOnlyReport 独自の die メッセージだったから、最初は何が起きたのかわからなかった。メソッドが存在しなければ、エラーメッセージそのものが原因を教えてくれる」

「ええ。問題を《カスタムエラーで隠された障害》から《メソッド不在という明確なエラー》に変えた。それが構造を変える効果です」

「次に、偽の契約が消えますwith 'Identifiable' は《私は識別可能です》という正直な宣言です。嘘がありません」

「最後に、継承階層の汚染が消えますBaseEntity に何が追加されても、ReadOnlyReport には影響しません。独立しているからです。archive メソッドがいくつ追加されようと、空のオーバーライドを書く必要は二度とございません」

悩めるくんの眉間のしわが、少しだけ——ほんの少しだけ緩んだように見えた。

解決策2: 委譲による公開制限

「もう一つの方法もございます」

マスターが続けた。

「既存の BaseEntity を変更できない場合——たとえば、社内の他のチームがそのクラスを管理していて、勝手に触れない場合には、委譲という手段があります」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package ReadOnlyReport;
use v5.36;
use Moo;
use Types::Standard qw(Str InstanceOf);

has _entity => (
    is       => 'ro',
    isa      => InstanceOf['BaseEntity'],
    required => 1,
    handles  => [qw(id name)],  # 必要なメソッドだけ公開
);

has generated_at => (is => 'ro', isa => Str);

sub render ($self) {
    return sprintf("Report #%d: %s (%s)",
        $self->id, $self->name, $self->generated_at);
}

handles で公開するメソッドを明示的に選ぶ。idname だけを外に見せて、savedeleteupdate も——内側に閉じ込めます」

樽の中から、使いたい味だけを取り出す。ブレンダーの仕事そのものです」

悩めるくんが考え込むような表情で、二つのコードを見比べていた。

「Role と委譲、どちらを使うべきでしょうか」

「共通の《性質》を表現するなら、Role。既存の《部品》を活かすなら、委譲です」

マスターが手元のグレンドロナックのボトルと、棚の奥のもう一本を交互に見た。

「お客さまの場合、BaseEntity を設計から見直せるなら Role が適しています。社内のルールで BaseEntity に手を入れられないなら、委譲で包んでしまえばよい。——どちらの道を選ぶかは、お客さまの状況次第です」

ラストオーダー——器を変える勇気

悩めるくんが立ち上がった。来たときより、背筋が少し伸びていた。

帰り際、私のほうを向いた。

「ありがとうございます。あの一言で、目が覚めました」

「え、私? 何か言った?」

合わない仕組みは変えるもの——技術の話じゃなくて、そういう話だったんだなって」

頭を小さく下げて、扉の向こうへ消えていった。

私は面食らった。ただの独り言みたいなものだったのに。エンジニアの世界と経営の世界で、同じことが起きているとは思わなかった。

マスターがグレンドロナックのグラスを下げながら、静かに言った。

器を変える勇気も、ブレンダーの大切な仕事でございます

ブレンダーの仕事。合わない樽では味が出ない。合わない継承では——コードの力が出ない。マスターはそう言いたかったのだろうか。

ふと、スマホを取り出した。

今夜の話をメモしておきたい、と思った。「Refused Bequest」「器を変える」——親指で打ち始めたけれど、何をどう書けばいいのかわからなかった。あの悩めるくんのコードの何がまずくて、Role というものがどう解決したのか。わかったつもりでいて、言葉にしようとすると指が止まる。

画面を見つめたまま、結局スマホを閉じた。

マスターがそれを見て、穏やかに微笑んだ。カウンターの同じ場所を二度拭いてから、棚のボトルに手を伸ばした。

「知りたい」という気持ちは、たぶん「わかる」の手前にある。今の私は、その手前にいるのだろう。

帰り支度をして扉に向かう。背中の向こうで、マスターが取り置きボトルの位置をずらす微かな音が——したような気がした。振り向いたけれど、マスターはカウンターを拭いているだけだった。

「おやすみなさいませ」

静かな声を背中に受けて、路地裏へ出た。春の夜風がまだ少し冷たい。でも帰り道は、いつもより少しだけ短く感じた。


🥃 マスターのテイスティングノート

本日の銘柄: グレンドロナック
お客さまの症状: 拒否された遺産(Refused Bequest)

ノージング(香り)── 問題の検知

親クラスから継承したメソッドを空でオーバーライドしている、あるいは die でエラーを返しているコードを見つけたら、Refused Bequest を疑いましょう。extends しているのに親の機能を半分以上使っていなければ、その継承関係は正しくありません。

パレット(味わい)── 問題の本質

extends は「すべてを引き受ける」宣言です。親のメソッドを空でオーバーライドするということは、型が嘘をついている状態です。呼び出す側は親クラスのつもりで使うため、実行するまで壊れていることに気づけません。さらに、親クラスに機能が追加されるたびに、子クラスの拒否リストが膨らんでいきます。

フィニッシュ(余韻)── 解決の方針

extends(継承)を with(Role合成)または has + handles(委譲)に置き換えます。Role なら共通の「性質」だけを選んで合成でき、委譲なら既存クラスの必要なメソッドだけを外部に公開できます。いずれの場合も、不要なメソッドは「存在しない」状態になり、カスタムの die による曖昧なエラーが、メソッド不在という明確なエラーに変わります。

ペアリング(相性の良いパターン)

  • Composition over Inheritance(GoF)
  • リスコフの置換原則(SOLID の L)
  • Strategy パターン(振る舞いの差し替えが必要な場合の代替手段)

「樽の選択が味を決めます。——器を変える勇気もまた、ブレンダーの大切な仕事でございます」

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