世界を動かす技術を、日本語で。

Ifsを押し上げ、Forsを押し下げる

概要

  • if文 は呼び出し元に「上げる」ことで、制御フローを明確化することが推奨される提案。
  • for文 は「下げる」ことで、バッチ処理やパフォーマンス向上を図る設計指針。
  • 制御フローの集中管理 により、冗長性やデッドコードの発見が容易になることを強調。
  • バッチ処理の導入 は、パフォーマンスや表現力の両面でメリットがあることを説明。
  • ifとforの最適な配置により、可読性・効率性の高い設計を目指すことを推奨。

if文は上に、for文は下に ― 制御フロー設計の指針

if文を「上に」押し上げる理由

  • 関数内部の if条件 は、可能であれば 呼び出し元に移動 することを推奨。
  • 前提条件(precondition) のチェックは、関数内で「何もしない」よりも、呼び出し側でチェック・型やassertで保証することが望ましい確認。
  • 前提条件のチェックを「上げる」ことで、 全体のチェック回数が減り、冗長な分岐が減少 する提案。
  • 制御フローやif文は複雑化・バグの温床 となりやすいため、1箇所に集約することで 全体像の把握やデッドコードの発見 が容易になる確認。
  • 例:
    • 一つの関数で複雑な分岐を管理し、 処理本体は単純なサブルーチンに委譲 することを推奨。

    • 分岐が各所に分散すると、 冗長性や死んだ分岐が見落とされやすい ことを指摘。

    • enumの「溶解」リファクタリング

      • 条件分岐がenumやmatchとして繰り返される場合、 分岐を上に集約することで重複や無駄を排除 することが可能。

for文を「下に」押し下げる理由

  • データ指向設計 の観点から、 多くのオブジェクトを一括で処理するバッチ処理 を推奨。
  • バッチ処理関数 を基本とし、スカラー(個別)処理は特殊ケースとして扱うことを提案。
    • 例:
      • GOOD: frobnicate_batch(walruses)
      • BAD: for walrus in walruses { frobnicate(walrus) }
  • パフォーマンス向上 が主な動機であり、バッチ処理により 初期化コストの分散や処理順序の柔軟化 が可能となる確認。
    • ベクトル化や構造体配列化 など、高度な最適化も実現可能な設計。
  • FFTによる多点評価 のように、 一括処理が個別処理の繰り返しより高速 になるケースも存在する指摘。
  • 表現力の向上 にも寄与し、jQueryのような「コレクション指向」なAPI設計も例示。

ifとforの組み合わせパターン

  • if文をfor文の外側に配置 することで、条件評価の重複を避け、 ホットループ内の分岐を削減・ベクトル化を促進 することが可能。
    • GOOD:
      if condition {
        for walrus in walruses { walrus.frobnicate() }
      } else {
        for walrus in walruses { walrus.transmogrify() }
      }
      
    • BAD:
      for walrus in walruses {
        if condition { walrus.frobnicate() } else { walrus.transmogrify() }
      }
      
  • TigerBeetleの設計 のように、 データプレーンでバッチ処理を徹底 し、制御プレーンの意思決定コストを分散するアーキテクチャも紹介。

まとめ

  • if文は「上」に、for文は「下」に 配置することで、 制御フローの明確化・パフォーマンス向上・表現力強化 を実現することを推奨。
  • 設計・リファクタリング時にこの指針を意識 することが、バグの削減やメンテナンス性向上に寄与する提案。

Hackerたちの意見

コードの複雑さをスキャンするツールは、最終的にif文を下に押し込むことになるんだよね。でもこの記事は逆を勧めてる。if文を上に押し上げることで、制御フローを一つの関数に集中させることができるんだけど、その関数は複雑な分岐ロジックを持ってて、実際の作業はストレートなサブルーチンに委任されることが多いんだ。

