4 ポイント 投稿者 GN⁺ 2024-10-30 | 1件のコメント | WhatsAppで共有
  • コードは書かれた瞬間に保守コストを生むため、再利用性よりも後で削除・置き換えしやすい構造のほうが重要になることが多い
  • APIの利用者が増えるほど変更コストは大きくなり、サードパーティAPIへの依存が深いほど外部の変化でコードベースが大きく揺さぶられる
  • 重複、ボイラープレート、レイヤリング、大きなコードの塊、モジュール分割、機能フラグは、いずれも状況に応じて依存関係管理の道具になる
  • 良い分離とは共通機能をまとめることではなく、変えにくい、あるいは変わる可能性の高い設計上の決定を互いに隠すことに近い
  • 良いコードとは最初から完璧なコードではなく、時間が経っても邪魔になりにくいレガシーコードであり、結局は削除しやすいコードである

コードはコストであり、削除はコスト削減である

  • すべてのコードは書かれた瞬間に保守コストを生み、再利用はコード量を減らせる一方で、後から考えを変えにくくすることがある
  • APIを使うコードが多いほど、API変更にはより多くの書き換えコストが伴う
  • サードパーティAPIに大きく依存するほど、そのAPIが変わったときの影響も大きくなる
  • 大規模システムでは、コード同士がどう噛み合っているか、どの部分がどの部分に依存しているかが、時間が経つほど難しい問題になる
  • コード行数を「生産した行」ではなく「支払ったコスト」と見なすなら、コードを削除することは保守コストを下げることになる
  • 目標は再利用可能なソフトウェアだけを作ることではなく、捨てられるソフトウェアを作ることである

0段階: コードを書かない

  • コード行数そのものがすべてを物語るわけではないが、50行・500行・5,000行・10,000行・25,000行といった規模は重要である
  • 100万行のモノリスは、1万行のモノリスより置き換えに多くの時間、費用、労力を必要とする
  • コードが多いほどなくしにくいが、1行減らしただけではほとんど何も節約できない
  • 最も削除しやすいコードは、そもそも書かなかったコードである

1段階: コピー&ペーストする

  • 再利用可能なコードは、将来の利用先を予測して先回りで作るより、複数のユースケースが実際に生まれてから作るほうが容易である
  • コードベース内で何度かコピー&ペーストしてみると、実際の使われ方を把握しやすい
  • あるコードを共有APIにした瞬間、そのコードは変更しにくくなる
  • 関数を呼び出すコードは、文書化された振る舞いだけでなく、実装から観察される意図的・非意図的な振る舞いにも依存するようになる
  • 関数そのものを削除するより、関数内部のコードを削除するほうが単純である

2段階: コピー&ペーストをやめる

  • 十分に繰り返されたコードは、関数へ引き上げるべき時点に来ている
  • 設定ファイルを開いてハッシュテーブルを返すコードや、ディレクトリ削除コードのような、標準ライブラリの上にある頻出のユーティリティ的コードがこれに当たる
  • utilは単一ファイルよりディレクトリにし、異なるユーティリティは別々のファイルに置くほうがよい
    • 単一のutilファイルは成長し続け、大きくなりすぎた後では分割しにくくなる
  • アプリケーションやプロジェクトへの特化度が低いコードほど再利用しやすく、変更や削除の可能性も低い
    • ロギング、サードパーティAPI、ファイルハンドル、プロセスのようなライブラリコードがこれに当たる
    • リスト、ハッシュテーブル、コレクションは、単純なインターフェースだけでなく、時間とともに役割の範囲が広がりにくい性質ゆえに削除されにくい
  • 要点は、削除しにくい部分を削除しやすい部分からできるだけ遠ざけることである

3段階: もっとボイラープレートを書く

  • ライブラリを作ればコピー&ペーストは避けられるが、実際にはライブラリを使うために多くのボイラープレートを書くことになる
  • ボイラープレートは、毎回少しずつ異なる場所を変えるという点で、コピー&ペーストに似ている
  • こうした重複は、依存関係を減らし柔軟性を得る代わりに、冗長さを受け入れるやり方である
  • ボイラープレートを必要とするライブラリは、ネットワークプロトコル、ワイヤフォーマット、パースツールのように、ポリシーとプロトコルを混ぜにくい場合が多い
    • プロトコルはプログラムができることを扱う
    • ポリシーはプログラムがすべきことを扱う
  • こうしたコードは、他のコンピュータと通信したり別のファイルを扱ったりするための要件であることが多く、削除しにくい
  • ビジネスロジックをこうしたコードに撒き散らさないことが重要である
  • 行数が増えても、その行を削除しやすい部分に使うほうがよい

