マクロツイーター

はてダから移行した記事の表示が崩れてますが、そのうちに直せればいいのに(えっ)

野生の難解TeX言語クイズ(アゲイン)

「野生の難解TeX言語クイズ」の続編です🙂

先日、「TeX言語入門入門」という記事が公開されました。

そこにTeX言語のプログラミングの例としてFizzBuzzが載っています。このコードがかなりアレみ🍣の強いものだったので、クイズを出題しました(えっ😲)

問題の準備

件のFizzBuzzのプログラムはTeXINIモード用のもので、実行すると以下のように動作します。

  • 標準入力から正の整数値を読み込む。これがFizzBuzzの「上限値」となる。
  • 1から「上限値」までのFizzBuzzの結果を標準出力に出力する。

プログラムコードは次の通りです。

\catcode`\{=1%
\catcode`\}=2%
\catcode`\#=6%
\count255 = 255%
\def\newcount#1{\advance\count255by-1\countdef#1=\count255}%
\immediate\write18{read -r line <&0 && echo "$line" > line.tmp && echo}%
\relax%
\newcount\maxnum
\newcount\inter%
\newcount\modval%
\newcount\tempval%
\newcount\remthree%
\newcount\remfive%
\newcount\remfifteen%
\def\temp{}%
\openin0=line.tmp%
\read0 to\temp%
\maxnum=\temp%
\closein0%
\inter=0%
\def\domod#1#2#3{%
    \ifnum#2=0
    \else%
    \ifnum#1=0
    \else%
        \modval=#1%
        #3=#1%
        \divide\modval by#2%
        \multiply\modval by#2%
        \global\advance#3 by-\modval%
    \fi\fi%
}%
\def\fizzbuzz{%
    \advance\inter by1%
    \ifnum\inter=0
    \else%
    \domod\inter3\remthree%
    \domod\inter5\remfive%
    \domod\inter{15}\remfifteen%
    \ifnum\remfifteen=0
        \immediate\write18{echo FIZZ BUZZ}%
    \else%
        \ifnum\remthree=0
            \immediate\write18{echo FIZZ}%
        \else%
            \ifnum\remfive=0
                \immediate\write18{echo BUZZ}%
            \else%
                \immediate\write18{echo \the\inter}%
            \fi%
        \fi%
    \fi%
    \fi%
    \ifnum\inter<\maxnum%
        \fizzbuzz%
    \fi%
}%
\immediate\write18{echo }%
\immediate\write18{echo 1}%
\ifnum\maxnum=1
\else%
\fizzbuzz%
\fi%
\end

標準入出力の部分でわざわざシェル実行(\write18)を利用しているのがフツーではない感じですが、元の記事によると、これは「標準入出力」を厳密に捉えたからで、TeX言語でフツーに「標準入出力」の役割で使われる「オープンされてないストリームに対する\read\write」(つまり\read-1とか\write16とかを指します)は状況によっては標準入出力にならないからのようです。

ここでは出題の都合のため、問題に無関係な部分について以下のように簡略化します。

  • 「標準入出力」についてはフツーの「TeX言語的な標準入出力」(つまり\read-1とか\write16)に置き換える。
  • INIモードではなく、フツーにplainやLaTeXを前提にする。
  • 「行末の%」について、制御綴で終わる行に%を付けるのは明らかに冗長なので除去する。

問題の本題

簡略化した後のプログラムは以下の通りです。

% \newcount はplain/LaTeXのものを利用する
%
\newcount\maxnum
\newcount\inter
\newcount\modval
\newcount\tempval
\newcount\remthree
\newcount\remfive
\newcount\remfifteen
%
% 端末から整数値を入力する
\def\temp{}
\read-1 to\temp
\maxnum=\temp
%
\inter=0%
\def\domod#1#2#3{%
    \ifnum#2=0
    \else
    \ifnum#1=0
    \else
        \modval=#1%
        #3=#1%
        \divide\modval by#2%
        \multiply\modval by#2%
        \global\advance#3 by-\modval
    \fi\fi
}%
\def\fizzbuzz{%
    \advance\inter by1%
    \ifnum\inter=0
    \else
        \domod\inter3\remthree
        \domod\inter5\remfive
        \domod\inter{15}\remfifteen
        \ifnum\remfifteen=0
            \immediate\write16{FIZZ BUZZ}%
        \else
            \ifnum\remthree=0
                \immediate\write16{FIZZ}%
            \else
                \ifnum\remfive=0
                    \immediate\write16{BUZZ}%
                \else
                    \immediate\write16{\the\inter}%
                \fi
            \fi
        \fi
    \fi
    \ifnum\inter<\maxnum
        \fizzbuzz
    \fi
}%
%
\immediate\write16{}%
\immediate\write16{1}% ←(1)
\ifnum\maxnum=1 % ←(2)
\else
\fizzbuzz
\fi
%
% plainとLaTeXの両方で終了する
\csname stop\endcsname \end

一見すると割とフツーのFizzBuzzの実装に見えますが、よく見ると最後の「\FizzBuzz再帰ループに入る」部分のコードがチョット異様です。

  • (1)では「1」の出力だけ別に扱っている。
  • (2)では「上限値」が1であるかを判定している。

\FizzBuzz再帰ループは以下のような構造になっています。

  • \interの初期値は0である。
  • 先頭で\interをインクリメントしている。
  • 末尾で上限検査をしている。

ここから考えるとループの初回は「\interが1のときの処理」なので、(1)や(2)の特別扱いは不要な気がします。

しかし実際にはこの(1)や(2)がないとプログラムの動作は想定仕様を満たさないものになります。まあ実際(1)をわざわざ書いてあるということは「\FizzBuzzを実行しても『1』は(なぜか)出力されない」のでしょう。ここで問題です。

(2)の行を \iffalse に変更した(つまり(2)の判定を無効化した)上で、
「1」を上限値として入力した場合、
このプログラムの出力はどうなるでしょうか。

問題の正解

実際にやってみると、「1」を入力すると、プログラムは​「1」と「2」を出力します。

1
2

問題の解説

事態をヤヤコシイことにしているのは、\FizzBuzzの定義本体の先頭行のコレです。

    \advance\inter by1%

この行の中の1は数値を表しますが、その後に%があるため「終結の空白文字」がありません。以下でこれの影響を調べますが、その前に、TeX​「数値の終結」​の仕様について復習しましょう。

数値の終結の話

\def\nice{8*}

%(A)
\nice\count@=42 \nice

%(B)
\nice\count@=42\nice

(A)の段落では整数を表す数字列42は直後にある空白文字で終結しています。従って、TeXはこの空白文字(トークン)を読んだ時点で整数値を「42」と確定させてcount@への代入を実行します。結果的に、\count@には42が代入され「8*8*」が印字1されます。代入が2つ目の\niceを展開する前に行われることにも注意してください。

対して(B)の段落では42終結させる空白文字がありません。従ってTeX42を読んだ後で数字を探すために後続のトークン列を読むことになります。直後にあるのはマクロ\niceですが、TeX文字トークンを探しているのでこれは展開されて8*となります。8は数字なので数字列の一部として読まれます。次の*は数字でないので、この時点で数字列が終結して\count@への代入が実行されます。結果的に、\count@には428が代入され「8**」が印字されます。今の場合、代入は2つ目の\niceの展開の後に行われました。

数値がなかなか終結しない話

ではFizzBuzzのコードの話に戻ります。後半の部分((2)を\iffalseで置き換えたもの)を再度示します。

\def\fizzbuzz{%
    \advance\inter by1% ←(3)
    \ifnum\inter=0 % ←(4)
    \else % ←(5)
        \domod\inter3\remthree
        \domod\inter5\remfive
        \domod\inter{15}\remfifteen
        \ifnum\remfifteen=0
            \immediate\write16{FIZZ BUZZ}%
        \else
            \ifnum\remthree=0
                \immediate\write16{FIZZ}%
            \else
                \ifnum\remfive=0
                    \immediate\write16{BUZZ}%
                \else
                    \immediate\write16{\the\inter}%
                \fi
            \fi
        \fi
    \fi % ←(6)
    \ifnum\inter<\maxnum % ←(7)
        \fizzbuzz % ←(8)
    \fi
}%
%
\immediate\write16{}%
\immediate\write16{1}% ←(1)
\iffalse % ←(2)を書き換えた
\else
\fizzbuzz % ←(9)
\fi % ←(10)

クイズの出題に従って、端末で「1」を入力すると、\maxnumの値が1、\interの値が0である状態で(9)の\FizzBuzzが実行(展開)されます。

\advance\inter by1\ifnum\inter=0␣\else \domod ……

(3)の行末に空白文字がないため、1から始まる数字列を読む際にTeXは「何か展開不能トークンが現れるまで後ろのトークン列を展開し続ける」ことになります。

(4)の\ifnum\inter=0␣の判定はどうなるでしょうか。今は\interへの代入文(\advanace)の実行の途中ですが、まだ代入は行われていないので、\interの値は0であり、\ifnum真になります。真なのでそのまま続きを読みます。

% (4)の \ifnum が真
\advance\inter by1\else \domod\inter3\remthree ……

展開不能トークンが見つからないまま((4)のifに対応する)(5)の\elseに到達したので、(4)のifに対応する(6)の\fiまで読み飛ばします。

\advance\inter by1\ifnum\inter<\maxnum \fizzbuzz \fi \fi ……

※最後にある\fiは(10)の行によるものです。

またif文が現れました。(7)の\ifnum\inter<\maxnumの判定については、\interは相変わらず0であり\maxnumは1なので真となります

% (7)の \ifnum が真
\advance\inter by1\fizzbuzz \fi \fi ……

\fizzbuzzはマクロであり展開可能なので展開します。

% 1回目の(7)の \ifnum が真
\advance\inter by1\advance\inter by1\ifnum\inter=0␣……

なんと、1回目の\advanceの実行の途中なのに“2回目”の\advanceが出てきてしまいました😲

そして、\advanceは展開不能トークンなので、ここでようやく1から始まる数字列が終結することになり、数値は「1」に決まったので1回目の\advanceが実行されて、\interの値が0から1に変わります。

これで「再帰ループの1回目」の実行が終わったことになります。想定に反して、「1」の出力は行われず、さらにループの2回目に突入してしまいました。

% 1回目の(7)の \ifnum が真
\advance\inter by1\ifnum\inter=0␣\else \domod ……

1から始まる数字列を読もうとしてまた1回目と同様の状況になります。ただし今度は\interの値は1なので今度は(4)の\ifnumは偽となり、(5)の\elseまで読み飛ばされます。

% 1回目の(7)の \ifnum が真
% 2回目の(4)の \ifnum が偽
\advance\inter by1\ifnum3=0␣\else \ifnum\inter=0␣\else \modval=\inter ……

詳細は省略しますが、2つの\ifnumを処理(どちらも偽になる2)した後に展開不能トークンである\modval(countdefトークン)に到達します。ここでようやく数値が「1」と確定して\interの値が1から2に変更されます。

ここから先は「2回目のループがあった場合の3想定された動作」と一致するはずで、つまり「2」が端末に出力されます。(6)の\fiまで到達した後の動作をみましょう。

% 1回目の(7)の \ifnum が真
\ifnum\inter<\maxnum \fizzbuzz \fi \fi \fi ……

\interは2で\maxnumは1なので判定は偽となり1つ目の\fiまで読み飛ばされて2回目の(7)の\ifnumが完了し、次の\fiにより1回目の(7)の\ifnumも完了して、これで(9)の\FizzBuzzの実行が完了したことになります。最後の\fiは(10)に由来するもの((2)の\iffalseに対応する)です。

問題がようやく終結した話

改めて何が出力されたかを振り返ってみると以下のようになります。

  • (1)で「1」が出力された。
  • (9)の\FizzBuzzの実行で「2」が出力された。

まとめ

皆さん、難解TeX言語コードをドンドン書いていきましょう!💁(ええええっ😲)


  1. 数値を空白文字で終結させた場合はその空白文字は吸収されます。つまり空白文字トークンは実行されないので出力に空白は含まれません。
  2. ただしここでも\interは2ではなく1であることに注意が必要です。
  3. \maxnumが1の場合は2回目のループはないはずなのですが🙃