自分の経験では、これはよく局所最適に陥ることが多いかな。「局所」というのは、要件が変わったりエッジケースが見つかるまでは、ループの外で分岐が必要になることがあるってこと。そうなると、ループの内外で分岐があると、考えるのが難しくなるんだ。ケースによるけど、条件がループ内のものにだけ影響するって確信できるなら、そこに置いてもいいと思う。でも、ループの外でも分岐する可能性がある要件を想像するのが難しくないなら、あらかじめそうデザインした方がいいかも。コードは冗長になるかもしれないけど、追いやすくなることが多いし、後でスパゲッティになる可能性も低くなるはず。これがHaskellをやめた理由なんだ。最も簡潔で「局所最適」なロジックを書きたくなるけど、それはロジックの意図を表現するのではなく、ロジックそのものを表現してしまうから、ちょっとした要件が変わるとひどいことになることがある。少なくとも、自分の経験ではそうだった。

これを解決する方法は、決定を実行から分けることだね。これは昔の友達、ベルナール・メイヤーから得た考え方。if (weShouldDoThis()) { doThis(); } これが機能的コアと命令的シェルの一部を補完するんだ。チェックを別にすることでテストが簡単になるし、複雑さが気になるなら、チェックの各条項ごとに関数を分けることもできるよ。

コードスキャナーのレポートは疑ってかかるべきで、鵜呑みにしちゃダメだよ。特にSonarは「コードの匂い」っていう実際にはバグじゃないものを報告するからね。これらの「バグじゃない」問題に対処すると、新しいエラーをゼロから生み出すリスクが高まるし、実際のプロダクションの問題に対処するための開発者の時間を無駄にすることになる。

昨日、LLMについてのスレッドがあって、「コーディングで他にどんな信頼できないツールを受け入れてる?」って誰かが聞いてたんだ。で、今、答えがあるよ…

コードの複雑さをスキャンするツールが大嫌いなんだ。だって、完璧に読みやすい大きな関数について文句を言ってるのを見てからずっとそう。ロジックが一箇所にある方がずっと読みやすいし、詳細が全体像を見失わせるときだけ分割すればいいと思う。

余談だけど、HNのコメントでその上付きの0をどうやって表示させたの?

これに関しては、Sandi Metzの「99 Bottles of OOP」から学んだことがあるよ。全体的には自分のスタイルじゃないけど、ロジックの分岐をコールスタックの上に移動させるっていうポイントは、フラグがたくさんあって何層も下に渡されていたコードベースで作業していたときにすごく納得できた。

うん、すぐに同じ著者の「The Wrong Abstraction」を思い出したよ。forループの中に分岐を入れるのは抽象化で、「forループがルールで、分岐が振る舞いだ」ってことなんだ。でも、しばしば新しい要件がその抽象化を壊すから、回避策を考えなきゃいけなくなる。その結果、抽象化が一部のケースにしか適用されないコードになったり、抽象化を適用するために余分なパラメータを強引に入れたりすることになる。そうすると、どこでも適用されるけど追いやすくなくなる。一方で、最初から抽象化をしなければ、結果的にコードを修正しやすく理解しやすくなることがある。

コードの可読性を向上させるためには、すべてを下に押し込むのがいいよ。printInvoice(invoice, options) // は、if(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ っていうのよりずっといい。ループについても同じことが言えるよ。printInvoices(invoices) // は、for(invoice of invoices){ printInvoice(invoice) } よりずっといい。結局、コードの可読性はすごく重要だけど、カプセル化の方がもっと重要だから、両方をうまく混ぜるべきだね。

printInvoice(invoice, options) printInvoice関数は請求書を印刷するべきだよね。もし請求書が印刷できない場合、指定された条件のうちの一つがfalseだったらどうなるの?例外を投げるか、センチネルやエラータイプを返すかもしれないけど、その場合に何をするかはすぐには分からないよね。特に、例外が一般的なコードフローであまり好まれない言語(例えばJavaやC++)では、コードを第二のスタイルに似た形で構造化する方が良いかもしれない。 (もちろん、ポートレート形式は請求書プリンタが処理すべきだけど、エラーを表す場合は別だけどね。)> コードの可読性は非常に重要だけど、カプセル化の方がもっと重要だよね。カプセル化は主に、長期的なコードの可読性を保つためのツールであり、コードをローカルにリファクタリングしたり変更したりする能力、そしてローカルオブジェクトにのみ関心を持つことでグローバルな振る舞いを考える能力を提供するみたい。二つの指標を比較して、一方がより重要だと考えるのは、カテゴリエラーの一種に見えるよ。

