Repeat Yourself

Matthias Endler

繰り返せ

原文は Matthias Endler により に公開されました。 このブログを購読する

ソフトウェアのキャリアを通じて、最も何度も聞かされてきたアドバイスの一つが「繰り返すな(Don’t Repeat Yourself)」、いわゆるDRY原則だ。長い間、私はそれを額面通りに受け取り、その正しさを疑うことすらなかった。

それが変わったのは、本物のエキスパートがコードを書くのを見たときだった。彼らは平気でコードをコピーするのだ1。そこで気づいた。繰り返すことには、実はいくつもの大きな利点があるのだと。

なぜ人々はDRYを愛するのか

よく言われるのは、コードを繰り返せば同じバグを何カ所も直さなければならなくなるが、共通の抽象化があれば一度直せば済む、というものだ。

繰り返しを避けたくなるもう一つの理由は、それが自分を賢く見せてくれるからだ。「ほら、繰り返しを避ける賢い方法をたくさん知っているぞ! インターフェースも、ジェネリクスも、高階関数も、継承も使いこなせるんだ!」といった具合に。

だが、どちらの理由も的外れだ。繰り返すことには、長期的に見れば私たちを目標へ近づけてくれる多くの利点がある。

勢いを保つ

コードを書いているときは、勢いを保ってフロー状態に入りたい。完璧な抽象化を設計しようと何度も手を止めていては、簡単に勢いを失ってしまう。

それよりも、コードのコピペを自分に許せば、思考の流れを途切れさせずに目の前の問題に集中できる。適切な抽象化を探すという別の問題を同時に抱え込む必要もなくなる。

既存のコードをコピーして、負担になるまで手を加えていく方が、たいていは楽だ。手に負えなくなったところでリファクタリングすればいい。

「書くモード」と「リファクタリングモード」は、プログラミングにおける別々のモードだと私は考えている。書くモードでは、アイデアを形にすることに集中し、「コードがひどい」と囁く内なる批評家を黙らせる。一方、リファクタリングモードでは逆の役割、すなわち批評家になる。適切な抽象化を探し、重複を取り除き、可読性を高める方法を探すのだ。

この2つのモードは分けておこう。同時にやろうとしないことだ。2

適切な抽象化を見つけるのは難しい

書き始めた時点では、まだ適切な抽象化が何なのかわからない。しかし、コードをコピーしていれば、適切な抽象化は自ずと姿を現す。同じコードを何度もコピーするのは面倒になってきて、そこで初めて抽象化する方法を探し始めるのだ。私の場合、たいてい同じコードを1回コピーした時点でそう感じるが、2回か3回コピーするまではその衝動を抑えるようにしている。

早すぎる段階で始めると、問題に合わない拙い抽象化を作ってしまうかもしれない。それが間違っていることは、なんとなく使いにくいと感じることでわかる。典型的な症状は次のようなものだ。

  • 意図が伝わらない汎用的な名前。たとえば、generate_invoiceとすべきところをrender_pdf_fileにしてしまう
  • 追加の文脈なしでは理解しにくい
  • 抽象化が1、2カ所でしか使われていない
  • 実装の詳細に強く結合している

間違った抽象化を取り除くのは難しい

私たちは思いついた最初の抽象化に安易に落ち着いてしまいがちだが、それはたいてい正しいものではない。そして間違った抽象化を取り除くのは大変な作業だ。なぜなら、すでにデータフローがそれに依存してしまっているからだ。

また、自分で作った抽象化には時間と労力がかかっているため、愛着を持ってしまいがちだ。その結果、もはや問題に合わなくなっても手放すのをためらってしまう。いわゆるサンクコストの誤謬だ。

他のプログラマーがそれに依存し始めると、事態はさらに悪化する。変更すればコードベースの他の部分を壊しかねないため、慎重にならざるを得なくなる。一度抽象化を導入してしまえば、長い間、時には永遠にそれと付き合わなければならなくなる。

コピーしたコードであれば、他を壊す心配をせずに、必要な一カ所だけを変更すれば済む。

重複は、間違った抽象化よりもはるかに安くつく

—Sandi Metz、The Wrong Abstraction

抽象化を確定させるのは、問題領域をしっかり理解できた最後の瞬間まで待つ方がよい。3

抽象化の認知的負担

抽象化はコードの重複を減らしてくれるが、代償を伴う。

抽象化は、コードが何をしているのかを理解するために何層もの間接参照を行き来しなければならなくなるため、コードを読みにくく、理解しにくく、保守しにくくすることがある。抽象化は別のファイルやモジュール、ライブラリに存在しているかもしれないのだ。

こうしたレイヤーを辿るコストは高い。熟練したプログラマーなら数段階の抽象化を頭の中に留めておけるかもしれないが、誰にでも保持できるコンテキストには限りがある(それはコードベースへの習熟度にも左右される)。

コードをコピーすれば、すべてのロジックを一カ所にまとめておける。全体をそのまま読んで、何をしているのかを理解できるのだ。

尚早な抽象化への衝動を抑える

