繰り返そう
ソフトウェア業界で働いてきた中で、何度も耳にした助言の一つが「同じことを繰り返すな」、すなわちDRY原則です。長い間、私はその言葉を額面どおりに受け取り、妥当性を疑ったことはありませんでした。
ところが、実際のエキスパートがコードを書く様子を見て、考えが変わりました。彼らはいつもコードをコピーしています1。同じことを繰り返すことには、優れた利点がいくつもあると気づいたのです。
なぜ人はDRYを好むのか
一般には、同じことを繰り返すと、同じバグを複数箇所で修正しなければならない一方、共通の抽象化があれば修正は一度で済む、と考えられています。
繰り返しを避けるもう一つの理由は、自分が賢くなったように感じられることです。「見てください、繰り返しを避ける賢い方法をこんなに知っています! インターフェース、ジェネリクス、高階関数、継承の使い方を知っています!」
どちらの理由も的を外しています。長い目で見れば目標に近づける、繰り返しの利点は数多くあります。
勢いを保つ
コードを書くときは、フロー状態に入るために勢いを保ちたいものです。完璧な抽象化を設計しようとして何度も立ち止まると、勢いを失いやすくなります。
代わりに、コードのコピー&ペーストを自分に許せば、思考の流れを維持したまま、目の前の問題に取り組めます。適切な抽象化を見つけるという別の問題を、同時に持ち込まずに済みます。
既存のコードをコピーして、負担が大きくなりすぎるまで修正していくほうが、たいていは簡単です。その時点でリファクタリングすればよいのです。
「書くモード」と「リファクタリングするモード」は、プログラミングにおける二つの異なるモードだと思います。書くモードでは、アイデアを書き出すことに集中し、「このコードはひどい」と言い続ける内なる批評家を黙らせたいものです。リファクタリングするモードでは、逆に批評家の役を担います。適切な抽象化を見つけ、重複をなくし、可読性を上げることで、コードを改善する方法を探します。
この二つのモードは分けてください。同時に両方をやろうとしてはいけません。2
適切な抽象化を見つけるのは難しい
コードを書き始めた段階では、まだ適切な抽象化はわかりません。しかしコードをコピーしていけば、適切な抽象化が見えてきます。同じコードを何度もコピーするのは面倒すぎるため、その時点で抽象化する方法を探し始めるのです。私の場合はたいてい同じコードを最初にコピーした後でそうなりますが、2回目か3回目のコピーまでは衝動を抑えるようにしています。
早すぎる段階で始めると、問題に合わない悪い抽象化に行き着くかもしれません。それが誤りだとわかるのは、使いにくく感じるからです。典型的な症状には、次のようなものがあります。
- 意図を伝えない汎用的な名前。たとえば、
generate_invoiceではなくrender_pdf_file - 追加の文脈がなければ理解しにくい
- その抽象化が使われる場所が1か所か2か所しかない
- 実装の詳細と密結合している
誤った抽象化を取り除くのは難しい
最初に思いついた抽象化で簡単に手を打ってしまいますが、たいていはそれが正しいものではありません。そして誤った抽象化を取り除くのは大変な作業です。データフローがその抽象化に依存するようになっているからです。
また、自分で時間と労力をかけて作った抽象化には愛着を持ちがちです。そのため、もはや問題に合わなくなっても捨てるのをためらいます。これはサンクコストの誤謬です。
ほかのプログラマーまでそれに依存し始めると、さらに悪化します。変更するとコードベースのほかの部分を壊すかもしれないため、慎重にならなければなりません。いったん抽象化を導入したら、長い間、ときには永久に付き合うことになります。
代わりにコードのコピーがあれば、ほかを壊す心配をせず、その場所だけを変更できます。
重複は、誤った抽象化よりはるかに安い。
—Sandi Metz、The Wrong Abstraction
問題領域をしっかり理解できる、抽象化を決める最後の瞬間まで待つほうがよいのです。3
抽象化がもたらす認知的な負荷
抽象化はコードの重複を減らしますが、代償も伴います。
抽象化があると、コードの動作を理解するには複数の間接参照の層を行き来する必要があり、コードの読み取り、理解、保守が難しくなることがあります。抽象化は別のファイル、モジュール、ライブラリに存在するかもしれません。
こうした層をたどるコストは高いものです。熟練したプログラマーなら数段階の抽象化を頭の中で保持できるかもしれませんが、私たちのコンテキストウィンドウには限りがあります(その大きさはコードベースへの慣れに左右されます)。
コードをコピーすれば、すべてのロジックを一か所に置けます。全体を読めば、何をしているか理解できます。
早すぎる抽象化への衝動を抑える
コードは見た目には似ていても、異なる目的を果たしていることがあります。
たとえば、アイテムのコレクションを反復処理して合計を計算する、二つのコードを考えてみましょう。
total = 0
for item in shopping_cart:
total += item.price * item.quantityそしてコードの別の場所には、次のものがあります。
total = 0
for item in package_items:
total += item.weight * item.rateどちらもコレクションを反復処理し、合計を計算しています。ヘルパー関数を導入したくなるかもしれませんが、この二つの計算は大きく異なります。
何度か繰り返すうちに、この二つのコードは異なる方向へ発展していくかもしれません。
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
要約
適切な抽象化を探すのは構いませんが、それにこだわりすぎないでください。勢いを保ち、適切な抽象化を見つける助けになるなら、コードをコピーすることを恐れないでください。
繰り返しますが、「繰り返そう」。
例としては、Rustendo64に取り組むFerrisや、C++ゲームエンジンに取り組むtokiospliffをご覧ください。 ↩
私は文章もこのように書いています。まず下書きを書き、内なる批評家を遮断します。その後、編集者兼批評家の役を担い、文章を「リファクタリング」します。こうすると、創造性を妨げない素早いフィードバックループと、より洗練され、構成の整った最終成果物という、両方の利点を得られます。もちろん、この方法を考案したのは私ではありません。この手法についてさらに知りたい方には、Anne Lamottの著書Bird by Bird: Instructions on Writing and Lifeに収録されている「Shitty first drafts」を読むことをおすすめします。 ↩
これは「Observe, Orient, Decide, Act(観察、状況判断、意思決定、行動)」を表すOODAループの概念にも似ています。軍事戦略家のJohn Boydが考案したものです。戦闘機のパイロットは、行動方針を決める責任を持てる最後の瞬間まで待つために使います。そうすることで、現在の状況と得られる情報にもとづき、最善の決定を下せます。 ↩
記事をランダムに読む