コードの可読性を高めるためにすべてを下に押し込む > 矢印のアンチパターンを示す うわー、気持ち悪い。ダメだよ、代わりにこうしよう:if(!printerReady){ return; } if(!printerHasInk){ return; } if(!printerHasPaper){ return; } if(!invoiceFormatIsPortrait){ return; } 拡張された矢印よりもずっと可読性が高いよ。> printInvoices(invoices) // これの方がずっと良い でも、ループをその前提条件がすでに処理された状態で自分の関数に入れるのは良いね。

printInvoice(invoice, options) // これはエリクサーランドでは maybe_print_invoice って名前にするのがいいかなって思うけど、こっちの方がずっと好きだな。

みんな意見が違うけど、これはPCのプリンタードライバーか、プリンター内部の回路の問題かもしれないね。プリンター自体は、紙がないときに動作をしようとするべきじゃないよ。それを機能チェックに入れた方がいいと思う!

より一般的なルールは、if文を入力のソースに近づけることだね。https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p... これは、外部からプログラムへのエントリーポイントを見つけること(他のサービスから取得するデータを含む)に関することで、コアロジックに到達するまでにできるだけ多くの保証を作るようにすることが大切なんだ。特にリソースを多く使う部分ではね。

これって、コアロジックを理解する際にどんな仮定ができるかを曖昧にしない?どこでもすべての呼び出しチェーンを調べる方がいいの?

「enumを溶解するリファクタリング」として挙げられている例は、要するにポリモーフィズムだよね。つまり、matchをenumに対するポリモーフィックメソッド呼び出しに置き換えられる。目的は、ケースの区別が確立されるポイント(最初のif)と、それが作用するポイント(foo/barの呼び出し)を切り離すことなんだ。ケースの区別はオブジェクト(この場合はenumの値)やクロージャによって運ばれ、呼び出しのポイントで繰り返す必要はない(もしmatchがポリモーフィックディスパッチに置き換えられたらね)。つまり、ケースの区別が変わった場合、確立されるポイントだけを変更すればよくて、それに基づく異なるアクションがトリガーされるポイントは変更する必要がない。これはトレードオフだね。アクションがトリガーされるポイントで考慮すべき個々のケースを見えるようにするのは有益だけど、その代わりに個々のケースのリストに対する追加のコードレベルの依存が生じる。

時々、条件ロジックを呼び出される側に置くのが好きなんだ。そうすれば、呼び出し側が間違った順序で処理を行うのを防げるから。例えば、冪等操作を行いたい場合、まずその処理がすでに行われたか確認して、まだなら実行するって感じ。もしその条件を呼び出し側に押し出すと、関数の呼び出し側はそれぞれ正しい方法で呼び出さないと冪等性を保証できなくなるし、その保証を抽象化できない。こういうことをこの哲学を適用する際にどう対処するの?もう一つの例として、データベーストランザクション内で操作を行う前にチェックのシーケンスを実行したい場合、トランザクションの境界内でチェックを保持しながらこの哲学をどう適用する?

