👌

自己追記によるFizzBuzz

5 min read 1

はじめに

スーパーコンピュータシステムのファイル消失のお詫び

年末にこんなの↑が話題になったようで。

処理に時間がかかるシェルスクリプトの実行中に、そのスクリプトを上書きしたところ処理の途中から上書きした方のスクリプトの方が途中から動きだしてしまい、整合性が取れてないために大事故になったらしい

自分はそのときとっくに仕事納めして遊んでたので反応が遅れてるけど、今さらながらちょっとこの件について触れておいたほうがいいかもしれないので斜め上から言及しておく。つまり、外部要因によるスクリプト書き換えではなく、スクリプトが自分自身を書き換えながら動作を続ける例について。

自己追記FizzBuzz

このFizzBuzzを解読したものを誰もポストしてくれないので、ウケなかったギャグを自分で説明する悲しさを感じつつ解説する。

n+=:; s=; : ${p=$(<$0)}
[[ $n =~ ^(...)+$ ]] && s=Fizz
[[ $n =~ ^(.....)+$ ]] && s+=Buzz
echo ${s:-${#n}}
[[ ${#n} =~ ... ]] || echo "$p">>$0

このシェルスクリプトのポイントは2つ。

  1. FizzBuzzなのに「割り算して余りがゼロだったら」という計算をしていない
  2. 1から100までループするのに forwhile などのループ文やシェル関数の再帰呼び出しなどを利用していない

スクリプトの書き換えに関するのは後者の方だけど、まずはそれ以外の部分から。

カウンタ

forwhile といったループ構文は使っていないが、ループカウンタはちゃんと存在する。このスクリプトにおけるカウンタ変数は $n。ただし、この値は 1、2、3、...という数値ではない。1行目の n+=: が変数のカウントアップだが、bash において += という代入文は他の一般的なプログラム言語とは意味が異なる。

n=12
n+=34

一般的な言語ではこのような記述では 12+34 が計算されて 46 という値が変数 n に格納されることになるが、bash では += は文字列連結をおこなう代入文なので、n の値は 1234 という文字列になる。n+=: は、変数 n の末尾に : を追加するという意味。これにより、変数 n は文字列の長さがひとつ大きくなる。 この文字列長をカウンタとして利用している(ので、追加する文字は : でなくても1文字なら何でもよい)。文字列長は ${#n} で数値として得ることができ、4行目ではそのようにして値を出力している。

ちなみに、変数 n が事前に整数型に宣言されていると(declare -i n)、文字列の連結ではなくふつーに足し算になる。ひじょーにわかりづらいがそういう仕様。また、+= だけが存在し、-=*= などはない。

剰余演算

3で「割り切れる」ならFizz、5で「割り切れる」ならBuzz、どちらでも「割り切れる」ならFizzBuzz、いずれも「割り切れない」なら数値を出力するるのがFizzBuzzなので、「割り切れるかどうか」を判定する必要があるが、このスクリプトでは剰余演算をしていない。

「3で割って余りがゼロだったら」という処理に相当するのは2行目の [[ $n =~ ^(...)+$ ]] の部分。正規表現である。... は「任意の3文字」にマッチする。ということは、(...)+ は「(任意の3文字)の1回以上の繰り返し」すなわち「3文字、6文字、9文字...の文字列」を含む文字列にマッチし、^(...)+$ は「文字列の先頭から末尾に含まれる文字が3文字、6文字、9文字...である文字列」すなわち「長さが3の倍数の文字列」にマッチする。同様に、3行目の [[ $n =~ ^(.....)+$ ]] では文字列長が5の倍数かどうかを判定している。

このスクリプトでは終了条件の判定も正規表現を使っていて、5行目の [[ ${#n} =~ ... ]] がそれ。ループカウンタ(の文字列長を数値に変換したもの)が3文字、つまり1からカウントアップされていって3桁、100になったときにこの条件を満たして終了する。

ループ

京大での事故で広く知られたように、シェルは最初にスクリプト全体を読み込むのではなく、「1行ずつ順次読み込む。そしてスクリプト終端に達したり exit や致命的エラーなどによりスクリプトは終了する。では、この終了条件がいつまでも満たされなかったらどうなるか? つまり、スクリプトが終了する前に、スクリプト末尾に別の処理が追加されたら?

答は、追加された部分も実行する、である。末尾への追加だけではなく、スクリプトの一部を書き換えた場合でも、いま実行している部分より後の部分は書き換えた後の内容で実行される。これが京大事故の原因だったようだ。

FizzBuzzでは「1から100までループする」、つまり「カウンタを増やしながら同じことを100回する」ことになる。ならば、「同じこと」を100回コピペする、厳密には自分自身を1回コピーして99回ペーストすればよい。

コピペのコピにあたるのが1行目の : ${p=$(<$0)} の部分。${var=value} は変数 var が未定義のときにかぎり、value を代入する。また、$(<file)$(cat file) と同じで、file の中身を取得する。よって、: ${p=$(<$0)}p が未定義のときにかぎり、自分自身($0)の中身全体を p に代入する。このスクリプトは何度も自分自身をペーストするが、p が未定義のときの最初の1回だけしかコピーされない。

コピペのペは5行目の echo "$p">>$0。1行目でコピーした自分自身(の中身)をそのまま自分自身(のファイル)に追記する。もともとのスクリプトは5行目で終わりだが、この追記によりスクリプト終端に達することなく追記された部分がそのまま継続実行される。

この「ペ」の前に終了条件があり、条件(カウンタ変数の文字列長3桁)を満たしていればペーストはされない。このときスクリプトの終端に達して終了する。

これは文章で説明するより、実際に動作を見るのがいちばんわかりやすいと思う。5行だったスクリプトが、実行後には同じ5行の100回繰り返しで500行に増えているのが一目瞭然である。

もっと簡単な例

単純に自己追記による動作を見るだけならば、以下の例がわかりやすいかもしれない。

p='echo Hello, world.; echo "$p">>$0'
eval $p

これは終了条件がなく、永遠に(現実にはディスクがあふれるかファイルサイズの上限にひっかかるまで) Hello, world. を出力し続ける。

もっと複雑な例

以下のスクリプトは100までの素数を出力する。

p=${p-$(<$0)}
q='{ [[ $j =~ $i || $i =~ ^($j)*$ ]] && echo "$p" || { j+=.; echo $q;} }>>$0'
[[ ${i-.} = $j ]] && echo ${#i}
[[ ${#i} =~ ... ]] || { i+=.; j=..; echo "$q">>$0;}

やっていることは上のFizzBuzzとほぼ同じ。ただし、FizzBuzzは単一ループだったが、こちらは二重ループである。ひとつめのループはFizzBuzzと同じくカウンタを100(数値ではなく文字列長)まで増やすもので変数 $p を、ふたつめのループはカウンタ変数が「2で割り切れるか3で割り切れるか...」と素数かどうかを調べるもので変数 $q を、それぞれ自分自身に追記している。

これも文章で説明するより実際に動作を見たほうがわかりやすいだろう。4行のスクリプトが実行後には1500行以上に増えているが、単に同じ内容を99回ペーストしていたFizzBuzzとは異なり、状況に応じてペーストされる内容が異なっている様子が見てとれる。

bash 拡張を使わない例

実行中書き換えられた部分が動いてしまうのは bash だから、ではなく、知っているかぎり bash 以外のシェルも同じように動く。bash の独自拡張を使わないで書いたものを挙げておく。

FizzBuzz。

x=.$x
n=
>/dev/null expr "$x" : '\(...\)*$' && echo -n Fizz || n=${#x}
>/dev/null expr "$x" : '\(.....\)*$' && echo Buzz || echo $n
>/dev/null expr "$n" : ... && exit || sed /q/q $0 >> $0

素数。

[ -n "$a" -a "$a" = "$b" ] && echo ${#a}
expr ${#a} : ... >/dev/null && exit
a=.$a
b=..
{ [ ${#a} -le ${#b} ] || expr $a : "\($b\)*$" >/dev/null && sed /q/q || { b=.$b; sed '/q/!d;/q/q';} } <$0>>$0

初出はここ。12年前だと…!?

「もっと簡単な例」の節に挙げた無限 Hello world も bash 拡張を使っていない例である。

逐次読み込みが有益な用途

スクリプトの自己書き換えは2回目以降の実行に難があるので(できないわけではない)、目くらまし以外の使い道はないと思われる。しかし、シェルがスクリプトを一括ではなく逐次読み込みしかしないという性質は実は重要で、これにより以下のようなスクリプトが動作するようになる。

#!/bin/sh
sed 1,/^exit/d $0 | tar xvzf -
exit $?
(tar+gzipアーカイブをバイナリのままここに貼り付け)

ファイルの冒頭はシェルスクリプトだが、途中から tar+gzip なバイナリが置かれている。もし、シェルがスクリプト全体を一括で読み込んで構文解析を済ませてから処理を開始するような動作をするならば、tar+gzip のパートに来たところで構文エラーになってしまう。しかしながら、実際にはそうではなく逐次読み込みながら処理をするので、バイナリ部分に到達する前に exit でスクリプトを終了する。そしてこのスクリプトが exit の前に何をするかというと、exit の後のバイナリ部分を取り出して展開する、すなわちシェルによる自己展開アーカイブである。

昔むかしの UNIX には shar (shell archive) というコマンドがあり、このような自己展開アーカイブを作成することができた。現代でも、商用アプリケーションのインストーラなどではライセンスに同意するかどうかの確認を取ってからパッケージを展開したりするためにこのような構造になっているスクリプトを時折見かけることがある。特殊な事情によりスクリプトとデータを別ファイルではなくひとつにまとめておきたいときには有用な手法。

もし、シェルがスクリプトを一括で読み込むように仕様を変更すると、既存のこういったスクリプトが動かなくなってしまう。逐次読み込みが POSIX などで規定されているかはどうかは確認していないが、shar で使われていたようにこの動作は昔から一般的なものであり、互換性を考えると今後も変わることはないだろうと思われる。

Discussion

https://qiita.com/ko1nksm/items/8f5115e0de3962b8a739 によると、2019年にPOSIXの仕様が改訂され、「シェルスクリプトは逐次読み込みすること」ではないようですが、「シェルスクリプトの後にバイナリがある形式のファイルも処理できること」は要求されるようになったとのことです。
(仕様の記述的には、「exitが見つかるまで一括読み込みする」実装も許容されるように読めました。)

ログインするとコメントできます