4段階: ボイラープレートを書かない

  • ボイラープレートが多くなりすぎたら、柔軟なライブラリを包み込む、ポリシー・ワークフロー・状態に意見を持つライブラリを作るべき時点である
  • 使いやすいAPIを作ることは、ボイラープレートをライブラリ化することに近い
  • Python HTTPクライアントrequestsは、より冗長なurllib3の上に単純なインターフェースを提供する例である
    • requestsはHTTP利用の一般的なワークフローを扱い、実用的な細部を隠す
    • urllib3はパイプライニング、接続管理などを提供し、利用者に詳細を隠さない
  • あるライブラリを別のライブラリで包むことは、詳細を隠すだけでなく、関心の分離でもある
  • utilディレクトリにはビジネスロジックを入れず、実装が単純なライブラリの上に使いやすいライブラリを積み重ねるほうがよい
  • サードパーティライブラリも包んだほうがよい場合がある
    • プロジェクト全体が特定の選択に固定されないよう、自分たちのコードに合ったライブラリを作れる
  • 使いやすいAPIと拡張しやすいAPIは、しばしば衝突する
  • レイヤリングは、後で削除するコードを書くことというより、削除しにくいコードでビジネスロジックを汚染せずに使いやすくすることに近い

5段階: 大きなコードの塊を書く

  • コピー&ペースト、リファクタリング、レイヤリング、合成をしても、結局コードは何かの仕事をしなければならないため、ときには残りをつなぎ止める大きなコードの塊が必要になる
  • ビジネスロジックは、無限にあるエッジケースと素早いハックによって特徴づけられることがある
  • ゲームコードや創業者コードも、かなりの時間を節約するために近道を選ぶ同種のコードと見なせる
  • 互いに絡み合った小さなミス18個をなくすより、大きなミス1つを削除するほうが簡単なこともある
  • 多くのプログラミングは探索的なので、最初から当てにいくより、何度か間違えて反復するほうが速いことがある
  • 最初のゲームを作るときにエンジンから作るべきではなく、アプリケーションを書く前にWebフレームワークから作るべきでもない
  • Monorepoも似たようなトレードオフである
    • コードをどう分割すべきかを前もって知るのは難しく、強く結合した20個より大きな1つの失敗のほうがデプロイしやすい
  • すぐ捨てられる、削除される、あるいは容易に置き換えられるコードだと分かっていれば、より多くの近道を選べる
  • 目標は同じ泥の塊を10回繰り返して失敗を完成させることではなく、毎回新しい失敗をし、新しいリスクを引き受けながら反復で積み上げることである
  • プロジェクトは結局失敗するかレガシーコードになり、成功より失敗のほうが頻繁に起こる
  • コードを断片ごとに削除するより、全体を削除するほうが簡単である

6段階: コードを断片に分ける

  • 大きな泥の塊は作るのが最も簡単だが、保守コストは最も高い
  • 単純に見える変更でも、コードベースのほぼすべての部分にその場しのぎの修正を加えさせることがある
  • 全体としては削除しやすかったコードが、断片ごとでは削除しにくくなる
  • モジュールは共通機能ではなく、他と共有しないものと、隠すべき設計上の決定を基準に分けるほうがよい
  • D. Parnasの基準のように、難しい、あるいは変わる可能性の高い設計上の決定を列挙し、各モジュールがそうした決定を他のモジュールから隠すように設計できる
  • モジュールは再利用のためではなく、変更可能性のために作るものである
  • 単一責任の原則は「各モジュールは1つの難しい問題だけを扱うべきだ」と見なせるが、より重要なのは「各難題は1つのモジュールだけで扱われるべきだ」という点である
  • あるモジュールが2つの仕事をしているなら、片方を変えるためにもう片方も変えなければならないことが多いからである
  • 単純なインターフェースを持つひどいコンポーネント1つのほうが、綿密な調整を要するコンポーネント2つより簡単なこともある