あなたは自分の質問にある程度答えてるよね。> もしその条件を呼び出し側に押し出すと、関数の呼び出し側はそれぞれ正しい方法で呼び出さないと冪等性を保証できなくなる。この状況では、あなたの関数はもはや冪等ではないから、当然その保証はできないよね。正直なところ、個々の関数に状態管理を実装させて冪等性を提供しようとしているなら、何か非常におかしなことをしていると思うし、一つの関数の中に論理が詰まりすぎていると思う。冪等なコードは大きく二つのキャンプに分かれる:1. データモデルと実行される操作が本質的に冪等であるために、元々冪等なコード。つまり、ステートレスな操作を行っているか、入力データに書き込む必要があるすべての状態が含まれている「PUT」スタイルの操作を行っている。2. より複雑なビジネス操作を行っているコードで、ロールバックを行ったり、部分的な失敗が壊れた状態を引き起こさないようにするための原子的な適用抽象を提供することで冪等な抽象を作成している。ポイント1については、操作の順序をチェックする必要はない。なぜなら、それは重要ではないから。すべてが本質的に冪等だから、単に操作を再実行すればいい。ポイント2については、適用できるシンプルな抽象はない。望ましい操作を記録する何かが必要で、それが完了するか失敗することを保証する必要がある。そして、それが起こったら、その完了または失敗が永久に持続することを保証する。でも、そういう論理は関数に入れて他の操作と組み合わせるものではないよ。

チェックなしで関数を書いて、チェックだけを行って内部関数を呼び出すラッパー関数を作るのはどう?

僕の変なメンタルモデル:可能な状態/プログラムフローの木がある。条件がその木を剪定する。できるだけ早く木を剪定して、少ない枝に対して作業をするようにする。すべての枝を細かく評価して剪定して、結局は全体の枝を剪定しなきゃいけないってことにならないように。さらに変なことに、条件文は何の作業をしなくていいかを見つけることに関するもの。ループは「作業」だ。最終的には、僕の関数は一つのことに関するものであってほしい:プログラムの木を歩くか、作業をするか。

完全に良いモデルだね。

隣接するモデルを浮かせてもいい?クラスは名詞で、関数は動詞だよ。

僕のメンタルモデル:自分が書いている非常に特定のコードが存在する世界に合わせること。ドメインの特性から、コードベースの既存のパターン、データパイプラインのどの段階にいるか、パフォーマンスプロファイルなど。こういうルールやヒューリスティックをコード構造のために作ろうとしたこともあったけど、結局は十分なコードを書くとそれらは保持する価値がない抽象レベルにあることを受け入れたよ。作り上げた関数名や単一の文字に頼る傾向があるのは、その時点で「コードの島」を作ってしまって、外には何も存在しない状態になってしまうからで、ほとんどどんなルールでも意味を持つことができるんだ。 - 完璧な例は「冗長性と死んだ条件」についてのもので、ghの唯一の呼び出し元であり、今後もずっと唯一の呼び出し元であるという非常に便利な仮定をして、このルールを使って死んだブランチを露出したと主張していることだよ…それは島の中では機能するけど、実際のコードベースでは通常、ghが最初からお互いに統合されなかった理由があるんだ。

これは「小ステップ」のPL理論/ラムダ計算の見た目とちょうど合ってるね。専門用語で言うと、式は「還元規則」と呼ばれるルールに従って繰り返し「書き換えられる」ことで評価されるんだ。例えば、(1 + 2) + 4は3 + 4に書き換えられ、さらに7に書き換えられる。これらのルールには2種類あって、「合同」ルールはどこで作業をするかを指示するもので(「次にどの部分式を評価する?」)、もう一つは「計算」ルール(ピアスが呼ぶように)で、実際に式を書き換えてプログラムの状態を変えるんだ。「厳密」/「非遅延」言語(ほぼすべての人気のある汎用言語?ハスケルを除いて)は合同ルールでいっぱいで、親式を評価する前にすべての部分式を完全に評価する必要がある。重要な例外は条件文や無限ループのような特別な構文。特に条件文では、計算ルールが働く前に合同ルールがすべての部分式を評価するように指示するんだ。これが式の木を非常に文字通りに剪定することになる。 [1]: ベンジャミン・C・ピアス, タイプとプログラミング言語(おすすめ!)

そんなに変じゃないよ、これを論理的に結論づけると、実質的にプロログの実行モデルになるからね。