コードが見た目は似ていても、果たす目的が異なることがある。

たとえば、コレクションを反復処理して合計を計算する2つのコード片を考えてみよう。

total = 0
for item in shopping_cart:
    total += item.price * item.quantity

そして、コードの別の場所にはこんなものがある。

total = 0
for item in package_items:
    total += item.weight * item.rate

どちらの場合もコレクションを反復して合計を計算している。ヘルパー関数を導入したくなるかもしれないが、この2つの計算はまったく別物だ。

何度か手を加えるうちに、この2つのコードはそれぞれ別の方向へ進化していくかもしれない。

def calculate_total_price(shopping_cart):
    if not shopping_cart:
        raise ValueError("Shopping cart cannot be empty")

    total = 0.0
    for item in shopping_cart:
        # Round for financial precision
        total += round(item.price * item.quantity, 2)

    return total

一方、送料の計算はこんなふうになるかもしれない。

def calculate_shipping_cost(package_items, destination_zone):
    # Use higher of actual weight vs dimensional weight
    total_weight = sum(item.weight for item in package_items)
    total_volume = sum(item.length * item.width * item.height for item in package_items)
    dimensional_weight = total_volume / 5000  # FedEx formula

    billable_weight = max(total_weight, dimensional_weight)
    return billable_weight * shipping_rates[destination_zone]

もし「繰り返すな」を早まった段階で適用していたら、それぞれの計算が持つ文脈や固有の要件を見失っていただろう。

DRYは複雑さを招くこともある

DRY原則は、あらゆる重複を何としても避けるべき絶対的なルールだと誤解されがちだが、それは複雑さを招きかねない。

繰り返しを避けるために抽象化を導入すると、実際のビジネスロジックから遠く離れた場所で、あらゆるエッジケースに対処しなければならなくなる。あらゆるケースで動くようにしようとして、抽象化に冗長なチェックや条件を付け足すことになる。やがて、なぜそのチェックを入れたのか理由を忘れてしまっても、呼び出し元を壊したくないから「念のため」と残しておく。その結果、コードベースに複雑さを加えるだけのデッドコードが残る。すべては繰り返しを避けようとしたがために起きることだ。

よく言われるのは、繰り返せば同じバグを何カ所も直さなければならないという話だが、これはバグがすべてのコピーに存在するという前提に立っている。実際には、それぞれのコピーは異なる進化を遂げているかもしれず、バグはそのうちの一つにしか存在しないこともある。

共通の抽象化を作ると、そこにバグがあればすべての呼び出し元が壊れ、複数の機能が同時に故障する。重複したコードであれば、バグは特定のユースケースだけに隔離される。

あとで片付ける

共有された抽象化で何も壊していないことを確認するのは、単一のコピーをチェックするよりもはるかに難しい。もちろん、コピーがたくさんあれば、すべてを修正し忘れるリスクもある。

これをうまく機能させる鍵は、あとで片付けることだ。それはコードをコミットする前でも、コードレビューの最中でもよい。

この段階で、コピーしたコードを見直し、そのままにしておくのが妥当か、あるいは適切な抽象化が見えてきたかを判断する。私は、問題をよりよく理解できてからリファクタリングするようにしているが、それより早くはやらない。

拙い抽象化を元に戻すコツは、コードをインライン化して使われていた場所に戻すことだ。しばらくはコードベースで再び「繰り返し」が発生することになるが、それで構わない。新たに得た情報に基づいて問題を考え直せば、たいていは問題によりフィットする、より良い抽象化が見つかる。

抽象化が間違っているとき、前へ進む最速の方法は後戻りすることだ

—Sandi Metz、The Wrong Abstraction

tl;dr

適切な抽象化を探すのはよいことだが、とらわれすぎないことだ。勢いを保ち、適切な抽象化を見つける助けになるのであれば、コードをコピーすることを恐れないでほしい。

繰り返す価値があるので、もう一度言おう。「繰り返せ。」

  1. 例としては、Ferris working on Rustendo64tokiospliff working on a C++ game engineを参照。

  2. これは私が文章を書くときのやり方でもある。まず下書きを書き、内なる批評家をブロックする。それから編集者/批評家の役割を演じて文章を「リファクタリング」するのだ。こうすることで、創造性を妨げない素早いフィードバックループと、より磨かれて構造化された最終成果物という、両方のいいとこ取りができる。もちろん、この手法を発明したのは私ではない。このテクニックについてもっと知りたいなら、アン・ラモット(Anne Lamott)の著書Bird by Bird: Instructions on Writing and Lifeの「Shitty first drafts(クソみたいな初稿)」を読むことをおすすめする。

  3. これはOODAループの考え方に似ている。OODAは「観察(Observe)、情勢判断(Orient)、意思決定(Decide)、行動(Act)」の略だ。軍事戦略家のジョン・ボイドによって考案された。戦闘機のパイロットはこれを用いて、行動方針を決定するのを最後の責任ある瞬間まで待つ。そうすることで、現在の状況と利用可能な情報に基づいて最善の決定を下せるのだ。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント