7 ポイント 投稿者 GN⁺ 5 시간 전 | 5件のコメント | WhatsAppで共有
  • 間違った抽象化よりもコードの重複のほうがはるかに安く、性急な共通化は長期的な保守コストを増やすという考え
  • 最初は妥当に見えた抽出でも、要件が少しずつ変わるとパラメータや条件分岐が追加され、元の意図がぼやけていく
  • 共通の抽象化が複数のアイデアを抱え込み始めると、コードは条件中心の手続きへと変わり、新機能を入れるほど壊れやすくなる
  • 既存コードに注いだ努力を守ろうとするサンクコストの誤謬に注意し、必要であれば抽象化を呼び出し側へ再びインライン化して、本当に必要なコードだけを残すべき
  • 間違った抽象化が明らかになったなら、重複を再導入して現在の要件の共通点をあらためて観察し、その後でもう一度抽出するほうが速い

間違った抽象化が生まれる流れ

  • duplication is far cheaper than the wrong abstraction」という文は RailsConf 2014 の発表の一部だったが、その後も繰り返し引用されている
  • よくある失敗の経路は次のとおり
    • 開発者 A が重複を見つける
    • 重複をメソッドやクラスとして抽出し、名前を付けて新しい抽象化を作る
    • 呼び出し側の反復コードを新しい抽象化の呼び出しに置き換える
    • 時間が経ち、ほぼ合っているが完全には同じではない新しい要件が現れる
    • 開発者 B が既存の抽象化を維持しようとしてパラメータを追加し、値に応じて別の経路を通る条件分岐を入れる
    • その後、新しい要件が出るたびにパラメータと条件分岐が増え、コードの理解はますます難しくなる
  • いったん作られたコードは、守るべき投資のように見えやすい
    • すでに費やした努力を惜しむ心理が働く
    • コードが複雑で理解しにくいほど、それだけ重要で時間もかかったはずだと感じられ、捨てにくくなる
    • これはサンクコストの誤謬につながる

重複に戻って再び抽出する

  • 間違った抽象化の上で新しい要件を実装し続けると、共有コードは条件分岐中心に変わり、機能を追加するほど不安定になる
  • このときの近道は、さらに押し進めることではなく一度戻ることである
    • 抽象化されたコードを各呼び出し側に再びインライン化して、重複を再導入する
    • 各呼び出し側で渡していたパラメータを基準に、実際に実行されるコードだけを確認する
    • その呼び出し側に不要なコードを削除する
  • インライン化の過程では、抽象化と条件分岐を同時に取り除き、各呼び出し側を自分に必要なコードだけを持つ状態まで縮小する
  • 同じ抽象化を呼び出しているように見えたコードでも、実際には各呼び出し側がかなり固有のコード経路を実行していた可能性がある
  • 以前の抽象化を完全に取り除いてはじめて、重複を再び観察し、現在の要件に合った新しい抽象化を抽出できる
  • パラメータや条件経路が共有コードに追加され続けているなら、その抽象化はもはや適切ではない可能性が高い
    • 最初は適切な抽象化だったかもしれない
    • 要件が変わるにつれて、もはや同じ形のまま維持するのが難しくなったのかもしれない
  • 間違った抽象化においては、重複を再導入することは後退ではなく、より良い前進になる

5件のコメント

 
dieafterwork 1 시간 전

これが二項対立的な解釈を必要とするテーマなのかは、よく分かりません。

 
hanje3765 2 시간 전

ああ、すごく共感します。
整理されていないものは整理すればいいですが、
すでに整理されているものをひっくり返すほうが、より大きなコストがかかる気がします。

 
jimmy2056 3 시간 전

ponytail が上げていたんだけど、まさにこういう文章だね(笑)

 
shakespeares 4 시간 전