これが「良い」ルールだっていう考えにはあまり賛同できないな。時々はそうかもしれないけど、文脈に依存しすぎてて、結論を出すのが難しいよ。まるで「iの前はe、ただしcの後は除く」みたいで、ルールにはたくさんの例外があるから、存在しないのと同じだよね。

関数内にif条件があるなら、呼び出し元に移動できるか考えてみて これは反例が多すぎるよ。 - 関数が37箇所から呼ばれているなら、全部がif文を繰り返すべきなの? - もしその関数がgetaddrinfoやEnterCriticalSectionだったら、APIのユーザーにifを押し付けることになるの?この変換は、せいぜい二箇所から呼ばれる内部関数についてのみ考えられると思うし、決定がその関数の関心の範囲外である場合のみだと思う。もう一つのアイデアは、その関数がif文だけを実行して、二つのヘルパー関数を呼び出すようにすること。呼び出し元がループを書いて、決定をループの外に持ち出す必要がある場合、呼び出し元は低レベルの「デコード条件ヘルパー」を使えるよ。ループの中や周りにない単一のifを持つ呼び出し元は、ifを隠す便利な関数を使えるけど、最適化のためにこれをやっていることを忘れないようにしよう。最適化は良いプログラムの構成としばしば対立するからね!呼び出し元が条件を知るのは良いデザインではないかもしれないし、条件を呼び出し元のループの外に持ち出すために開放しただけだしね。こういうジレンマはOOPに現れるんだ。「if」の決定が呼び出される側にあるとき、それはメソッドのディスパッチ:どのメソッドが呼ばれるかを選択すること。ループからメソッドディスパッチを取り出す技術は、デザインの流れに逆らうこともあるよ。いくつかのパターンがあるけど。例えば、画像をループで回してcanvas.putpixel(x, y, color)を呼び出してキャンバスオブジェクトにラスタ画像を埋め込むのは避けたいよね。キャンバスに画像をブリッティングするためのメソッドが必要だよ(またはその長方形の領域)。

もし関数が37箇所から呼ばれているなら、コードをリファクタリングする必要があるけど、その点についての質問には、状況によるとしか言えないよ。DRYが正しい答えのように感じるけど、実際のコード例を見ないと決められないと思う。ライブラリ関数について話している場合、ライブラリとして特別な場所にいることを受け入れなければならないと思う:所有権の境界にいるんだ。データがドメインを越えて移動している。DDDの言葉で言うと、限界コンテキストを越えて移動しているんだ。だから、自分のものを管理するんだ。他の人のものに要求を出さないで、そのドメイン内で制御フローをエッジに移動させるのが良いアドバイスのように思えるよ。しかし、いつも言うように、イディオムはあくまでそれだけで、実際の世界で何をしているかを知っている人たちによって評価される必要があるんだ。

もしその関数が37箇所から呼ばれたら、みんなif文を繰り返すべきなの?ここでのアイデアは、場合によっては関数を真と偽の分岐に分けて、それぞれ21箇所と16箇所から呼び出せるかもしれないってことだね。

ここでのキーワードはconsiderだよ。この記事は、特にタグ付きユニオンや似たようなものを使うときに出てくる、やや特定のデザイン問題を対象にしてる。

これはかなり意見が分かれるところで、あまりルールとして扱うべきじゃないよ。誰かが言ったように、ここには全くルールなんてないけど、もし自分がルールを作るとしたら、逆のことを言うかもね:- DRYのためにifを下に押し込むべき。- パフォーマンスが許すなら、forを上に押し上げることを考えるべき。そうすれば、filter/map/reduceや関数合成を使って、どのオブジェクトにどのアクションを適用するかを選べるから、実質的にコードをベクトル化できるよ。

あなたが名前をひっくり返したか、引用した理由が結論を支持していない気がする。ifを下に押し込むと通常はベクトル化を妨げるし、記事で言及されているケースは、スタックの下で似たような分岐がたくさんの関数に対して繰り返される非DRYなものだから、しばしばタイプが内部でタグ付けされているからなんだ。