3 ポイント 投稿者 GN⁺ 2025-05-18 | 1件のコメント | WhatsAppで共有
  • 関数内部の条件分岐は呼び出し側へ引き上げ、反復対象の処理は個別呼び出しより バッチ演算 側へ下ろすことで、コードの流れと性能を同時に単純化できる
  • if を上へ引き上げると、前提条件を型や assert で保証しやすくなり、関数内の 重複チェック や不要な分岐が減る
  • 分岐ロジックが複数の関数に散らばると、死んだ条件や繰り返された判定を見つけにくくなり、enum を作って再び match する構造も、同じ分岐を データ構造として繰り返す 形になりうる
  • for を下へ下ろすと、複数のオブジェクトをまとめて処理して起動コストを償却でき、処理順序の並べ替えや ベクトル化 のような最適化の余地が生まれる
  • 2つの規則を併用すると、ループ内部での条件再評価やホットループの分岐が減り、制御プレーンの意思決定コストをデータプレーンのバッチ処理へ移す構造に近づく

if は呼び出し側へ引き上げる

  • 関数内部に if 条件があるなら、その条件を 呼び出し側 に移せないかをまず検討できる
    • Option<Walrus> を受け取って None なら return する関数より、最初から Walrus を受け取る関数のほうが単純な形である
    • 関数が前提条件を確認して「何もしない」よりも、呼び出し側が確認したうえで型や assert によって保証できる
  • 前提条件チェックを上へ引き上げる方法は呼び出し経路全体へ広がりうるため、結果として チェック回数 を減らす動機になる
  • 制御フローと if は、コードの複雑さやバグの可能性を高めやすい
    • 複雑な分岐ロジックは上位関数1か所に集め、実際の処理は直線的な下位関数へ委譲する構造のほうが読みやすい
    • 複雑な制御フローがファイル全体に散らばるより、1画面に収まる単一の関数へ集まっているほうが、重複条件や死んだ条件に気づきやすい
  • “dissolving enum” リファクタリングは、enum の生成と match が同じ分岐を繰り返しているときに有用である
    • f() が条件に応じて E::Foo または E::Bar を作り、g() が再び match して foo() または bar() を呼ぶなら、分岐は2回現れる
    • 条件を上へ引き上げると、main() で同じ条件に基づいて直接 foo(x) または bar(y) を呼ぶ形になる

for はバッチ演算側へ下ろす

  • データ指向の考え方では、プログラムは通常複数オブジェクトのまとまりを扱い、ホットパスも多くのエンティティを処理するために熱くなることが多い
    • オブジェクトの バッチ という概念を導入し、スカラー版をバッチ版の特殊ケースとして扱う方法が有用である
    • for walrus in walruses { frobnicate(walrus) } より frobnicate_batch(walruses) のほうがよりよい形である
  • バッチ処理の主な利点は 性能 にある
    • 複数の対象を一度に扱えば、起動コストを償却できる
    • 処理順序を柔軟に変えられ、特定の順序でエンティティを処理する必要も減る
    • すべてのエンティティのあるフィールドを先に処理してから別のフィールドへ進むような、ベクトル化や struct-of-array 手法を使える
  • 極端な性能例として Vectorized Interpreters Talk が挙げられている
  • 面白い例として FFT ベースの多項式乗算 がある
    • 複数の点で多項式を同時に評価する方法は、各点での評価を個別に何度も行うより速いことがある
  • iffor の規則は一緒に適用できる
    • 条件がループの外にあれば、condition を何度も再評価しない
    • ホットループから 分岐 が取り除かれる
    • ベクトル化の可能性が開かれる
  • 同じパターンは、ミクロなレベルでもマクロなレベルでも機能する
    • TigerBeetle の構造は、データプレーンでオブジェクトのバッチを同時に処理し、制御プレーンの意思決定コストを償却する方式である
  • for 規則の主な動機は性能だが、表現力の面でも役立つことがある
    • jQuery は要素コレクションを対象に動作し、過去には成功していた
    • 抽象ベクトル空間の言語は、座標ごとの方程式の集まりよりも思考の道具として優れていることがある

