1 ポイント 投稿者 GN⁺ 2023-11-16 | 1件のコメント | WhatsAppで共有
  • 条件分岐は呼び出し側へ引き上げ、反復処理はバッチ演算側へ押し込むという、2つのコード構造化ルールが核心
  • if を上に持ち上げると前提条件を型や assert で強制しやすくなり、複雑な制御フローを一か所に集めて重複条件や死んだ分岐を見つけやすくなる
  • enum を作ったあとすぐに再び match する構造は、同じ条件を分岐、データ構造、再分岐として繰り返すため、foo(x)bar(y) の呼び出しへ単純化できる余地がある
  • for を下に押し下げると、個別オブジェクトの反復よりバッチ演算が基本単位となり、起動コストの償却、処理順序の変更、ベクトル化や struct-of-array 最適化が可能になる
  • 条件をループの外に出せばホットループ内の分岐を減らせ、TigerBeetle のように制御プレーンの意思決定コストをデータプレーンのバッチ処理へ償却する構造にもつながる

if を上に持ち上げる

  • 関数内の if 条件は、まず呼び出し側へ移せないか検討すべき対象
    • 例としては、Option<Walrus> を受け取って None なら return する関数より、Walrus を直接受け取る関数のほうが望ましい形とされる
    • 前提条件を関数内部で確認して「何もしない」より、呼び出し側が確認し、型や assert で成立を強制するほうが明確
  • 前提条件チェックを上へ持ち上げるリファクタリングは呼び出し経路に沿って伝播し、結果として全体のチェック回数を減らせることがある
  • 制御フローと if は複雑さを高め、バグの原因になりやすい
    • if を上に持ち上げると、複雑な分岐ロジックは1つの関数に集まり、実際の処理は直線的な下位ルーチンへ下りていく
    • 複雑な制御フローは、ファイル全体に散らばっている場合よりも、1画面に収まる1つの関数にあるときのほうが、重複や死んだ条件を発見しやすい
  • dissolving enum」リファクタリングは、分岐をデータ構造に変えたあと再び分岐するパターンをなくす方法
    • f() が条件に応じて E::Foo(x) または E::Bar(y) を作り、g(e) が再度 matchfoo(x) または bar(y) を呼び出すなら、同じ条件が複数の形で繰り返されている
    • 条件を main() へ引き上げると、if condition { foo(x) } else { bar(y) } の形に単純化できる

for を下に押し下げる

  • データ指向の観点では、プログラムやホットパスはたいてい多数のオブジェクトを扱うため、オブジェクトのバッチを導入し、バッチ演算を基本ケースにする
    • 個別オブジェクト向けのスカラ版は、バッチ演算の特殊ケースになる
    • for walrus in walruses { frobnicate(walrus) } より frobnicate_batch(walruses) のほうが望ましい形
  • 主な利点は性能
    • バッチ全体を一度に扱えば起動コストを償却できる
    • 処理順序を柔軟に変えられ、全エンティティのある1フィールドを先に処理するベクトル化や struct-of-array 方式も可能になる
    • 極端な例として Vectorized Interpreters Talk が挙げられている
  • 興味深い例として FFT ベースの多項式乗算 が登場する
    • 複数の点で多項式を同時に評価する方法は、個々の点での評価を何度も行うより高速になりうる
  • iffor のルールは一緒に適用できる
    • 良い形は、if condition の外側で分岐ごとに for walrus in walruses を実行する構造
    • 悪い形は、for ループ内で毎回 if condition を評価する構造
    • 条件の再評価を避け、ホットループから分岐を取り除き、ベクトル化を可能にできる
  • このパターンはミクロなレベルにもマクロなレベルにも適用できる
    • TigerBeetle のアーキテクチャは、データプレーンでオブジェクトのバッチを同時処理し、制御プレーンの意思決定コストを償却する構造
    • for に関する助言の主な動機は性能だが、jQuery のようにコレクション単位で動作する方式は表現力にも役立ちうる