疎結合と共通インターフェース

  • 他の部分を書き直さずに一部を削除できるシステムは、通常疎結合と呼ばれる
  • 疎結合とは、考えを変えたときに多すぎるコードを変更しなくて済む状態に近い
  • 変数を一度ハードコードすることや、変数の代わりにコマンドラインフラグを使うことさえ、場合によっては疎結合になりうる
  • Microsoft Windowsは外部APIと内部APIを分けることでこの目的を達成している
    • 外部APIはデスクトッププログラムのライフサイクルに結び付いている
    • 内部APIは基盤カーネルに結び付いている
    • APIを隠せば、多くのソフトウェアを壊さずに柔軟性を確保できる
  • HTTPも疎結合の例を示している
    • HTTPサーバーの前にキャッシュを置ける
    • 画像をCDNへ移し、リンクだけ変更してもブラウザは壊れない
    • HTTPエラーコードは共通の問題に固有コードを与え、クライアントが多くのエラーを代わりに処理できるようにする
  • 障害処理のやり方は、コードを小さな断片に分ける際に一緒に考えるべきである

障害処理と結合度

  • Erlang/OTPは監督ツリーで障害を扱う、比較的ユニークな方法を使う
  • Erlangシステムの各プロセスは、おおむねsupervisorが起動して監視する
    • プロセスに問題が起きると終了する
    • プロセスが終了するとsupervisorが再起動する
    • supervisorに障害が起きるとbootstrap processが再起動する
  • 要点は、エラーを処理するよりも素早く失敗して再起動したほうが速い、という考え方である
  • 一時的な障害は、再起動で抑え込める場合がある
  • エラー処理と回復はコードベースの外側のレイヤーで行うほうがよく、これはend-to-end principleとして知られている
  • 接続の途中よりも両端で失敗を扱うほうが簡単であり、内部で処理しても結局は最上位の検査が必要である
  • エラー処理は、システムを強く結び付けてしまう多くの方法の1つである

IMAP、ファイルシステム、SQL、ミドルウェア

  • IMAPはほとんどすべての操作が固有のオプションと処理を持つ例外的な形で、エラー処理が苦痛である
  • IMAPでは、エラーが別の操作結果の途中に現れることがある
  • UUIDの代わりに各メッセージを識別する固有トークンを作り、このトークンも操作結果の途中で変わることがある
  • 多くのIMAP操作はアトミックではない
  • メールをあるフォルダから別のフォルダへ安全に移動する方法が登場するまで、25年以上かかった
  • 特殊なUTF-7エンコーディングと独自のbase64エンコーディングもある
  • ファイルシステムとデータベースは、リモートストレージのより良い比較対象になる
    • ファイルシステムは固定された操作集合と複数のオブジェクトを持つ
    • SQLはファイルシステムより広いインターフェースに見えるが、集合に対する複数の操作と複数行というパターンに従う
  • データベースは常に相互置換できるわけではないが、自作のクエリ言語よりSQLで動くものを見つけるほうが容易である
  • TwitterのFinagleは、サービス向けの共通APIを使うことで、タイムアウト処理、リトライ機構、認証チェックをクライアントコードとサーバーコードに簡単に追加できるようにしている
  • 疎結合の良い例は、しばしば均一なインターフェースの例でもある
  • 健全なコードベースは完全にモジュール化されている必要はないが、動く部分のあいだに十分な距離が必要である
  • 疎結合なコードは必ずしも削除しやすいわけではないが、置き換えや変更ははるかにしやすい

7段階: そのままコードを書き続ける

  • 古いコードを触らずに新しいコードを書けるなら、新しいアイデアを試すのははるかに容易になる
  • 要点はマイクロサービスかモノリスかではなく、何を作るべきかを学んでいる最中に、システムの上で1つか2つの実験を動かせることにある
  • 機能フラグは、後から考えを変えられるようにする方法である
  • 機能フラグは機能実験だけでなく、ソフトウェアを再デプロイせずに変更を展開できるようにもする
  • Google Chromeは、定期リリースサイクルにおいて長寿命の機能ブランチをマージするまでの時間が最も難しい部分だと分かった
  • 新しいコードを再コンパイルなしでオン・オフできれば、大きな変更を小さなマージに分割でき、既存コードへ影響を与えずに済む
  • 新機能が同じコードベースにより早く現れれば、長期的な機能開発が他の部分に及ぼす影響もより明確に見える
  • 機能フラグは単なるコマンドラインスイッチではなく、機能リリースをブランチのマージやコードのデプロイから切り離す方法である
  • 新しいソフトウェアの配布に数時間、数日、数週間かかることがあるなら、ランタイムで考えを変えられる能力はさらに重要になる