1件のコメント

 
GN⁺ 2025-05-18
Hacker News のコメント
  • 私の奇妙な思考モデルでは、あり得る状態とプログラムの流れからなるがあり、条件文はその木を刈り込むものだ、ということになる。
    できるだけ早い段階で刈り込み、より少ない枝だけを相手に作業できるようにするのがよい。
    結局のところ、関数にはプログラムの木を歩くことか、実際の作業をすることか、そのどちらかに集中してほしい。

    • この見方は、プログラミング言語理論やラムダ計算の小ステップ意味論で見られる姿とよく合っている。
      式は簡約規則に従って繰り返し「書き換え」られ、評価される。たとえば (1 + 2) + 43 + 4 に、さらに 7 に変わる。
      ここには、次にどの部分式を評価するかを決める合同規則と、実際に式を変えてプログラム状態を変える計算規則がある。
      厳格、つまり非遅延の言語はたいてい、親の式より先にすべての部分式を評価させるが、条件文や無限ループのような特殊な構文は例外だ。
      条件文では、すべての部分式を評価するよう合同規則が指示する前に計算規則が先に適用され、文字どおり式の木を刈り込む。
      [1]: Benjamin C. Pierce, Types and Programming Languages を推薦。
    • 私の思考モデルは、いま書いているコードが置かれている具体的な世界に合わせることだ。
      ドメインの特性、既存コードベースのパターン、データパイプラインのどの段階なのか、性能特性などを見る必要がある。
      以前はこうしたコード構成の規則やヒューリスティックを作ろうとしていたが、十分にコードを書いていると、抽象化レベルが間違っていて長くこだわる価値はないと受け入れるようになる。
      こうした議論が架空の関数名や一文字の変数に頼っているのも示唆的だ。外部の文脈がない「コードの島」では、ほとんどどんな規則でももっともらしく見せられるからだ。
      gh の唯一の呼び出し元で、今後もそうだという都合のよい仮定を置いて初めて、この規則で死んだ分岐を露出させたと言える。
      実際のコードベースでは、たいてい gh をそもそも結合していない理由がある。
    • 近いモデルを一つ投げてみるなら、クラスは名詞で、関数は動詞だ。
    • そこまで奇妙なモデルではなく、突き詰めると実質的に Prolog の実行モデルに近くなる。
  • より一般的な規則は、if を入力の発生源の近くに置くことだ: https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p...
    外部からプログラムへ入ってくるエントリポイント、別サービスから取得したデータまで含めてその地点を見つけ、コアロジック、とくにリソースを多く使う部分に到達する前に、できるだけ多くの保証を作るよう整えるのが要点だ。
    可能なら、その保証を型にエンコードするほうがよい。

    • これはほとんど検証するな、パースせよと同じ話だ: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
    • そうすると、コアロジックを理解するときにどんな前提を置いてよいのか、かえって曖昧にならないか? すべての呼び出しチェーンをいちいちたどって確認するほうを好むということなのか?
  • 「関数の中に if 条件があるなら、呼び出し元側へ移せるか検討せよ」というような緩い推測には反例が多すぎる。
    関数が 37 か所から呼ばれているなら、すべてで if 文を繰り返すべきなのか?
    その関数が getaddrinfoEnterCriticalSection なら、API 利用者に if を外へ押し出させるべきなのか?
    この変換は、多くても 2 か所から呼ばれる内部関数で、その判断が関数の関心事の外にある場合にだけ考えられるものだと思う。
    別の方法は、その関数が if だけを行い、2 つのヘルパー関数を呼ぶようにすることだ。
    呼び出し元がループ内で条件を外へ引き上げる必要があるなら、低レベルの「条件解釈ヘルパー」を使えばよい。ループの内外ではなく、1 回の if だけが必要な呼び出し元は、if を隠す便利関数を使えばよい。
    ただし、これは最適化のためのものであり、最適化は良いプログラム構造としばしば衝突する。
    オブジェクト指向では、呼び出し先の中にある if 判断は、どのメソッドを呼ぶかを選ぶメソッドディスパッチとして現れる。
    メソッドディスパッチをループの外へ出す技法も、設計の流れに逆らうことがある。
    たとえばキャンバスオブジェクトをラスター画像で埋めたいときに、画像のピクセルを走査しながら canvas.putpixel(x, y, color) を呼びたいとは思わない。画像をキャンバスやその矩形領域へ blit するメソッドがあるべきだ。

    • 関数が 37 か所から呼ばれているなら、この場合は関数を真/偽の分岐を実装する 2 つの関数に分け、それぞれ 21 か所と 16 か所から呼ばれるようにできる、という意味に近いのだろう。
    • ここでのキーワードは consider だ。
      この記事は、タグ付きユニオンのようなものを使うときに特に現れる、かなり具体的な設計上の問題を狙っている。
    • 関数が 37 か所から呼ばれているならコードをリファクタリングすべきだが、それでも答えは「状況による」だ。
      DRY が正しい答えのように感じられるが、実際のコード例を見なければ判断できない。
      ライブラリ関数なら位置づけが特殊だ。所有権の境界にあり、データがドメインを横断し、DDD 的に言えば境界づけられたコンテキストを越える。だから自分の領域は自分で守るべきだ。
      EnterCriticalSection は、入る時点での強い検証、if 条件まで含む検証が妥当なコードパスを示唆しており、ドメイン境界と見るべきだ。
      一方でアプリケーションを書いていて、通常のアプリケーション関数に if 文があるなら、安全に外へ押し出せる。
      ライブラリや重要なコード領域の中でも、if を深い内側ではなく端へ上げるのは安全だ。
      自分のドメインを管理し、他人のドメインに要求せず、そのドメイン内では制御フローを端へ移せという助言は、合理的に聞こえる。
      もちろんイディオムはイディオムにすぎず、現実の世界では文脈を理解し、合理的に判断できる人が評価すべきだ。
  • 「列挙型分解リファクタリング」の例は、本質的にはポリモーフィズムである
    matchを列挙型に対するポリモーフィックなメソッド呼び出しに置き換えられる
    目的は、場合分けが作られる地点、つまり最初のifと、それに応じてfoobarを実行する地点を分離することにある
    場合分けはオブジェクト、ここでは列挙型の値やクロージャが持ち運び、呼び出し地点で再び繰り返す必要はない
    つまり場合分けが変わったら、その分け方が作られる地点だけを変えればよく、分岐ごとの動作を引き起こす地点は変える必要がなくなる
    ただしトレードオフはある。動作が実行される地点で考慮すべき個々の場合を直接見られることが役に立つ場合もあるが、その代わりに個々の場合の一覧へのコードレベルの依存が追加される

  • コード複雑度スキャナーは、結局ifを下へ押し込むよう強制する。この記事はその逆を勧めている
    ifを上へ持ち上げると、制御フローが1つの関数に集中することが多く、その関数は複雑な分岐ロジックを持つ一方、実際の作業は直線的な下位ルーチンへ委譲される
    https://docs.sonarsource.com/sonarqube-server/latest/user-gu...

    • 解決策は判断と実行を分離することで、この概念はBertrand Meyerから得たものだ
      if (weShouldDoThis()) { doThis(); }
      これは関数型コアと命令型シェルを補完するもの、あるいはその一部である
      検査を別にしておけばテストしやすくなり、複雑度が気になるなら検査内の各節を関数に切り出せばよい
    • コードスキャナーのレポートは福音のように受け取らず、疑ってかかるべきだ
      特にSonarは実際のバグではなく「コードスメル」を報告する
      こうした「バグではない」項目を直していると、新しいエラーが生まれるリスクは0から0より大きい値に上がり、実際の運用上の問題に対処する開発者の時間を浪費しかねない
    • 私の経験では、これはしばしば局所最適解である
      「局所」というのは、要件が変わったり例外状況が見つかったりして、ループの外側でも分岐が必要になるまでに限ってそうだ、という意味だ
      ループの内側と外側の両方に分岐が生じると、推論はさらに難しくなる
      条件がループの内側だけに影響するとかなり確信できるなら、そこに置いてもよい
      しかし、ループの外側でも分岐が必要になる要件を想像するのが難しくないなら、あらかじめそういう構造で設計したほうがよい場合がある
      コードは冗長になるかもしれないが、追いやすくなり、後でスパゲッティ化する可能性も下がる
      これが私がHaskellを使うのをやめた理由だ。Haskellは最も簡潔で「局所最適」なロジックを書きたくさせるが、それはロジックの意図よりもロジックそのものを表現することに偏っている。ささいな要件変更の際に、ひどくほどかなければならないことがあり得た
    • 完全に読みやすい大きな関数に文句を言っているのを見て以来、コード複雑度スキャナーはずっと嫌いだった
      ロジックが1か所にあるほうがはるかに読みやすく、細部のせいで全体像を見失い始めたときだけ分割を考えるべきだ
    • 昨日LLMのスレッドで、誰かが「人々がコーディングで受け入れている、信頼できないツールは他に何があるか」と尋ねていたが、これで答えができた
  • ときには条件ロジックを呼び出し先の中に置くほうが好きだ。呼び出し元が誤って間違った順序で作業するのを防げるからである
    たとえば冪等な操作を作りたいなら、まずその作業がすでに完了しているかを確認し、そうでなければ実行できる
    その条件を呼び出し元の外へ押し出すと、その関数のすべての呼び出し元が、冪等性の保証を得るために正しい方法で呼び出しているかを各自で確認しなければならず、その保証を抽象化できない
    データベーストランザクション内で何かを行う前に、一連の検査を実行しなければならない場合もある。この哲学を適用しつつ、検査をトランザクション境界の内側に保つにはどうすればよいのか?

    • 検査のない関数を書き、そのうえで検査だけを行って内部関数を呼び出すラッパー関数を用意すればよいかもしれない
    • 実は質問の中に答えがある
      条件を呼び出し元の外へ押し出すと、その関数はもはや冪等ではないので、当然その保証は提供できない
      ただし、個々の関数が状態管理を実装して冪等性を提供しなければならないなら、かなり奇妙な構造である可能性が高く、単一の関数の中であまりに多くのロジックが起きているように思える
      冪等なコードは通常2種類に分かれる
      1つは、データモデルと実行される操作そのものが本質的に冪等である場合だ。ステートレスな操作か、入力データが記録すべきすべての状態を含んでいるPUTスタイルの操作である
      もう1つは、より複雑なビジネス操作で、ロールバックを実行したり、部分的な失敗が状態を壊さないよう保証したりする原子的な適用の抽象化を提供して、冪等な抽象化を作る場合である
      最初の場合は、作業順序を検査する必要はない。本質的に冪等なので、そのまま再実行すればよい
      2つ目の場合は、単純な抽象化は適用できない。やりたい作業を記録し、それが完了するか失敗するよう保証したうえで、その完了または失敗が永続的に保持されるようにしなければならない
      その種のロジックは、1つの関数に入れて他の操作と合成するようなものではない
  • これらの助言は非常に意見が強いので、経験則のように扱うべきではない
    ここには経験則そのものがないと思う。あえて作るなら、おそらく逆のことを言うだろう
    ifはDRYのために下へ押すべきだ
    性能が許すなら、forは上へ持ち上げることを検討すべきだ。そうすればfilter/map/reduceと関数合成を使って、どのオブジェクトにどの動作を適用するかを選べ、事実上コードをベクトル化できる

    • 名前を逆にしているか、提示された理由が結論を支えていないように思える
      ifを下へ押し込むと、通常はベクトル化を妨げる
      記事で述べている事例はDRYでない場合、特に型が内部的にタグ付けされていて、似たような分岐がスタック下方の多くの関数に増殖しなければならない場合である
  • これが従う価値のある「良い」ルールだという考えには確信が持てない
    場合によってはそうかもしれないが、あまりに文脈依存で結論を出しにくい
    「cの後を除き、iはeの前に来る」のような規則に似ていると感じる。例外が多すぎて、実質的には規則がないのと同じだ

  • Sandi Metz の 99 Bottles of OOP から、これに似たバージョンを得た
    全体として自分のスタイルではないが、多くのフラグを何層も下へ渡してしまっているコードベースで作業するとき、ロジックの分岐点を呼び出しスタックの上へ移すべきだというポイントは非常に説得力があった
    https://sandimetz.com/99bottles

    • すぐに同じ著者の The Wrong Abstraction を思い出した
      分岐を for ループの中に入れるのは、「for ループがルールで、分岐が振る舞いだ」と言っている抽象化である
      しかし新しい要件がその抽象化を壊すことは非常によくある
      そうなると回避策が必要になり、結果のコードは、ある場合には適用され、別の場合には適用されない抽象化を持つことになるか、どこにでも適用できるように抽象化へ大量のパラメータを押し込んで、追いにくくなる
      そもそもその抽象化を作らなければ、結果のコードはより変更しやすく、理解しやすかったかもしれない
      https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
  • コードの可読性のためには、すべてを下へ押し込むほうがよい
    printInvoice(invoice, options)if(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}} よりはるかによい
    ループも同様に、printInvoices(invoices)for(invoice of invoices){ printInvoice(invoice) } よりよい
    結局、コードの可読性は非常に重要だが、カプセル化のほうがより重要なので、両者を適切に混ぜる必要がある

    • printInvoice 関数は請求書を印刷すべきである
      もし名前で示された条件のうちの1つが偽で、請求書を印刷できないならどうなるのか?
      例外を投げる、センチネル値やエラー型を返す、といったことはできるが、その場合に何をすべきかはすぐには明らかでない
      特に、通常の制御フローに例外を使うことを避けがちで、モナド的なエラーが一般的でない Java や C++ のような言語では、2つ目のスタイルに近い形で構造化するほうがよいかもしれない
      ただし、縦向き形式はエラーを表すものではないなら、請求書プリンタが処理すべきである
      カプセル化は主に、長期的なコード可読性、局所的なリファクタリングと変更可能性、そして局所的なオブジェクトだけを気にしながら全体の振る舞いを推論する能力のための道具に見える
      だから可読性とカプセル化を比較して、どちらか一方がより重要だと見るのは、カテゴリー錯誤のように感じる
    • 「すべてを下へ押し込んで可読性を高める」と言いながら アロー・アンチパターン を示すのはあまりよくない
      代わりにこうすべきである
      if(!printerReady){ return; }
      if(!printerHasInk){ return; }
      if(!printerHasPaper){ return; }
      if(!invoiceFormatIsPortrait){ return; }
      大きくなっていく矢印よりはるかに読みやすい
      ただし、ループを独自の関数に入れ、残りの前提もすでに処理しておくのはよい
    • これは PC のプリンタドライバかもしれないし、プリンタ内部の回路かもしれないので、人によって意見は分かれうる
      紙がないときにプリンタ自体が車輪を空回りさせては絶対にいけない。そのチェックは関数の中に置く
    • Elixir 方面なら、その関数名を maybe_print_invoice くらいにするだろうが、そのほうがずっと好みだ