いつも対立していますね。

 
GN⁺ 5 시간 전
Hacker Newsのコメント
  • 単一の信頼できる情報源(single source of truth) の原則は、常に守るべきだと思う
    食い違うとバグになるような重複コードなら、リファクタリングすべきである。そうしないと、将来の開発者がバグが起きるまで気づきにくい 長距離結合 が生まれる
    ただし、その原則に反しないのであれば、抽象化は単なる便宜にすぎず、不便になり始めたなら役割を果たしていないので使う理由はない。関数がカスタム動作のために複数のフラグを必要とするなら、誤った抽象化か、単一責任の原則違反である可能性が高い
    本当に多くのカスタマイズが必要なら、引数として関数/ファンクタを受け取る方式がよいことが多い。たとえば solve(f:double -> double, max_iters = 99, x_abs_tol = 1e-15, x_rel_tol = 1e-15, ...) の代わりに solve(f:double -> double, stopping_criteria: StoppingCriteriaClass) のようにできる

    • 記事の要点は、まだ 信頼できる情報源 がいくつあるのか明確でない場合を扱っていることにある
      コードの2か所が同じアルゴリズムを使っているのか、それとも少し違う版なのか、さらに重要なのは同じ理由で変更されるのかが不明である
      タイトルの格言は、異なるものを無理やり同じにするほうが、同じものを重複させて後から違うものにするよりも苦しいと言っており、私もその通りだと思う。後者なら同じ変更を2回行うか、抽象化を導入するリファクタリングをすればよいが、前者は抽象化に継ぎ足し続けるか巻き戻さなければならない
      とりわけ 局所性(locality) を壊してしまうが、変更時に本当に重要なのはこれだけである。単にこの変更だけを行って、システムの無関係な部分に副作用が出ないか心配したくない
    • 極度のプレッシャーのせいでソフトウェアが 2つの信頼できる情報源 を持つ状況に追い込まれたなら、2つのソースが一致しない限り main にマージされない CI テストを追加するのはかなり有効である
      典型例として pyproject.toml / requirements.txt の同期が実際に最善な場合があり、もっと広く適用できそうだ。前提として、すでに単一の信頼できる情報源が不可能なほど事態がこじれているのであり、治療というより被害軽減に近い
    • 「食い違うとバグになる」という基準は、とてもよい経験則である
      ある時点で2つのコード片が似て見えたために過剰に抽象化し、後になって分岐していく事態を何度も経験した
    • 理論的には正しいが、現実にはどんな重複でも無条件に避けようとする人が多い
      特にジュニア開発者は、重複 をあらゆる悪の根源のように扱うことがある
  • ときどきこの問題について考える。最近、個人プロジェクトで RTS ユニット用の 2Dスプライト を扱っていて直面したのだが、ユニットスプライトはスプライトシート内で一貫した形で配置されていた。8方向に5つのスプライトがあり、そのうち3方向はミラーリングされ、順番は stand, move, attack, die だった
    そこで action + direction を受け取り、再生するスプライト配列を返すローダーを作った
    しかし、方向性のない爆発スプライト、4方向で2つだけミラーリングされる死体スプライト、さらに最初の4つを除けばオークと人間が大半を共有しているケースが出てきた
    これらすべての共通抽象化が何なのか少し考えたが、結局ローディングコードの一部だけを切り出し、UnitLoader、CorpseLoader、EffectLoader を作って先に進んだ。3つのローダーは少しずつ同じものを扱っているので、よりよい抽象化があるかもしれないが、それは後で見つければよい。今、複雑な EverythingLoader を作ってすべてのケースを処理しようとするより、後でその時点の重複を取り除くほうが簡単だ

    • 「物事は可能な限り単純であるべきだが、それ以上に単純であってはならない」という言葉が好きだ
      プログラミングでは一般化によってコードを単純化したいという本能があるが、現実は雑然としているので、しばしば単純化しすぎる。本文のように時間が経って新しい要件が出ると、早すぎる単純化 だったことが明らかになる
      「性急な抽象化は多くのまずさの元である」という格言になりそうだ
    • すでに共通抽象化は切り出されている可能性が高い。それは単一スプライトのピクセルを読み込んで表示するコードである
      その上の段階、つまりスプライトシート配置の解釈や再生モードの処理には複数のバリエーションがあり、すべてに当てはまる共通抽象化がないのかもしれない
      見えていない抽象化を無理やり作ったり、不完全な抽象化に合わせようとしたりするより、今のように進めるほうを好む。抽象化が完全に明確になり、必要性もはっきりするまで待つのはよいことである
      DRY の反対側の解毒剤として WET がある。あらゆるものを2回、3回書けという意味だ。さらに重要なのは、実際に立証されたユースケース、たいていはまず重複として現れたものに対してのみ抽象化すべきだということだ。まだ存在しない将来のユースケースのために書いたコードは、実際に持っているものを抽象化するうえでしばしば邪魔になり、そういうことが起こるたびに滑稽でもある
    • このやり方で正しい。ゲーム作りは本来楽しいものであるべきだ
      難しくて退屈な作業は、プロジェクトの最後の10%に到達したときにやればよい
      しかも、重複が生み出した「バグ」が、プレイヤーに好まれる面白い機能になることすらある
  • OOP を使っていた頃は抽象化に苦しめられたが、ほぼ純粋な関数型アプローチに移ってからはコードの重複がまれになった
    単に関数を作って2か所から呼び出せばよい。主な抽象化の問題はデータ構造だが、TypeScript のインターフェースは本質的に ダックタイピング なので、ここでもあまり問題にならない
    だから、抽象化の問題から生じるコード重複はまれである。開発者のサイロ化 によって生じるコード重複のほうがずっと一般的だ

    • 趣味で関数型言語を使っているが、覚えるべき核心はテクニックだと思う
      現代の言語の多くは関数型プログラミング理論の上に容易に乗せられるし、Haskell を必ず知っている必要はない。人によって頭の働き方は違うだろうが、小さく単純で、ときには柔軟な部品が全体を作るという考え方は自分にはよく合っている
      大きく複雑で、何でもこなす形態変換マシンとは正反対だ
    • コードの重複を経験するのに、開発者が必ずしもサイロ化している必要はない
      チーム規模がある程度を超えて、それぞれが他の人のしていることをすべて把握できなくなると、コード重複 はかなり避けがたくなる。全員が関数型スタイルで書いていても同じだ
      実際、先月会社でこういうことがあった。新しい純粋なヘルパー関数を書いてファイルの前半に置いたところ、1週間後に同僚から、実質的には同じ機能だがシグネチャの異なる似たヘルパー関数が同じファイルの末尾にすでにあると教えられた
    • 「関数を2か所で呼び出す」というのが、正確にはどういう意味なのか気になる
  • 本文と同じ文脈で、両方を経験した人なら同意するはず。設計不足のコードベースのほうが、過剰設計されたコードベースよりはるかに扱いやすい

  • 保守しなければならなかった最悪のコードは、DRYに従おうとしていたコードだった。ただし、その原則の本来の意図を理解しようとはしていなかった。
    その混沌から抜け出す唯一の方法は、広い範囲でのコード重複を再導入することだった

    • 大丈夫だから心配せず、新しいユースケースをサポートするために再利用関数へ曖昧なブール引数をいくつか追加してデプロイすればいい
    • 重要なのは「試みた」という点にある。しばらくそうしているうちに、抽象化が間違っていたため、もはや忠実に従えない地点に到達したのだ
  • ここでは2つの発表を思い出す: Mike ActonのData-Oriented Design and C++ [1] と Brian CantrillのThe Complexity of Simplicity [2]。
    Mikeの発表は、コード上の解決策は現実世界をモデル化する必要はなく、異なるデータは異なる問題を生み、したがって異なる解決策が必要だと述べている。発表の内容を十分うまく言い換えるのは難しいが、私には大きな影響を与えた。
    Brianの発表は、抽象化全般と、「正しい」抽象化を見つけることがいかに難しいかを扱っている。

    1. https://www.youtube.com/watch?v=rX0ItVEVjHc
    2. https://www.youtube.com/watch?v=Cum5uN2634o
    • かなり優秀なエンジニアでさえ、コードベースの実際の必要性より現実世界のメタファーを優先することがあり、いつも奇妙に感じていた。
      昔、学校を出て数年しか経っていなかったころ、Rustでコネクションプールを実装していたのだが、最も合理的な実装は、コネクションオブジェクトがプールへの弱参照を持ち、dropされたときに自動的に返却されるようにするものだった。
      非常に経験豊富な管理職だった私のマネージャーは、「図書館が本を持つのであって、本が図書館を持つわけではない」という理由でこのアイデアを嫌っていた。設計を変えるほど説得力のある理由には感じなかったが、彼はそのメタファーのレンズを通さずにはこの問題を扱おうとしなかった。
      結局、別のマネージャーが「図書館の本が図書館そのものを含んでいるわけではないが、返却先を示す図書館名のスタンプは裏に押されている」と提案したことで行き詰まりは解消した。そのマネージャーは、この比喩の拡張を合理的だと見なしたようだった。
      もっと経験があれば、論点を譲らずにその比喩の中で会話する方法を見つけられたのかもしれないが、今でも、コードの抽象化やライブラリ利用体験の結果を検討する代わりに、そのメタファーを標準フレームとして押し通したのは完全に奇妙だったと感じる
  • 誰も聞こうとしない。本当に誰も聞かない。会社の90%には、新しい抽象化を作ることに恍惚とする、いわゆるシニア開発者がいる。
    過剰設計、抽象化、早すぎる最適化は、エンジニアリングにおける三大災厄だ。
    同時に、それらがあるからこそ常に仕事があるのだと思うと嬉しくもある

    • Kubernetes、エンジニアの人数より多いマイクロサービス、数バイトのオーバーヘッドを減らすための複雑なプロトコル、何でもかんでもクラウド、そして単純な関数で済んだはずの無数のクラスが、その典型例だ
  • 同様に、インラインの文字列や数値定数はすべて悪だと考えている開発者もいるようだ。あるPRでこんなものを見た。
    HTTPS_SCHEME = 'https'
    DOMAIN = 'www.example.com'
    url = HTTPS_SCHEME + '://' + DOMAIN
    「定数をベタ書きするな」という言葉をカーゴカルトのように守っている以外に、これで何が得られるのかわからない。しかも定数定義はファイルの一番上にあり、URLを作るコードは数百行も離れていた

    • コードでは近接性をとても重視している。使う場所のできるだけ近くに定義するのを好む。これは本当に気に障る習慣だ。
      正規表現もファイルの先頭に置くのではなく、使う場所に置けばいい。言語は賢いので、おそらくそれが定数だと判断してくれる。
      とても小さな関数なら、ただラムダを使えばいい。1回か2回しか使わない1行関数を、ずっと離れた場所に作らないでほしい
    • 定数を先頭に置けば、より簡単にカスタマイズできる。特にこのファイルが複製される場合はなおさらだ。
      テストやステージングでhttpsの代わりにhttpへ変えなければならないなら、スキームとドメインを分けて、定数を上部や別ファイルに置くのは理にかなっている。urlが複数箇所で組み立てられるのか、1箇所だけなのかも重要だ。
      ファイルの先頭に名前付き定数を置くのは非常に一般的なスタイルで、ときにはチームのコーディング標準の一部でもある。
      他の理由があるかもしれないので、Chesterton’s Fenceを思い出すとよい。いずれにせよ、カーゴカルトだと断定するのは良い考えではない。インラインリテラルを使うことのほうこそ同じようにカーゴカルトだと言う人もいるだろう。変に見えるなら聞いてみればよく、ちゃんとした理由があるかもしれないし、誰も気にしていなかったのでリファクタリングして定数をインライン化しても喜ばれるかもしれない
    • 私もこういうことを経験した。Eventに名前があれば、巨大なモノリスやマイクロサービスのリポジトリ群全体でそのままgrepして、そのイベントに関連するすべてのファイルを見つけられる。
      それを定数に切り出すと、またプロジェクトを1つずつ開いて参照を検索しなければならない
  • マイクロサービスを使えば、両方できる

    • 冗談なのはわかっているが、理想的な世界のマイクロサービスには、サービス間のコード重複という概念はない。
      あるサービスの保守担当者なら、他のサービスにあるコードを気にする理由はない。別チームのコードなのだから、なぜ気にする必要があるのか。そのチームが存在することさえ知る必要はない。大規模なシステムでは、すべてのアプリケーションの存在を現実的に把握できないことすらある
    • ちょっと待ってください! まだあります!
      たった$19.95で、単一障害点1つを複数の単一障害点に変えて差し上げます!
    • 10回中9回は、マイクロサービス同士がひどく依存し合って分散モノリスになる。
      サービス指向アーキテクチャを使うにしても、ただモノリスをデプロイするほうがましだ。テストがしやすく、シリアライズ/デシリアライズという余計な層も避けられる
  • 多くのシニアは、DRYを盲目的に守るべきではないと分かっているはずだと思う。それでも私たちの多くは、重複したコードソースを複数維持しなければならないという考えに居心地の悪さを覚える
    これに対処するには、2つの呼び出し元が共通コードに依存するという単純なモデルを注意深く見る必要がある。共通コードが一方の呼び出し元だけの都合で変更されなければならないなら、そのコードは共通に属していない
    DRYの誤った目標は、カプセル化で解決しようとすることだ。カプセル化は、リファクタリングの作業を呼び出し元から共通コードへ移す。しかし共通コードを更新することの影響は呼び出し元よりはるかに大きいため、望ましい方向ではない
    カプセル化を避けつつDRYを守ることもできる。呼び出し元が認識すべき複数の薄い抽象化を置くほうがよい。OOPではそのためにSRPIoCを学び、手続き型プログラミングでは一連のヘルパー関数を呼び出す形で自然に現れる