1件のコメント

 
GN⁺ 2023-11-16
Hacker Newsのコメント
  • 最初はこの記事がここまで反発を受けているのが意外だったが、これがデータ指向設計についての助言だと思い出したら納得した
    このフォーラムの多くの人たちと同じく、自分も普段は業務用のWebアプリを作っていて、その文脈ではこの助言はたわごとのように見えるかもしれない
    日常業務で命令キャッシュを気にすることがないなら、この助言はだいたい無視してよい、というヒューリスティックは妥当に思える
    これがいつ重要になるのか感覚をつかみたければ、Mike Actonの「Typical C++ Bullshit」を見るとよく、この記事はその内容をよりわかりやすく整理したもののように感じる
    Casey Muratoriの問題意識にはかなり共感するが、たいていの業務用ソフトウェアは性能よりも変更容易性と正確性、つまり「時間をまたいだプログラミング」を最適化すべきだと思う

    • この助言はオブジェクト指向プログラミングにも依然としてかなり当てはまると思う
      開発者は複雑なビジネスロジックをクラス内の小さなprivateメソッドに分割してDRYにしようとするが、「ifを上に持ち上げろ」は分岐ロジックが複数のメソッドに散らばるのを防いでくれる
      「forを下に下げろ」も重要だ。多くの呼び出しフローは結局高コストなデータベースクエリに行き着くが、上位のループが下位で複数回のDB呼び出しを生むことはよくある
      そのループはSQLのwhere句やjoinで置き換えられることが多く、集計やフィルタリングも大量のオブジェクトを取り出して反復するよりDAOの近くへ押し込んだほうがDBの近くで最適化しやすい
      ただし、どんな設計原則でもそうだが、教義のように適用せず意識的に判断すべきだ
    • データ指向設計はいつも人を刺激するようだ。既存の支配的なオブジェクト指向アプローチの多くが間違っているという含意を持つからだと思う
      変更容易性と性能を互いに反対のものとして見るのは残念だ。実際に両者が衝突するという説得力のある証拠はまだ見たことがない
    • 変更容易性、正確性、性能は互いに排他的ではなく、むしろしばしば一緒に動く
      性能で最も重要な側面のひとつは小さくすることだ。小さなコード、小さなデータ構造、少ない実行命令数が鍵であり、「命令キャッシュを意識する」というのも本質的には小さなコードを書くことにほかならない
      コードが小さいほどバグが入り込む余地が減り、一度に理解しやすく、テストカバレッジも確保しやすいため、正確性に有利だ。変更容易性もコードが小さいほど変更が小さくなる
      もちろん、並列化、キャッシュ、低レベル最適化のようにコードを複雑にする最適化もあるが、それは性能最適化の一部にすぎず、本気で性能を気にするプログラマならプロファイリングや分析なしにそんなことはしない
      if/forfor/ifは機能的には大差なくても、一方がより速いことはありうるので、コストをかけずに正しく使えるなら、わざわざ遅いほうを選ぶ理由はない
    • John Ousterhoutの話を聞くと、実務において変更容易性・正確性は最初に思うほど性能と衝突しない
      単純なプログラムは呼び出しスタックが短く、複雑で高コストな抽象化を避けるため、メモリ使用量が少なく、より高速に動く傾向がある
      最高水準の性能を追求するとプログラムは複雑になりうるが、本当の単純さはたいてい妥当な性能をもたらす
      「forを下に下げる」は主にデータ指向の助言だが、「ifを上に持ち上げる」はプログラムをより単純にする方向に近い。より正確には、ソースコードの局所性を高めるという話であり、分岐ロジックを一か所に集めるのが核心だ: https://loup-vaillant.fr/articles/source-of-readability
    • 業務アプリにもこの2つの助言はかなり当てはまると思う
      特に「ループを下に下げろ」はCRUDアプリで強力だ。可能な限り作成と更新を一括処理すれば、CPUボトルネックよりはるかに大きな時間を節約できることが多い
      items.map(insertToDb/postToServer)insertToDb/postToServer(items)の差は、ほぼあらゆる場合で桁違いになる
      こうした最適化によって作業時間が秒・分単位からミリ秒単位にまで短縮されるのを見てきたし、APIもよりクリーンになり、ログも読みやすくなることが多かった
  • 経験を積むほど、あまりに多くのプログラマが小さな単位の「きれいなコード」には気を配る一方で、コードベース全体の設計には十分に注意を払っていないと感じる
    簡潔でよく磨かれた関数は良いが、この種の話題は PR や議論で生産性のない自転車置き場論争に発展しがち
    関数が少し雑かどうか、iffor をどこに置くか、mapfilter を使うかどうかは、関数名が適切で、インターフェースと型が表現力を持ち、目的が明確で、文書化されており、副作用を過度に使っていないなら、それほど気にしない

    • 「if を上に持っていけ」というのは自転車置き場論争ではなく、むしろ言われている通り、判断すべき アーキテクチャ上の選択に近い
      入力値があるときにどこで検証するかを考えると、このヒューリスティックに従えば入力を受け取る最上位で検証すべき
      これはコードの理解にも役立つし、証明の観点でも前提条件を上位へ伝播させる必要があるため、技術的にも必要
      最初の助言はたしかに些細な論争ではなく、二つ目はやや曖昧
    • セキュリティの仕事をしながら、FAANG 基準で L7〜L9 程度の技術リードやアーキテクトに多く接してきたが、「正しい設計」に最も執着する人たちは、たいていキャリア 5〜10 年目であることが多かった
      ジュニアが痛い目を見ないようにしようとはするが、複雑性のコストやコードが長期的にどう変化するかへの感覚は、まだ十分でない場合が多い
      逆に 20 年以上やっていると、たいていの技術的要素よりも シンプルさを高く評価するようになる
      「どれだけ早く分岐するか」「このコードが実際にやるべきことは何か」といった問いのほうが、長期的には最も価値のある答えを与える傾向がある
      抽象化やカプセル化に関する問いは、むしろ前述の種類の論争につながりやすく、「大きな絵」しか見ない人たちのコードでセキュリティ問題が最も多く出るのも、実際にはそのよく設計されたコードベースが何をしているのかを十分に理解していないから
    • こうした些細に見える原則があらかじめ決まっていれば、PR や議論で同じ種類の 消耗的な論争は減らせるかもしれない
      データ指向設計がうまく合う性能重視のソフトウェアでは、こうした小さな単位のことにもっと気を配る必要がある。コンパイラ最適化がそのように動くから
      そうなるとゲームのルールが変わる。文の意味が重要になり、自己説明的なコードがより意味を持ち、コードコメントは推論のための用途だけに残り、「ドキュメント」は仕様やユーザー向け説明書に近づく
    • 逆の例も多く見てきた。過剰設計された大聖堂を設計するのは好きなのに、アルゴリズムの低レベルな効率は考えない人たちも多い
    • 理由の一つは、プログラミング言語には関数よりはるかに大きいコンポーネントや、関数とは異なる形で結び付くコンポーネントを意味のある形で表現する手段が不足しているから
      せいぜい関数を束ねられる程度で、その先は各自で何とかするしかない
  • 「if を上に持っていく」ことには、関数定義の中で 事前条件と事後条件が直接見えず、各呼び出し箇所で確認しなければならないという欠点がある
    複数人が関わる大規模プロジェクトでは、そのような関数が意図した文脈の外で再利用され、バグにつながることがある
    契約フレームワークを使えば解決できるが、条件を契約とコードの両方に二重に書くことになり、依存型でも同様
    特定の文脈に属するコード領域にタグを付け、その文脈の中でのみ呼び出せる関数を定義する方式は興味深い
    Python なら @requires_context("VALIDATED_XY") のようなデコレータと validated_xy コンテキストマネージャで、検証済みの領域でのみ関数を呼べるようにできる
    ランタイムはその文脈の意味を知らないが、ツールとテストを整えれば、特定の条件が満たされたときにだけ望む文脈を確立するよう設計できる
    Haskell のような言語では恒等モナドのようなもので型レベルに強制できるし、型レベルの強制でなくても、「unsafe」なコード領域を保護する興味深い方法になりうる

    • 著者の議論の二つ目の部分を見落としている気がする。「事前条件のチェックを呼び出し側に押し上げ、型で強制できる」という話
      したがって事前条件は依然として関数定義に直接見えており、ただ if 文ではなく型シグネチャの一部として現れる
      これは記事で使われている Rust では一般的なパターンで、if チェックと違ってランタイムではなくコンパイル時に確認される厳格な事前条件なので、満たされなければプログラムはコンパイルされない
    • Python でも型でこれを表現できる
      たとえば do_something(position: ValidatedPosition) のように受け取り、通常の Position を検証したあと ValidatedPosition に変換して渡せばよい
      実際には ValidatedPosition のコンストラクタの中に検証をカプセル化するだろうが、要点は Position をそのまま渡そうとすると mypy が誤った型を渡したと知らせてくれる点
      Python の型検査は Rust ほど包括的ではないが、渡されるデータが適切に処理されたことを保証したいとき、ますます有用になっている
    • 「if を上に持っていけ」という助言は、事前条件のチェックではなく 正しいコードパスの選択に関するもの
      事前条件のある関数なら、当然ながら関数の冒頭でアサートしてよい。たとえば Java の型システムは null を許容するので、オブジェクトが必要な関数は null が来たら例外を投げるべき
      各呼び出し箇所は、事前条件が真のときだけ関数を呼ぶ責任を負う。これは事前条件の定義上当然
      事前条件を破って関数を呼ぶのは呼び出し側のバグ。関数内でそれを確認して未定義動作を防ぐコードが必要な場合はあるが、そのようなアサートとプログラムの実際の制御フローは区別すべきであり、記事は後者について述べている
    • この例の fn frobnicate(walrus: Walrus) では、所有している Walrus でないものを渡そうとするとプログラムはコンパイルされない
      ジェネリクスであっても、引数は トレイト境界を満たさなければならず、関数内で引数をどう使うかに応じて、コンパイラが関数定義に必要な境界を要求する
    • publicprivate の意図は、ある程度は特定の文脈に合わせてコードをタグ付けする役割を果たそうとしていたのではないかと思う
      あるいは publicprivate、.NET エコシステムの protected が扱う領域をまたぐ、より具体的なセマンティクスが必要なのかもしれない
  • 最初の例がよくないのは iffor のせいではなく、別の理由によるもの
    一般に何かの「コンテナ」があるなら、コンテナではなくその中のドメインレベルの「対象」に対して関数を書くほうがよい
    Clojure で agent を使うときも、agent 用の関数を書くのではなく、agent が保持できる対象用の関数を書く
    Elixir でも中核となるドメイン関数は PID ではなく内部のドメインデータ構造に対して動作し、必要なら GenServer 呼び出しがそこへ委譲される
    こうするとより柔軟になり、中核ドメインである「Walrus を frobnicate すること」と、アプリケーション上の関心事である「Walrus があるかもしれないしないかもしれず、あれば frobnicate すること」を、よりきれいに分けられる

    • この助言は検証ロジックを上に持ち上げすぎる可能性があるように見える
      早い段階で検証するのはよいが、関数が奇妙なエラーで突然落ちる代わりに、検証エラーを明確に返すことも重要だ
      Haskell は newtype でこの問題を解決しており、「適切な検証をすでに済ませたことを保証する透明なコンテナ」を提供する形になっている
      本当に強調したい助言は「悲しい if」を好め、ということだ。正常系を何重にもネストするより、誤った条件を上から一つずつ調べてすぐ修正するか中断するコードのほうが、ほとんど常に読みやすく保守しやすい
      人は期待したケースに集中したがるものだが、コードはその逆で例外的なケースにまず集中してほしい。すべての if は頭の中の負荷を生み、外部システムから情報を取ってきたりエラーで早期終了したりする必要があるなら、検出と処理が隣り合っているとその負荷をすぐ下ろせる
    • 最初の例が walrus.frobnicate() ならよりよい、という意味なら、それは著者が言いたい本質というより 文法の好み に近い問題ではないかと思う
  • これが悪い助言だとまでは言わないが、必ずしもよい助言でもない
    選ばれている言語が Rust だという点が、かなり多くを物語っている。強い型システムは、他の言語で必要になる多くの防御的プログラミングを不要にしてくれる
    C プログラマが関数に渡されたポインタの妥当性を確認せず NULL デリファレンス を起こすなら、そんな人とはチームを組みたくない
    だから少なくとも一部の if は間違いなく下側にあるべきで、エラーがうまく上へ伝播する形になっているのが望ましい
    for についてはそこまで強く感じないが、C では配列引数がポインタに decay するため、反復も下ではなく上にあるべきだと思う。配列を作った関数では長さを信頼できるが、引数として受け取った関数ではそうではない

    • C のインターフェースでは期待事項を慎重に文書化し、その分だけを検査すべきだ
      文書が強い型システムの代わりを務めるべきであり、ランタイム検査がその代わりを務めるべきではない
      NULL チェックやその他の防御措置だらけのコードは、はるかに読みにくい
      ライブラリ境界では、より防御的な検査が必要だと言えるし、この記事もまさにそうした検査を上へ押し上げろと言っている
      セキュリティ上重要なコードは別かもしれないが、ほとんどの場合、偶発的な NULL デリファレンスはテスト、サニタイザ、ファジングで捕まえられれば十分だ
    • C では NULL はしばしば有効だがデリファレンスできないポインタ値である
      無効なポインタ値を検査するには、あり得る膨大な誤った値全体を検査しなければならず、NULL だけを確認するなら実際には無効値を検査していることにはならない
      関数の前提条件が「引数 p は NULL ではない」であるなら検査してもよい。だが「p は有効なポインタでなければならない」が前提条件なら、適切なアサーション条件を見つけられることを祈るしかない
    • 私のヒューリスティックは、型システムが誤った値を防げないなら、ランタイムがそれを防ぐ責任を持つべきだ、というものだ
      最近は T-SQL を多く書いているが、引数や変数を NOT NULL として宣言できない
      だから合理的に可能な限り早い時点、たいていはストアドプロシージャの先頭で NULL を検査するのがよい
      そうしないと NULL が呼び出し階層の深いところまで予期せず伝播し、より不明瞭な問題を生むことがある
      幸い、テーブルデータは NOT NULL として宣言できるので、こうしたバグが通常データを壊すことはないが、早く捕まえるほうがずっと楽だ
      ただし、ある引数の値に応じて DB に書き込むロジックがあり、その値が予期せず NULL だった場合、誤った値が書き込まれたり必要な値がまったく書き込まれなかったりして、実質的にデータが破損する可能性はある
      だから防御的プログラミングが答えになる
  • 適切な文脈なしに見ると、この助言はかなり奇妙で、ひょっとすると悪い助言かもしれない
    ループと if 文はどちらも 制御フロー 演算なので、記事の議論の一部はあまり筋が通っていない
    最も強い論拠は性能のようだが、性能は通常もっとも後で気にすべき問題であり、特にヒューリスティックな助言ならなおさらそうだと思う
    残念ながら著者はこれをキャッチフレーズ化してしまっており、広まらないことを願う

    • 性能は論拠にはなり得るが、強い論拠ではない
      著者の例のように改善後のコードが書けるなら、条件はループ実行中ずっと定数だ。毎回高コストな条件を評価するのでなければ、分岐予測 が大半を処理してくれる
      条件が const 値を含むブール式なら、コンパイラが見抜ける可能性もある
  • 記事全体がこういう例に続くのかと思っていた。条件に応じて walrus を分け、frobnicate_batch(fwalrus)transmogrify_batch(twalrus) を呼ぶような形だ
    ところが実際には、一つの条件でループ全体を二股に包む例になっていた

  • ソフトウェア工学の背景から来たプログラマが、これをよく間違えるのは驚きだ
    私は科学分野でプログラミングを始めたが、そこではこうしたことを考えるのが絶対的に必要だった
    ループ順序を間違えるだけで、シミュレーションが 1時間ではなく1週間 かかることがある
    そういう背景のおかげで、forif の順序を適切に取る小さな最適化を本能的に行うし、そうなっていないコードは単に間違って見える

  • こうした一般則が実際のコードに本当に適用できるのかは確信が持てない
    こうしたルールはしばしば置き場所を誤った教義のように見えるし、ブログ記事がヒューリスティックだと書き始めていても、若いプログラマーが常にそのように受け取るとは限らない
    数週間前、YouTube が "I'm a never-nester" という動画を何度も勧めてきたが、まるで if を絶対にネストするなという主張のようで、かなり滑稽だった
    記事の例のように、for の中に if condition があるコードを「悪い」としていたが、実際にはそう書かれているコードの大半では conditionwalrus に依存していて、if を上に持ち上げることはできない
    もし持ち上げられるのだとしたら、同じ条件をループごとに再評価することになるのはあまりにも明白なので、プログラマーは自然と避ける傾向がある
    しかし、ジュニアや学生が教義のように聞こえる助言を読むと、それを厳格に守ろうとして、かえってより悪いコードを作ってしまうことがある

    • 「絶対にネストしない」ことについて特別に教条的というわけではないが、文法的に可能だとしても、match (condition_a, condition_b) で4つのケースに分けるやり方がネストした if より良くないケースを経験的に見たことがない
    • これはむしろ if を「上に持ち上げるべき」よい例だと思う
      コードの目的は、条件に応じて walrus に特定の演算を実行することだが、実際には if多態性 と型システムの代わりに使われている
      なぜ walrus に、異なるシナリオで呼び出すべき2つの関数が必要なのか? 1つの関数と2つの型を用意して、正しい型を渡せばよいのではないか?
      現在の構造のままでも、上の方で条件に応じて処理関数を選び、下の反復では選ばれた関数を毎回呼び出すようにできる
      判断は可能な限り早い時点で行えば、コード全体にばらまく必要がない。コードの内部は分岐なしで毎回同じ処理を行い、出力は構成グラフを通じてのみ変わる
      もちろん新しい考えではなく、15年前の時点ですでに古いアイデアだった: https://www.youtube.com/watch?v=4F72VULWFvc
    • 「GOOD」なリファクタリングは、条件が walrus に依存しないときにだけ機能し、その事実を明示的に示すのに役立つ
      ここで「for を下に下げろ」を再適用すると、if condition の中で frobnicate_batch(walruses)transmogrify_batch(walruses) を呼ぶ形になる
    • こういう記事が存在するのはよいことだと思う
      自分が何度も出会いながらうまく言語化できなかった問題を誰かが言葉にしてくれたし、頭に入れておく価値のあるよいモデルに見える
      同時に、こうした批判も価値がある。教条的なジュニア開発者が一緒に読んで、もう少し繊細に判断できるようになればと思う
    • すべての「ヒューリスティック」と「ベストプラクティス」から学ぶべき核心は、その背後にある 理由
      プログラマーは特に、こうしたものをコピーして機械的に適用してはいけない
      ヒューリスティックやベストプラクティスを盲目的に適用するのは良い考えではないかもしれず、結局のところすべては「ケースバイケース」だ
  • こうしたヒューリスティックにはたいてい知恵のかけらが含まれているが、たいていは新人プログラマーから再び 脱教条化 しなければならない対象も生み出す
    あまりに厳格に従おうとすると、かえって悪くなる場合はいつも多く、「いつこの助言を無視すべきかを知れ」が実質的な核心的難しさだ

    • 脱教条化はいずれにせよ必要だ
      こうしたルールは、いじってみるには有用だと思う。受け入れてみて、最後まで押し進めてみて、1日後でも1年後でもひっくり返してみて、どこまで行けるか見ればいい
      限界を学ぶことも、塗り重ねられていく羊皮紙の上に積み上がる、もう一つの材料だ
    • この記事は、より大きなコレクションの中の 公案 としては有用かもしれない
      その公案のいくつかは、互いに矛盾しているべきだ