AetherEchoesEngineering
Engineering#017911 min2,7516 view

間違った抽象を作るくらいなら重複を選ぶ — 「Prefer duplication over the wrong abstraction」を運用者目線で読み直す

重複を消すために作った抽象が的外れだと、その抽象は元の重複よりずっと高くつきます。Sandi Metz の格言を入り口に、誤った抽象が生まれる仕組み、見抜くための 5 つのサイン、まず重複へ戻す剥がし方、重複を許してよい境界線を運用者目線で整理しました。

SoSoraEndo2026年6月22日 09:0811 min2,751

動画で読む

重複は安い、間違った抽象のほうが高くつく

結論から書きます。重複を消すために作った抽象が的外れだったとき、その抽象は元の重複よりもずっと高い代償を要求します。抽象の正しい形がまだ見えていない段階では、無理に共通化せず重複を残しておくほうが、後で保守する人にとって安全です。

この考え方の出どころは、Sandi Metz が 2016 年 1 月に書いた The Wrong Abstraction という記事です。もとは RailsConf 2014 の講演で語られた一節で、「duplication is far cheaper than the wrong abstraction(重複は、間違った抽象よりはるかに安い)」と言い切っています。先日この話題が Hacker News で再浮上していて、コメント欄が 10 年経っても賑わっているのを見て、運用者目線で読み直したくなりました。

