[前回までのあらすじ]
LaTeX の原稿に記されている文字を別の文字に置き換えて出力したくて TeX マクロを書いてみたけど、なぜかうまく動かない。 TeX はチューリング完全なプログラミング言語だって聞いたけど、こんな基本的なテキスト編集の機能さえ持ち合わせてないのでは話にならない。 TeX も LaTeX も滅ぶべき。
Note:この記事は、TeX & LaTeX Advent Calendar 2013 の20日目の記事です。昨日は zr さんによる「テキストの装飾の指定: CSSとLaTeXの比較」です。明日の21日目は shigeyas さんの予定です。
LaTeX のことはきっぱり忘れてください(「TeX」といったら1982年に書き直されたいわゆる TeX82 と、その直系にあたるバージョン3のバグフィックス版たちのことです)。 ここでは TeX で文字の置換に「近い」ことをしてみましょう。 再帰的な TeX マクロをどうやって書くか、何となく感じてもらうのが本章のゴールです。
再帰!帰!!
いま、与えられた文字列中のすべての文字を「*
」に変換して伏字化した結果が欲しいとします。「brainfuck
」→「*********
」という具合です。パスワードの入力欄みたいな雰囲気ですね。
ぱっと思いつくのは、こんな再帰的なマクロを使った例です。これは実際、上記の例「brainfuck
」→「*********
」ならうまく変換してくれます。
\def\ast#1{\recur#1!} \def\recur#1{% \ifx!#1\else*\expandafter\recur\fi}
\ast{brainfuck}
とすると TeX の中で何がおきるのか、順番に見ていきましょう。
\ast
マクロは、引数の末尾に !
をつけてから、ブレース {}
を取っ払った状態で \recur
に引き渡します。
「 \recur brainfuck!
」という形で \recur
マクロに引数が渡されるということです。
この状態で \recur
マクロが引数だと思うのは、 brainfuck!
という文字列ではなく、先頭の b
だけです。
ナンデと思うかもしれませんが、ブレースがない状態でマクロに何かを渡すと、その先頭だけが引数としてマクロに渡されるのです。
これは、TeX において \def
を使って定義できるのは、一般的なプログラミング言語における関数とかサブルーチンとかクロージャーとかではなく、「置き換えルール」だからです。
置き換えルールを \def
で定義する際には、置き換えの対象となるパターンを指定するときのプレースホルダとして、 #1
のようなパラメータ引数が使えます。
たとえば \def\recur#1{foo #1 bar}
によって「定義」されるのは、「\recur
の直後に「何か」が一個あったら、それを #1
で示されるパラメータ引数に束縛して、 foo
と bar
で囲む」という置き換えルールです。
じゃあ、なぜ最初に \ast{brainfuck}
としたときには、文字列 brainfuck
が #1
に束縛されたのでしょうか?
それは、ブレースで囲まれて渡されたパラメータ引数は、そのブレースを取り除いた状態でまるごと束縛されることになってるからです。
トークンとトークンリスト
上記では「何か」とぼかしましたが、置き換えルールが適用される対象は「トークン」と呼ばれています。 トークンとは具体的には何なのでしょうか?
簡単にいうと TeX のトークンとは、「文字(カテゴリコードという TeX 特有の属性つき)」または「コントロールシーケンス(いわゆる \
ではじまるやつ)」です。
TeXは、読み込んだファイルに含まれる文字や数字や記号を、まずこの「トークン」の列へと咀嚼します。
そうして得られたトークンの列は、トークンリストと呼ばれます。
Note:文字トークンとコントロールシーケンス以外に、マクロ定義に出てくる #1
みたいなパラメータ引数もトークンとみなされてるのですが、忘れてかまいません
このトークンリストには、原稿を書いた人が入力したコントロールシーケンスなんかもみんな含まれているわけですが、それを最終的に出力するデータ用のトークンリストへと展開するのがTeXの次の仕事です。
ここまでをまとめると、 \ast{brainfuck}
は \ast
の定義によって \recur brainfuck!
に置き換えられ、 \recur brainfuck!
は \recur
の定義によって \ifx!b\else*\expandafter\recur\fi rainfuck!
に置き換えられます。
b
だけが \recur
に渡された結果、残りの rainfuck!
が後ろにくっついているのに注意してください。
\ifx は直後のトークンを比較して展開する場所を制御
\ifx
は、直後の2つのトークンが「同じ」なら \else
または \fi
までを読み込み、違ったら読み飛ばすという TeX の命令です。
さきほど \ast
マクロを利用したときに、ブレースを使って引数を {foo}
のように囲んでグループとして渡せるといいました。
ブレースで囲んだ「何か」は、トークンではありません。
グループを開始する左ブレースというトークン、ブレースの間にあるトークンたち、それにグループを終了する右ブレースというトークンからなる、トークンリストの一部です。
したがって、たとえば \ifx {foo}\x
のようにしても、 x
が文字列 foo
か調べて条件分岐することにはなりません。
もし \ifx {foo}\x
と書いたら、 {
と f
が「同じ」かどうか調べた挙句、当然 {
と f
は「同じ」じゃないので、 \else
または \fi
まで読み飛ばされて意図しない結果になります。
Note:\ifx
は直後の2つのトークンをとって、それらを展開せずに、同じかどうかを調べます。 もしマクロ定義の中で\ifx #1!
のように書いて(#1
と!
が逆になってることに注目)、展開時に#1
にbrainfuck
が束縛されたとしたら、 先頭の2つのトークンb
とr
が比較されて常に\else
または\fi
まで読み飛ばされてしまいますよ。
\ast{brainfuck}
の例に戻りましょう。
!
と b
は「同じ」じゃないので、次の条件分岐は、
\ifx!b\else*\expandafter\recur\fi rainfuck!
else節のようなものに処理がわたってこんなふうな結果になるように思えるかもしれません。
*\expandafter\recur rainfuck!
しかし、実際にはこうなります。
*\recur rainfuck!
\expandafter
はいったいどこにいってしまったんでしょう?
実は \ifx
のような条件分岐命令は、TeX が「展開」するトークンを条件に基づいて制御するものであり、「条件を満たさなかったら \else
から \fi
までの部分を実行する」ような代物ではありません。
この場合は、「条件を満たさなかったら \else
までを読み飛ばす」だけです。
なので \ifx!b
まで展開した TeX は、条件を満たさないので次の \else
まで読み飛ばし、 *\expandafter\recur\fi rainfuck!
の展開を試みることになります。
展開順の制御は \expandafter から
いまはトークンリストを展開しているので、最初に出てくる *
は単なる文字ではなく、カテゴリコードという値がついたトークンです。
最終的にはアスタリスクが出力されるし、単なる文字との違いを意識しなければならないわけではないのですが、トークンリストに読み込まれているのは、あくまでも記号などの文字を意味するカテゴリコード12がついた文字トークンです。
その次にくるのが、\ifx
によって消えてしまったかのように見える \expandafter
です。これは「直後のトークンの展開を待たせる」という仕事をする TeX のプリミティブです。
ここに \expandafter
があるため、 TeX はトークンリストで次にくる \recur
のことは忘れて、 \fi
を展開します。これは言うまでもなく、条件分岐が一つ終わりますという命令です。
なので、先に \ifx
から始まった条件分岐は、この時点で無事に終わります。
ここで、先ほど \expandafter
されていた \recur
が元の位置に戻されます。
その後の rainfuck!
は、全部文字トークンです。
結果として得られるトークンリストが *\recur rainfuck!
というわけです。
というわけで、 \recur brainfuck!
が *\recur rainfuck!
になりました。
ここで再び \recur
が展開されて **\recur ainfuck!
になり、さらに \recur
が展開されて ***\recur infuck!
になり、…… *********\recur !
まで同様に展開されていきます。
最後の !
は、 \recur
に読まれると \ifx !!
となり条件を満たすため、今度は \fi
まで一気に読み飛ばされます。
その結果得られるトークンは何もないので、最終的に一連の文字トークン *********
が TeX の出力処理に回ることになります。
これが TeX プログラミングにおける末尾再帰です。まるでパズルですね。
さらに深く
この \ast
には重大な制約があります。
まず明らかな制約として、再帰の基底ケースとして !
を使っていることから、 !
が含まれる文字には使えません。
さらに、文字列の中に対応するブレースで囲まれた部分があると、その全体が一つの *
に変換されてしまうという問題があります
( \recur {foo}bar
が何に展開されるかを上記と同じ要領で考えると、なぜだかわかりますよね)。
Note:ブレースで囲んで入力するとトークンリスト上に1つのトークンとしてまとめられるわけではないことに注意してください。このようなブレース区切りは、マクロ定義にあるパラメータ引数(#1
sなど)にトークンをマッチさせるときだけ意味をなします。 たとえば\ifx
の条件チェックでブレース区切りが意味を持たないことは説明しましたが、さらに、たとえば\ifx ... \else ... \fi
の中で左右のブレースが閉じていないようなマクロを書くことも可能です。 この性質は、実用的なマクロの多くでも実際に利用されています。
いちばん大きな制約というより欠陥は、この \ast
マクロはスペースを文字として勘定してくれないことです。
TeX は、スペース(空白)を読み込むとき、暗黙に二通りの解釈をしてトークンリストを作ります。
空白のつもりでファイルに入力したのにトークンリストには何も詰め込まれなかったり、逆に無意識に入れていた何かが空白を表すトークンとしてトークンリストに詰め込まれたりするのです。
空白を意図通りにトークンリストに詰め込むためには、 TeX の文字解釈と展開とマクロのパラメータに対するさらなる理解が無駄に求められます。
そもそも、ここまで「文字列」とぶっちゃけて言ってきましたが、 TeX にそんなデータ型はありません。 ドキュメントを書くのに入力している文字たちは、あくまでもトークンリスト上のトークンを形成するものです。 それを意識しながら TeX ソースを読んだり書いたりすると、 TeX マクロってエンジニアリング的には微妙だけど仕組みとしては意外と面白いかもと感じてきます。 見た目のプログラミング言語っぽさに惑わされると不自由さにがっかりするかもしれませんが、トークンリストの展開順序をコントロールして遊ぶゲームだと思うとなかなか挑戦しがいがある時間泥棒だったりします。
最後に
「!
」の制約はおいといて(ほんとはおいといちゃだめだけど)、代入を使わずにブレースとスペースの問題に対処すると、「TeX 芸人二段」 という称号がもらえることになっています。
ひまなときにチャレンジしてみよう!
この記事は「Learn You A Haskell for Great Good!」というHaskellのチュートリアルのパロディーです。ほかの章はありません。TeXに関する説明には慎重を期したつもりですが、内容には執筆者の勘違いが含まれている可能性があるので、うそを見つけたらぜひコメントをください。
「空白が無視されること」についての記述はちょっとアレな感じがします。
返信削除この説明を読む限りだと、「\ast で空白が飛ばされるのは、何らかの原因で空白文字が空白トークンにならなかったからだ」と誤解される恐れがあると感じます。
「何故か、展開限定文脈(\edef)での話になっている」という点もアレといえばアレなんですが、
「本当のこと」を知るためには「展開と実行が混ざった処理」を理解する必要があるので、
この話の段階では「厳密には嘘である」のは仕方ないといえそうです。
あとは \fi が 1 箇所 \if になってるとか、\else のフォントが 1 箇所アレとか。
ありがとうごさいます!スペースの説明についてはもうちょっと考えて工夫してみたいです!誤字は直します。。
返信削除もともと2段をとりに行ったついでに書き始めたので、最後にこじつけ感があるのはアレだという自覚はあります。
スペースの扱いについては、それだけで一章ぶんが必要ですね。