
2025/12/26 21:35
**Unix `find`式のバイトコード化** * **目的** 複雑な検索条件を、`find` ユーティリティが効率的に評価できるコンパクトで実行可能な形に変換すること。 * **主な構成要素** - **演算子**:論理(`-and`、`-or`、`!`)、比較(`-eq`、`-ne`、`-lt`、`-gt` 等) - **オペランド**:ファイル属性(名前・サイズ・種類)、タイムスタンプ、パーミッション、所有者、内容チェックなど * **コンパイル手順** 1. ユーザーが入力した式を抽象構文木(AST)に解析する。 2. AST をバイトコード命令列へ変換する。 3. 定数の折りたたみや不要な分岐の簡略化で最適化する。 * **バイトコード形式** - 各命令は固定長レコード:opcode + オペランド。 - opcode は `CHECK_NAME`、`COMPARE_SIZE`、`JUMP_IF_FALSE` 等を表す。 - オペランドは即値、文字列リテラル、または制御フロー用オフセットになる。 * **実行** `find` エンジンは検索ツリー内の各ファイルに対してバイトコードを解釈し、小さな仮想マシンでブール結果スタックを更新する。 * **メリット** - 各ファイルごとに式を再解析するより高速。 - 完全な AST をメモリに保持するよりも記憶域が節約できる。 * **典型的な使用例** ```bash find /path -type f -size +1M -exec gzip {} \; ``` `-type f -size +1M` の式は実行前にバイトコードへコンパイルされる。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Summary
この記事では、Unix の
find コマンド式を効率的で単一レジスタの機械語に変換するバイトコードコンパイラ findc を紹介しています。実行時に各式を解釈する代わりに、findc はまずシェンティング・ヤードアルゴリズムを用いて中置記法を後置記法へ変換し、次に 5 つの単純なオペコード(
halt、not、braf、brat、および action)を発行します。
find 式にはループが含まれないため、すべての分岐は前方のみ相対ジャンプになります。コンパイラは fragment stack を使用します ― 各フラグメントはバイトコード命令列であり、ブランチターゲットを再計算せずにコードブロックを連結できます。この設計により、論理演算子(-a、-o、単項 !)を処理し、明示的な副作用アクションがない式は自動的にデフォルトの -print でラップされます。
ピープルオプティマイザは冗長命令(二重否定や不要ジャンプなど)を削除してバイトコードをコンパクトに保ちます。
記事では、生成されたバイトコードの具体例(例えば
$ findc → action -print; halt; $ findc -type f -executable → 一連の action、braf、および halt 命令)を提示しています。
オンライン WebAssembly デモでは、ユーザーが式がどのようにコンパイルされるかをインタラクティブに確認でき、オプティマイザの動作も見ることができます。式をバイトコードへ事前コンパイルすることで、findc は従来の木構造走査型解釈子より高速な実行と低メモリ使用量を提供し、他のコマンドラインツールやスクリプト環境で同様の手法を採用するインスピレーションになる可能性があります。
本文
2025年12月23日 – nullprogram.com/blog/2025/12/23/
(著者は現在米国での雇用機会に対してオープンです。)
はじめに
将来のプロジェクトに備えて、Unix の
find ユーティリティについて考えていました。find はファイルシステム階層を対象にし、専用の式言語で選択した基本操作を
組み合わせてフィルタリングします。ユーザーは単項演算子と二項演算子を使い、
優先順位を付けるために括弧でグループ化して式を構築します。
find は多くのファイルに対して式を適用する可能性があるため、できるだけ前もって式をコンパイルし、バイトコードへ変換し、実行時の 要素ごとの処理量を最小限に抑えることは賢明です。
思案した結果、期待よりも単純な手法が見つかり、結果に満足しています。
後になって驚いたのは、すべての実際の
find 実装が木走査型インタプリタを
使用していることです。本記事では私のコンパイラがどのように動作するか、
実行可能な例と改善案をまとめています。
簡易概要
$ find [-H|-L] path... [expression...]
技術的には少なくとも 1 つのパスは必要ですが、ほとんどの実装では パスが指定されない場合に
.(現在のディレクトリ)を暗黙的に使用します。式が与えられなければデフォルトで
-print が適用されます。例えば次のようにすべてのファイルとディレクトリを表示します。
$ find .
これにより現在のディレクトリ以下の全ツリー(ディレクトリも含む)が 出力されます。
ファイルのみを表示したい場合
$ find . -type f -a -print
ここで
-a は論理 AND(二項演算子)です。-print は常に真となるため、実際には -a を書く必要はありません。隣接した操作は暗黙的に
-a で結合されます。
ファイルを連鎖させてさらに条件を付ける例:
$ find . -type f -executable -print
-exec、-ok、-print(または -print0 や -delete などの副作用付き拡張)が
含まれていない場合、全体の式は暗黙的に ( expr ) -print に包まれます。したがって次のように書くこともできます。
$ find . -type f -executable
論理 OR の使用
$ find . -type f \( -executable -o -name '*.exe' \)
-o は論理 OR(二項演算子)です。ここで
\( … \) を使う理由は、-o が -a より優先順位が低く、
また括弧自体がシェルのメタ文字であるためにエスケープが必要だからです。
([ と ] を代わりに使用していない点は残念です。)単項否定演算子
! もあります。
$ find . -type f ! -executable
二項演算子は短絡評価されます。例えば次のように書くと:
$ find -type d -a -exec du -sh {} +
ディレクトリでない場合、左辺が偽になるため右辺(
-exec)は
評価されません。等価な OR 版もあります。
$ find ! -type d -o -exec du -sh {} +
正規表現の扱い
GNU、BSD、BusyBox のすべての実装には
-regex 拡張があり、
式が評価される前に正規表現を即座にコンパイルします。
$ find . -print -o -regex [ find: bad regex '[': Invalid regular expression
これは「第 1 式が真なら第 2 式は評価しない」という元の精神と合わず、 私には驚きでした。再コンパイルを遅延させ、実際に使用されたときだけエラーにする設計も考えられます。
バイトコード設計
バイトコードインタプリタは 1 ビットのレジスタ(結果)だけを追跡すればよいので、 単一レジスタマシンとして実装できます。私が考案したオペコードは次の 5 種類です。
halt // プログラム停止 not // レジスタの値を否定 braf LABEL // false のときジャンプ(branch if false) brat LABEL // true のときジャンプ(branch if true) action NAME [ARGS] // アクション実行(例:-name, -type)
は明示的に終了を指定できるので便利です。halt
とbraf
を使ってbrat
と-a
を表現します。-o
実際にはループは存在せず、ジャンプは常に前方(forward)になります。
実装では各アクション(
-name, -ok, -print, -type 等)が
専用オペコードを持つべきですが、ここでは汎用の action を使って
外観で判定するようにしています。それぞれのアクションはレジスタを設定し、
-print などは常に真にします。
このコンパイラを findc(“find compiler”)と呼びます。
更新情報:Wasm を通じてオンラインデモを試してみてください!
このバージョンには記事公開後に書いたピープルオプティマイザが含まれています。
例
push と Slice マクロのため、非常に新しい C コンパイラ(例:GCC 15 または Clang 22)が必要です。いくつかの
find コマンドを試し、バイトコードがどのようになるか確認できます。
最も単純なケース
$ findc // path: . action -print halt
少し複雑に
$ findc -type f -executable // path: . action -type f braf L1 action -executable L1: braf L2 action -print L2: halt
ここで、パスがファイルでない場合に残りのプログラムをスキップするよう、 二番目のブランチ(
braf)が機能します。しかし既に改善余地があります。
action -type f braf L1 action -executable braf L1 action -print L1: halt
より複雑な例
$ findc -type f \( -executable -o -name '*.exe' \) // path: . action -type f braf L1 action -executable brat L1 action -name *.exe L1: braf L2 action -print L2: halt
括弧内で
-executable が成功すると右辺(-name)をスキップします。brat は直ちに braf にジャンプし、さらに前方へ移動させるとより良いです。
action -type f braf L2 action -executable brat L1 action -name *.exe braf L2 L1 action -print L2: halt
無駄な not
の削除
not$ findc ! ! -executable // path: . action -executable not not braf L1 action -print L1: halt
連続する二つの
not は相殺されるため、指令を削除できます。
ピープルオプティマイザ
コンパイラは以下のような小さな改善を繰り返し行うことで最適化します:
を削除not-not
→brat
(または逆)の再ターゲットbraf- 同一ジャンプ先へのジャンプはそのターゲットへ直接移動
はnot-braf
に、brat
はnot-brat
に変換braf
前の副作用を持たない命令(例:halt
)を削除not-halt- 常に真となるアクション(例:
)はブランチを省略-print-braf
実装には、分岐先を検出して修正できるよう少しだけ豊富な内部表現が必要です。
さらに複雑なケース
$ findc -type f ! \( -executable -o -name '*.exe' \) // path: . action -type f braf L1 action -executable brat L2 action -name *.exe L2: not L1: braf L3 action -print L3: halt
最適化されていないジャンプが見えるのは、コンパイラ構造上の欠点を示しています。
挑戦したい方はここで止まり、どのようにコンパイラを設計すればこうしたアーティファクトを生成しないか考えてみてください。
パーシングとコンパイル
バイトコードの形を決める前に、
find の中置表記(infix)を
コンパイラが扱いやすい後置表記(postfix)へ変換する必要があります。例:
-type f -a ! ( -executable -o -name *.exe )
は
-type f -executable -name *.exe -o ! -a
となり、括弧が消去されます。
入力は
argv 配列として受け取るため、すでにシェルやランタイムによってトークン化されています。
ショーティングヤードアルゴリズム
- アクション はそのまま出力キューへ
- 特殊スタックトークン(
,-a
,-o
,!
)を見たら、 より高い優先順位の演算子をスタックからポップしてキューへ追加し、(
に到達するまで続ける(
を見たら、)
が出てくるまでスタックからポップしてキューへ(
トークンがなくなったら残りのスタックをすべてキューへ。
パーサは暗黙的に
-a(論理 AND)を挿入し、-exec, -ok, -print がない場合は解析完了後に
-print と -a をキューに追加して ( expr ) -print で包みます。
バイトコードへの変換
コンパイラは「バイトコードフラグメント」のスタックを維持します。
各フラグメントは一連のバイトコード命令です。
ジャンプは相対アドレスを使用するため位置に依存せず、
フラグメントを結合してもブランチ先は変更しません。
トークンごとの処理:
- アクション –
命令を作成し、新しいフラグメントとしてスタックへプッシュaction
– スタックのトップフラグメントを取り出し、末尾に!
を追加して再度プッシュnot
– スタックから上位 2 フラグメントをポップし、真偽が偽の場合に次のフラグメントへジャンプする-a
を挿入して結合。braf
(左辺が偽なら右辺をスキップ)
– 上記と同様だが、左辺が真の場合にジャンプする-obrat
式が正当であれば、処理後のスタックには 1 つのフラグメントだけ残ります。
その末尾に
halt を追加し、これが最終的なプログラムです。もし最後のフラグメントにブランチがある場合は、そのブランチ先として
halt が位置します。
ピープルオプティマイザを加えることで、この命令セットで最適なプログラムを生成できると考えられます。