私は最初この格言を「DRY(Don't Repeat Yourself、同じことを繰り返すな)を否定するもの」だと誤読していました。違います。これは DRY を いつ適用するか のタイミングの話です。賢く重複を消すこと自体は正しい。ただ、消す相手をまだ理解していないのに消すと、傷が深くなる。それだけのことです。

なぜ「間違った抽象」は生まれるのか

間違った抽象は、たいてい善意の DRY から生まれます。最初は正しく見えた共通化が、要件の追加に耐えられず、引数とフラグで分岐を抱え込んでいくのが典型的な崩れ方です。

流れはいつもこうです。最初に似たコードが 2 箇所にできる。良心的な開発者がそれを 1 つの関数にまとめる。ここまでは正しい。次に、3 箇所目が「ほぼ同じだが少しだけ違う」要件で現れる。共通関数に分岐を 1 つ足す。4 箇所目でフラグがもう 1 つ増える。そうやって半年も経つと、本来 1 つの責務だったはずの関数が、呼び出し側のあらゆる事情を引数で受け取る巨大な分岐装置に育ちます。

# 最初は素直だった
def send_notification(user)
  Mailer.welcome(user).deliver_later
end

# 半年後、こうなっている
def send_notification(user, channel: :email, urgent: false, locale: nil, retry_count: 0, skip_optout: false)
  return if user.opted_out? && !skip_optout
  # チャンネルごとの分岐が 7 つ、locale ごとに 3 つ……
end

私の手元にも、git blame で辿ったら 2 年前の自分がコミットしていた、引数 9 個の「共通メソッド」がありました。読みにくいコードは過去の他人が書いたものだと思いたいのですが、犯人はだいたい過去の自分です。

Sandi Metz が指摘するのは、この崩壊が 一歩ずつ正しく見える という点です。どの 1 ステップも「既存の抽象に分岐を足す」という合理的な判断で、誰も間違えていない。にもかかわらず、積み重なった結果は誰も触りたがらない関数になる。これは個人の能力の問題ではなく、構造の問題です。

誤った抽象を見抜くサイン

誤った抽象は、コードの「におい」で気づけます。中でも分かりやすいのは、真偽値フラグ引数の増殖と、呼び出し側がフラグの意味を知らないと使えない状態です。

私が現場で使っているサインは次の 5 つです。影響の重いものから並べました。

  1. 真偽値 / mode フラグの引数が 2 つ以上 ある。do_thing(x, fast: true, dry_run: false) のような呼び出しは、実は 2 つの違う処理が 1 つの皮をかぶっている合図です。
  2. 関数の中が分岐で前半後半に割れている。共通部分がほとんどなく、分岐の中だけが本体なら、それは別々の関数です。
  3. 呼び出し側がコメントで意図を補っている。「urgent を true にするとリトライも有効になる」のような注釈が要るなら、引数が振る舞いを語れていません。
  4. 変更のたびに無関係な箇所が壊れる。1 つの要件を直したら、共通化された別の呼び出し元のテストが赤くなる。影響範囲が読めないのは、抽象が複数の理由で変わる証拠です。
  5. 名前が抽象的すぎるManager Helper process のような名前は、責務を 1 文で言えなかった敗北宣言であることが多いです。

この「変更理由が複数ある」感覚は、設計の判断を値ではなく役割で記述する話と地続きです。私は以前 DESIGN.md に値ではなく役割を書く で、抽象が表すべきは「何であるか」ではなく「どういう役割か」だと書きました。役割が 2 つあるなら、抽象も 2 つに割れているべきです。

剥がし方 — まず重複に戻す

誤った抽象を見つけたら、いきなり作り直そうとせず、まず重複した状態へ戻すのが安全です。Sandi Metz の処方箋もこれで、共通化を一度ほどいて、呼び出し元それぞれに本来のコードをインライン展開してから、改めて本当の共通部分を探します。

手順はシンプルです。まず、共通関数の中身を各呼び出し元にコピーで戻す。フラグ引数で死んでいた分岐は、その呼び出し元では常に通る側だけを残す。これで一時的にコードは増えますが、各呼び出し元は「自分のことだけ」を語る素直な状態に戻ります。重複は目に見えるので、後から正しい抽象を見つけたときに消すのは簡単です。隠れた誤抽象より、見えている重複のほうがずっと御しやすい。

ここで効くのが撤退の判断力です。私は 始める前にやめ方を決める で個人開発に撤退基準を持ち込む話を書きましたが、抽象も同じで「この共通化が間違っていたらこう戻す」を最初に決めておくと、剥がす決断が軽くなります。作った抽象を捨てるのは、Postgres の運用方針を 大量 DELETE から DROP TABLE に切り替えた ときと同じで、これまで費やした手間を認めて方針ごと差し替える勇気の問題でした。

注意点が 1 つ。剥がす作業はテストがある前提です。テストがないまま共通関数をインライン展開すると、どの分岐がどの呼び出し元で生きていたのか分からなくなります。先に characterization test(現状の振る舞いをそのまま固定するテスト)を書いてから手を入れます。

重複を許容してよい境界線

では、どこまで重複を許すのか。目安は「その 2 箇所が、同じ理由で同時に変わるか」です。同じ理由で変わるなら共通化、たまたま今が同じ形なだけなら重複のままにします。

判断の軸を表に整理します。

状況推奨理由
同じ業務ルールを 2 箇所が参照共通化する変更理由が 1 つ。片方だけ直す事故を防ぐ
形は同じだが責務が別重複のまま将来、別方向に育つ。早すぎる結合は負債
まだ 2 回しか現れていない重複のまま様子見3 回目で初めて共通の形が見える(Rule of Three)
コピペが 5 箇所以上に拡散共通化する重複のコストが理解コストを上回った

この「同じ理由で変わるか」は、ソフトウェア設計でいう関心の分離そのものです。古典的な DRY 原則も、もとをたどれば The Pragmatic Programmer で「knowledge(知識)の重複を避けよ」と書かれていて、文字列としてのコードの重複を消せとは一言も言っていません。同じ知識が 2 箇所にあるのが問題なのであって、たまたま似た見た目のコードが 2 つあること自体は罪ではないのです。

運用者としてどう判断するか

最後に運用者の立場でまとめます。抽象は「正しく作る」ことより「間違えたら安く剥がせる」ことを優先して設計したほうが、長く保守する現場では得をします。

Kent C. Dodds は 2019 年に AHA Programming(Avoid Hasty Abstractions、急いだ抽象を避けよ)という言葉で同じ趣旨を整理しています。早すぎる抽象も、抽象を全くしない原始的な重複も、どちらも極端で、間を取れというのが要点です。私の運用ルールはこうです。3 回目までは重複を許す。共通化するときは引数フラグを足さない。フラグを足したくなったら、それは別の関数に割れるサインだと考える。

金曜の夜、近所のドトールで自分の古いコードを読み返すと、たいてい「よかれと思って早めに共通化した関数」が一番読みにくくなっています。当時の私は重複を恐れていました。今の私が恐れるのは、剥がせない抽象のほうです。重複は目で見えて、見えるものは直せる。直せないのは、見えなくなった構造です。

この記事は AI が下書きを書き、運営者である私が公開前に内容を確認・編集して公開しています。

よくある質問

重複は常に消すべきではないのですか?
いいえ。消すべきは「同じ理由で同時に変わる知識の重複」です。たまたま見た目が同じだけのコードは、将来別方向に育つことが多く、早すぎる共通化はかえって負債になります。
「間違った抽象」かどうかはどう見分けますか?
真偽値やモードのフラグ引数が2つ以上ある、関数が分岐で前後に割れている、呼び出し側がコメントで意図を補っている、変更のたび無関係な箇所が壊れる、名前が抽象的すぎる、といったサインが目印になります。
誤った抽象を見つけたら最初に何をしますか?
いきなり作り直さず、まず共通化をほどいて各呼び出し元に重複として戻します。重複は目に見えるので、後から本当の共通部分が見えたときに消すのが簡単だからです。先にテストで現状の振る舞いを固定してから着手します。
DRY 原則と矛盾しませんか?
矛盾しません。The Pragmatic Programmer の DRY は「知識の重複を避けよ」であって、コードの見た目の重複を消せとは書いていません。同じ知識が二重化していなければ、似たコードが2つあること自体は問題ではありません。

参考文献

  1. Sandi Metz — The Wrong Abstraction
  2. Kent C. Dodds — AHA Programming
  3. The Pragmatic Programmer (DRY 原則)

Reaction

Share

X (Twitter)