3 ポイント 投稿者 GN⁺ 2025-05-18 | 1件のコメント | WhatsAppで共有
  • 関数内部の if 文を呼び出し側へ引き上げると、コードの 複雑さの軽減に役立つ
  • 条件チェックと分岐処理を一か所に集約すると、重複や不要な分岐確認を見つけやすくなる
  • enum 分解リファクタリングを使うことで、同じ条件がコードのあちこちに散らばる問題を防げる
  • バッチ演算ベースの for 文は、性能向上と反復処理の最適化に効果的
  • if は上へ、for は下へというパターンを組み合わせることで、コードの可読性と効率を同時に高められる

2つの関連ルールについての簡単なメモ

  • 関数内に if 条件文がある場合、それを関数の呼び出し側へ移せないか考えるやり方が推奨される
  • 例のように、関数内部で precondition(前提条件)を検査するより、呼び出し側にその条件チェックを任せるか、型(あるいは assert)によって前提条件を保証するようにするのが望ましい
  • 前提条件の検査を上へ引き上げる(Push up)やり方はコード全体に影響し、全体として 不要な条件チェックの数を減らす効果をもたらす

制御フローと条件文の集約

  • 制御フローif 文は、コードの複雑さやバグの主な原因である
  • 条件文を呼び出し側など上位に集約して、分岐処理を1つの関数に集中させ、実際の処理は直線的な(ストレートラインの)サブルーチンに任せるパターンが有益
  • 分岐と制御フローが一か所に集まると、重複した分岐不要な条件を簡単に把握できる

例:

  • f 関数内にネストした if があるときは、死んだコード(Dead Branch)を認識しやすい
  • 複数の関数(g, h)を通じて分岐が分散すると、こうした把握が難しくなる

enum 分解リファクタリング(Dissolving enum Refactor)

  • コードが同じ条件分岐を enum などに内包している場合、条件を上位へ引き上げることで、分岐と処理をより明確に分離できる
  • この方法を適用すると、同じ条件がコード内で 何度も繰り返し現れる問題を防げる

例:

  • 同じ分岐条件が f、g 関数と enum E にそれぞれ表現されている状況を
  • 1つの上位条件分岐にまとめることで、コード全体を単純化できる

データ指向の考え方(Data Oriented Thinking)とバッチ演算

  • ほとんどのプログラムは複数のオブジェクト(エンティティ)で動作する。クリティカルパス(Hot Path)は、多数のオブジェクト処理によって性能が決まる
  • バッチ(batch)の概念を導入し、オブジェクト集合に対する演算を基本にして、単一オブジェクトへの演算は特殊ケースとして扱うのが望ましい

例:

  • frobnicate_batch(walruses) のように バッチ処理関数を基本に置き、

  • 個別オブジェクトは for ループで処理する特殊ケースへ変換できる

  • この方法は性能最適化の観点で重要な役割を果たし、大量処理ではスタートアップコストを減らし、順序の柔軟性を高める

  • SIMD 演算(struct-of-array など)の活用も可能で、特定フィールドだけを一括処理してから全体の処理を進められる

実践的な事例と推奨パターン

  • FFT ベースの多項式乗算のように、複数地点での同時演算を可能にして性能を最大化できる
  • 条件文を上へ、反復文を下へ移すルールは並行して適用できる

例:

  • 反復文の内部で同じ条件式を何度も検査するより、条件文を反復文の外へ出したほうが、ループ内の分岐が減り、最適化とベクトル化がしやすくなる
  • このアプローチは TigerBeetle の設計など、大規模システムのデータプレーンでも高い効率を保証する

結論

  • if 文(条件文)は **上位(呼び出し側、制御側)**へ、for 文(反復文)は **下位(演算部、データ処理部)**へ下ろすパターンを組み合わせることで、コードの 可読性効率性能をすべて向上できる
  • 抽象ベクトル空間の視点で考えること(集合単位の演算)は、反復的な分岐処理より優れた問題解決の道具となる
  • 要するに、if は上へ、for は下へ!

