if は上に持ち上げ、for は下に押し下げる
(matklad.github.io)- 条件分岐は呼び出し側へ引き上げ、反復処理はバッチ演算側へ押し込むという、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)が再度matchでfoo(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 ベースの多項式乗算 が登場する
- 複数の点で多項式を同時に評価する方法は、個々の点での評価を何度も行うより高速になりうる
ifとforのルールは一緒に適用できる- 良い形は、
if conditionの外側で分岐ごとにfor walrus in walrusesを実行する構造 - 悪い形は、
forループ内で毎回if conditionを評価する構造 - 条件の再評価を避け、ホットループから分岐を取り除き、ベクトル化を可能にできる
- 良い形は、
- このパターンはミクロなレベルにもマクロなレベルにも適用できる
- TigerBeetle のアーキテクチャは、データプレーンでオブジェクトのバッチを同時処理し、制御プレーンの意思決定コストを償却する構造
forに関する助言の主な動機は性能だが、jQuery のようにコレクション単位で動作する方式は表現力にも役立ちうる
1件のコメント
Hacker Newsのコメント
最初はこの記事がここまで反発を受けているのが意外だったが、これがデータ指向設計についての助言だと思い出したら納得した
このフォーラムの多くの人たちと同じく、自分も普段は業務用のWebアプリを作っていて、その文脈ではこの助言はたわごとのように見えるかもしれない
日常業務で命令キャッシュを気にすることがないなら、この助言はだいたい無視してよい、というヒューリスティックは妥当に思える
これがいつ重要になるのか感覚をつかみたければ、Mike Actonの「Typical C++ Bullshit」を見るとよく、この記事はその内容をよりわかりやすく整理したもののように感じる
Casey Muratoriの問題意識にはかなり共感するが、たいていの業務用ソフトウェアは性能よりも変更容易性と正確性、つまり「時間をまたいだプログラミング」を最適化すべきだと思う
開発者は複雑なビジネスロジックをクラス内の小さなprivateメソッドに分割してDRYにしようとするが、「ifを上に持ち上げろ」は分岐ロジックが複数のメソッドに散らばるのを防いでくれる
「forを下に下げろ」も重要だ。多くの呼び出しフローは結局高コストなデータベースクエリに行き着くが、上位のループが下位で複数回のDB呼び出しを生むことはよくある
そのループはSQLの
where句やjoinで置き換えられることが多く、集計やフィルタリングも大量のオブジェクトを取り出して反復するよりDAOの近くへ押し込んだほうがDBの近くで最適化しやすいただし、どんな設計原則でもそうだが、教義のように適用せず意識的に判断すべきだ
変更容易性と性能を互いに反対のものとして見るのは残念だ。実際に両者が衝突するという説得力のある証拠はまだ見たことがない
性能で最も重要な側面のひとつは小さくすることだ。小さなコード、小さなデータ構造、少ない実行命令数が鍵であり、「命令キャッシュを意識する」というのも本質的には小さなコードを書くことにほかならない
コードが小さいほどバグが入り込む余地が減り、一度に理解しやすく、テストカバレッジも確保しやすいため、正確性に有利だ。変更容易性もコードが小さいほど変更が小さくなる
もちろん、並列化、キャッシュ、低レベル最適化のようにコードを複雑にする最適化もあるが、それは性能最適化の一部にすぎず、本気で性能を気にするプログラマならプロファイリングや分析なしにそんなことはしない
if/forとfor/ifは機能的には大差なくても、一方がより速いことはありうるので、コストをかけずに正しく使えるなら、わざわざ遅いほうを選ぶ理由はない単純なプログラムは呼び出しスタックが短く、複雑で高コストな抽象化を避けるため、メモリ使用量が少なく、より高速に動く傾向がある
最高水準の性能を追求するとプログラムは複雑になりうるが、本当の単純さはたいてい妥当な性能をもたらす
「forを下に下げる」は主にデータ指向の助言だが、「ifを上に持ち上げる」はプログラムをより単純にする方向に近い。より正確には、ソースコードの局所性を高めるという話であり、分岐ロジックを一か所に集めるのが核心だ: https://loup-vaillant.fr/articles/source-of-readability
特に「ループを下に下げろ」はCRUDアプリで強力だ。可能な限り作成と更新を一括処理すれば、CPUボトルネックよりはるかに大きな時間を節約できることが多い
items.map(insertToDb/postToServer)とinsertToDb/postToServer(items)の差は、ほぼあらゆる場合で桁違いになるこうした最適化によって作業時間が秒・分単位からミリ秒単位にまで短縮されるのを見てきたし、APIもよりクリーンになり、ログも読みやすくなることが多かった
経験を積むほど、あまりに多くのプログラマが小さな単位の「きれいなコード」には気を配る一方で、コードベース全体の設計には十分に注意を払っていないと感じる
簡潔でよく磨かれた関数は良いが、この種の話題は PR や議論で生産性のない自転車置き場論争に発展しがち
関数が少し雑かどうか、
ifやforをどこに置くか、mapやfilterを使うかどうかは、関数名が適切で、インターフェースと型が表現力を持ち、目的が明確で、文書化されており、副作用を過度に使っていないなら、それほど気にしない入力値があるときにどこで検証するかを考えると、このヒューリスティックに従えば入力を受け取る最上位で検証すべき
これはコードの理解にも役立つし、証明の観点でも前提条件を上位へ伝播させる必要があるため、技術的にも必要
最初の助言はたしかに些細な論争ではなく、二つ目はやや曖昧
ジュニアが痛い目を見ないようにしようとはするが、複雑性のコストやコードが長期的にどう変化するかへの感覚は、まだ十分でない場合が多い
逆に 20 年以上やっていると、たいていの技術的要素よりも シンプルさを高く評価するようになる
「どれだけ早く分岐するか」「このコードが実際にやるべきことは何か」といった問いのほうが、長期的には最も価値のある答えを与える傾向がある
抽象化やカプセル化に関する問いは、むしろ前述の種類の論争につながりやすく、「大きな絵」しか見ない人たちのコードでセキュリティ問題が最も多く出るのも、実際にはそのよく設計されたコードベースが何をしているのかを十分に理解していないから
データ指向設計がうまく合う性能重視のソフトウェアでは、こうした小さな単位のことにもっと気を配る必要がある。コンパイラ最適化がそのように動くから
そうなるとゲームのルールが変わる。文の意味が重要になり、自己説明的なコードがより意味を持ち、コードコメントは推論のための用途だけに残り、「ドキュメント」は仕様やユーザー向け説明書に近づく
せいぜい関数を束ねられる程度で、その先は各自で何とかするしかない
「if を上に持っていく」ことには、関数定義の中で 事前条件と事後条件が直接見えず、各呼び出し箇所で確認しなければならないという欠点がある
複数人が関わる大規模プロジェクトでは、そのような関数が意図した文脈の外で再利用され、バグにつながることがある
契約フレームワークを使えば解決できるが、条件を契約とコードの両方に二重に書くことになり、依存型でも同様
特定の文脈に属するコード領域にタグを付け、その文脈の中でのみ呼び出せる関数を定義する方式は興味深い
Python なら
@requires_context("VALIDATED_XY")のようなデコレータとvalidated_xyコンテキストマネージャで、検証済みの領域でのみ関数を呼べるようにできるランタイムはその文脈の意味を知らないが、ツールとテストを整えれば、特定の条件が満たされたときにだけ望む文脈を確立するよう設計できる
Haskell のような言語では恒等モナドのようなもので型レベルに強制できるし、型レベルの強制でなくても、「unsafe」なコード領域を保護する興味深い方法になりうる
したがって事前条件は依然として関数定義に直接見えており、ただ
if文ではなく型シグネチャの一部として現れるこれは記事で使われている Rust では一般的なパターンで、
ifチェックと違ってランタイムではなくコンパイル時に確認される厳格な事前条件なので、満たされなければプログラムはコンパイルされないたとえば
do_something(position: ValidatedPosition)のように受け取り、通常のPositionを検証したあとValidatedPositionに変換して渡せばよい実際には
ValidatedPositionのコンストラクタの中に検証をカプセル化するだろうが、要点はPositionをそのまま渡そうとすると mypy が誤った型を渡したと知らせてくれる点Python の型検査は Rust ほど包括的ではないが、渡されるデータが適切に処理されたことを保証したいとき、ますます有用になっている
事前条件のある関数なら、当然ながら関数の冒頭でアサートしてよい。たとえば Java の型システムは null を許容するので、オブジェクトが必要な関数は null が来たら例外を投げるべき
各呼び出し箇所は、事前条件が真のときだけ関数を呼ぶ責任を負う。これは事前条件の定義上当然
事前条件を破って関数を呼ぶのは呼び出し側のバグ。関数内でそれを確認して未定義動作を防ぐコードが必要な場合はあるが、そのようなアサートとプログラムの実際の制御フローは区別すべきであり、記事は後者について述べている
fn frobnicate(walrus: Walrus)では、所有しているWalrusでないものを渡そうとするとプログラムはコンパイルされないジェネリクスであっても、引数は トレイト境界を満たさなければならず、関数内で引数をどう使うかに応じて、コンパイラが関数定義に必要な境界を要求する
publicとprivateの意図は、ある程度は特定の文脈に合わせてコードをタグ付けする役割を果たそうとしていたのではないかと思うあるいは
public、private、.NET エコシステムのprotectedが扱う領域をまたぐ、より具体的なセマンティクスが必要なのかもしれない最初の例がよくないのは
ifやforのせいではなく、別の理由によるもの一般に何かの「コンテナ」があるなら、コンテナではなくその中のドメインレベルの「対象」に対して関数を書くほうがよい
Clojure で agent を使うときも、agent 用の関数を書くのではなく、agent が保持できる対象用の関数を書く
Elixir でも中核となるドメイン関数は PID ではなく内部のドメインデータ構造に対して動作し、必要なら GenServer 呼び出しがそこへ委譲される
こうするとより柔軟になり、中核ドメインである「Walrus を frobnicate すること」と、アプリケーション上の関心事である「Walrus があるかもしれないしないかもしれず、あれば frobnicate すること」を、よりきれいに分けられる
早い段階で検証するのはよいが、関数が奇妙なエラーで突然落ちる代わりに、検証エラーを明確に返すことも重要だ
Haskell は newtype でこの問題を解決しており、「適切な検証をすでに済ませたことを保証する透明なコンテナ」を提供する形になっている
本当に強調したい助言は「悲しい if」を好め、ということだ。正常系を何重にもネストするより、誤った条件を上から一つずつ調べてすぐ修正するか中断するコードのほうが、ほとんど常に読みやすく保守しやすい
人は期待したケースに集中したがるものだが、コードはその逆で例外的なケースにまず集中してほしい。すべての
ifは頭の中の負荷を生み、外部システムから情報を取ってきたりエラーで早期終了したりする必要があるなら、検出と処理が隣り合っているとその負荷をすぐ下ろせるwalrus.frobnicate()ならよりよい、という意味なら、それは著者が言いたい本質というより 文法の好み に近い問題ではないかと思うこれが悪い助言だとまでは言わないが、必ずしもよい助言でもない
選ばれている言語が Rust だという点が、かなり多くを物語っている。強い型システムは、他の言語で必要になる多くの防御的プログラミングを不要にしてくれる
C プログラマが関数に渡されたポインタの妥当性を確認せず NULL デリファレンス を起こすなら、そんな人とはチームを組みたくない
だから少なくとも一部の
ifは間違いなく下側にあるべきで、エラーがうまく上へ伝播する形になっているのが望ましいforについてはそこまで強く感じないが、C では配列引数がポインタに decay するため、反復も下ではなく上にあるべきだと思う。配列を作った関数では長さを信頼できるが、引数として受け取った関数ではそうではない文書が強い型システムの代わりを務めるべきであり、ランタイム検査がその代わりを務めるべきではない
NULL チェックやその他の防御措置だらけのコードは、はるかに読みにくい
ライブラリ境界では、より防御的な検査が必要だと言えるし、この記事もまさにそうした検査を上へ押し上げろと言っている
セキュリティ上重要なコードは別かもしれないが、ほとんどの場合、偶発的な 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週間 かかることがある
そういう背景のおかげで、
forとifの順序を適切に取る小さな最適化を本能的に行うし、そうなっていないコードは単に間違って見えるこうした一般則が実際のコードに本当に適用できるのかは確信が持てない
こうしたルールはしばしば置き場所を誤った教義のように見えるし、ブログ記事がヒューリスティックだと書き始めていても、若いプログラマーが常にそのように受け取るとは限らない
数週間前、YouTube が "I'm a never-nester" という動画を何度も勧めてきたが、まるで
ifを絶対にネストするなという主張のようで、かなり滑稽だった記事の例のように、
forの中にif conditionがあるコードを「悪い」としていたが、実際にはそう書かれているコードの大半ではconditionがwalrusに依存していて、ifを上に持ち上げることはできないもし持ち上げられるのだとしたら、同じ条件をループごとに再評価することになるのはあまりにも明白なので、プログラマーは自然と避ける傾向がある
しかし、ジュニアや学生が教義のように聞こえる助言を読むと、それを厳格に守ろうとして、かえってより悪いコードを作ってしまうことがある
match (condition_a, condition_b)で4つのケースに分けるやり方がネストしたifより良くないケースを経験的に見たことがないifを「上に持ち上げるべき」よい例だと思うコードの目的は、条件に応じて walrus に特定の演算を実行することだが、実際には
ifが 多態性 と型システムの代わりに使われているなぜ walrus に、異なるシナリオで呼び出すべき2つの関数が必要なのか? 1つの関数と2つの型を用意して、正しい型を渡せばよいのではないか?
現在の構造のままでも、上の方で条件に応じて処理関数を選び、下の反復では選ばれた関数を毎回呼び出すようにできる
判断は可能な限り早い時点で行えば、コード全体にばらまく必要がない。コードの内部は分岐なしで毎回同じ処理を行い、出力は構成グラフを通じてのみ変わる
もちろん新しい考えではなく、15年前の時点ですでに古いアイデアだった: https://www.youtube.com/watch?v=4F72VULWFvc
walrusに依存しないときにだけ機能し、その事実を明示的に示すのに役立つここで「for を下に下げろ」を再適用すると、
if conditionの中でfrobnicate_batch(walruses)やtransmogrify_batch(walruses)を呼ぶ形になる自分が何度も出会いながらうまく言語化できなかった問題を誰かが言葉にしてくれたし、頭に入れておく価値のあるよいモデルに見える
同時に、こうした批判も価値がある。教条的なジュニア開発者が一緒に読んで、もう少し繊細に判断できるようになればと思う
プログラマーは特に、こうしたものをコピーして機械的に適用してはいけない
ヒューリスティックやベストプラクティスを盲目的に適用するのは良い考えではないかもしれず、結局のところすべては「ケースバイケース」だ
こうしたヒューリスティックにはたいてい知恵のかけらが含まれているが、たいていは新人プログラマーから再び 脱教条化 しなければならない対象も生み出す
あまりに厳格に従おうとすると、かえって悪くなる場合はいつも多く、「いつこの助言を無視すべきかを知れ」が実質的な核心的難しさだ
こうしたルールは、いじってみるには有用だと思う。受け入れてみて、最後まで押し進めてみて、1日後でも1年後でもひっくり返してみて、どこまで行けるか見ればいい
限界を学ぶことも、塗り重ねられていく羊皮紙の上に積み上がる、もう一つの材料だ
その公案のいくつかは、互いに矛盾しているべきだ