良いコードとは邪魔をしないレガシーコードである

  • 反復しているという事実そのものより重要なのは、フィードバックループを持つことである
  • モジュールを再利用のために作るのではなく、変更のためにコンポーネントを隔離することが核心である
  • 変化への対応には新機能開発だけでなく、古い機能の削除も含まれる
  • 拡張しやすいコードを書くことは、3か月後にも最初の選択が正しかったと願うことである
  • 削除可能なコードは、その逆の前提から出発する
  • レイヤリング、隔離、共通インターフェース、合成は、良いソフトウェアそのものを作る方法というより、時間とともに変わっていけるソフトウェアを作る方法である
  • すべてを捨てる必要はないが、一部は削除しなければならない
  • 良いコードは最初から正解だったコードではなく、邪魔をしないレガシーコードである
  • 良いコードとは削除しやすいコードである

1件のコメント

 
GN⁺ 2024-10-30
Hacker Newsの意見
  • 私の好きな言葉は、シンプルさは堅牢さだというもの
    Lehmanの継続的変化の法則と似ていて、システムの複雑度が低いほど変更しやすくなる、という意味
    将来に備えて拡張可能なコードを書くより、直感的なコードで将来に備えるほうがよいと思う
    例えば、本当に必要なときだけ抽象化し、単純な重複は許容し、最初はモノリスから始め、水平スケーリングより垂直スケーリングを先にする、といった具合
    複数の0→1システムを作ってきたが、共通する流れはすべてこちらだった
    https://en.m.wikipedia.org/wiki/Lehman%27s_laws_of_software_...

    • その通りだが、シンプルさは堅牢さの原則を適用するときは、内在的な複雑さも理解する必要がある
      境界ケースを扱わないからといってコードが堅牢になるわけではなく、どれほどより単純に見えたとしても同じこと
    • 私が従っているルールはこうだ:1回目はただ書き、2回目はコピーし、3回目でリファクタリングを検討する
    • 同意するが、simple is robustという表現が十分に直感的かは分からない
      「シンプルさ」とは何で、それがシステムにどう適用されるのかという議論が開かれてしまうし、Rich Hickeyが扱うほど複雑な問いでもある
      むしろ「愚直なものは堅牢だ」や「率直なものは堅牢だ」のほうが意図をよく表すかもしれない
    • 本当に強く同意する。ソフトウェアのあまりに多くのゴミは、想像上の問題を解決しようとして生まれる
      必要なことをするコードを書けばよい。仮想的な拡張問題を作らず、賢く見せようとして巧妙な抽象化を作らず、モノリスとして書いてVMに載せればすぐ運用できる
      問題が起きたらそのとき解決すればよく、できればキャッシュフローがプラスになった後がいい
      ユーザー0人の「犬向けAirBnb」スタートアップが、なぜC100Kを心配するのか? AWSがサーバーレスに金を払えと説得したのは、あなたの利益のためだったのか、それとも金を搾り取るためだったのか
    • ビジネスロジックの複雑さは、望んだからといって消えるものではない。膨大で互いに絡み合っているなら、コードもそうなる
  • 関連記事:
    Write code that is easy to delete, not easy to extend (2016) - https://news.ycombinator.com/item?id=24989351 - Nov 2020 (30 comments)
    Write code that is easy to delete, not easy to extend (2016) - https://news.ycombinator.com/item?id=23914486 - July 2020 (109 comments)
    Write code that is easy to delete, not easy to extend - https://news.ycombinator.com/item?id=18761739 - Dec 2018 (2 comments)
    Write code that is easy to delete, not easy to extend - https://news.ycombinator.com/item?id=11093733 - Feb 2016 (133 comments)

  • 若い頃にした失敗を短くまとめると、今では逆に削除のための設計を信じるようになった
    昔は、あらゆる状況を予測し、あらゆる要求を満たす見事な芸術作品を作れると思っていた。しかし、将来の要求をそこまでうまく予測できる人はいない
    いつか自分が作ったものは誰かにとって「あの間抜けなもの」になり、今の自分がどれほど誇りに思っていても、彼らが全部壊してしまうことは正当化され得る
    だから、取り除きやすくすることに力を注ぐほうがよい。そうすると結合度が下がることが多いが、重要なのは、すべてをメタ設定可能なフレームワークへ分離しようとする熱心な若手開発者の疎結合化とは違う、という点
    ときには理解しやすい強い結合のほうがよいこともある
    https://news.ycombinator.com/item?id=41219130

    • 取り除きやすく作ることはできるが、他の人たちが喜んで抽象化やロジックを作ってプロジェクトの中核に埋め込み、後には取り除けないレベルに固めてしまうこともある
      例えば、CommonExcelFileParser、CommonExcelFileParserUtilities、HasExcelParseStatus、ProductImportExcelParser、ProductImportExcelParserView、ProductImportExcelParserResultHandlerのようなものが生まれ、周辺コードの基盤になってしまう
      フロントエンドプロジェクトをReactやAngularで始めると、別のものへ移行することがシーシュポスの苦行になるのと似ている
      実際には、人々がプラットフォーム全体を作ってしまい、将来問題を起こす選択があったとしても、結合のせいで抽象化不足のコードベースよりリファクタリングがはるかに難しくなる
      人々はKISSやYAGNIを適用して削除しやすいコードを作るより、こういうことを好むように見えるので、そういうときどうすればいいのか分からない
    • それでも状況による。業務アプリケーションならその通りで、何度でもその通り
      ビジネス要件は変わり動くものなので、予測しようとせず、置き換えたり捨てたりしやすいものを書くべき
      フレームワークやライブラリは少し違う。世の中の変化に合わせる必要はあるが、ずっと穏やかなペースでよい
      最大の問題は、RailsやAsp.Netのようなフレームワークをすでに使っている業務アプリケーションで、開発者たちがさらに「フレームワーク」を作りたがるとき
    • 変わるものもあるし、ときには誤った抽象化を選んでしまう
      Linuxカーネルを使っているのでなければ、Linuxカーネルのように書くべきではない
  • この記事でテストと可観測性にまったく触れていないのは、かなり奇妙だ
    テストにも保守コストはかかるが、何かを削除したときに壊してしまうリスクを下げてくれる
    さらに、サービスを外部の呼び出し元に公開しているなら、一部の呼び出しを廃止予定としてマークし、後で削除できる堅牢な方法と、それがまだ呼ばれているか、誰が呼んでいるかを観測する方法の両方が必要になる
    最近、公開されている GraphQL リゾルバを初めて半自動で削除したが、特定のリゾルバがどれだけ頻繁に使われているかの指標がすでにあり、それをパースして削除できないリゾルバの一覧を得られた
    GraphQL にはすでに deprecated アノテーションがあるが、私たちのサービスはそのアノテーションを特別には処理していなかった
    そこで deprecated な関数が呼ばれたら表示する可観測性を追加し、本番環境で十分長く動かしたうえで、外部に公開されたコードを安全に削除できるようにした

    • 少し単純化して言えば、削除しやすいものを作れば、削除時に意図しないバグを作らずに済む
      過度に複雑にし始めると、すべてが互いにつながった混乱になり、開発者は変更がどんな影響を及ぼすのか分からなくなる
      もちろん、台無しにする方法はいくらでもある。愚かな「ベストプラクティス」の原則に従うこともできるし、誰がどのサービスを消費しているのか分からない形で「マイクロサービス」をやることもできる。だが、それでは削除しやすく作ったことにはならない
      外部からの利用は良い例だ。サービス廃止について利用者に合理的な警告を出すのは妥当だが、望むときに実際に止められないなら、簡単に削除できるよう設計されたシステムではない
      その方式が正しいなら、そうしてもよい。ただし、テストと観測によって壊れるかどうかを教えてくれると期待するのは、うまく機能しない可能性が高い
      テスト自体に反対しているわけではないが、長く複雑な連鎖の中で何かを壊したかを教えてくれる安全装置として、非常に優れているとは言いにくい。実際に守ってくれるテスト範囲を整えるのも非常に難しいからだ
    • コードの行数が多ければ、それに比例してテストの行数もある程度増えると予想できる
      コードの一部を削除すれば、テストも一部削除できる
      記事のようにコードだけを論じていて、テストへの関連する影響は暗黙に含まれていると見なせる
      記事がテストに言及していないからといって、テストを書くなという意味だと仮定することはできない
    • テストは良いものだが、プログラミングはテストを書くことだけで終わるわけではない。すべての記事でテストに言及する必要はない
  • この部分を見ると、タイトルが常に正しいわけではないと感じる。削除しやすいコードは、たいてい拡張もしやすいコードであることが多い
    レイヤー化され、モジュール式で、インターフェイスや他の型契約のような抽象化によって、異なる部品が互いに隔離されているからだ

  • 計算物理学の学生たちには、最高の計算とは、そもそもやらなくて済む計算だと言ってきた

  • 個人的にはコードをビジネスロジックと実際の実装の二つに分けている
    ビジネスロジックは本質的に重複しうるが、技術的な詳細があまりにも多く重複してはいけない
    実際の実装は、ビジネスロジックを直接含まず、アプリケーションから独立して保てているなら、いくら汚くても構わない
    そうしておけば、何かがぐちゃぐちゃでうまく動いていないと分かったとき、実装から実際の仕様を逆算して無理に直す代わりに、実装全体を消してしまう選択肢が生まれる

  • 最初の段落の「コード再利用の問題は、後で考えを変える妨げになることだ」というのは明らかな誤りだ
    一般論としては間違っている。考えが変わったときにコードが10か所にコピー&ペーストされていたら、10か所を直さなければならない
    逆に関数の中にあれば、一度だけ変えればよい。10個の呼び出しのうち1つは変わってはいけないと分かったとしても、そのときにコピー&ペーストするか、関数をより一般化すればよい
    道を渡るときに見ずに渡るようなもので、コピー&ペーストはほとんど常に悪い考えだ

    • 私の経験では、悪いコピー&ペーストのコードは、いら立つ午後半日分の技術的負債の返済と修正で済む
      だが悪い抽象化は、数か月分の技術的負債の返済につながる
      もちろん答えは「悪い抽象化を作るな」だが、チームと変化するプロダクト要件の中でそれがどうなるかは誰もが知っている
    • 再利用されたコードは複数の場所で正しいコードであることが多いので、変更するには速度を落として、それらの箇所を分離しなければならない
      共通 UI ウィジェットを含む git サブモジュールがあるが、今ではそのうちの1つを変更するのはほぼ不可能で、コンポーネントをプロジェクト内にコピーしてローカルで変更するほうが簡単だ
      これは問題だ。共有コードは可能な限り最小限であるべきで、共有そのものが変更を難しくする
    • 関数呼び出し10個のうち、3個はある方法で、5個は別の方法で変更する必要があり、残り2個はもはや同じ抽象化を使わないため完全に書き直さなければならないとしたらどうなるか
      すべてが1つの関数に入っていれば、ほとんどの開発者は10個すべてのケースを満たすようにその関数を変えようとするだろう。そもそも1つの関数であるべきではなかったのにだ
      一度結ばれてシステムの部品を縛り付けている間違って結ばれた結び目をほどくより、コピー&ペーストされた10か所を直すほうがはるかに簡単だ
    • 筆者ならおそらく、そのコードはモジュールや関数に移すべきだったと答えるだろう
      表面的にはこのテーマで自己矛盾しているように見えるが、ゆっくり読めば、コピー&ペーストを、どのコードが抽象化されるべきか、また何が本当に従うべきパターンなのかを知らせるシグナルとして使っている
  • ソフトウェアに関するあらゆる戒律、ほとんど宗教のような原則を繰り返し続けるのは奇妙だ
    紙の上ではどれも立派に見え、常識のように感じられるが、50年たってもソフトウェアは90%の場合ゴミだ
    それでもこうしたものを、天才的洞察や銀の弾丸のように何度も持ち出し続ける

    • そのゴミの90%は、こういう記事を読んだり書いたりしない人たちによって作られているからだと思う
  • ここには優れた系がある。悪いコードは取り除くのがずっと難しいため、長く残る