1件のコメント

 
GN⁺ 2025-05-18
Hacker News の意見
  • 自分の独特なメンタルモデルでは、さまざまな状態やプログラムフローは木構造をなしている。条件文はその木の枝を刈り込む役割を果たす。できるだけ早く枝刈りして、その後に処理しなければならない枝の数を減らしたい。すべての枝をいちいち評価して片付けた末に、結局まとめて全部の枝を切り落とさなければならない、という状況は避けたい。少し変わった見方をすると、条件文は「不要な仕事を見つけ出す過程」であり、ループは「実際の仕事」だ。最終的に自分が望む関数は、プログラムツリーを探索することか、実際の仕事を処理することのどちらか一方に集中している形だ
    • 自分の別のモデルも示してみたい。クラスは名詞で、関数は動詞だと考えている
    • 自分のメンタルモデルは、自分が書いている具体的なコードが存在する世界に合わせるものだ。ドメイン特性、既存コードのパターン、データパイプラインの段階、性能プロファイルなどによって変わる。最初はこうしたルールやヒューリスティックを作ろうとしていたが、たくさんコードを書いてみて、この種の抽象ルールは実際にはあまり意味がないと気づいた。多くの場合、場当たり的に関数名や一文字だけ決めて、その「島のようなコード」の中でだけルールが成り立つが、実際のコードベースではたいてい、そうした関数をわざわざ統合していない理由がある。例として「重複と死んだ条件」の話が出てくるが、それはその関数がたった1か所でしか呼ばれないという都合のよい前提に立ったルールだ。実際には別の理由で分離されていることが多い
    • とても良いモデルだと思う
  • もっと一般的なルールは、条件文を入力ソースのできるだけ近くに置くことだ。外部からプログラムに入ってくる入口点(他サービスから取得したデータも含む)をできるだけ早く識別し、コアロジックに到達する前に(特にリソースを多く使う部分に到達する前に)できるだけ多くの保証を作っておくのが重要だ。型でそれを明示的に表現するのもとても良い
    • そうすると、コアロジックを理解するときにどんな前提を置いているのか分かりにくくならないか? コード全体の呼び出しチェーンを全部追わなければならないのでは?
  • 「if 条件が関数の内部にあるなら、それを呼び出し側へ移せないか検討せよ」という助言には反例が多すぎる。もし関数が37か所で呼ばれているなら、すべての呼び出し箇所で同じ if 文を繰り返すのか? たとえば getaddrinfo や EnterCriticalSection のような関数に、こういう形で if を外へ出せと言えるだろうか? こうした変換は、せいぜい2か所くらいでしか呼ばれず、その判断が関数の関心事の外にあるときにだけ検討できると思う。ひとつの方法は、条件文だけを行う関数にヘルパー関数を委譲して書くことだ。そしてループの外へ条件を移す必要があるときには、より低レベルな条件ヘルパーを呼び出し側が直接使えるようにする。しかしこの議論の核心は「最適化」にある。最適化はしばしば、より良いプログラム設計と衝突する。呼び出し側が条件を知る必要がないほうが、より良い設計かもしれない。この種のジレンマは OOP でもよく現れる。「if」で表される判断が、実際にはメソッドディスパッチで行われるケースだ。こうしたディスパッチをループの外へ出すことも、設計原則と摩擦を起こしうる。たとえばキャンバスに画像を描くとき、毎回 putpixel を繰り返し呼ぶより blit のようなメソッドを使うほうがよい、というのがその一例だ
    • 関数が37か所で呼ばれているなら、コードのリファクタリングは必要ではある。その質問に答えるなら、ケースバイケースだ。DRY が正解のように感じられるが、実際のサンプルコードを見て判断すべきだ。ライブラリであれば所有権の境界にあるので、それぞれが自分のデータと責務を管理しなければならない。EnterCriticalSection のような関数は、入口で強力な検証(条件文を含む)を行うのが妥当だ。だがアプリケーションコードでは、if を呼び出し側へ移しても構わない。ライブラリやコアコードでは制御フローを境界へ移すのが適切だ。自分が扱うドメインの中では、制御フローを端に置くのがよい。ただし、こうしたルールはいつでも慣用的なものにすぎないので、状況に応じて合理的に判断できる人が文脈に合わせて決めるべきだ
  • 「dissolving enum リファクタ」の例は、実質的にはポリモーフィズムのパターンだ。match 文をポリモーフィックなメソッド呼び出しに置き換えられる。この方式の目的は、最初の条件分岐を決定する時点と、実際の動作が実行される時点を分離することにある。ケース分けはオブジェクト(ここでは enum の値)やクロージャが保持しているので、呼び出しのたびにそれを繰り返す必要はない。ケース分けが変わったら分岐点だけを変えればよく、実際の振る舞いが起こる箇所は修正しなくて済む。欠点は、各ケース別の動作分岐を直接確認できる手軽さとのトレードオフと、コードレベルでケース一覧に依存性が生まれる点だ
  • 条件文を関数内部に置くのが好きなこともある。意図的に、呼び出し側が関数の呼び出し順序を間違えられないようにできるからだ。たとえば冪等性の保証が必要な場合、すでに処理済みの状態を先に確認し、そうでなければ実行する、という形になる。この条件を呼び出し側へ出してしまうと、すべての呼び出し側がその手順を正しく守らない限り冪等性を保証できず、抽象化の中でその保証を提供できない。このような状況では、この哲学をどう適用すべきなのか気になる。別の例では、データベーストランザクションの中で一連のチェックをすべて行ったうえで処理したいとき、それらのチェックをどこに置くべきか悩む
    • もう自分でその問いに答えているように思う。条件文を呼び出し側へ出せば、関数はもはや冪等ではなくなり、当然その保証もできない。冪等性を保証するために各関数へ状態管理ロジックを入れるなら、かなりおかしなコードを書いている可能性があり、ビジネスロジックを単一の関数に詰め込みすぎているという意味でもある。冪等なコードは大きく2種類に分かれる。第一に、データモデルや演算そのものが冪等なコード。この場合は処理順序をそれほど気にする必要はない。第二に、もっと複雑なビジネス処理で冪等な抽象化を作るケースだ。ロールバックや atomic apply に対する抽象化のような複雑なロジックが必要で、これは単一の関数に簡単に収まる話ではない
    • チェックなしの内部関数を作り、外側のラッパー関数でチェックしてから内部関数を呼ぶ形で管理するのも方法だ
  • コード複雑度スキャナーは、結局 if 文を下へ押しやる傾向のあるツールだ。しかしこの記事では逆に、if 文を上へ、つまりより上位の関数へ持ち上げることを勧めている。そうすれば複雑な分岐ロジックを単一の関数に集中的に置けて、実際の具体的な作業はサブルーチンへ委譲できる
    • 解決策は「決定」と「実行」を分離することだ。Bertrand Meyer から学んだ考え方だ。たとえば if (weShouldDoThis()) { doThis(); } のようにし、各チェックを別々の関数へ切り出せば、テストと複雑度管理がしやすくなる
    • コードスキャナーのレポートは真剣に疑ったほうがいい。sonarqube などは実際のバグではない「code smell」まで無差別に報告する。こうして「問題のないコード」まで修正しようとすると、かえって新しいバグを生むリスクが高まり、本当に重要な問題への対処時間だけを浪費しかねない
    • こうした最適化はたいてい「局所最適」にすぎない。つまり、新しい要件や例外ケースが見つかると、分岐ロジックがループの外にも必要になる。そうなるとループの内外の両方に分岐が混ざり、理解しづらくなる。条件がループ内部にだけ必要だと確信できるならそうしておけばよいが、そうでないなら、むしろ設計を少し長めに引っ張ってでも、コードが冗長になっても理解しやすいほうがよいと思う。Haskell を使っていて、こうした経験をした。ロジックを最も簡潔で最適化された形の局所最適へ追い込むと、要件がほんの少し変わっただけで設計の意図ではなく単なるロジックだけが残り、小さな変更でもコードのアンローリングが激しく起こる
    • コード複雑度スキャナーにはいつも不満があった。読みやすい大きめの関数にまで文句をつけてくる。ロジックを一か所に置けば全体の文脈を把握しやすいが、関数を分割するときは、本当の文脈を見失わないよう注意しなければならない
    • 昨日、LLM に関するスレッドで「開発者がみんな受け入れている信頼できないツール」について話題になっていた。これで答えが分かった……
  • 場合によっては、むしろ逆に考えて SIMD を使うべきこともある。たとえば AVX-512 などでは、分岐のあるコードをブランチレスなコードにして、ベクタマスクレジスタを使って処理できる。たとえば for 文の中の if 文は、for 文の外の if 文より管理しやすく、メモリアクセス効率も高い。具体例として、奇数なら +1、偶数なら -2 する演算があるとき、本来は各ループで分岐しなければならないが、SIMD でベクトル処理すれば 16 個ずつすべての int を同時に処理でき、ブランチも不要になる。コンパイラがうまくベクトル化すれば、元のコードをブランチレスな最適化版へ変えてくれる
    • 提示された before のコードは、この記事の論点には少し合っていない気がする。むしろ最適化された SIMD 版こそが、記事の要点に合致していると思う。例での for 文内 if はデータ依存の分岐なので、簡単には外へ引き上げられない。もしアルゴリズムが if (length % 2 == 1) { ... } else { ... } のようにループ外の条件だけを使う構造なら、そうした条件は当然 for 文の上へ出すのが正解だ。SIMD 版では if が完全に消えており、こういうのが理想的なコードパターンで、記事の著者も好みそうなやり方だ
    • 自分も、for ループの要素値によって分岐するコードがすぐに思い浮かんだ。こういうコードをコンパイラが自動ベクトル化するのがどれくらい難しいのか、知っている人はいる? その境界が気になる
  • 個人的には、これを「良い」ルールだとは思わない。適用できる場面はあるが、文脈によって違いすぎて、きっぱりした結論を出しにくい。英語の綴り規則のように例外が多すぎて、実質的にはルールとして扱いづらいと感じる
  • (2023年)当時の議論へのリンク(662ポイント、295件のコメント) https://news.ycombinator.com/item?id=38282950
  • 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