「if文は上へ、for文は下へ」:コードを劇的にクリーンにするアルゴリズム的イディオムを徹底解説
Push ifs up and fors down: The idiom, its algebra, and its limits
Push ifs up and fors down: The idiom, its algebra, and its limits
プログラミングにおいて、条件分岐(if)を外側に追い出し、ループ処理(for)を内側に押し込むという手法があります。このイディオムが持つ数学的な性質や代数的な背景、そして実務で適用する際の限界について深掘りします。
LLMっていうのは、本当にどうでもいいアイデアを引っ張り出してきて、やたらと長くて分かりにくいブログ記事に仕立て上げ、おまけに不必要な例え話まで盛り込む能力が凄まじいよね。感心しちゃうよ。
この記事の主張を裏付けるコード構造のベンチマークが見当たらないのが痛いね。
特筆すべきは、C#9(それ以前からかもだけど)以降、dotnetランタイムは安全と判断されれば自動的にこれを行っているという点だよ。https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/
この手法は、現在の一般的なCコンパイラ(gcc, llvmなど)でも、安全とみなされる場合には最適化として適用されているしね。
なぜこの記事に測定結果や、現代のほとんどの言語で自動的に行われているという参照情報が一切含まれていないのか、すごく疑問だよ。
自分ももう何年もやってるよ。毎回やるわけじゃないけど、コードが読みやすくなったり保守しやすくなったりする場合はね。
スピードが理由だったことはほとんどないけど。
記事には書かれていなかったけど、if文を先に持ってくるのって「ガード節」って呼ばれるやつじゃない?
あのパターンは好きだけど、単なる一般的なベストプラクティスだと思ってたよ。
ブランチ予測の恩恵だけでも、やる価値はあるだろうね。
え、違うんじゃない? f(w: Walrus) -> Walrus みたいに書いて、あとは呼び出し元で Walrus|None とか Iterable[Walrus] を好きに扱わせればいいだけじゃない?
もし誰かが、コードベースに Iterable[Walrus|None] の抽象化(と、それを処理するための専用関数)が必要だと判断したとしたら、その時は天気でも見て、散歩にでも行けって勧めてやるさ。(天気をチェックするのは、傘を貸してあげるべきか判断するためね。)
何を見落としてるんだろう?
これってCS(アルゴリズム最適化)の視点と、SE(コード設計)の視点のどちらで話してるんだろう?
SEの視点なら、Collection<Optional<Walrus>> を明示的に扱う flatmap 関数を作るべき。実装はどうでもいい。もし言語やフレームワークに互換性のある flatmap 関数が既にあるなら、Optional<Walrus> を引数にして、flatmap(frobnicate) で捨てられるような値を返す frobnicate 関数を1つ作ればいい。
CSの視点で言えば、Collection<Optional<Walrus>> から Collection<Walrus> へのフィルタリングをするのは、たぶん悪手だね。コレクションが小さければどうでもいい話だけど、大きい場合はコピーを作るのに時間を使いたくないはず。もしフィルタリングがハードコピーではなくビューを返すだけなら、最適化の恩恵なんてないから、SEの視点で一番筋の通るやり方を選ぶべき。frobnicate が安価なら、いつ実行しようがブランチ予測失敗のコストは払うことになるし、高価なら並列化してスレッドごとに Optional のアンパック処理をさせるべきだろう。いずれにせよ、コピーを作るのに時間をかけるのは避けるべきだね。
これらはあくまで仮定に基づいた一般論だし、例外は山ほどある。でも、ざっくり言うとこの記事には強い根拠が見当たらないな。最適化が重要なら自分の状況をプロファイリングして最適化すべきだし、そうでないなら自分の分野で一般的かつ利用可能な機能やパラダイムに基づいて設計するのが一番だよ。
時間を無駄にしないために、元の投稿を読むのがおすすめだよ:https://matklad.github.io/2023/11/15/push-ifs-up-and-fors-down.html
自分はいつも逆を信じてる。条件分岐はコードの深いところに押し込んで、高レベルな制御フローを規則的に保つ方がいいっていう考え方さ。
ただ、バグを避けるためのより大きな哲学として、データを扱うときは「分配」と「決定」という2つの処理があると思ってる。
この「分配」と「決定」が同じ場所に混ざるのは避けたほうがいい。
「分配」は forループのこともあるし、データをキーに基づいてN個のバケットに振り分けることでもある。
「決定」はデータを詳しく見て判断を下す場所(「これは大口顧客か?小口顧客か?」みたいな)だよ。
分配に判断が伴うことは多いけど、全部を一箇所に混ぜると決定のポイントが見えにくくなる。分ければ「明らかに正しい」か「明らかに間違っている」かがはっきりする。パフォーマンスの話はまた別だけど、実際にはほとんどのケースでそこまで気にする規模じゃないはず。
by_category = defaultdict(list)
for d in data:
by_category[category(d)].append(d)
for category, per_category_data in by_category.items():
do_thing(category, per_category_data)
ミスが目に見えるようになる、あるいはミスを入り込ませにくくするコードパターンをすごく重視してるんだ。ただ、このモデルでは説明しにくいパターンもあるけどね。
(コレクションを扱うための語彙を一貫させるという原則は好きだけど、トップレベルで条件分岐を多用するとすぐに「……なぜこのメソッドは呼ばれていないんだ?」という領域に陥りがちで、それは「なぜ遅いんだ?」という問題よりも厄介だと思うよ。)
「偶発的なケースワーク」は上に移動させるべきで、「再利用可能な大規模オントロジー(概念体系)」は下に移動させるべき、という考え方なのかな?
空間を完全に制約するような、非常に強力な抽象化っていうのはあるよね。優れた定義の例としては、それを比較するための外側の何かを思いつくことさえできず、ただそこに「存在」するもの。そういうものは、その概念を規定しているからこそ長く生き残るんだ。
でも、そういう哲学をプログラムの非常に深いところでやろうとすると、おそらく微妙なところで不変条件をいくつも破ることになるんじゃないかな。
結局のところ、スパゲッティコードの経験から分かる通り、完全に分離なんて無理なんだけどね :)